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.
|
* 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.
|
* The entry lives exactly as long as the attempt: a later request retries.
|
||||||
*
|
*
|
||||||
* Accepted residual risk (docs/service-contract.md): one presentation per
|
* Known residual risk (docs/service-contract.md): one presentation per
|
||||||
* process per burst, so N processes may present one token N times.
|
* 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>>>();
|
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"] {
|
function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchangeAuthorizationCode"] {
|
||||||
return (options) =>
|
return async (options) => {
|
||||||
requestProviderTokens(provider, options.config, {
|
const tokenSet = await requestProviderTokens(provider, options.config, {
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code: options.code,
|
code: options.code,
|
||||||
redirect_uri: options.redirectUri,
|
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"]> {
|
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", () => {
|
describe("what the callback mints is decided by mode and provider role", () => {
|
||||||
const returnTo = "https://streampixels.gamecult.org/auth/connect";
|
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) => {
|
globalThis.fetch = (async (input: string | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
|
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)) {
|
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.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" }] });
|
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");
|
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) => {
|
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");
|
const rig = await makeRig("joystick");
|
||||||
stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" });
|
stubProviders({ joystick: "channel-1", twitch: "t-1", youtube: "y-1" });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue