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:
parent
4e312828c3
commit
1d856ebd40
3 changed files with 45 additions and 6 deletions
|
|
@ -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>>>();
|
||||
|
||||
|
|
|
|||
12
src/oauth.ts
12
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<OAuthProviderRuntime["refreshAccessToken"]> {
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
Loading…
Reference in a new issue