diff --git a/docs/decisions.md b/docs/decisions.md index acedc57..f722f2e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1462,3 +1462,32 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu `account` from the API's userinfo. It is shown, never relied on: a site that does not grant the scope, a pre-0.8 login and an API key report `null`, and the scope is not part of the granted-scope check. + +## D-089 One process's refresh does not break the others + +- The API ends the previous access token the moment a refresh issues a new + one. With the 30-minute refresh window (D-088), a refresh in one process (a + terminal, another agent, the DeepSeek Harness host) broke every other holder + of the old token: a running `wait`, or a host-provided credential, until the + next read. +- The refresh now runs entirely under the credentials lock, as lark-cli does: + under the lock the CLI re-reads the profile and adopts a token another + process already refreshed, so parallel commands spend the refresh token once. + The B3 adoption after a failed refresh stays for CLIs before 0.8.1, which + refresh outside the lock. +- From the refresh request to the save, forced exits (a second Ctrl-C, the end + of SIGTERM / SIGHUP's grace period) wait (`withExitHeld`). A process killed + after the API answered but before the save would leave only a spent refresh + token, and the next refresh would end the login. +- After a 401 with an OAuth token, from the store or from a host, the transport + asks for the token stored now for the same login (profile login id; for a + host credential the profile whose user id or login id is `DSH_MESHY_SUBJECT`) + and retries once when it differs and has not expired. A 401 is answered + before the request is acted on, so the retry cannot repeat a create. Bodies + that cannot be read twice (none today) are not retried. Another account's + token is never used. +- The Windows key store (DPAPI through Windows PowerShell) waits up to 30 s + instead of 5 s. PowerShell took 3–5 s to start on CI runners, so a cold or + busy machine could fall back to the key file when sealing, or fail to open a + stored login. DPAPI never prompts; the Keychain and the Secret Service keep + 5 s, since a locked keychain may. diff --git a/docs/usage.md b/docs/usage.md index 8ccd3a8..bc24f36 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -85,6 +85,15 @@ spend the stored login. It uses a still-valid token as it is, and for an expired one exits `3` saying where to refresh it; the stored login stays intact. `auth login` checks the same thing before it opens the browser. +The refresh runs under the credentials lock, so commands started together +refresh once and the rest pick up the new token. Once a refresh request is +sent it is always saved, even if a second Ctrl-C or a terminate signal arrives +meanwhile; the exit follows the save. The API ends the previous access token as +soon as it issues a new one, so a command whose token another process replaced +gets a 401: it then retries once with the token now stored for the same login +(the same profile, or for `DSH_MESHY_CREDENTIAL` the login named by +`DSH_MESHY_SUBJECT`), and never with another account's. + **Who is signed in:** `auth status` and `auth list` show the `account` (name and email) of a browser login. It is reported by the site at sign-in, so a login made before 0.8, an API key, and the China site (which does not report it yet) diff --git a/src/client/index.ts b/src/client/index.ts index c971daa..abf16b2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -19,7 +19,7 @@ import { type TaskResourceDescriptor, } from "./resource-registry.js"; import { createTransport, type Transport } from "./transport.js"; -import { assertCredentialAllowedForOrigin, type MeshyConfig } from "../internal/config.js"; +import { assertCredentialAllowedForOrigin, storedTokenAfterRejection, type MeshyConfig } from "../internal/config.js"; import { UsageError } from "../internal/errors.js"; @@ -35,7 +35,12 @@ export class MeshyClient { constructor(config: MeshyConfig, opts: { fetchImpl?: typeof fetch } = {}) { this.config = config; - const common = { readTimeoutMs: config.readTimeoutMs, fetchImpl: opts.fetchImpl, credentialKind: config.credentialKind }; + const common = { + readTimeoutMs: config.readTimeoutMs, + fetchImpl: opts.fetchImpl, + credentialKind: config.credentialKind, + renewCredential: (rejected: string) => storedTokenAfterRejection(config, rejected), + }; this.v1 = createTransport({ ...common, baseUrl: config.baseUrlV1, apiKey: config.apiKey }); this.v2 = createTransport({ ...common, baseUrl: config.baseUrlV2, apiKey: config.apiKey }); this.balance = new BalanceEndpoint(this.v1); @@ -67,6 +72,7 @@ export class MeshyClient { apiKey: this.config.apiKey, credentialKind: this.config.credentialKind, readTimeoutMs: this.config.readTimeoutMs, + renewCredential: (rejected) => storedTokenAfterRejection(this.config, rejected), }); return this.creativeLabTransport; } diff --git a/src/client/transport.ts b/src/client/transport.ts index 65f94b9..d6ce934 100644 --- a/src/client/transport.ts +++ b/src/client/transport.ts @@ -85,6 +85,11 @@ export interface TransportConfig { baseUrl: string; apiKey?: string; credentialKind?: "oauth" | "api_key"; + /** + * Asked once after a 401: the credential to retry with, or null. Another + * process may have refreshed the login while this one held the old token. + */ + renewCredential?: (rejected: string) => string | null; readTimeoutMs: number; fetchImpl?: typeof fetch; userAgent?: string; @@ -166,10 +171,26 @@ export function createTransport(cfg: TransportConfig): Transport { const authenticated = Boolean(cfg.apiKey); const userAgent = cfg.userAgent ?? USER_AGENT; const maxBody = cfg.defaultMaxBodyBytes ?? DEFAULT_MAX_JSON_BODY_BYTES; + let apiKey = cfg.apiKey; + + /** + * On a 401, switch to the credential renewCredential offers and report + * whether the request should be sent again. A 401 is answered before the + * request is acted on, so sending it again cannot repeat a side effect. + */ + function renewAfter(resp: Response, path: string): boolean { + if (resp.status !== 401 || !apiKey || !cfg.renewCredential) return false; + const next = cfg.renewCredential(apiKey); + if (!next || next === apiKey) return false; + logger.debug(`401 on ${path}: another process refreshed the login; retrying with the stored token`); + apiKey = next; + resp.body?.cancel().catch(() => undefined); + return true; + } function headersFor(extra?: ConstructorParameters[0]): Headers { const headers = new Headers(extra); - if (cfg.apiKey) headers.set("Authorization", `Bearer ${cfg.apiKey}`); + if (apiKey) headers.set("Authorization", `Bearer ${apiKey}`); else headers.delete("Authorization"); headers.delete("Cookie"); headers.set("User-Agent", userAgent); @@ -262,15 +283,19 @@ export function createTransport(cfg: TransportConfig): Transport { url.searchParams.set(k, String(v)); } } - const headers = headersFor(opts.headers); - const init: RequestInit = { method }; - if (opts.body !== undefined) { - headers.set("Content-Type", "application/json"); - init.body = JSON.stringify(opts.body); - } - init.headers = headers; + const body = opts.body === undefined ? undefined : JSON.stringify(opts.body); + const send = () => { + const headers = headersFor(opts.headers); + if (body !== undefined) headers.set("Content-Type", "application/json"); + return doFetch(url, { method, headers, ...(body === undefined ? {} : { body }) }, timeoutMs, opts.signal, path); + }; const timeoutMs = opts.timeoutMs ?? cfg.readTimeoutMs; - const { resp, release, timeoutSignal } = await doFetch(url, init, timeoutMs, opts.signal, path); + let sent = await send(); + if (renewAfter(sent.resp, path)) { + sent.release(); + sent = await send(); + } + const { resp, release, timeoutSignal } = sent; try { refuseRedirect(resp, path); if (!resp.ok) { @@ -300,9 +325,15 @@ export function createTransport(cfg: TransportConfig): Transport { async fetchRaw(path, init = {}) { const url = resolveApiUrl(baseUrl, path); - const headers = headersFor(init.headers); const timeoutMs = init.timeoutMs ?? cfg.readTimeoutMs; - const { resp, release } = await doFetch(url, { ...init, headers }, timeoutMs, init.signal ?? undefined, path); + const send = () => doFetch(url, { ...init, headers: headersFor(init.headers) }, timeoutMs, init.signal ?? undefined, path); + let sent = await send(); + // Only a body that can be read twice is sent twice. + if ((init.body == null || typeof init.body === "string") && renewAfter(sent.resp, path)) { + sent.release(); + sent = await send(); + } + const { resp, release } = sent; try { refuseRedirect(resp, path); if (!resp.ok) throw await mapHttpError(resp, path, cfg.credentialKind); @@ -332,16 +363,20 @@ export function createTransport(cfg: TransportConfig): Transport { async openStream(path, opts = {}) { const url = resolveApiUrl(baseUrl, path); - const headers = headersFor({ Accept: "text/event-stream", ...(opts.headers ?? {}) }); const controller = new AbortController(); const signal = combineSignals(controller.signal, opts.signal)!; const connectTimeoutMs = opts.connectTimeoutMs ?? cfg.readTimeoutMs; const connectTimer = setTimeout(() => controller.abort(new Error(`stream connect timed out after ${connectTimeoutMs}ms`)), connectTimeoutMs); const fetchImpl = cfg.fetchImpl ?? globalThis.fetch; + const open = () => { + logger.debug(`HTTP GET (stream) ${url.href}`); + const headers = headersFor({ Accept: "text/event-stream", ...(opts.headers ?? {}) }); + return fetchImpl(url, { method: "GET", headers, signal, redirect: "manual" }); + }; let resp: Response; try { - logger.debug(`HTTP GET (stream) ${url.href}`); - resp = await fetchImpl(url, { method: "GET", headers, signal, redirect: "manual" }); + resp = await open(); + if (renewAfter(resp, path)) resp = await open(); } catch (err) { clearTimeout(connectTimer); throw wrapFetchError(err, "request", path, opts.signal, controller.signal, connectTimeoutMs); diff --git a/src/index.ts b/src/index.ts index 403cd7a..9e1dfb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import type { Command } from "commander"; import { buildRootCommand, LOCAL_COMMANDS } from "./root.js"; -import { currentCommand, interruptedBy, markInterrupted, wasInterrupted } from "./internal/context.js"; +import { currentCommand, exitWhenSafe, interruptedBy, markInterrupted, wasInterrupted } from "./internal/context.js"; import { isCommanderInformational, CliError } from "./internal/errors.js"; import { painterFor } from "./internal/color.js"; import { emitEnvelope, printWarnings, type OutputFormat } from "./internal/output.js"; @@ -67,10 +67,10 @@ function installSignalHandlers(): void { return; } process.stderr.write(`\ninterrupted by ${signal} — finishing local bookkeeping\n`); - setTimeout(() => process.exit(130), TERMINATE_GRACE_MS).unref(); + setTimeout(() => exitWhenSafe(130), TERMINATE_GRACE_MS).unref(); return; } - process.exit(130); + exitWhenSafe(130); }); } } diff --git a/src/internal/config.ts b/src/internal/config.ts index 1a840a9..2c2dd85 100644 --- a/src/internal/config.ts +++ b/src/internal/config.ts @@ -28,7 +28,7 @@ * belongs to) and DSH_MESHY_SUBJECT (the account it binds journal records to). */ -import { credentialsPath, readCredentials, resolveStoredCredential, type CredentialKind } from "./credentials.js"; +import { credentialsPath, profileSecrets, readCredentials, resolveStoredCredential, type CredentialKind } from "./credentials.js"; import { loadEnvFile } from "./env-file.js"; import { authRequiredError, CliError, UsageError } from "./errors.js"; import { setLogLevel, type LogLevel } from "./logger.js"; @@ -288,6 +288,32 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { return cfg; } +/** + * After a 401, the access token the store holds now for the same login, when + * it is a different, unexpired one: another process refreshed while this one + * held the old token, and the API ends the old access token the moment it + * issues the new one. Null for API keys, another account, or nothing newer. + * A host-provided credential matches by its subject (the profile's user id or + * login id), a stored one by its login id. + */ +export function storedTokenAfterRejection(cfg: MeshyConfig, rejected: string): string | null { + if (cfg.credentialKind !== "oauth" || (cfg.credentialSource !== "file" && cfg.credentialSource !== "host")) return null; + try { + const state = readCredentials(cfg.credentialsFile); + const name = cfg.credentialSource === "file" ? cfg.credentialProfile : state?.active_profile; + const profile = name ? state?.profiles[name] : undefined; + if (!profile || profile.kind !== "oauth" || (profile.expires_at ?? 0) <= Date.now()) return null; + const sameLogin = cfg.credentialSource === "host" + ? Boolean(cfg.credentialSubject) && (cfg.credentialSubject === profile.user_id || cfg.credentialSubject === profile.login_id) + : !cfg.credentialLoginId || cfg.credentialLoginId === profile.login_id; + if (!sameLogin) return null; + const token = profileSecrets(cfg.credentialsFile, profile).access_token; + return token && token !== rejected ? token : null; + } catch { + return null; + } +} + /** * Stored profiles and host-provided credentials were issued for the v1 origin * they were resolved against. A different Creative Lab origin only receives an diff --git a/src/internal/context.ts b/src/internal/context.ts index a7d820b..d1a1911 100644 --- a/src/internal/context.ts +++ b/src/internal/context.ts @@ -59,6 +59,32 @@ export function interruptedBy(): string { return interruptSignal; } +let heldExits = 0; +let pendingExit: number | undefined; + +/** + * Runs `fn` with forced exits (a second Ctrl-C, the end of a terminate + * signal's grace period) held until it settles. For the few steps that must + * not stop half-way: once the API has answered an OAuth refresh, the old + * refresh token is spent, and a process killed before saving the answer + * would leave only that behind. + */ +export async function withExitHeld(fn: () => Promise): Promise { + heldExits += 1; + try { + return await fn(); + } finally { + heldExits -= 1; + if (heldExits === 0 && pendingExit !== undefined) process.exit(pendingExit); + } +} + +/** process.exit, unless withExitHeld is running: then once it settles. */ +export function exitWhenSafe(code: number): void { + if (heldExits > 0) pendingExit = code; + else process.exit(code); +} + /** Test hook: reset module state between in-process runs. */ export function resetCommandContextForTests(): void { current = null; diff --git a/src/internal/credentials.ts b/src/internal/credentials.ts index ef35a8c..b369b57 100644 --- a/src/internal/credentials.ts +++ b/src/internal/credentials.ts @@ -178,13 +178,15 @@ function sleepSync(ms: number): void { } /** - * Runs `fn` while holding an exclusive lock keyed to `file`. + * Takes the exclusive lock keyed to `file` and returns its release. Callers + * that hold it across an await (an OAuth refresh) use this directly; the rest + * use withCredentialsLock. * * O_EXCL creation is the atomic primitive here — `flock` is not exposed by * Node's fs module. A lock whose mtime is older than LOCK_STALE_MS is treated * as abandoned and broken, so a killed process cannot wedge the CLI forever. */ -export function withCredentialsLock(file: string, fn: () => T): T { +export function acquireCredentialsLock(file: string): () => void { const lock = lockPathFor(file); mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); const deadline = Date.now() + LOCK_TIMEOUT_MS; @@ -213,15 +215,23 @@ export function withCredentialsLock(file: string, fn: () => T): T { sleepSync(25); } } - try { - return fn(); - } finally { + return () => { closeSync(fd); try { unlinkSync(lock); } catch { /* already gone */ } + }; +} + +/** Runs `fn` while holding the exclusive lock keyed to `file`. */ +export function withCredentialsLock(file: string, fn: () => T): T { + const release = acquireCredentialsLock(file); + try { + return fn(); + } finally { + release(); } } @@ -347,16 +357,22 @@ export function updateCredentials( file: string, mutate: (state: CredentialsFile) => CredentialsFile | void, ): CredentialsFile { - return withCredentialsLock(file, () => { - const own = readStateFile(file); - const legacy = own ? null : legacyCredentialsPath(file); - const seeded = legacy ? readStateFile(legacy) : null; - const current = own ?? seeded ?? emptyFile(); - const next = mutate(current) ?? current; - writeCredentials(next, file); - if (legacy && seeded) unlinkUnderLock(legacy); - return next; - }); + return withCredentialsLock(file, () => updateCredentialsHeld(file, mutate)); +} + +/** updateCredentials for a caller that already holds the lock (acquireCredentialsLock). */ +export function updateCredentialsHeld( + file: string, + mutate: (state: CredentialsFile) => CredentialsFile | void, +): CredentialsFile { + const own = readStateFile(file); + const legacy = own ? null : legacyCredentialsPath(file); + const seeded = legacy ? readStateFile(legacy) : null; + const current = own ?? seeded ?? emptyFile(); + const next = mutate(current) ?? current; + writeCredentials(next, file); + if (legacy && seeded) unlinkUnderLock(legacy); + return next; } function unlinkUnderLock(file: string): boolean { diff --git a/src/internal/keyring.ts b/src/internal/keyring.ts index 3dbbf52..78710c4 100644 --- a/src/internal/keyring.ts +++ b/src/internal/keyring.ts @@ -33,7 +33,14 @@ export type KeyStore = "keychain" | "dpapi" | "secret-service" | "file"; const KEY_BYTES = 32; const IV_BYTES = 12; const TAG_BYTES = 16; +/** A locked keychain may prompt, and waiting longer on a prompt helps nobody. */ const STORE_TIMEOUT_MS = 5_000; +/** + * Windows PowerShell can take several seconds to start on a cold or busy + * machine (CI runners measured 3–5 s); DPAPI never prompts, so it is waited + * for rather than mistaken for an unusable store. + */ +const DPAPI_TIMEOUT_MS = 30_000; const SEAL_PREFIX = "v1"; const STORES: readonly KeyStore[] = ["keychain", "dpapi", "secret-service", "file"]; @@ -61,11 +68,11 @@ function decodeKey(text: string, where: string): Buffer { return key; } -function run(cmd: string, args: string[], input?: string): string { +function run(cmd: string, args: string[], input?: string, timeout = STORE_TIMEOUT_MS): string { return execFileSync(cmd, args, { encoding: "utf8", input, - timeout: STORE_TIMEOUT_MS, + timeout, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); @@ -117,7 +124,7 @@ function dpapi(direction: "Protect" | "Unprotect", input: string): string { ].join("; "); const encoded = Buffer.from(script, "utf16le").toString("base64"); try { - return run("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], input); + return run("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], input, DPAPI_TIMEOUT_MS); } catch (err) { if (isMissingBinary(err)) throw new StoreUnavailable("powershell.exe"); throw err; diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index a8e3a92..6d8e1d6 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -16,7 +16,8 @@ import { Command } from "commander"; import { looksLikeFile } from "./paths.js"; import { MeshyClient } from "../client/index.js"; import { loadConfig, type ConfigOverrides, type MeshyConfig } from "./config.js"; -import { credentialsPath, profileSecrets, readCredentials, saveProfile, storeWritable } from "./credentials.js"; +import { acquireCredentialsLock, credentialsPath, profileSecrets, readCredentials, storeWritable, updateCredentialsHeld } from "./credentials.js"; +import { withExitHeld } from "./context.js"; import { authRequiredError, HintedError, UsageError } from "./errors.js"; import { logger, setLogLevel } from "./logger.js"; import { refreshTokens } from "./oauth.js"; @@ -126,35 +127,37 @@ export async function refreshOAuthCredentialIfNeeded( const originalAccessToken = secrets.access_token; const profileName = config.credentialProfile; + // The whole refresh runs under the store's lock, so concurrent CLI + // processes refresh once between them: whoever waited re-reads the store + // and finds the new token instead of spending the refresh token again. + const release = acquireCredentialsLock(credFile); try { - const tok = await refreshTokens({ - baseUrlV1: config.baseUrlV1, - refreshToken: secrets.refresh_token, + const current = readCredentials(credFile)?.profiles[profileName]; + const refreshToken = current?.kind === "oauth" ? profileSecrets(credFile, current).refresh_token : undefined; + if (current && (current.expires_at ?? 0) - Date.now() >= REFRESH_SKEW_MS) { + logger.debug("OAuth token already refreshed by another process"); + } else if (current && refreshToken) await withExitHeld(async () => { + const tok = await refreshTokens({ baseUrlV1: config.baseUrlV1, refreshToken }); + // Write back: preserve profile name, created_at, region and account. + updateCredentialsHeld(credFile, (state) => { + state.profiles[profileName] = { + kind: "oauth", + access_token: tok.access_token, + refresh_token: tok.refresh_token, + expires_at: Date.now() + tok.expires_in * 1000, + user_id: tok.user_id ?? current.user_id, + ...(current.login_id ? { login_id: current.login_id } : {}), + ...(current.region ? { region: current.region } : {}), + ...(current.account ? { account: current.account } : {}), + created_at: current.created_at ?? Date.now(), + }; + }); }); - // Write back: preserve profile name, created_at and account, update tokens. - saveProfile( - credFile, - profileName, - { - kind: "oauth", - access_token: tok.access_token, - refresh_token: tok.refresh_token, - expires_at: Date.now() + tok.expires_in * 1000, - user_id: tok.user_id ?? profile.user_id, - ...(profile.login_id ? { login_id: profile.login_id } : {}), - ...(profile.region ? { region: profile.region } : {}), - ...(profile.account ? { account: profile.account } : {}), - created_at: profile.created_at, - }, - { makeActive: false }, - ); - // Re-run loadConfig so the caller carries the fresh token. - return loadConfig(overrides); } catch (err) { - // B3: Read-after-write — another concurrent CLI process may have - // already rotated the token (server-side refresh-token rotation). - // Re-read the file; if the active profile now holds a DIFFERENT - // access token that is comfortably unexpired (>REFRESH_SKEW_MS), + // B3: Read-after-write — a CLI before 0.8.1 refreshes outside the lock + // and may already have rotated the token (server-side refresh-token + // rotation). Re-read the file; if the active profile now holds a + // DIFFERENT access token that is comfortably unexpired (>REFRESH_SKEW_MS), // adopt it and proceed rather than failing the command. const afterFailure = readCredentials(credFile); const afterProfile = profileName ? afterFailure?.profiles[profileName] : undefined; @@ -176,7 +179,11 @@ export async function refreshOAuthCredentialIfNeeded( logger.debug("OAuth refresh failed (token still valid, proceeding)", err); return config; } + } finally { + release(); } + // Re-run loadConfig so the caller carries the token now in the store. + return loadConfig(overrides); } export function configOverridesFrom(flags: GlobalFlags): ConfigOverrides { diff --git a/tests/host-session.test.ts b/tests/host-session.test.ts index e0be4ae..e9fdd86 100644 --- a/tests/host-session.test.ts +++ b/tests/host-session.test.ts @@ -193,3 +193,115 @@ test("operationsRoot follows DSH_MESHY_STATE_DIR", () => { assert.equal(operationsRoot({ MESHY_CONFIG_DIR: "/cfg" }), join("/cfg", "operations")); assert.equal(operationsRoot({ MESHY_CONFIG_DIR: "/cfg", DSH_MESHY_STATE_DIR: "/ws/.meshy" }), join("/ws/.meshy", "operations")); }); + +// --------------------------------------------------------------------------- +// One process's refresh must not break the others: the API ends the previous +// access token the moment it issues a new one. +// --------------------------------------------------------------------------- + +const authOf = (r: { headers: Record }): unknown => r.headers["authorization"]; + +test("processes that need a refresh at the same time refresh once and all use the new token", async () => { + let refreshes = 0; + const m = await startMockApi(async (req, res) => { + if (req.path.endsWith("/oauth/token")) { + refreshes++; + await new Promise((r) => setTimeout(r, 500)); + return jsonReply(res, 200, { access_token: "msy_at_fresh", token_type: "Bearer", expires_in: 3600, refresh_token: "msy_rt_fresh" }); + } + if (req.path.endsWith("/balance")) return jsonReply(res, 200, { balance: 42 }); + return jsonReply(res, 404, {}); + }); + try { + const file = oauthStore(Date.now() + 10 * 60_000); + const env = m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file }); + const runs = await Promise.all([1, 2, 3].map(() => runCli(["balance", "--format", "json"], { env }))); + for (const run of runs) assert.equal(run.code, 0, run.stderr); + assert.equal(refreshes, 1, "the refresh token is spent once"); + assert.deepEqual(m.requests.filter((r) => r.path.endsWith("/balance")).map(authOf), Array(3).fill("Bearer msy_at_fresh")); + assert.equal(openStoredProfile(file).refresh_token, "msy_rt_fresh"); + } finally { + await m.close(); + } +}); + +test("Ctrl-C twice during a refresh still saves the new tokens before exiting", { skip: process.platform === "win32" && "no POSIX signals" }, async () => { + let child: import("node:child_process").ChildProcess | undefined; + const m = await startMockApi(async (req, res) => { + if (req.path.endsWith("/oauth/token")) { + // The second Ctrl-C forces an exit; it must wait for the save. + child?.kill("SIGINT"); + await new Promise((r) => setTimeout(r, 100)); + child?.kill("SIGINT"); + await new Promise((r) => setTimeout(r, 300)); + return jsonReply(res, 200, { access_token: "msy_at_fresh", token_type: "Bearer", expires_in: 3600, refresh_token: "msy_rt_fresh" }); + } + return jsonReply(res, 200, { balance: 42 }); + }); + try { + const file = oauthStore(Date.now() - 1_000); + const run = await runCli(["balance", "--format", "json"], { + env: m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file }), + onSpawn: (c) => { child = c; }, + }); + assert.equal(run.code, 130, "the forced exit happens once the save is done"); + assert.equal(openStoredProfile(file).refresh_token, "msy_rt_fresh", "the rotated refresh token was not lost"); + assert.equal(m.requests.some((r) => r.path.endsWith("/balance")), false, "nothing runs after the forced exit"); + } finally { + await m.close(); + } +}); + +function rejectingApi(valid: string, onFirstRejection?: () => void): Promise { + let rejected = false; + return startMockApi((req, res) => { + if (!req.path.endsWith("/balance")) return jsonReply(res, 404, {}); + if (authOf(req) === `Bearer ${valid}`) return jsonReply(res, 200, { balance: 42 }); + if (!rejected) onFirstRejection?.(); + rejected = true; + return jsonReply(res, 401, { message: "Invalid access token" }); + }); +} + +test("a host credential another process replaced is retried once with the stored token of the same login", async () => { + const m = await rejectingApi("msy_at_stored_token"); + try { + const file = oauthStore(Date.now() + 3_600_000); + const run = await runCli(["balance", "--format", "json"], { + env: m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file, DSH_MESHY_CREDENTIAL: "msy_at_replaced", DSH_MESHY_SUBJECT: "login-1" }), + }); + assert.equal(run.code, 0, run.stderr); + assert.deepEqual(m.requests.map(authOf), ["Bearer msy_at_replaced", "Bearer msy_at_stored_token"]); + } finally { + await m.close(); + } +}); + +test("a 401 is not retried with another account's stored token", async () => { + const m = await rejectingApi("msy_at_stored_token"); + try { + const file = oauthStore(Date.now() + 3_600_000); + const run = await runCli(["balance", "--format", "json"], { + env: m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file, DSH_MESHY_CREDENTIAL: "msy_at_replaced", DSH_MESHY_SUBJECT: "someone-else" }), + }); + assert.equal(run.code, 3); + assert.equal(m.requests.length, 1); + } finally { + await m.close(); + } +}); + +test("a running command whose token another process refreshed carries on with the new one", async () => { + const file = oauthStore(Date.now() + 3_600_000); + // The first request finds its token replaced in the store, as after another process's refresh. + const m = await rejectingApi("msy_at_rotated", () => + saveProfile(file, "default", { kind: "oauth", access_token: "msy_at_rotated", refresh_token: "msy_rt_rotated", expires_at: Date.now() + 3_600_000, login_id: "login-1" }), + ); + try { + const run = await runCli(["balance", "--format", "json"], { env: m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file }) }); + assert.equal(run.code, 0, run.stderr); + assert.deepEqual(m.requests.map(authOf), ["Bearer msy_at_stored_token", "Bearer msy_at_rotated"]); + } finally { + await m.close(); + } +});