diff --git a/README.md b/README.md index b86dbdc..a34dc85 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,6 @@ The point is painfully simple: become "everything phones home for every request" - keep app-domain data separate so "shared auth" does not become "shared swamp" -## Front Door - -- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally. -- **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps. -- **Entry points:** - - `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows. - - `src/verifier.ts`: the local verifier helper an app backend imports to validate Heimdall JWTs without phoning home. - - `src/odin-publication.ts` and `plugins/gamecult.heimdall.access`: the redacted Odin/Eve provider advertisement and the `gamecult.heimdall.access` plugin (`docs/eve-access-plugin.md`). - - `docs/architecture.md`, `docs/service-contract.md`, `docs/verse-service-contract.md`, `docs/app-profiles/`: the contracts. - - `pnpm install`, `pnpm dev`, `pnpm test` (see Dev Commands). -- **Live:** a stateful first slice: signing and JWKS, OAuth state for six providers, in-memory and Postgres stores, a real Discord callback path for the Repixelizer flow, sealed provider tokens, the loopback CultNet operation boundary, Idunn health publication, and the deployed Ghostlight Eve host consuming the discovered boundary. -- **Planned:** the two-account and eight-account Ghostlight human canaries, refresh and revocation flows, admin surfaces, and broader inspection projections beyond the redacted access boundary. - ## Current Shape This repo now has a stateful first slice instead of a decorative skeleton. 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 1567ae5..13b1374 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -149,12 +149,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 @@ -168,7 +185,12 @@ 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 Launch note: @@ -189,7 +211,9 @@ 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` - 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..4eb8e55 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,13 @@ "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", "cultmesh-ts": "file:vendor/CultLib/packages/cultmesh-ts", "cultnet-ts": "file:vendor/CultLib/packages/cultnet-ts", - "fastify": "^5.12.6", + "fastify": "^5.8.5", "pg": "^8.20.0", "zod": "^3.25.76" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 855ad01..eb1d1db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@fastify/cookie': + specifier: ^11.1.3 + version: 11.1.3 '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 @@ -28,8 +31,8 @@ importers: specifier: file:vendor/CultLib/packages/cultnet-ts version: file:vendor/CultLib/packages/cultnet-ts fastify: - specifier: ^5.12.6 - version: 5.12.7 + specifier: ^5.8.5 + version: 5.8.5 pg: specifier: ^8.20.0 version: 8.20.0 @@ -229,6 +232,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.1.3': + resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==} + '@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'} @@ -532,23 +542,20 @@ packages: fast-json-stringify@6.3.0: resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} - fast-json-stringify@7.1.3: - resolution: {integrity: sha512-5TDM1yOF/BDFOclKz2G6rs1RsIl5HXMCrg/npmdNIPNQCezshR5kIHjhwBNuMY67jhkTUAm9DguL8xkL/x0Wtw==} - fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fast-uri@4.2.3: - resolution: {integrity: sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==} - fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - fastify@5.12.7: - resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} + fastify-plugin@6.0.1: + resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -562,8 +569,8 @@ packages: picomatch: optional: true - find-my-way@9.10.0: - resolution: {integrity: sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==} + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} engines: {node: '>=20'} fsevents@2.3.3: @@ -859,8 +866,8 @@ packages: process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - process-warning@5.1.1: - resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1186,6 +1193,11 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.0 + '@fastify/cookie@11.1.3': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.1 + '@fastify/cors@11.2.0': dependencies: fastify-plugin: 5.1.0 @@ -1393,6 +1405,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 @@ -1481,26 +1495,17 @@ snapshots: json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 - fast-json-stringify@7.1.3: - dependencies: - '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - fast-uri: 4.2.3 - json-schema-ref-resolver: 3.0.0 - rfdc: 1.4.1 - fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 fast-uri@3.1.0: {} - fast-uri@4.2.3: {} - fastify-plugin@5.1.0: {} - fastify@5.12.7: + fastify-plugin@6.0.1: {} + + fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -1508,11 +1513,11 @@ snapshots: '@fastify/proxy-addr': 5.1.0 abstract-logging: 2.0.1 avvio: 9.2.0 - fast-json-stringify: 7.1.3 - find-my-way: 9.10.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 light-my-request: 6.6.0 pino: 10.3.1 - process-warning: 5.1.1 + process-warning: 5.0.0 rfdc: 1.4.1 secure-json-parse: 4.1.0 semver: 7.7.4 @@ -1526,7 +1531,7 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - find-my-way@9.10.0: + find-my-way@9.5.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 @@ -1750,7 +1755,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pino-std-serializers: 7.1.0 - process-warning: 5.1.1 + process-warning: 5.0.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 @@ -1775,7 +1780,7 @@ snapshots: process-warning@4.0.1: {} - process-warning@5.1.1: {} + process-warning@5.0.0: {} quick-format-unescaped@4.0.4: {} diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 3489a48..70a9b34 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 40ceb20..96f107b 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 { @@ -93,6 +94,7 @@ function buildOAuthStateToken(options: { connection: OAuthConnectionBinding | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; + requestedScopes: string[] | undefined; keys: ReturnType; }): { token: string; expiresAt: string } { const issuedAt = nowEpochSeconds(); @@ -113,6 +115,7 @@ function buildOAuthStateToken(options: { connection: options.connection ?? null, handoff: options.handoff, entitlement_policy: options.entitlementPolicy ?? null, + requested_scopes: options.requestedScopes ?? null, }; const token = signJwt(payload as unknown as Record, options.keys); @@ -151,6 +154,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" || @@ -168,7 +176,8 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay typeof payload.nbf !== "number" || typeof payload.exp !== "number" || !handoffIsValid || - !entitlementPolicyIsValid + !entitlementPolicyIsValid || + !requestedScopesAreValid ) { return null; } @@ -180,10 +189,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)); } @@ -554,7 +652,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) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { return { statusCode: 401, body: { error: "app_auth_required" } }; } @@ -566,7 +664,6 @@ export async function startOAuthFlow( }; } - const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; const state = buildOAuthStateToken({ config, provider, @@ -576,6 +673,7 @@ export async function startOAuthFlow( connection: input.connection, handoff, entitlementPolicy, + requestedScopes: input.requestedScopes, keys, }); @@ -585,16 +683,9 @@ export async function startOAuthFlow( provider, appSlug: profile.slug, mode: input.mode, - callbackUrl, - authorizationUrl: buildAuthorizationUrl({ - provider, - clientId: providerConfig.clientId, - redirectUri: callbackUrl, - state: state.token, - requestedScopes: input.requestedScopes, - }), + callbackUrl: oauthCallbackUrl(config, provider), + authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`, handoff, - stateToken: state.token, stateExpiresAt: state.expiresAt, }, }; @@ -738,6 +829,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise ({ ok: true, @@ -872,6 +964,72 @@ 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" }; + } + + 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, + }), + 302 + ); + } + ); + app.get<{ Params: { provider: Provider }; Querystring: { code?: string; state?: string; error?: string; error_description?: string }; @@ -905,16 +1063,29 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { diff --git a/src/contracts.ts b/src/contracts.ts index d6cab2a..daa559a 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -94,6 +94,8 @@ export interface OAuthStatePayload { connection: OAuthConnectionBinding | 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/index.ts b/src/index.ts index 0bc13c3..4c89a70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js"; import { buildApp } from "./app.js"; import { loadConfig } from "./config.js"; @@ -6,16 +7,14 @@ import { publishHeimdallOdinState } from "./odin-publication.js"; import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js"; import type { CultNetOperationServer } from "cultnet-ts"; import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js"; -import { openPresenceAuthority } from "./runtime-presence.js"; +import { + buildPresenceStatement, + nextPublisherSequence, + readRuntimeBundleFacts, + type RuntimeBundleFacts, +} from "./runtime-presence.js"; const config = loadConfig(); -// Under Idunn the launch's presence keys, bundle facts and publisher sequence -// open here, before anything listens. A launch-contract fault throws, uncaught, -// and the process exits non-zero with no listener: Idunn fails Warming in -// seconds rather than waiting on a candidate that can never sign. -const presenceAuthority = config.idunnRuntimeBundlePath - ? await openPresenceAuthority(config) - : undefined; const app = await buildApp({ config }); const versePulseIntervalMs = 60_000; const healthPulseIntervalMs = 10_000; @@ -71,6 +70,13 @@ async function publishVerseState(): Promise { await publishHeimdallOdinState(config, pulse); } +/** + * Read once and keep: the bundle is Idunn's immutable record of this launch and + * cannot change while the process lives, so re-reading it every pulse would be + * work that can only ever return the same answer or fail. + */ +let runtimeBundleFacts: RuntimeBundleFacts | undefined; + async function publishHealthState(): Promise { const pulse = createHeimdallRuntimePulse(config); @@ -88,17 +94,32 @@ async function publishHealthState(): Promise { ? buildHeimdallHealthDetail(config, pulse) : `Heimdall candidate warming; ${lease.reason}`; - if (!presenceAuthority) { + if (!config.idunnRuntimeBundlePath) { // Outside Idunn there is no launch to attest and nothing that admits the // statement; publishing is simply not part of that world. return; } - const state = lease.mayWrite ? "active" : "warming"; - const presence = presenceAuthority.sign(state, detail, Date.parse(pulse.updatedAt)); + runtimeBundleFacts ??= await readRuntimeBundleFacts(config.idunnRuntimeBundlePath); + + const observedAtUnixMillis = Date.parse(pulse.updatedAt); + const presence = await buildPresenceStatement({ + bundle: runtimeBundleFacts, + state: lease.mayWrite ? "active" : "warming", + detail, + boundEndpoint: `http://${config.host}:${config.port}`, + // A warming candidate holds no lease and must not claim one; the contract + // refuses the pair, and Idunn needs the warming statement to promote it. + writeLeaseSha256: null, + observedAtUnixMillis, + publisherSequence: await nextPublisherSequence( + path.join(config.dataRoot, "runtime-presence-sequence"), + ), + providerHealthIdentityPath: config.providerHealthIdentityPath, + }); await publishIdunnRudpHealth(config, { daemonId: config.daemonId, - state, + state: lease.mayWrite ? "active" : "warming", detail, observedAt: pulse.updatedAt, presence, 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/provider-health-identity.ts b/src/provider-health-identity.ts index ca89f2c..847d39c 100644 --- a/src/provider-health-identity.ts +++ b/src/provider-health-identity.ts @@ -7,7 +7,7 @@ import { sign, type KeyObject, } from "node:crypto"; -import { closeSync, fstatSync, readSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { chmod, readFile } from "node:fs/promises"; import { SingleFileMessagePackBackingStore, type CultCacheEnvelope } from "@gamecult/cultcache-ts"; import { readPrivateStoreRecord } from "./idunn-store.js"; @@ -71,7 +71,7 @@ export async function openOrEnrollProviderHealthIdentity(path: string): Promise< if (existing.length !== 1) { throw new Error("Provider-health identity store must contain exactly one record."); } - return decodePrivateIdentity(existing[0]!.payload, await readMachineId(), "bin"); + return decodePrivateIdentity(existing[0]!.payload, await readMachineId()); } const PRESENCE_IDENTITY_FD_NAME = "gamecult-runtime-presence-identity"; @@ -124,50 +124,19 @@ export function passedDescriptor( */ export async function openProviderHealthIdentity( path: string, - env: NodeJS.ProcessEnv = process.env, - machineId: () => Promise = readMachineId + env: NodeJS.ProcessEnv = process.env ): Promise { const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env); if (descriptor === undefined) { - if (env.GAMECULT_IDUNN_RUNTIME_BUNDLE) { - // Inside an Idunn generation a self-enrolled key is one Idunn does not - // trust, and its private store would land in a state root the next - // generation must share. The descriptor is the only identity there. - throw new Error( - `Idunn runtime bundle is set but the ${PRESENCE_IDENTITY_FD_NAME} descriptor was not passed; refusing to self-enroll` - ); - } return openOrEnrollProviderHealthIdentity(path); } const payload = readPrivateStoreRecord( - readPassedDescriptor(descriptor), + readFileSync(descriptor), "Idunn runtime presence identity" ); - // The descriptor's writer is the Rust owner, whose key fields are integer arrays. - return decodePrivateIdentity(payload, await machineId(), "int-array"); -} - -/** - * Read a passed descriptor whole, from offset 0, and close it. A descriptor is - * a one-shot input: reading it again starts at the offset the last read left, - * which is the end, so the owner reads once at open and keeps what it decoded. - */ -export function readPassedDescriptor(descriptor: number): Buffer { - try { - const size = fstatSync(descriptor).size; - const bytes = Buffer.alloc(size); - let read = 0; - while (read < size) { - const count = readSync(descriptor, bytes, read, size - read, read); - if (count === 0) break; - read += count; - } - return bytes.subarray(0, read); - } finally { - closeSync(descriptor); - } + return decodePrivateIdentity(payload, await readMachineId()); } export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array { @@ -199,58 +168,23 @@ export function providerHealthPublicIdentity(identity: PrivateIdentity): { return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") }; } -/** - * Which writer produced the record. The self-enrolling store this process - * writes keeps key fields as MessagePack bin; the Rust owner (cultnet-rs - * service_identity.rs) serialises a Vec without serde_bytes, so its key - * fields are arrays of integers. Each source is read in its own shape and the - * other is refused. - */ -type KeyShape = "bin" | "int-array"; - -function keyBytes(value: unknown, shape: KeyShape, field: string): Uint8Array { - const bytes = - shape === "bin" - ? value instanceof Uint8Array - ? value - : undefined - : Array.isArray(value) && - value.every((byte) => typeof byte === "number" && Number.isInteger(byte) && byte >= 0 && byte <= 255) - ? Uint8Array.from(value as number[]) - : undefined; - if (bytes === undefined || bytes.length !== 32) { - throw new Error( - `Provider-health identity key shape check failed: ${field} is not a 32-byte ${shape === "bin" ? "binary" : "integer-array"} key field.` - ); - } - return bytes; -} - -function decodePrivateIdentity(payload: Uint8Array, machineId: string, shape: KeyShape): PrivateIdentity { +function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity { const value = decode(payload); if (!Array.isArray(value) || value.length !== 10) { - throw new Error("Provider-health identity schema check failed: payload is not a 10-field record."); + throw new Error("Provider-health identity payload is malformed."); } - const [schema, identityId, publicKeyField, protectedSeedField, protector, binding, version] = value; - if (schema !== PRIVATE_SCHEMA) { - throw new Error("Provider-health identity schema check failed: unexpected schema id."); - } - if (typeof identityId !== "string") { - throw new Error("Provider-health identity id check failed: id is not a string."); - } - const publicKey = keyBytes(publicKeyField, shape, "public key"); - const protectedSeed = keyBytes(protectedSeedField, shape, "protected seed"); - if (protector !== "linux_file_mode_machine_id_binding") { - throw new Error("Provider-health identity protector check failed: unexpected protector."); - } - if (binding !== protectorBinding(machineId)) { - throw new Error("Provider-health identity binding check failed: not bound to this host's machine id."); - } - if (version !== "v1") { - throw new Error("Provider-health identity version check failed: unexpected version."); - } - if (identityId !== deriveIdentityId(publicKey)) { - throw new Error("Provider-health identity id check failed: id does not derive from the public key."); + const [schema, identityId, publicKey, protectedSeed, protector, binding, version] = value; + if ( + schema !== PRIVATE_SCHEMA || + typeof identityId !== "string" || + !(publicKey instanceof Uint8Array) || publicKey.length !== 32 || + !(protectedSeed instanceof Uint8Array) || protectedSeed.length !== 32 || + protector !== "linux_file_mode_machine_id_binding" || + binding !== protectorBinding(machineId) || + version !== "v1" || + identityId !== deriveIdentityId(publicKey) + ) { + throw new Error("Provider-health identity does not belong to this host or profile."); } const seed = xorSeed(protectedSeed, binding); const privateKey = privateKeyFromSeed(seed); diff --git a/src/runtime-presence.ts b/src/runtime-presence.ts index 3c1054b..8cf0615 100644 --- a/src/runtime-presence.ts +++ b/src/runtime-presence.ts @@ -19,6 +19,8 @@ import path from "node:path"; import { createHash, createPrivateKey, type KeyObject } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { readFile, writeFile, rename } from "node:fs/promises"; import { decode } from "@msgpack/msgpack"; import { @@ -32,14 +34,11 @@ import { type RuntimePresenceState, } from "cultnet-ts"; -import type { HeimdallConfig } from "./config.js"; import { readIdunnStoreRecord } from "./idunn-store.js"; import { openProviderHealthIdentity, passedDescriptor, - readPassedDescriptor, signWithIdentity, - type PrivateIdentity, } from "./provider-health-identity.js"; const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); @@ -179,37 +178,70 @@ export async function readRuntimeBundleFacts(bundlePath: string): Promise { + let current = 0; + try { + const raw = (await readFile(sequencePath, "utf8")).trim(); + const parsed = Number(raw); + if (Number.isSafeInteger(parsed) && parsed > 0) { + current = parsed; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + const next = current + 1; + // Written through a temporary file: a torn write here would look like a + // regression and cost the target every future observation. + const temporary = `${sequencePath}.next`; + await writeFile(temporary, `${next}\n`, { mode: 0o600 }); + await rename(temporary, sequencePath); + return next; } export interface PresenceStatementInputs { bundle: RuntimeBundleFacts; - identity: PrivateIdentity; - activationKey: KeyObject; state: RuntimePresenceState; detail: string; boundEndpoint: string | null; writeLeaseSha256: string | null; observedAtUnixMillis: number; publisherSequence: number; + providerHealthIdentityPath: string; + env?: NodeJS.ProcessEnv; } export interface PresenceStatement { @@ -218,14 +250,24 @@ export interface PresenceStatement { } /** - * Build and dual-sign one presence statement from keys and facts already open. + * Build and dual-sign one presence statement. * * Returns the target alongside the bytes because the document key must equal * the signed target; Odin refuses the pair when they differ, and that is the * one part of the envelope the transport cannot infer. */ -export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement { - const { identity } = inputs; +export async function buildPresenceStatement( + inputs: PresenceStatementInputs, +): Promise { + const env = inputs.env ?? process.env; + const activationKey = openActivationSigner(env); + if (activationKey === undefined) { + throw new Error( + "Idunn passed no runtime activation credential, so this process cannot prove it is the " + + "incarnation Idunn started", + ); + } + const identity = await openProviderHealthIdentity(inputs.providerHealthIdentityPath, env); const presence: RuntimePresenceHealth = { target: inputs.bundle.target, @@ -253,7 +295,7 @@ export function buildPresenceStatement(inputs: PresenceStatementInputs): Presenc const proof = runtimePresenceProofPayload(presence); const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof)); const activationSignature = signWithIdentity( - { ...identity, privateKey: inputs.activationKey }, + { ...identity, privateKey: activationKey }, runtimePresenceActivationSigningMessage(proof), ); @@ -262,72 +304,3 @@ export function buildPresenceStatement(inputs: PresenceStatementInputs): Presenc payload: encodeRuntimePresenceHealth(presence, signature, activationSignature), }; } - -export interface PresenceAuthority { - sign( - state: RuntimePresenceState, - detail: string, - observedAtUnixMillis: number, - ): PresenceStatement; -} - -/** - * Open everything this launch's presence statements stand on, once, before the - * process listens: Idunn's bundle facts, the activation seed and the presence - * identity (each descriptor is read whole and closed), and the one publisher - * sequence. It owns them for the life of the process; a pulse supplies only its - * state, detail and observation time. - * - * A launch-contract fault (a missing or malformed bundle, descriptor or - * identity) throws here, so the process exits before it accepts a request and - * Idunn fails Warming at once, instead of a listener that can never sign. - */ -export async function openPresenceAuthority( - config: Pick, - env: NodeJS.ProcessEnv = process.env, - machineId?: () => Promise, -): Promise { - if (!config.idunnRuntimeBundlePath) { - throw new Error("no Idunn runtime bundle path: presence has no launch to attest"); - } - const bundle = await readRuntimeBundleFacts(config.idunnRuntimeBundlePath); - - const activationDescriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env); - if (activationDescriptor === undefined) { - throw new Error( - "Idunn passed no runtime activation credential, so this process cannot prove it is the " + - "incarnation Idunn started", - ); - } - const seed = readPassedDescriptor(activationDescriptor); - if (seed.length !== 32) { - throw new Error("Idunn runtime activation credential is not a 32-byte seed"); - } - const activationKey = createPrivateKey({ - key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]), - format: "der", - type: "pkcs8", - }); - - const identity = await openProviderHealthIdentity(config.providerHealthIdentityPath, env, machineId); - const boundEndpoint = `http://${config.host}:${config.port}`; - const sequence = new PublisherSequence(); - - return { - sign: (state, detail, observedAtUnixMillis) => - buildPresenceStatement({ - bundle, - identity, - activationKey, - state, - detail, - boundEndpoint, - // A warming candidate holds no lease and must not claim one; the - // contract refuses the pair, and Idunn needs the warming statement to - // promote it. - writeLeaseSha256: null, - observedAtUnixMillis, - publisherSequence: sequence.next(observedAtUnixMillis), - }), - }; -} diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index 6a53dad..5fc91ba 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 CreateAuditEventInput, type CreateCapabilityGrantInput, @@ -48,6 +49,8 @@ export class InMemoryStore implements HeimdallStore { private readonly privateCommandReceipts = new Map(); private readonly authCompletions = 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(); @@ -424,6 +427,20 @@ export class InMemoryStore implements HeimdallStore { return this.consumeAuthCompletion(appSlug, code, at); } + 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 487f375..4ee0ea1 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 CreateAuditEventInput, type CreateCapabilityGrantInput, @@ -378,6 +379,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 420f5e6..80dfa6e 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -119,6 +119,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 5307e1f..d6a2f93 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -165,6 +165,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 @@ -272,5 +280,15 @@ export interface HeimdallStore { */ consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: 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 4789bf8..6afcdb8 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 7f4d96d..1cfe062 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,96 +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" }, - }, - }; -} - -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; @@ -134,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", @@ -146,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 () => { @@ -207,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( @@ -249,6 +161,161 @@ 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 app = await buildApp({ config: createTestConfig() }); + 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, ...(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 @@ -302,9 +369,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( @@ -348,11 +415,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( @@ -396,9 +463,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( @@ -442,10 +509,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( @@ -473,10 +540,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); @@ -689,10 +757,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; @@ -731,10 +800,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"); @@ -815,10 +885,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; @@ -862,11 +933,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", }, }); @@ -912,12 +984,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", }, }); @@ -1123,11 +1196,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; @@ -1210,11 +1284,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; @@ -1276,10 +1351,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; @@ -1377,7 +1453,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([ @@ -1393,14 +1469,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([]); @@ -1432,9 +1509,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); @@ -1453,7 +1532,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"); @@ -1467,10 +1546,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"); @@ -1518,14 +1598,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"); @@ -1542,11 +1623,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); @@ -1575,10 +1657,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"); @@ -1589,10 +1672,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(); @@ -1787,11 +1871,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"); @@ -1823,9 +1907,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); @@ -1843,16 +1929,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; @@ -1866,11 +1952,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(/