diff --git a/README.md b/README.md index a34dc85..b86dbdc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,19 @@ 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 f816a14..8ae5c19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -154,11 +154,7 @@ The important split: - Heimdall owns provider OAuth, identity linking, entitlement refresh, signed sessions, claims, grants, audit surfaces, and managed provider credential - 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 + custody - 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, @@ -617,9 +613,8 @@ Only the browser-driven OAuth flow: | Path | Why it must be public | |---|---| -| `/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 | +| `/v1/oauth/{provider}/start` | the user's browser goes here | +| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable | | `/.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 13b1374..1567ae5 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -149,29 +149,12 @@ Response: "appSlug": "repixelizer", "mode": "sign_in", "callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback", - "authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=", + "authorizationUrl": "https://discord.com/oauth2/authorize?...", + "stateToken": "", "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 @@ -185,12 +168,7 @@ 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, - 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 + `handoff.kind` or app slug; a caller with no policy to attach needs no secret Launch note: @@ -211,9 +189,7 @@ Purpose: Current status: -- 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` +- validates signed `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 f3c98e9..fcfa4ac 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -11,8 +11,7 @@ evidence only. ## Owner Map -- Owner: Heimdall owns provider identity/linking (an authenticator handle, not - GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim +- Owner: Heimdall owns provider identity/linking, 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 4eb8e55..36394ef 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,12 @@ "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.8.5", + "fastify": "^5.12.6", "pg": "^8.20.0", "zod": "^3.25.76" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb1d1db..855ad01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,9 +12,6 @@ importers: .: dependencies: - '@fastify/cookie': - specifier: ^11.1.3 - version: 11.1.3 '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 @@ -31,8 +28,8 @@ importers: specifier: file:vendor/CultLib/packages/cultnet-ts version: file:vendor/CultLib/packages/cultnet-ts fastify: - specifier: ^5.8.5 - version: 5.8.5 + specifier: ^5.12.6 + version: 5.12.7 pg: specifier: ^8.20.0 version: 8.20.0 @@ -232,9 +229,6 @@ 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==} @@ -475,10 +469,6 @@ 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'} @@ -542,20 +532,23 @@ 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-plugin@6.0.1: - resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==} - - fastify@5.8.5: - resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastify@5.12.7: + resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -569,8 +562,8 @@ packages: picomatch: optional: true - find-my-way@9.5.0: - resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + find-my-way@9.10.0: + resolution: {integrity: sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==} engines: {node: '>=20'} fsevents@2.3.3: @@ -866,8 +859,8 @@ packages: process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process-warning@5.1.1: + resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1193,11 +1186,6 @@ 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 @@ -1405,8 +1393,6 @@ 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 @@ -1495,17 +1481,26 @@ 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-plugin@6.0.1: {} - - fastify@5.8.5: + fastify@5.12.7: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -1513,11 +1508,11 @@ snapshots: '@fastify/proxy-addr': 5.1.0 abstract-logging: 2.0.1 avvio: 9.2.0 - fast-json-stringify: 6.3.0 - find-my-way: 9.5.0 + fast-json-stringify: 7.1.3 + find-my-way: 9.10.0 light-my-request: 6.6.0 pino: 10.3.1 - process-warning: 5.0.0 + process-warning: 5.1.1 rfdc: 1.4.1 secure-json-parse: 4.1.0 semver: 7.7.4 @@ -1531,7 +1526,7 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - find-my-way@9.5.0: + find-my-way@9.10.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 @@ -1755,7 +1750,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pino-std-serializers: 7.1.0 - process-warning: 5.0.0 + process-warning: 5.1.1 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 @@ -1780,7 +1775,7 @@ snapshots: process-warning@4.0.1: {} - process-warning@5.0.0: {} + process-warning@5.1.1: {} quick-format-unescaped@4.0.4: {} diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 70a9b34..3489a48 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -45,17 +45,6 @@ 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. */ @@ -153,7 +142,6 @@ 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 96f107b..40ceb20 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,6 @@ import { createHmac, randomUUID } from "node:crypto"; -import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; -import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply } from "fastify"; +import Fastify, { type FastifyError, type FastifyInstance } from "fastify"; import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js"; import { @@ -94,7 +93,6 @@ function buildOAuthStateToken(options: { connection: OAuthConnectionBinding | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; - requestedScopes: string[] | undefined; keys: ReturnType; }): { token: string; expiresAt: string } { const issuedAt = nowEpochSeconds(); @@ -115,7 +113,6 @@ 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); @@ -154,11 +151,6 @@ 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" || @@ -176,8 +168,7 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay typeof payload.nbf !== "number" || typeof payload.exp !== "number" || !handoffIsValid || - !entitlementPolicyIsValid || - !requestedScopesAreValid + !entitlementPolicyIsValid ) { return null; } @@ -189,99 +180,10 @@ 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)); } @@ -652,7 +554,7 @@ export async function startOAuthFlow( if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") { handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId }; } - if ((entitlementPolicy || profile.startRequiresAppCaller) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { + if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { return { statusCode: 401, body: { error: "app_auth_required" } }; } @@ -664,6 +566,7 @@ export async function startOAuthFlow( }; } + const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; const state = buildOAuthStateToken({ config, provider, @@ -673,7 +576,6 @@ export async function startOAuthFlow( connection: input.connection, handoff, entitlementPolicy, - requestedScopes: input.requestedScopes, keys, }); @@ -683,9 +585,16 @@ export async function startOAuthFlow( provider, appSlug: profile.slug, mode: input.mode, - callbackUrl: oauthCallbackUrl(config, provider), - authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`, + callbackUrl, + authorizationUrl: buildAuthorizationUrl({ + provider, + clientId: providerConfig.clientId, + redirectUri: callbackUrl, + state: state.token, + requestedScopes: input.requestedScopes, + }), handoff, + stateToken: state.token, stateExpiresAt: state.expiresAt, }, }; @@ -829,7 +738,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise ({ ok: true, @@ -964,72 +872,6 @@ 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 }; @@ -1063,29 +905,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { diff --git a/src/contracts.ts b/src/contracts.ts index daa559a..d6cab2a 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -94,8 +94,6 @@ 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 4c89a70..0bc13c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js"; import { buildApp } from "./app.js"; import { loadConfig } from "./config.js"; @@ -7,14 +6,16 @@ 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 { - buildPresenceStatement, - nextPublisherSequence, - readRuntimeBundleFacts, - type RuntimeBundleFacts, -} from "./runtime-presence.js"; +import { openPresenceAuthority } 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; @@ -70,13 +71,6 @@ 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); @@ -94,32 +88,17 @@ async function publishHealthState(): Promise { ? buildHeimdallHealthDetail(config, pulse) : `Heimdall candidate warming; ${lease.reason}`; - if (!config.idunnRuntimeBundlePath) { + if (!presenceAuthority) { // Outside Idunn there is no launch to attest and nothing that admits the // statement; publishing is simply not part of that world. return; } - 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, - }); + const state = lease.mayWrite ? "active" : "warming"; + const presence = presenceAuthority.sign(state, detail, Date.parse(pulse.updatedAt)); await publishIdunnRudpHealth(config, { daemonId: config.daemonId, - state: lease.mayWrite ? "active" : "warming", + state, detail, observedAt: pulse.updatedAt, presence, diff --git a/src/private-command-plane.ts b/src/private-command-plane.ts index 8e9fb8d..6d3025b 100644 --- a/src/private-command-plane.ts +++ b/src/private-command-plane.ts @@ -305,6 +305,7 @@ 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", @@ -315,7 +316,7 @@ async function beginAuth( expiresAt: attempt.expiresAt, navigation: { url: authorizationUrl, - allowedOrigins: [new URL(config.publicBaseUrl).origin], + allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin], }, }, }; diff --git a/src/provider-health-identity.ts b/src/provider-health-identity.ts index 847d39c..ca89f2c 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 { readFileSync } from "node:fs"; +import { closeSync, fstatSync, readSync } 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()); + return decodePrivateIdentity(existing[0]!.payload, await readMachineId(), "bin"); } const PRESENCE_IDENTITY_FD_NAME = "gamecult-runtime-presence-identity"; @@ -124,19 +124,50 @@ export function passedDescriptor( */ export async function openProviderHealthIdentity( path: string, - env: NodeJS.ProcessEnv = process.env + env: NodeJS.ProcessEnv = process.env, + machineId: () => Promise = readMachineId ): 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( - readFileSync(descriptor), + readPassedDescriptor(descriptor), "Idunn runtime presence identity" ); - return decodePrivateIdentity(payload, await readMachineId()); + // 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); + } } export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array { @@ -168,23 +199,58 @@ export function providerHealthPublicIdentity(identity: PrivateIdentity): { return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") }; } -function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity { +/** + * 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 { const value = decode(payload); if (!Array.isArray(value) || value.length !== 10) { - throw new Error("Provider-health identity payload is malformed."); + throw new Error("Provider-health identity schema check failed: payload is not a 10-field record."); } - 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 [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 seed = xorSeed(protectedSeed, binding); const privateKey = privateKeyFromSeed(seed); diff --git a/src/runtime-presence.ts b/src/runtime-presence.ts index 8cf0615..3c1054b 100644 --- a/src/runtime-presence.ts +++ b/src/runtime-presence.ts @@ -19,8 +19,6 @@ 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 { @@ -34,11 +32,14 @@ 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"); @@ -178,70 +179,37 @@ 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; + next(observedAtUnixMillis: number): number { + if (!Number.isSafeInteger(observedAtUnixMillis)) { + throw new Error("observedAtUnixMillis is not a safe integer"); } + this.last = Math.max(observedAtUnixMillis, this.last + 1); + return this.last; } - 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 { @@ -250,24 +218,14 @@ export interface PresenceStatement { } /** - * Build and dual-sign one presence statement. + * Build and dual-sign one presence statement from keys and facts already open. * * 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 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); +export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement { + const { identity } = inputs; const presence: RuntimePresenceHealth = { target: inputs.bundle.target, @@ -295,7 +253,7 @@ export async function buildPresenceStatement( const proof = runtimePresenceProofPayload(presence); const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof)); const activationSignature = signWithIdentity( - { ...identity, privateKey: activationKey }, + { ...identity, privateKey: inputs.activationKey }, runtimePresenceActivationSigningMessage(proof), ); @@ -304,3 +262,72 @@ export async function buildPresenceStatement( 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 5fc91ba..6a53dad 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -3,7 +3,6 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra import { type CreateAccountInput, type CreateAuthAttemptInput, - type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, @@ -49,8 +48,6 @@ 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(); @@ -427,20 +424,6 @@ 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 4ee0ea1..487f375 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -5,7 +5,6 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f import { type CreateAccountInput, type CreateAuthAttemptInput, - type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, type CreateAuditEventInput, type CreateCapabilityGrantInput, @@ -379,7 +378,6 @@ 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 80dfa6e..420f5e6 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -119,20 +119,6 @@ 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 d6a2f93..5307e1f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -165,14 +165,6 @@ 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 @@ -280,15 +272,5 @@ 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 6afcdb8..4789bf8 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -8,7 +8,6 @@ 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 }> = []; @@ -108,12 +107,9 @@ 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; allowedOrigins: string[] }; + const navigation = begin.navigation as { url: 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", @@ -128,11 +124,9 @@ 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 1cfe062..7f4d96d 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -4,8 +4,7 @@ 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, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js"; -import { resolveAppCaller } from "../src/app-caller.js"; +import { buildApp, errorIdentity } from "../src/app.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"; @@ -13,7 +12,96 @@ 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"; -import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.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"], + }, + }, + ], + }; + }, + }; +} const apps: Awaited>[] = []; const originalFetch = globalThis.fetch; @@ -46,7 +134,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", @@ -58,7 +146,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise { }); expect(response.statusCode).toBe(201); - return launchFlow(app, response.json()); + return response.json().stateToken as string; } afterEach(async () => { @@ -119,9 +207,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); + expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -161,161 +249,6 @@ 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 @@ -369,9 +302,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); + expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -415,11 +348,11 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = await providerRedirect(app, payload); + const authorizationUrl = new URL(payload.authorizationUrl as string); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -463,9 +396,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); + expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -509,10 +442,10 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = await providerRedirect(app, payload); + const authorizationUrl = new URL(payload.authorizationUrl as string); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -540,11 +473,10 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); expect(response.statusCode).toBe(201); @@ -757,11 +689,10 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); const completionCode = callbackResponse.json().completion.code as string; @@ -800,11 +731,10 @@ describe("Heimdall service", () => { const auditEvents = () => [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string }; const created = auditEvents().filter((event) => event.eventType === "auth_completion_created"); @@ -885,11 +815,10 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); expect(callbackResponse.statusCode).toBe(201); const refreshToken = callbackResponse.json().refreshToken as string; @@ -933,12 +862,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, headers: { - cookie: flow.cookie, accept: "text/html", }, }); @@ -984,13 +912,12 @@ describe("Heimdall service", () => { }, }); expect(startResponse.statusCode).toBe(201); - const flow = await launchFlow(app, startResponse.json()); + const stateToken = startResponse.json().stateToken as string; const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, headers: { - cookie: flow.cookie, accept: "text/html", }, }); @@ -1196,12 +1123,11 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const flow = await launchFlow(app, startResponse.json()); + const stateToken = startResponse.json().stateToken as string; const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); const accountId = callbackResponse.json().account.id as string; @@ -1284,12 +1210,11 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const flow = await launchFlow(app, startResponse.json()); + const stateToken = startResponse.json().stateToken as string; const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); const accountId = callbackResponse.json().account.id as string; @@ -1351,11 +1276,10 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); expect(callbackResponse.statusCode).toBe(201); const evaluationsAtSignIn = evaluations; @@ -1453,7 +1377,7 @@ describe("Heimdall service", () => { expect(start.statusCode).toBe(400); expect(start.json().error).toBe(error); expect(start.json().authorizationUrl).toBeUndefined(); - expect(start.json()).not.toHaveProperty("stateToken"); + expect(start.json().stateToken).toBeUndefined(); }); it.each([ @@ -1469,15 +1393,14 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); - const verified = verifyJwt(flow.state, keys.publicKey); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, 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([]); @@ -1509,11 +1432,9 @@ 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(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, }); expect(callback.statusCode).toBeLessThan(300); @@ -1532,7 +1453,7 @@ describe("Heimdall service", () => { apps.push(app); const start = await connect(app); expect(start.statusCode).toBe(201); - const url = await providerRedirect(app, start.json()); + const url = new URL(start.json().authorizationUrl as string); 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"); @@ -1546,11 +1467,10 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "deru-chan", username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); + const stateToken = (await connect(app)).json().stateToken as string; const connected = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`, }); const accountId = connected.json().account.id as string; const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); @@ -1598,15 +1518,14 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); - const verified = verifyJwt(flow.state, keys.publicKey); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, 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"); @@ -1623,12 +1542,11 @@ 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 flow = await launchFlow(app, (await connect(app)).json()); + const stateToken = (await connect(app)).json().stateToken as string; const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`, }); expect(callback.statusCode).toBeLessThan(300); @@ -1657,11 +1575,10 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "chan-9", username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); + const stateToken = (await connect(app)).json().stateToken as string; const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, }); const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick"); expect(row?.displayName).toBe("deru"); @@ -1672,11 +1589,10 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); + const stateToken = (await connect(app)).json().stateToken as string; const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull(); @@ -1871,11 +1787,11 @@ describe("error answers never echo an input", () => { const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie, accept: "text/html" }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + headers: { accept: "text/html" }, }); expect(response.headers["content-type"]).toContain("text/html"); @@ -1907,11 +1823,9 @@ 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(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`, }); expect(response.statusCode).toBe(502); @@ -1929,16 +1843,16 @@ describe("the relayed provider error", () => { const injected = `"\\\u0007é${"x".repeat(300)}`; const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/; - 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 } }; + 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()}`; } it("is printable and bounded in the JSON answer", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app))); + const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) }); expect(response.statusCode).toBe(400); const { providerError, providerErrorDescription } = response.json() as Record; @@ -1952,8 +1866,11 @@ describe("the relayed provider error", () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const request = providerErrorRequest(await startDiscordSignIn(app)); - const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } }); + const response = await app.inject({ + method: "GET", + url: providerErrorUrl(await startDiscordSignIn(app)), + headers: { accept: "text/html" }, + }); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body.match(/