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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-19 14:35:11 +02:00
commit 1d856ebd40
3 changed files with 45 additions and 6 deletions

View file

@ -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<HeimdallStore, Map<string, Promise<ManagedCredentialOutcome>>>();

View file

@ -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<OAuthProviderRuntime["refreshAccessToken"]> {

View file

@ -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<string, string>) {
function stubProviders(identity: Record<string, string>, tokenAnswer: Record<string, unknown> = { 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" });