diff --git a/src/handwritten/auth/load-sdk-client.ts b/src/handwritten/auth/load-sdk-client.ts index cf46ff1..7b91886 100644 --- a/src/handwritten/auth/load-sdk-client.ts +++ b/src/handwritten/auth/load-sdk-client.ts @@ -60,13 +60,24 @@ function buildInterceptor( } }, onTokenRefresh: async ({ accessToken, refreshToken, expiresAt }) => { + let saved = false await store.update((cfg) => { const a = cfg.envs[envName]?.accounts?.[email] if (!a) return a.access_token = accessToken a.refresh_token = refreshToken a.access_token_expires_at = expiresAt + saved = true }) + // Dropping a rotation on the floor is worse than it looks: the server has + // already invalidated the token we still hold, so the next refresh reads + // as reuse and revokes the whole family. Say so rather than skip quietly. + if (!saved) { + process.stderr.write( + `wspc: token rotated but not saved: no account '${email}' in env '${envName}'; ` + + "the next refresh will present a superseded token and may sign you out\n", + ) + } }, }) } diff --git a/test/load-sdk-client-multi.test.ts b/test/load-sdk-client-multi.test.ts index ffbd8ed..560426d 100644 --- a/test/load-sdk-client-multi.test.ts +++ b/test/load-sdk-client-multi.test.ts @@ -98,6 +98,47 @@ describe("loadSdkClient (multi-account)", () => { expect(acctB.refresh_token).toBe("rt_b") }) + it("warns on stderr when a rotated token cannot be written back to the account slot", async () => { + const dir = await fs.mkdtemp(join(tmpdir(), "wspc-load-lost-slot-")) + const store = new ConfigStore({ configDir: dir }) + await store.write(baseConfig()) + + let callCount = 0 + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL): Promise => { + callCount++ + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url + if (callCount === 1) return new Response("", { status: 401 }) + if (url.includes("/auth/oauth/token")) { + return new Response( + JSON.stringify({ access_token: "at_a2", refresh_token: "rt_a2", expires_in: 900 }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + return new Response("{}", { status: 200 }) + }, + ) + + const { fetch: af } = await loadAuthedFetch({ store }) + + // The slot disappears after the interceptor resolved the account but before + // the rotation lands, which is the shape that made a successful rotation + // vanish silently and left the next refresh presenting a superseded token. + const withoutA = baseConfig() + delete (withoutA.envs.prod.accounts as Record)["a@x.com"] + await store.write(withoutA) + + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true) + await af("https://api.wspc.ai/auth/me") + + const written = stderr.mock.calls.map((call) => String(call[0])).join("") + expect(written).toContain("a@x.com") + expect(written).toContain("prod") + expect(written).toMatch(/rotated/i) + expect(written).not.toContain("rt_a2") + }) + it("errors when multiple accounts and no current_account", async () => { const dir = await fs.mkdtemp(join(tmpdir(), "wspc-load-ambig-")) const store = new ConfigStore({ configDir: dir })