From 1d856ebd40118fe82eaf3afa57966ac2c516ffe9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 14:35:11 +0200 Subject: [PATCH] Code exchange: a rotating provider's grant without a refresh token fails the callback Joystick rotates refresh tokens, so a code-exchange answer whose refresh_token is absent or empty would store a credential the first refresh revokes. The shared exchange now throws a typed ProviderResponseError before the identity is resolved or anything is written. The residual-risk comment on the in-flight refresh map cites its follow-up. Co-Authored-By: Claude Opus 5.5 --- src/managed-credential.ts | 6 ++++-- src/oauth.ts | 12 ++++++++++-- tests/managed-credential.test.ts | 33 ++++++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/managed-credential.ts b/src/managed-credential.ts index e2fd124..32e9f51 100644 --- a/src/managed-credential.ts +++ b/src/managed-credential.ts @@ -68,8 +68,10 @@ function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity { * attempt is in flight join it and receive its outcome, success or failure. * The entry lives exactly as long as the attempt: a later request retries. * - * Accepted residual risk (docs/service-contract.md): one presentation per - * process per burst, so N processes may present one token N times. + * Known residual risk (docs/service-contract.md): one presentation per + * process per burst, so N processes may present one token N times. The + * cross-process failed-refresh marker that would close it is + * joystick:follow_up:cross-process-failed-refresh-marker. */ const refreshesInFlight = new WeakMap>>(); diff --git a/src/oauth.ts b/src/oauth.ts index e5411ff..b6658e8 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -244,12 +244,20 @@ function usableExpiresIn(value: unknown): number | undefined { } function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] { - return (options) => - requestProviderTokens(provider, options.config, { + return async (options) => { + const tokenSet = await requestProviderTokens(provider, options.config, { grant_type: "authorization_code", code: options.code, redirect_uri: options.redirectUri, }); + // A rotating provider's refresh token is the only way back to the + // credential: a grant that arrives without one would be stored as a row + // the first refresh revokes, so the callback fails before it writes. + if (providerCatalog[provider].rotatesRefreshTokens && !tokenSet.refreshToken) { + throw new ProviderResponseError(200, "provider_body_not_token_set", `${providerCatalog[provider].displayName} token exchange failed`); + } + return tokenSet; + }; } function sharedRuntimeRefresh(provider: Provider): NonNullable { diff --git a/tests/managed-credential.test.ts b/tests/managed-credential.test.ts index 8a9b8de..50ca9ba 100644 --- a/tests/managed-credential.test.ts +++ b/tests/managed-credential.test.ts @@ -561,12 +561,12 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag describe("what the callback mints is decided by mode and provider role", () => { const returnTo = "https://streampixels.gamecult.org/auth/connect"; - function stubProviders(identity: Record) { + function stubProviders(identity: Record, tokenAnswer: Record = { refresh_token: "tok-refresh-CANARY" }) { globalThis.fetch = (async (input: string | URL) => { const url = String(input); const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); if (url.startsWith("https://api.joystick.tv/api/oauth/token") || Object.values(providerCatalog).some((d) => d.tokenEndpoint === url)) { - return json({ access_token: "tok-access", refresh_token: "tok-refresh-CANARY", token_type: "Bearer", expires_in: 3600 }); + return json({ access_token: "tok-access", token_type: "Bearer", expires_in: 3600, ...tokenAnswer }); } if (url === "https://api.joystick.tv/api/v1/me/identity") return json({ channel_id: identity.joystick, username: "deru" }); if (url === "https://api.twitch.tv/helix/users") return json({ data: [{ id: identity.twitch, login: "deru", display_name: "Deru" }] }); @@ -618,6 +618,35 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag expect(rig.custody.decrypt(row.refreshTokenEncrypted!)).toBe("tok-refresh-CANARY"); }); + it.each([ + ["no refresh_token", {}], + ["an empty refresh_token", { refresh_token: "" }], + ])("a rotating provider's code exchange with %s fails the callback before anything is written", timeout, async (_name, tokenAnswer) => { + const rig = await makeRig("joystick"); + const before = await rig.row(); + const auditBefore = (await rig.audit()).length; + stubProviders({ joystick: "channel-new", twitch: "t-1", youtube: "y-1" }, tokenAnswer); + const app = rig.apps[0]!; + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + payload: { appSlug: "streampixels", mode: "connect", returnTo, connection: { kind: "creator", targetId: "creator-1" } }, + }); + expect(start.statusCode).toBe(201); + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + }); + expect(callback.statusCode).toBeGreaterThanOrEqual(400); + expect(callback.body).not.toContain("tok-access"); + expect(await rig.row()).toEqual(before); + expect(await rig.stores[0]!.findStoredLinkedIdentity("joystick", "channel-new")).toBeNull(); + const failures = (await rig.audit()).slice(auditBefore); + expect(failures).toEqual([ + { eventType: "oauth_callback_failed", payload: expect.objectContaining({ errorClass: "ProviderResponseError", providerStatus: 200 }) }, + ]); + }); + it.each(["twitch", "youtube"] as const)("a %s sign_in on streampixels still yields viewer_access and a refresh token", timeout, async (provider) => { const rig = await makeRig("joystick"); stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" });