diff --git a/docs/architecture.md b/docs/architecture.md index 8ae5c19..f816a14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -154,7 +154,11 @@ The important split: - Heimdall owns provider OAuth, identity linking, entitlement refresh, signed sessions, claims, grants, audit surfaces, and managed provider credential - custody + custody. It authenticates; it does not issue GameCult identity. Bifrost is + the identity provider, and the account id Heimdall mints is an authenticator + handle. Remaining scar: Heimdall still creates that account id on first + login (`store.createAccount`, `src/app.ts`), which the chat work does not + need moved - the host experiment owns its workload, queue, editor, render path, viewer profile model, creator model, or other product-specific machinery - the host experiment also owns which local entity a connection attaches to, @@ -613,8 +617,9 @@ Only the browser-driven OAuth flow: | Path | Why it must be public | |---|---| -| `/v1/oauth/{provider}/start` | the user's browser goes here | -| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable | +| `/v1/oauth/{provider}/start` | the app's server calls it, with its app secret where the profile requires one | +| `/v1/oauth/{provider}/launch` | the user's browser goes here first; it sets the binding cookie and redirects to the provider, once per state | +| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot: it needs the binding cookie and consumes the state | | `/.well-known/jwks.json` | *nothing external consumes it today* — see below | Everything else is internal by the design already written down: diff --git a/docs/service-contract.md b/docs/service-contract.md index df2fcc9..e6ad6c1 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -150,12 +150,29 @@ Response: "appSlug": "repixelizer", "mode": "sign_in", "callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback", - "authorizationUrl": "https://discord.com/oauth2/authorize?...", - "stateToken": "", + "authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=", "stateExpiresAt": "2026-04-26T12:10:00.000Z" } ``` +Send the user's browser to `authorizationUrl` unchanged. It is Heimdall's launch +hop, not the provider's URL. + +### `GET /v1/oauth/{provider}/launch?state=` + +The first thing the user's browser does with a state. Heimdall verifies the +state (signature, issuer, provider, `nbf`/`exp` with 30 s of skew), claims the +state's single launch, sets the binding cookie +`__Host-heimdall-oauth-` (`Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, +`Max-Age` until the state expires; the value is a Heimdall-signed binding for +that state) and answers `302` to the provider's authorize URL, with +`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A second launch of +the same state is `400 state_already_launched` and sets no cookie. The state is +refused with `400 invalid_state`, `state_provider_mismatch` or `state_expired`. + +The binding cookie needs an `https` public base URL (or `localhost`). A browser +must finish the flow in the browser that launched it. + Failure modes: - `503 provider_not_configured` when the provider client id is missing @@ -169,7 +186,17 @@ Failure modes: - `401 app_auth_required` when the request body carries `entitlementPolicy` and the caller did not authenticate as that app via `x-heimdall-app-secret` (`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of - `handoff.kind` or app slug; a caller with no policy to attach needs no secret + `handoff.kind` or app slug; a caller with no policy to attach needs no secret, + unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then + every start for that app, with or without a policy, needs that app's secret. + Bifrost binds browser completion to a nonce that rides readable in the signed + state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose + browser starts are public by design leave the flag unset +- `401 app_auth_required` also when `mode` is `connect` and the body carries a + `connection` (a creator or account target): a connection target is app-backend + authority, so only the authenticated app named by `appSlug` may start it. No + secret, or another app's, is refused before any state is signed. `sign_in` + starts carrying a `connection` are `400 connection_not_allowed_in_sign_in` Launch note: @@ -189,7 +216,8 @@ door into a person who already has one. client): `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`, which is `https://heimdall.gamecult.org/v1/oauth/google/callback` in production. - Scopes `openid email profile`. The authorization request carries `state` - (signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge. The verifier + (signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge, all built at the + launch hop, which is where the browser first meets the provider URL. The verifier and the nonce are derived from the state's `jti` and Heimdall's signing key, so nothing is stored between start and callback. - The callback verifies the `id_token` (RS256 against Google's published @@ -228,7 +256,11 @@ Purpose: Current status: -- validates signed `state` +- validates signed `state` (signature, issuer, provider, expiry), requires the + binding cookie the launch hop set, and consumes the state: a second callback + is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`, + an expired state `400 state_expired`; the binding is checked before the claim, + so a browser without the cookie cannot burn the launching browser's state - returns provider errors cleanly - for Discord, exchanges the authorization code, resolves the provider identity, persists the local account/link/session/audit records, evaluates diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index fcfa4ac..f3c98e9 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -11,7 +11,8 @@ evidence only. ## Owner Map -- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim +- Owner: Heimdall owns provider identity/linking (an authenticator handle, not + GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim issuance, entitlement refresh, managed provider token custody, grants, auth-completion handoffs, refresh sessions, and audit events. - Inputs: provider OAuth callbacks, app profile requests, caller-supplied diff --git a/package.json b/package.json index 36394ef..626ab8a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/heimdall --external:pg-native --banner:js=\"#!/usr/bin/env node\nimport{createRequire}from'node:module';const require=createRequire(import.meta.url);\"" }, "dependencies": { + "@fastify/cookie": "^11.1.3", "@fastify/cors": "^11.2.0", "@msgpack/msgpack": "^3.1.3", "@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 855ad01..c84f4d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@fastify/cookie': + specifier: ^11.1.3 + version: 11.1.5 '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 @@ -229,6 +232,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.1.5': + resolution: {integrity: sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==} + '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} @@ -469,6 +475,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory} engines: {node: '>=18'} @@ -547,6 +557,9 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-plugin@6.0.1: + resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==} + fastify@5.12.7: resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} @@ -1186,6 +1199,11 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.0 + '@fastify/cookie@11.1.5': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.1 + '@fastify/cors@11.2.0': dependencies: fastify-plugin: 5.1.0 @@ -1393,6 +1411,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: dependencies: '@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts @@ -1500,6 +1520,8 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-plugin@6.0.1: {} + fastify@5.12.7: dependencies: '@fastify/ajv-compiler': 4.0.5 diff --git a/src/app-profiles.ts b/src/app-profiles.ts index d4d2d8e..c10f704 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -45,6 +45,17 @@ export interface AppProfile { * plane; absent for apps with no such requirement. */ requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"]; + /** + * The app binds browser completion to its own pending attempt, and the + * binding is the secrecy of the attempt nonce, which rides readable in the + * signed (not encrypted) state's return_to. If anyone could start a flow for + * this app, whoever learned a victim's nonce could mint a completion bound + * to it and sign the victim's browser in as themselves (login CSRF, finding + * known-nonce-login-csrf). So a start for this app is refused unless the + * caller is this app's authenticated caller: only the app can mint its own + * state. Absent for apps whose browser starts are public by design. + */ + startRequiresAppCaller?: boolean; } /** The membership signal shared by every app that gates on GameCult membership. */ @@ -142,6 +153,7 @@ const bifrostProfile: AppProfile = { entitlementSources: ["discord", "patreon"], managedConnectionProviders: [], allowedReturnOrigins: ["https://bifrost.gamecult.org"], + startRequiresAppCaller: true, capabilities: [ { key: "member_access", diff --git a/src/app.ts b/src/app.ts index 2a19125..8ef4547 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,7 @@ import { createHmac, randomUUID } from "node:crypto"; +import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; -import Fastify, { type FastifyError, type FastifyInstance } from "fastify"; +import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply } from "fastify"; import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js"; import { @@ -95,6 +96,7 @@ function buildOAuthStateToken(options: { initiatorAccountId: string | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; + requestedScopes: string[] | undefined; keys: ReturnType; }): { token: string; jti: string; expiresAt: string } { const issuedAt = nowEpochSeconds(); @@ -116,6 +118,7 @@ function buildOAuthStateToken(options: { initiator_account_id: options.initiatorAccountId ?? null, handoff: options.handoff, entitlement_policy: options.entitlementPolicy ?? null, + requested_scopes: options.requestedScopes ?? null, }; const token = signJwt(payload as unknown as Record, options.keys); @@ -155,6 +158,11 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay entitlementPolicy !== null && (entitlementPolicy as Record).kind === "patreon_membership_access" && typeof (entitlementPolicy as Record).requiredTierTitle === "string")); + const requestedScopes = payload.requested_scopes; + const requestedScopesAreValid = + requestedScopes === null || + requestedScopes === undefined || + (Array.isArray(requestedScopes) && requestedScopes.every((scope) => typeof scope === "string")); if ( payload.typ !== "heimdall_oauth_state" || typeof payload.provider !== "string" || @@ -173,7 +181,8 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay typeof payload.exp !== "number" || !handoffIsValid || !entitlementPolicyIsValid || - !(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") + !(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") || + !requestedScopesAreValid ) { return null; } @@ -188,10 +197,99 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay if (!normalizedPayload.entitlement_policy) { normalizedPayload.entitlement_policy = null; } + if (!normalizedPayload.requested_scopes) { + normalizedPayload.requested_scopes = null; + } return normalizedPayload; } +type OAuthStateVerdict = + | { ok: true; payload: OAuthStatePayload } + | { ok: false; error: "invalid_state" | "state_provider_mismatch" | "state_expired" }; + +/** + * The one place a signed OAuth state becomes trusted: signature, shape, issuer, + * audience and provider, and the time window with the same 30 s skew refresh + * tokens use. Every route that turns a state into navigation or a completion + * runs this first; the refusals are fixed codes and never echo the token. + */ +function verifyOAuthState( + config: HeimdallConfig, + keys: ReturnType, + token: string, + provider: Provider +): OAuthStateVerdict { + const verification = verifyJwt(token, keys.publicKey); + if (!verification.valid) { + return { ok: false, error: "invalid_state" }; + } + + const payload = parseOAuthStatePayload(verification.payload); + if (!payload || payload.iss !== config.issuer) { + return { ok: false, error: "invalid_state" }; + } + if (payload.aud !== payload.provider || payload.provider !== provider) { + return { ok: false, error: "state_provider_mismatch" }; + } + + const now = nowEpochSeconds(); + if (payload.nbf > now + 30 || payload.exp <= now - 30) { + return { ok: false, error: "state_expired" }; + } + + return { ok: true, payload }; +} + +const oauthBindingType = "heimdall_oauth_binding"; + +function oauthBindingCookieName(jti: string): string { + return `__Host-heimdall-oauth-${jti}`; +} + +/** The signed claim that this browser launched this state: it is the cookie's value, and needs no store read to check. */ +function signOAuthBinding( + config: HeimdallConfig, + keys: ReturnType, + state: OAuthStatePayload +): string { + return signJwt( + { typ: oauthBindingType, iss: config.issuer, jti: state.jti, iat: nowEpochSeconds(), exp: state.exp }, + keys + ); +} + +function oauthBindingHolds( + config: HeimdallConfig, + keys: ReturnType, + token: string | undefined, + state: OAuthStatePayload +): boolean { + if (!token) { + return false; + } + const verification = verifyJwt(token, keys.publicKey); + if (!verification.valid) { + return false; + } + const { typ, iss, jti, exp } = verification.payload; + return typ === oauthBindingType && iss === config.issuer && jti === state.jti && typeof exp === "number" && exp > nowEpochSeconds(); +} + +function setOAuthBindingCookie(reply: FastifyReply, state: OAuthStatePayload, value: string, maxAgeSeconds: number): void { + reply.setCookie(oauthBindingCookieName(state.jti), value, { + path: "/", + secure: true, + httpOnly: true, + sameSite: "lax", + maxAge: Math.max(0, maxAgeSeconds), + }); +} + +function oauthCallbackUrl(config: HeimdallConfig, provider: Provider): string { + return `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; +} + function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] { return grants.map((grant) => grantFact(grant.capability)); } @@ -525,8 +623,8 @@ export async function startOAuthFlow( } // A connection target is app-backend authority: it rides only on connect - // and link starts, which are the flows gated by x-heimdall-app-secret. - // A sign_in start is open to any browser and must not carry one. + // starts that carry the app's own secret (gated below), and never on a + // sign_in start, which is open to any browser. if (input.mode === "sign_in" && input.connection !== undefined) { return { statusCode: 400, @@ -629,7 +727,7 @@ export async function startOAuthFlow( if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") { handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId }; } - if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { + if ((entitlementPolicy || profile.startRequiresAppCaller || (input.mode === "connect" && input.connection !== undefined)) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { return { statusCode: 401, body: { error: "app_auth_required" } }; } @@ -641,7 +739,6 @@ export async function startOAuthFlow( }; } - const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; const state = buildOAuthStateToken({ config, provider, @@ -652,27 +749,18 @@ export async function startOAuthFlow( initiatorAccountId: options?.linkAccountId ?? input.initiatingAccountId, handoff, entitlementPolicy, + requestedScopes: input.requestedScopes, keys, }); - const oidc = descriptor.oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined; - return { statusCode: 201, body: { provider, appSlug: profile.slug, mode: input.mode, - callbackUrl, - authorizationUrl: buildAuthorizationUrl({ - provider, - clientId: providerConfig.clientId, - redirectUri: callbackUrl, - state: state.token, - requestedScopes: input.requestedScopes, - ...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}), - }), + callbackUrl: oauthCallbackUrl(config, provider), + authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`, handoff, - stateToken: state.token, stateExpiresAt: state.expiresAt, }, }; @@ -859,6 +947,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise ({ ok: true, @@ -1005,6 +1094,74 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + "/v1/oauth/:provider/launch", + { + schema: { + params: { + type: "object", + required: ["provider"], + additionalProperties: false, + properties: { + provider: { type: "string", enum: [...providers] }, + }, + }, + querystring: { + type: "object", + required: ["state"], + additionalProperties: false, + properties: { + state: { type: "string", minLength: 1 }, + }, + }, + }, + }, + async (request, reply) => { + const verified = verifyOAuthState(config, keys, request.query.state, request.params.provider); + if (!verified.ok) { + reply.code(400); + return { error: verified.error }; + } + const state = verified.payload; + const clientId = config.providers[state.provider].clientId; + if (!clientId) { + reply.code(503); + return { error: "provider_not_configured", provider: state.provider, expectedEnv: providerExpectedEnv(state.provider) }; + } + + const first = await store.claimOAuthStateUse({ + jti: state.jti, + phase: "launch", + appSlug: state.app_slug, + usedAt: new Date().toISOString(), + expiresAt: new Date(state.exp * 1000).toISOString(), + }); + if (!first) { + reply.code(400); + return { error: "state_already_launched" }; + } + + const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined; + setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state), state.exp - nowEpochSeconds()); + reply.header("Cache-Control", "no-store"); + reply.header("Referrer-Policy", "no-referrer"); + return reply.redirect( + buildAuthorizationUrl({ + provider: state.provider, + clientId, + redirectUri: oauthCallbackUrl(config, state.provider), + state: request.query.state, + requestedScopes: state.requested_scopes ?? undefined, + ...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}), + }), + 302 + ); + } + ); + app.get<{ Params: { provider: Provider }; Querystring: { code?: string; state?: string; error?: string; error_description?: string }; @@ -1038,16 +1195,29 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { diff --git a/src/contracts.ts b/src/contracts.ts index 8639fa4..0ee7c96 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -97,6 +97,8 @@ export interface OAuthStatePayload { initiator_account_id: string | null; handoff: OAuthHandoff | null; entitlement_policy: OAuthEntitlementPolicy | null; + /** The start's requestedScopes, carried so the launch hop can build the provider URL from the signed state alone. */ + requested_scopes: string[] | null; } export interface LinkedIdentityInput { diff --git a/src/private-command-plane.ts b/src/private-command-plane.ts index 6d3025b..8e9fb8d 100644 --- a/src/private-command-plane.ts +++ b/src/private-command-plane.ts @@ -305,7 +305,6 @@ async function beginAuth( throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied.")); } const authorizationUrl = String(startPayload.authorizationUrl ?? ""); - const authorizationOrigin = new URL(authorizationUrl).origin; return { status: "accepted", payloadSchema: "heimdall.auth_begin_receipt.v1", @@ -316,7 +315,7 @@ async function beginAuth( expiresAt: attempt.expiresAt, navigation: { url: authorizationUrl, - allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin], + allowedOrigins: [new URL(config.publicBaseUrl).origin], }, }, }; diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index cc97b15..2943e99 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -3,6 +3,7 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra import { type CreateAccountInput, type CreateAuthAttemptInput, + type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, type CreatePendingLinkInput, type StoredPendingLink, @@ -51,6 +52,8 @@ export class InMemoryStore implements HeimdallStore { private readonly authCompletions = new Map(); private readonly pendingLinks = new Map(); private readonly authCompletionsByAttempt = new Map(); + /** `${jti}:${phase}` -> the state's expiresAt. */ + private readonly oauthStateUses = new Map(); private readonly entitlementSnapshots = new Map(); private readonly auditEvents = new Map(); @@ -448,6 +451,20 @@ export class InMemoryStore implements HeimdallStore { return clone(link); } + async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise { + for (const [key, expiresAt] of this.oauthStateUses) { + if (expiresAt <= input.usedAt) { + this.oauthStateUses.delete(key); + } + } + const key = `${input.jti}:${input.phase}`; + if (this.oauthStateUses.has(key)) { + return false; + } + this.oauthStateUses.set(key, input.expiresAt); + return true; + } + async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise { const key = `${input.accountId}:${input.provider}:${input.scope}`; this.entitlementSnapshots.set(key, clone(input)); diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 7d75d47..e3a1c4d 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -5,6 +5,7 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f import { type CreateAccountInput, type CreateAuthAttemptInput, + type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, type CreatePendingLinkInput, type StoredPendingLink, @@ -417,6 +418,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly { + // No conflict target and no RETURNING: both need SELECT, and the role has INSERT only. + const result = await this.pool.query( + ` + INSERT INTO oauth_state_uses (jti, phase, app_slug, used_at, expires_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING + `, + [input.jti, input.phase, input.appSlug, input.usedAt, input.expiresAt] + ); + return result.rowCount === 1; + } + async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise { await this.pool.query( ` diff --git a/src/store/schema.ts b/src/store/schema.ts index c3da42e..54f1fca 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -136,6 +136,20 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL; +-- One use per (state jti, phase): a signed OAuth state launches once and +-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING +-- reports every later claim as no row. The store deletes nothing, so rows stay +-- like consumed auth_completions; after expires_at a state is refused before +-- any claim, so a row is never consulted again. +CREATE TABLE IF NOT EXISTS oauth_state_uses ( + jti TEXT NOT NULL, + phase TEXT NOT NULL, + app_slug TEXT NOT NULL, + used_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (jti, phase) +); + CREATE TABLE IF NOT EXISTS capability_grants ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, diff --git a/src/store/types.ts b/src/store/types.ts index 2e0526d..982bccc 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -167,6 +167,14 @@ export interface StoredAuthCompletion { payloadJson: Record; } +export interface ClaimOAuthStateUseInput { + jti: string; + phase: "launch" | "callback"; + appSlug: AppSlug; + usedAt: string; + expiresAt: string; +} + export interface CreateAuthCompletionInput { /** * Correlation handle only (see StoredAuthCompletion.attemptId). The store @@ -306,5 +314,15 @@ export interface HeimdallStore { */ consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise; upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise; + /** + * Claim one use of a signed OAuth state: true only for the first claim of + * (jti, phase), false for every later one. Without it a state is a bearer + * token any number of browsers can complete, so an attacker who learns a + * victim's state completes it as themselves and hands the victim a code + * for the attacker's account. `expiresAt` is the state's own expiry; the + * state is refused at that time before any claim, so the record is dead + * after it and a store may drop it. + */ + claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise; createAuditEvent(input: CreateAuditEventInput): Promise; } diff --git a/tests/access-plugin.test.ts b/tests/access-plugin.test.ts index ee902bd..6da0568 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -8,6 +8,7 @@ import { entitlementFacts } from "../src/facts.js"; import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js"; import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js"; import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { launchState } from "./oauth-flow.js"; const secret = "ghostlight-private-command-secret"; const resources: Array<{ close(): Promise }> = []; @@ -107,9 +108,12 @@ describe("Heimdall Eve access plugin", () => { const begin = openPrivateEnvelope(beginWire, secret); expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1"); const handle = String(begin.handle); - const navigation = begin.navigation as { url: string }; + const navigation = begin.navigation as { url: string; allowedOrigins: string[] }; const state = new URL(navigation.url).searchParams.get("state"); expect(state).toBeTruthy(); + // The browser goes to Heimdall's launch hop first, and only Heimdall's origin is allowed. + expect(navigation.url.startsWith("https://heimdall.gamecult.org/v1/oauth/discord/launch?state=")).toBe(true); + expect(navigation.allowedOrigins).toEqual(["https://heimdall.gamecult.org"]); const pendingEnvelope = sealPrivateEnvelope({ appSlug: "ghostlight", @@ -124,9 +128,11 @@ describe("Heimdall Eve access plugin", () => { const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret); expect(pending).toMatchObject({ status: "pending", handle }); + const { cookie } = await launchState(app, state ?? ""); const callback = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`, + headers: { cookie }, }); expect(callback.statusCode, callback.body).toBe(201); // The completion code is always minted by the store now, never the diff --git a/tests/app.test.ts b/tests/app.test.ts index 2b2e590..67d4183 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { inspect } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; -import { buildApp, errorIdentity } from "../src/app.js"; +import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js"; +import { resolveAppCaller } from "../src/app-caller.js"; import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js"; @@ -12,97 +13,7 @@ import { entitlementFacts, identityFacts } from "../src/facts.js"; import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; import { InMemoryStore } from "../src/store/index.js"; - -function createTestConfig(): HeimdallConfig { - return { - serviceName: "heimdall", - host: "127.0.0.1", - port: 4100, - workspaceRoot: "E:/Projects/Heimdall", - dataRoot: "E:/Projects/Heimdall/.heimdall-data", - cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc", - publicBaseUrl: "https://heimdall.gamecult.org", - issuer: "https://heimdall.gamecult.org", - daemonId: "yggdrasil-heimdall", - idunnRudpHealth: undefined, - idunnHealthContract: "heimdall.cultnet-rudp-provider-health", - providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc", - sessionTtlSeconds: 3600, - refreshTtlSeconds: 60 * 60 * 24 * 30, - stateTtlSeconds: 600, - completionTtlSeconds: 300, - bootstrapSigningPrivateKeyOnMissing: false, - tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), - appSharedSecrets: { - streampixels: "streampixels-secret", - repixelizer: "repixelizer-secret", - bifrost: "bifrost-secret", - }, - appBackendCallbacks: { - bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"], - repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"], - }, - storage: { - backend: "memory", - applySchemaOnStartup: true, - }, - providers: { - discord: { clientId: "discord-client", clientSecret: "discord-secret" }, - patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" }, - github: { clientId: "github-client", clientSecret: "github-secret" }, - twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" }, - youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, - spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" }, - joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" }, - google: { clientId: "google-client", clientSecret: "google-secret" }, - }, - }; -} - -function createMockDiscordRuntime(): OAuthProviderRuntime { - return { - async exchangeAuthorizationCode() { - return { - accessToken: "discord-access-token", - refreshToken: "discord-refresh-token", - tokenType: "Bearer", - scope: ["identify", "guilds.members.read"], - expiresAt: "2026-04-26T13:00:00.000Z", - raw: { source: "test" }, - }; - }, - async resolveIdentity() { - return { - provider: "discord", - providerUserId: "discord-user-123", - username: "meta", - displayName: "Meta", - primaryEmail: "meta@gamecult.org", - profile: { id: "discord-user-123", username: "meta" }, - }; - }, - async evaluateEntitlements({ callback }) { - return { - facts: [entitlementFacts.appAccess], - snapshots: [ - { - accountId: callback.accountId, - provider: "discord", - scope: "repixelizer:discord_role_access:gamecult-guild", - evaluatedAt: "2026-04-26T12:00:00.000Z", - isAllowed: true, - reasonCode: "matched_role", - reasonDetail: "Matched Repixelizer access role.", - rawSummaryJson: { - guildId: "gamecult-guild", - matchedRoles: ["role-repixelizer"], - }, - }, - ], - }; - }, - }; -} +import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js"; const apps: Awaited>[] = []; const originalFetch = globalThis.fetch; @@ -135,7 +46,7 @@ function getHeimdallContext(app: FastifyInstance) { }).heimdallContext; } -async function startDiscordSignIn(app: FastifyInstance): Promise { +async function startDiscordSignIn(app: FastifyInstance): Promise { const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", @@ -147,7 +58,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise { }); expect(response.statusCode).toBe(201); - return response.json().stateToken as string; + return launchFlow(app, response.json()); } afterEach(async () => { @@ -208,9 +119,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -250,6 +161,170 @@ describe("Heimdall service", () => { expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" })); }); + describe("apps that bind browser completion to their own attempt", () => { + // Bifrost's browser binding is the secrecy of its attempt nonce, which + // travels readable in the signed state. Only Bifrost may mint a Bifrost + // state, so a known nonce cannot carry a stranger's completion + // (finding known-nonce-login-csrf). + const victimAttempt = "VICTIM-ATTEMPT-NONCE"; + const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`; + const bifrostStart = (handoff?: { kind: "browser_completion" }) => ({ + appSlug: "bifrost" as const, + mode: "sign_in" as const, + returnTo: bifrostReturn, + ...(handoff ? { handoff } : {}), + }); + + it("refuses an unauthenticated Bifrost start for browser_completion and for an omitted handoff", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + for (const payload of [bifrostStart({ kind: "browser_completion" }), bifrostStart()]) { + const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload }); + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: "app_auth_required" }); + } + }); + + it("the attack fails: a stranger cannot obtain a Bifrost state carrying a chosen return_to nonce", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + payload: bifrostStart({ kind: "browser_completion" }), + }); + expect(response.statusCode).toBe(401); + expect(response.body).not.toContain(victimAttempt); + expect(response.body).not.toContain("stateToken"); + expect(response.body).not.toContain("authorizationUrl"); + }); + + it("still starts for Bifrost's own app secret, with the requested return_to in the state", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + headers: { "x-heimdall-app-secret": "bifrost-secret" }, + payload: bifrostStart({ kind: "browser_completion" }), + }); + expect(response.statusCode).toBe(201); + const verified = verifyJwt(stateOf(response.json()), getPublicKey(app)); + expect(verified.valid).toBe(true); + if (verified.valid) { + expect(verified.payload).toEqual(expect.objectContaining({ app_slug: "bifrost", return_to: bifrostReturn })); + } + }); + + it("refuses another app's credentials on a Bifrost start", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + for (const secret of ["streampixels-secret", "repixelizer-secret"]) { + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + headers: { "x-heimdall-app-secret": secret }, + payload: bifrostStart({ kind: "browser_completion" }), + }); + expect(response.statusCode, secret).toBe(401); + expect(response.json()).toEqual({ error: "app_auth_required" }); + } + }); + + it("refuses another app's authenticated caller on a direct Bifrost start, with no policy attached", async () => { + // Over HTTP the caller is resolved against the body's slug, so a foreign + // secret never becomes a caller at all. The command plane and direct + // callers hand startOAuthFlow a caller of their own, so the slug match is + // decided here too. + const config = createTestConfig(); + const app = await buildApp({ config }); + apps.push(app); + const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" }); + const own = resolveAppCaller(config, "bifrost", { "x-heimdall-app-secret": "bifrost-secret" }); + + const refused = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", foreign, bifrostStart({ kind: "browser_completion" })); + expect(refused.statusCode).toBe(401); + const allowed = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", own, bifrostStart({ kind: "browser_completion" })); + expect(allowed.statusCode).toBe(201); + }); + + it("gates every mode, provider and handoff kind Bifrost admits, not one spelling of them", async () => { + // Hand mutants narrowing the gate to browser_completion or to sign_in + // survived the single-case tests (finding gate-tests-narrow). + const store = new InMemoryStore(); + const now = new Date().toISOString(); + const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now }); + const app = await buildApp({ config: createTestConfig(), store }); + apps.push(app); + const handoffs = [ + undefined, + { kind: "browser_completion" }, + { + kind: "backend_callback", + attemptId: "bifrost-attempt-1", + callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback", + }, + ]; + + let cases = 0; + for (const mode of ["sign_in", "link", "connect"] as const) { + for (const provider of ["discord", "patreon"] as const) { + for (const handoff of handoffs) { + const label = `${mode} ${provider} ${handoff?.kind ?? "omitted"}`; + const payload = { + appSlug: "bifrost", + mode, + returnTo: bifrostReturn, + ...(mode === "link" ? { initiatingAccountId: initiator.id } : {}), + ...(handoff ? { handoff } : {}), + }; + const refused = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload }); + expect(refused.statusCode, label).toBe(401); + expect(refused.json(), label).toEqual({ error: "app_auth_required" }); + + const allowed = await app.inject({ + method: "POST", + url: `/v1/oauth/${provider}/start`, + headers: { "x-heimdall-app-secret": "bifrost-secret" }, + payload, + }); + expect(allowed.statusCode, label).toBe(201); + expect(allowed.json().authorizationUrl, label).toContain(`/v1/oauth/${provider}/launch?state=`); + cases += 1; + } + } + } + expect(cases).toBe(18); + }); + + it("leaves public-by-design apps unchanged: unauthenticated browser_completion starts still succeed", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + for (const [appSlug, provider, returnTo] of [ + ["repixelizer", "discord", "https://repixelizer.gamecult.org/app/"], + ["streampixels", "twitch", "https://streampixels.gamecult.org/app/"], + ] as const) { + const response = await app.inject({ + method: "POST", + url: `/v1/oauth/${provider}/start`, + payload: { appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } }, + }); + expect(response.statusCode, appSlug).toBe(201); + } + }); + + it("flags only Bifrost's profile", () => { + expect( + Object.values(builtInAppProfiles).filter((profile) => profile.startRequiresAppCaller).map((profile) => profile.slug) + ).toEqual(["bifrost"]); + }); + }); + it("accepts caller-owned entitlement policy for a browser completion handoff when the caller is trusted", async () => { // The other half of the same pin: a caller holding the secret may attach // policy even without a backend_callback handoff, because identity no @@ -303,9 +378,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -349,11 +424,11 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = new URL(payload.authorizationUrl as string); + const authorizationUrl = await providerRedirect(app, payload); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -397,9 +472,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -443,10 +518,10 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = new URL(payload.authorizationUrl as string); + const authorizationUrl = await providerRedirect(app, payload); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -474,10 +549,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(response.statusCode).toBe(201); @@ -690,10 +766,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const completionCode = callbackResponse.json().completion.code as string; @@ -732,10 +809,11 @@ describe("Heimdall service", () => { const auditEvents = () => [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string }; const created = auditEvents().filter((event) => event.eventType === "auth_completion_created"); @@ -816,10 +894,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callbackResponse.statusCode).toBe(201); const refreshToken = callbackResponse.json().refreshToken as string; @@ -863,11 +942,12 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, headers: { + cookie: flow.cookie, accept: "text/html", }, }); @@ -913,12 +993,13 @@ describe("Heimdall service", () => { }, }); expect(startResponse.statusCode).toBe(201); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, headers: { + cookie: flow.cookie, accept: "text/html", }, }); @@ -1124,11 +1205,12 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = callbackResponse.json().account.id as string; @@ -1211,11 +1293,12 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = callbackResponse.json().account.id as string; @@ -1277,10 +1360,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callbackResponse.statusCode).toBe(201); const evaluationsAtSignIn = evaluations; @@ -1362,11 +1446,31 @@ describe("Heimdall service", () => { const start = await app.inject({ method: "POST", url: "/v1/oauth/joystick/start", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra }, }); return start; } + it("gates a connect start that names a creator on the app's own secret: none and a foreign app's are 401, its own is 201", async () => { + const config = createTestConfig(); + const app = await buildApp({ config }); + apps.push(app); + const body = { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", connection: creator }; + const start = (headers: Record) => app.inject({ method: "POST", url: "/v1/oauth/joystick/start", headers, payload: body }); + + for (const headers of [{}, { "x-heimdall-app-secret": "repixelizer-secret" }, { "x-heimdall-app-secret": "wrong" }]) { + const refused = await start(headers); + expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]); + } + expect((await start({ "x-heimdall-app-secret": "streampixels-secret" })).statusCode).toBe(201); + + // A direct caller (the command plane hands startOAuthFlow its own) is held to the slug too. + const input = { appSlug: "streampixels" as const, mode: "connect" as const, returnTo: body.returnTo, connection: { kind: "creator" as const, targetId: "creator-1" } }; + const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" }); + expect((await startOAuthFlow(getHeimdallRuntimeContext(app), "joystick", foreign, input)).statusCode).toBe(401); + }); + it.each([ ["no connection", {}, "creator_connection_required"], ["an account connection", { connection: { kind: "account", targetId: "victim-1" } }, "creator_connection_required"], @@ -1378,7 +1482,7 @@ describe("Heimdall service", () => { expect(start.statusCode).toBe(400); expect(start.json().error).toBe(error); expect(start.json().authorizationUrl).toBeUndefined(); - expect(start.json().stateToken).toBeUndefined(); + expect(start.json()).not.toHaveProperty("stateToken"); }); it.each([ @@ -1394,14 +1498,15 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; - const verified = verifyJwt(stateToken, keys.publicKey); + const flow = await launchFlow(app, (await connect(app)).json()); + const verified = verifyJwt(flow.state, keys.publicKey); expect(verified.valid).toBe(true); const forged = signJwt({ ...(verified as { payload: Record }).payload, connection }, keys); const callback = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(calls).toEqual([]); @@ -1433,9 +1538,11 @@ describe("Heimdall service", () => { handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl }, }); expect(start.statusCode).toBe(201); + const flow = await launchFlow(app, start.json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeLessThan(300); @@ -1454,7 +1561,7 @@ describe("Heimdall service", () => { apps.push(app); const start = await connect(app); expect(start.statusCode).toBe(201); - const url = new URL(start.json().authorizationUrl as string); + const url = await providerRedirect(app, start.json()); expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize"); expect(url.searchParams.get("client_id")).toBe("joystick-client"); expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read"); @@ -1468,10 +1575,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "deru-chan", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const connected = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = connected.json().account.id as string; const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); @@ -1519,14 +1627,15 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; - const verified = verifyJwt(stateToken, keys.publicKey); + const flow = await launchFlow(app, (await connect(app)).json()); + const verified = verifyJwt(flow.state, keys.publicKey); expect(verified.valid).toBe(true); const forged = signJwt({ ...(verified as { payload: Record }).payload, mode }, keys); const callback = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(callback.body).not.toContain("viewer_access"); @@ -1543,11 +1652,12 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeLessThan(300); @@ -1576,10 +1686,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "chan-9", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick"); expect(row?.displayName).toBe("deru"); @@ -1590,10 +1701,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull(); @@ -1788,11 +1900,11 @@ describe("error answers never echo an input", () => { const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, - headers: { accept: "text/html" }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie, accept: "text/html" }, }); expect(response.headers["content-type"]).toContain("text/html"); @@ -1824,9 +1936,11 @@ describe("error answers never echo an input", () => { }, }); expect(start.statusCode).toBe(201); + const flow = await launchFlow(app, start.json()); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(response.statusCode).toBe(502); @@ -1844,16 +1958,16 @@ describe("the relayed provider error", () => { const injected = `"\\\u0007é${"x".repeat(300)}`; const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/; - function providerErrorUrl(stateToken: string): string { - const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected }); - return `/v1/oauth/discord/callback?${query.toString()}`; + function providerErrorRequest(flow: LaunchedFlow) { + const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected }); + return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } }; } it("is printable and bounded in the JSON answer", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) }); + const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app))); expect(response.statusCode).toBe(400); const { providerError, providerErrorDescription } = response.json() as Record; @@ -1867,11 +1981,8 @@ describe("the relayed provider error", () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const response = await app.inject({ - method: "GET", - url: providerErrorUrl(await startDiscordSignIn(app)), - headers: { accept: "text/html" }, - }); + const request = providerErrorRequest(await startDiscordSignIn(app)); + const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } }); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body.match(/