From dc173ad8ef4e87fe1ce4671dd449611d9ce8fd1c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 14:19:05 -0700 Subject: [PATCH 1/2] Add tests for a payload-parameterized connect state store Cover the sealed-state round trip carrying a caller-shaped payload, one-shot consumption (a replayed MCP callback is rejected before a second token exchange), TTL expiry, cross-provider AAD rejection, and payloads the caller's parser refuses. --- .../connections/src/mcp-oauth-routes.test.ts | 58 +++++++ packages/connections/src/pkce.test.ts | 155 ++++++++++++------ 2 files changed, 165 insertions(+), 48 deletions(-) diff --git a/packages/connections/src/mcp-oauth-routes.test.ts b/packages/connections/src/mcp-oauth-routes.test.ts index a1b576381..69c54733a 100644 --- a/packages/connections/src/mcp-oauth-routes.test.ts +++ b/packages/connections/src/mcp-oauth-routes.test.ts @@ -518,6 +518,64 @@ describe("MCP OAuth connect flow", () => { } }); + test("a replayed callback is rejected as state_expired without a second exchange (one-shot state)", async () => { + const as = startStubAuthorizationServer(); + try { + const hub = fakeHub(); + const routes = createMcpOAuthRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + credentialCipher: createNoopCredentialCipher(), + apiCall: hub.apiCall, + probe: async (): Promise => ({ + ok: true, + toolCount: 2, + }), + }); + const app = mountAs(routes); + + const startResponse = await app.request( + `/exa/start?url=${encodeURIComponent(as.resourcePath)}&name=Exa`, + { redirect: "manual" }, + ); + const cookieHeader = startResponse.headers.get("set-cookie") ?? ""; + const cookie = cookieHeader.split(";")[0] ?? ""; + const authorizeResponse = await fetch( + startResponse.headers.get("location") ?? "", + { redirect: "manual" }, + ); + const callbackUrl = new URL( + authorizeResponse.headers.get("location") ?? "", + ); + const replayableCallback = `${callbackUrl.pathname}${callbackUrl.search}`; + + const firstResponse = await app.request(replayableCallback, { + headers: { cookie }, + redirect: "manual", + }); + expect(firstResponse.headers.get("location") ?? "").toContain( + "outcome=connected", + ); + + // A browser (or an attacker holding a stolen state cookie) + // presenting the exact same callback again: the sealed state was + // burned by the first arrival, so the replay dies at the state + // check — it never reaches the token endpoint a second time. + const replayResponse = await app.request(replayableCallback, { + headers: { cookie }, + redirect: "manual", + }); + expect(replayResponse.status).toBe(302); + expect(replayResponse.headers.get("location") ?? "").toContain( + "code=state_expired", + ); + expect(hub.credentials).toHaveLength(1); + } finally { + as.stop(); + } + }); + test("a callback with no cookie redirects with a state_expired error", async () => { const hub = fakeHub(); const routes = createMcpOAuthRoutes({ diff --git a/packages/connections/src/pkce.test.ts b/packages/connections/src/pkce.test.ts index da1851c1c..8aa57c3c7 100644 --- a/packages/connections/src/pkce.test.ts +++ b/packages/connections/src/pkce.test.ts @@ -6,6 +6,7 @@ // signed/encrypted token rather than a server-side map lookup — never // across a process restart either, as long as the cipher key is stable. import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; import { createEnvKeyCredentialCipher } from "@intx/crypto"; import type { CredentialCipher } from "@intx/types"; import { @@ -48,24 +49,79 @@ describe("generatePKCEPair", () => { }); }); +// The verifier-only payload the fixed-registry OAuth connect flows seal +// (`./oauth-routes.ts`); the MCP connect flow seals a richer shape +// through the same store (see the MCP-shaped test below). +const VerifierPayload = type({ codeVerifier: "string" }); +function parseVerifierPayload(value: unknown) { + const parsed = VerifierPayload(value); + return parsed instanceof type.errors ? undefined : parsed; +} + +function verifierStore(args?: { + cipher?: CredentialCipher; + provider?: string; + ttlMs?: number; + now?: () => number; +}) { + return createConnectStateStore({ + cipher: args?.cipher ?? testCipher(), + provider: args?.provider ?? "openrouter", + parsePayload: parseVerifierPayload, + ...(args?.ttlMs !== undefined ? { ttlMs: args.ttlMs } : {}), + ...(args?.now !== undefined ? { now: args.now } : {}), + }); +} + describe("createConnectStateStore", () => { - test("a state yields its verifier exactly once", async () => { + test("a state yields its payload exactly once", async () => { + const store = verifierStore(); + const state = await store.issue({ + userId: "user_1", + payload: { codeVerifier: "v1" }, + }); + + expect(await store.consume({ state, userId: "user_1" })).toEqual({ + codeVerifier: "v1", + }); + expect(await store.consume({ state, userId: "user_1" })).toBeUndefined(); + }); + + test("carries a caller-shaped payload round trip (the MCP connect shape)", async () => { + const McpPayload = type({ + slug: "string > 0", + url: "string > 0", + returnPath: "string > 0", + oauthState: "string > 0", + "codeVerifier?": "string", + }); const store = createConnectStateStore({ cipher: testCipher(), - provider: "openrouter", + provider: "mcp-oauth", + parsePayload: (value) => { + const parsed = McpPayload(value); + return parsed instanceof type.errors ? undefined : parsed; + }, }); - const state = await store.issue({ userId: "user_1", codeVerifier: "v1" }); + const payload = { + slug: "exa", + url: "https://mcp.example/mcp", + returnPath: "/plugins", + oauthState: "nonce_1", + codeVerifier: "v1", + }; + const state = await store.issue({ userId: "user_1", payload }); - expect(await store.consume({ state, userId: "user_1" })).toBe("v1"); + expect(await store.consume({ state, userId: "user_1" })).toEqual(payload); expect(await store.consume({ state, userId: "user_1" })).toBeUndefined(); }); test("a state issued for one user is worthless to another", async () => { - const store = createConnectStateStore({ - cipher: testCipher(), - provider: "openrouter", + const store = verifierStore(); + const state = await store.issue({ + userId: "user_1", + payload: { codeVerifier: "v1" }, }); - const state = await store.issue({ userId: "user_1", codeVerifier: "v1" }); expect(await store.consume({ state, userId: "user_2" })).toBeUndefined(); // Consumed by the attempt: single-use means gone, not retryable — @@ -74,10 +130,7 @@ describe("createConnectStateStore", () => { }); test("an unknown state yields nothing", async () => { - const store = createConnectStateStore({ - cipher: testCipher(), - provider: "openrouter", - }); + const store = verifierStore(); expect( await store.consume({ state: "never-issued", userId: "user_1" }), ).toBeUndefined(); @@ -85,37 +138,31 @@ describe("createConnectStateStore", () => { test("an expired state yields nothing", async () => { let clock = 0; - const store = createConnectStateStore({ - cipher: testCipher(), - provider: "openrouter", - ttlMs: 1000, - now: () => clock, + const store = verifierStore({ ttlMs: 1000, now: () => clock }); + const state = await store.issue({ + userId: "user_1", + payload: { codeVerifier: "v1" }, }); - const state = await store.issue({ userId: "user_1", codeVerifier: "v1" }); clock = 999; const fresh = await store.issue({ userId: "user_1", - codeVerifier: "v2", + payload: { codeVerifier: "v2" }, }); clock = 1000; expect(await store.consume({ state, userId: "user_1" })).toBeUndefined(); - expect(await store.consume({ state: fresh, userId: "user_1" })).toBe("v2"); + expect(await store.consume({ state: fresh, userId: "user_1" })).toEqual({ + codeVerifier: "v2", + }); }); test("a state minted for one provider is worthless to another's callback", async () => { const cipher = testCipher(); - const openrouterStore = createConnectStateStore({ - cipher, - provider: "openrouter", - }); - const huggingfaceStore = createConnectStateStore({ - cipher, - provider: "huggingface", - }); + const openrouterStore = verifierStore({ cipher, provider: "openrouter" }); + const huggingfaceStore = verifierStore({ cipher, provider: "huggingface" }); const state = await openrouterStore.issue({ userId: "user_1", - codeVerifier: "v1", + payload: { codeVerifier: "v1" }, }); expect( @@ -124,50 +171,62 @@ describe("createConnectStateStore", () => { // The rightful provider can still redeem it — the cross-provider // attempt didn't burn it (it never decrypted under that provider's // AAD in the first place). - expect(await openrouterStore.consume({ state, userId: "user_1" })).toBe( - "v1", - ); + expect(await openrouterStore.consume({ state, userId: "user_1" })).toEqual({ + codeVerifier: "v1", + }); }); - test("survives a restart: a new store built from the same key redeems a state minted before it existed", async () => { - const before = createConnectStateStore({ - cipher: testCipher(), + test("a payload the caller's parser rejects yields nothing", async () => { + const cipher = testCipher(); + const UnrelatedPayload = type({ unrelated: "number" }); + const richStore = createConnectStateStore({ + cipher, provider: "openrouter", + parsePayload: (value) => { + const parsed = UnrelatedPayload(value); + return parsed instanceof type.errors ? undefined : parsed; + }, }); + const store = verifierStore({ cipher }); + const state = await richStore.issue({ + userId: "user_1", + payload: { unrelated: 1 }, + }); + + expect(await store.consume({ state, userId: "user_1" })).toBeUndefined(); + }); + + test("survives a restart: a new store built from the same key redeems a state minted before it existed", async () => { + const before = verifierStore(); const state = await before.issue({ userId: "user_1", - codeVerifier: "v1", + payload: { codeVerifier: "v1" }, }); // Simulates a process restart: a brand-new store, sharing nothing in // memory with `before`, built from a fresh cipher over the same // stable key bytes (as a stable CREDENTIAL_ENCRYPTION_KEY would // produce across a real restart). - const after = createConnectStateStore({ - cipher: testCipher(), - provider: "openrouter", - }); + const after = verifierStore(); - expect(await after.consume({ state, userId: "user_1" })).toBe("v1"); + expect(await after.consume({ state, userId: "user_1" })).toEqual({ + codeVerifier: "v1", + }); // Single-use survives the restart boundary too: replaying the same // state against the post-restart store fails. expect(await after.consume({ state, userId: "user_1" })).toBeUndefined(); }); test("a key rotation invalidates every state minted under the old key", async () => { - const before = createConnectStateStore({ - cipher: testCipher(), - provider: "openrouter", - }); + const before = verifierStore(); const state = await before.issue({ userId: "user_1", - codeVerifier: "v1", + payload: { codeVerifier: "v1" }, }); const rotatedKey = Buffer.alloc(32, 9); - const after = createConnectStateStore({ + const after = verifierStore({ cipher: createEnvKeyCredentialCipher(rotatedKey), - provider: "openrouter", }); expect(await after.consume({ state, userId: "user_1" })).toBeUndefined(); From 37ac2e422026893e1c3eacb6d9629bae4a897dce Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 14:22:03 -0700 Subject: [PATCH 2/2] Unify MCP OAuth connect state onto the shared ConnectStateStore The MCP-server connect flow carried its own sealed-state machinery (TTL, nonce, AAD, cipher calls) beside the store ./pkce.ts already provides. The store now seals a caller-shaped payload inside its hardened envelope (user binding, TTL, single-use replay guard, per-flow AAD), the fixed-registry OAuth routes seal their verifier through the same shape, and the MCP routes consume it one-shot -- a replayed callback now dies at the state check instead of reaching the token endpoint a second time. --- packages/connections/src/mcp-oauth-routes.ts | 77 ++++++++++--------- packages/connections/src/oauth-routes.ts | 36 +++++++-- packages/connections/src/pkce.ts | 79 +++++++++++--------- 3 files changed, 114 insertions(+), 78 deletions(-) diff --git a/packages/connections/src/mcp-oauth-routes.ts b/packages/connections/src/mcp-oauth-routes.ts index 6bedbc051..b4e67ad60 100644 --- a/packages/connections/src/mcp-oauth-routes.ts +++ b/packages/connections/src/mcp-oauth-routes.ts @@ -31,6 +31,7 @@ import { import { createHubAPI } from "@workbench/hub-client"; import type { OAuthClientInformationMixed } from "@modelcontextprotocol/sdk/shared/auth.js"; import { createMcpOAuthProvider, type McpOAuthSession } from "./mcp-oauth"; +import { createConnectStateStore, randomToken } from "./pkce"; import { mcpPresetBySlug } from "./mcp-presets"; import { probeMcpServer, type McpProbeResult } from "./mcp-probe"; import { @@ -51,27 +52,31 @@ const ErrorEnvelope = (code: string, message: string) => ({ const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; +// One provider label for the whole MCP connect surface — the sealed +// state already carries the target slug/url, and the shared store's AAD +// separates this flow's states from the fixed-registry connectors'. +const MCP_OAUTH_STATE_PROVIDER = "mcp-oauth"; + const McpOAuthStatePayload = type({ - principalId: "string > 0", slug: "string > 0", name: "string > 0", url: "string > 0", returnPath: "string > 0", - nonce: "string > 0", - expiresAt: "number", + // The `state` value `/start` sent to the authorization server, which + // the callback requires echoed back — the CSRF binding, distinct from + // the sealed envelope's own single-use replay nonce (the shared + // store's concern). + oauthState: "string > 0", "codeVerifier?": "string", "clientInformation?": "unknown", }); type McpOAuthStatePayload = typeof McpOAuthStatePayload.infer; -function randomNonce(): string { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); -} - -function stateAad(): string { - return JSON.stringify(["mcp-oauth-connect-state"]); +function parseMcpOAuthStatePayload( + value: unknown, +): McpOAuthStatePayload | undefined { + const parsed = McpOAuthStatePayload(value); + return parsed instanceof type.errors ? undefined : parsed; } function cookieName(slug: string): string { @@ -114,6 +119,12 @@ export function createMcpOAuthRoutes( const returnPathAllowlist = deps.returnPathAllowlist ?? DEFAULT_RETURN_PATH_ALLOWLIST; const secureCookies = deps.hubUrl.startsWith("https:"); + const stateStore = createConnectStateStore({ + cipher: deps.credentialCipher, + provider: MCP_OAUTH_STATE_PROVIDER, + parsePayload: parseMcpOAuthStatePayload, + ttlMs: OAUTH_STATE_TTL_MS, + }); function redirectPath( returnPath: string, @@ -158,7 +169,7 @@ export function createMcpOAuthRoutes( // already be on the session by the time `redirectToAuthorization` // fires. The same value is what `/callback` requires the provider's // `?state=` to match. - const nonce = randomNonce(); + const nonce = randomToken(); const session: McpOAuthSession = { state: nonce }; const provider = createMcpOAuthProvider({ callbackUrl, @@ -196,13 +207,11 @@ export function createMcpOAuthRoutes( } const payload: McpOAuthStatePayload = { - principalId: principal.id, slug: target.slug, name: target.name, url: target.url, returnPath, - nonce, - expiresAt: Date.now() + OAUTH_STATE_TTL_MS, + oauthState: nonce, ...(session.codeVerifier !== undefined ? { codeVerifier: session.codeVerifier } : {}), @@ -210,10 +219,10 @@ export function createMcpOAuthRoutes( ? { clientInformation: session.clientInformation } : {}), }; - const sealed = await deps.credentialCipher.encrypt( - JSON.stringify(payload), - stateAad(), - ); + const sealed = await stateStore.issue({ + userId: principal.id, + payload, + }); setCookie(c, cookieName(target.slug), sealed, { httpOnly: true, sameSite: "Lax", @@ -249,16 +258,16 @@ export function createMcpOAuthRoutes( ); } - let payload: McpOAuthStatePayload; - try { - const plaintext = await deps.credentialCipher.decrypt( - sealed, - stateAad(), - ); - const parsed = McpOAuthStatePayload(JSON.parse(plaintext)); - if (parsed instanceof type.errors) throw new Error(parsed.summary); - payload = parsed; - } catch { + // One-shot: the shared store burns the sealed state on this + // attempt (decrypt + AAD + TTL + user binding + replay guard all + // inside `consume`), so a replayed callback dies here without a + // second token exchange. + const principal = c.get("principal"); + const payload = await stateStore.consume({ + state: sealed, + userId: principal.id, + }); + if (payload === undefined) { return c.redirect( redirectPath(fallbackReturn, { mcpOauth: slugParam, @@ -274,14 +283,8 @@ export function createMcpOAuthRoutes( defaultReturnPath, returnPathAllowlist, ); - const principal = c.get("principal"); const code = c.req.query("code"); - if ( - payload.expiresAt <= Date.now() || - payload.principalId !== principal.id || - code === undefined || - code === "" - ) { + if (code === undefined || code === "") { return c.redirect( redirectPath(returnPath, { mcpOauth: payload.slug, @@ -297,7 +300,7 @@ export function createMcpOAuthRoutes( // must echo that exact value back -- never optional-when-absent. // A missing or mismatched `state` means this callback did not // originate from the authorize redirect this session minted. - if (c.req.query("state") !== payload.nonce) { + if (c.req.query("state") !== payload.oauthState) { return c.redirect( redirectPath(returnPath, { mcpOauth: payload.slug, diff --git a/packages/connections/src/oauth-routes.ts b/packages/connections/src/oauth-routes.ts index 5ae50909c..cb71364f9 100644 --- a/packages/connections/src/oauth-routes.ts +++ b/packages/connections/src/oauth-routes.ts @@ -32,10 +32,15 @@ // restart-proofing hardened. import { Hono, type Context } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; +import { type } from "arktype"; import type { AppEnv } from "@intx/hub-api"; import type { CredentialCipher } from "@intx/types"; import { cookiesFromHeader } from "@workbench/hub-client"; -import { createConnectStateStore, generatePKCEPair } from "./pkce"; +import { + createConnectStateStore, + generatePKCEPair, + type ConnectStateStore, +} from "./pkce"; import type { ConnectorDescriptor } from "./descriptor"; import { CONNECTOR_REGISTRY } from "./registry"; @@ -208,6 +213,22 @@ export type CreateOAuthConnectRoutesDeps = { const CONNECT_STATE_TTL_MS = 10 * 60 * 1000; const CONNECT_START_RATE_LIMIT_MS = 10_000; +const VerifierStatePayload = type({ + // Empty for a non-PKCE flow (GitHub's confidential-client web flow + // seals `codeVerifier: ""`), so this must accept the empty string — + // `string > 0` here silently expired every non-PKCE callback + // (CL-6394). + codeVerifier: "string", +}); +type VerifierStatePayload = typeof VerifierStatePayload.infer; + +function parseVerifierStatePayload( + value: unknown, +): VerifierStatePayload | undefined { + const parsed = VerifierStatePayload(value); + return parsed instanceof type.errors ? undefined : parsed; +} + export function createOAuthConnectRoutes( deps: CreateOAuthConnectRoutesDeps, ): Hono { @@ -221,7 +242,7 @@ export function createOAuthConnectRoutes( const stateStores = new Map< string, - ReturnType + ConnectStateStore >(); function stateStoreFor(connectorId: string) { let store = stateStores.get(connectorId); @@ -229,6 +250,7 @@ export function createOAuthConnectRoutes( store = createConnectStateStore({ cipher: deps.credentialCipher, provider: connectorId, + parsePayload: parseVerifierStatePayload, ttlMs: CONNECT_STATE_TTL_MS, }); stateStores.set(connectorId, store); @@ -341,7 +363,7 @@ export function createOAuthConnectRoutes( : undefined; const state = await stateStoreFor(connectorId).issue({ userId: user.id, - codeVerifier: pkce?.codeVerifier ?? "", + payload: { codeVerifier: pkce?.codeVerifier ?? "" }, }); setCookie(c, stateCookieName(connectorId), state, { httpOnly: true, @@ -467,11 +489,11 @@ export function createOAuthConnectRoutes( ); } - const codeVerifier = await stateStoreFor(connectorId).consume({ + const statePayload = await stateStoreFor(connectorId).consume({ state: cookieState, userId: user.id, }); - if (codeVerifier === undefined) { + if (statePayload === undefined) { // Not necessarily a real failure: a browser that fires this exact // callback twice burns the state on its first, successful arrival // and only ever sees this branch on the second. @@ -504,7 +526,9 @@ export function createOAuthConnectRoutes( const exchangeArgs: Parameters[0] = { code, redirectUri: callbackUrl, - ...(descriptor.oauth.usesPKCE ? { codeVerifier } : {}), + ...(descriptor.oauth.usesPKCE + ? { codeVerifier: statePayload.codeVerifier } + : {}), ...(clientId !== undefined ? { clientId } : {}), ...(clientSecret !== undefined ? { clientSecret } : {}), }; diff --git a/packages/connections/src/pkce.ts b/packages/connections/src/pkce.ts index 0c6423138..c5840284a 100644 --- a/packages/connections/src/pkce.ts +++ b/packages/connections/src/pkce.ts @@ -1,13 +1,15 @@ // The RFC 7636 PKCE mechanics and the single-use state store shared by -// every OAuth connect flow this package offers (OpenRouter today, -// Hugging Face alongside it): a verifier/S256-challenge pair, and a -// short-TTL state that keys the server-held verifier to the signed-in -// user who started the flow. Each connect module owns its own TTL and -// endpoints — only the cryptographic and bookkeeping primitives live -// here, so a third connect flow never re-derives them. +// every OAuth connect flow this package offers (OpenRouter and Hugging +// Face through `./oauth-routes.ts`, the MCP-server connect through +// `./mcp-oauth-routes.ts`): a verifier/S256-challenge pair, and a +// short-TTL state that keys a caller-shaped payload to the signed-in +// user who started the flow. Each connect module owns its own TTL, +// payload schema, and endpoints — only the cryptographic and +// bookkeeping primitives live here, so a third connect flow never +// re-derives them. // // The state itself carries no server-side bookkeeping: `issue` seals -// `{ userId, codeVerifier, nonce, expiresAt }` into a single +// `{ userId, nonce, expiresAt, payload }` into a single // AEAD-encrypted token through the caller's `CredentialCipher` (the same // seam `CREDENTIAL_ENCRYPTION_KEY` backs everywhere else a secret is // encrypted at rest — see `apps/hub`'s `credentialCipherFrom`) and hands @@ -34,7 +36,10 @@ function base64url(bytes: Uint8Array): string { .replace(/=+$/, ""); } -function randomToken(): string { +/** 43 base64url chars from 32 random bytes — the entropy grade shared + * by PKCE verifiers, state-envelope nonces, and the OAuth `state` + * values connect flows send to an authorization server. */ +export function randomToken(): string { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64url(bytes); @@ -59,15 +64,11 @@ export async function s256Challenge(codeVerifier: string): Promise { return base64url(new Uint8Array(digest)); } -const ConnectStatePayload = type({ +const ConnectStateEnvelope = type({ userId: "string > 0", - // Empty for a non-PKCE flow (GitHub's confidential-client web flow - // seals `codeVerifier: ""`), so this must accept the empty string — - // `string > 0` here silently expired every non-PKCE callback - // (CL-6394). - codeVerifier: "string", nonce: "string > 0", expiresAt: "number", + payload: "unknown", }); /** Binds a sealed state to the one connect flow it was minted for, so a @@ -78,21 +79,29 @@ function connectStateAad(provider: string): string { return JSON.stringify(["onboarding-connect-state", provider]); } -export type ConnectStateStore = { - issue(args: { userId: string; codeVerifier: string }): Promise; - /** Returns the verifier exactly once; a second consume, a wrong user, - * an expired state, or a state sealed for a different provider all - * come back undefined. */ - consume(args: { state: string; userId: string }): Promise; +export type ConnectStateStore = { + issue(args: { userId: string; payload: Payload }): Promise; + /** Returns the payload exactly once; a second consume, a wrong user, + * an expired state, a state sealed for a different provider, or a + * payload `parsePayload` refuses all come back undefined. */ + consume(args: { + state: string; + userId: string; + }): Promise; }; -export function createConnectStateStore(args: { +export function createConnectStateStore(args: { cipher: CredentialCipher; provider: string; + /** Trust-boundary parse of the decrypted payload — the envelope's + * user/nonce/expiry bookkeeping is validated here, but the payload's + * shape is the connect flow's own contract. Return undefined to + * reject. */ + parsePayload: (value: unknown) => Payload | undefined; ttlMs?: number; now?: () => number; -}): ConnectStateStore { - const { cipher, provider } = args; +}): ConnectStateStore { + const { cipher, provider, parsePayload } = args; const ttlMs = args.ttlMs ?? 10 * 60 * 1000; const now = args.now ?? Date.now; const aad = connectStateAad(provider); @@ -109,39 +118,39 @@ export function createConnectStateStore(args: { } return { - async issue({ userId, codeVerifier }) { - const payload = { + async issue({ userId, payload }) { + const envelope = { userId, - codeVerifier, nonce: randomToken(), expiresAt: now() + ttlMs, + payload, }; - return cipher.encrypt(JSON.stringify(payload), aad); + return cipher.encrypt(JSON.stringify(envelope), aad); }, async consume({ state, userId }) { sweep(); - let payload: typeof ConnectStatePayload.infer; + let envelope: typeof ConnectStateEnvelope.infer; try { const plaintext = await cipher.decrypt(state, aad); - const parsed = ConnectStatePayload(JSON.parse(plaintext)); + const parsed = ConnectStateEnvelope(JSON.parse(plaintext)); if (parsed instanceof type.errors) return undefined; - payload = parsed; + envelope = parsed; } catch { return undefined; } - if (payload.expiresAt <= now()) return undefined; + if (envelope.expiresAt <= now()) return undefined; // Consumed by the attempt regardless of outcome — a wrong-user // redeem burns the state exactly like the rightful user's would, // so a stolen state cookie is worthless to everyone after one try. - if (consumedNonces.has(payload.nonce)) return undefined; - consumedNonces.set(payload.nonce, payload.expiresAt); + if (consumedNonces.has(envelope.nonce)) return undefined; + consumedNonces.set(envelope.nonce, envelope.expiresAt); - if (payload.userId !== userId) return undefined; - return payload.codeVerifier; + if (envelope.userId !== userId) return undefined; + return parsePayload(envelope.payload); }, }; }