diff --git a/README.md b/README.md index a34dc85..f0e0a0e 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, Google OIDC for linking), 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/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml index f520258..48c3577 100644 --- a/deployment/idunn/recipe.toml +++ b/deployment/idunn/recipe.toml @@ -157,6 +157,8 @@ optional_environment = [ "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE", + "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID", + "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID", "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE", "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", 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..e6ad6c1 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -119,6 +119,7 @@ Supported providers in the skeleton: - `youtube` - `spotify` - `joystick` +- `google` Request body: @@ -149,12 +150,29 @@ Response: "appSlug": "repixelizer", "mode": "sign_in", "callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback", - "authorizationUrl": "https://discord.com/oauth2/authorize?...", - "stateToken": "", + "authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=", "stateExpiresAt": "2026-04-26T12:10:00.000Z" } ``` +Send the user's browser to `authorizationUrl` unchanged. It is Heimdall's launch +hop, not the provider's URL. + +### `GET /v1/oauth/{provider}/launch?state=` + +The first thing the user's browser does with a state. Heimdall verifies the +state (signature, issuer, provider, `nbf`/`exp` with 30 s of skew), claims the +state's single launch, sets the binding cookie +`__Host-heimdall-oauth-` (`Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, +`Max-Age` until the state expires; the value is a Heimdall-signed binding for +that state) and answers `302` to the provider's authorize URL, with +`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A second launch of +the same state is `400 state_already_launched` and sets no cookie. The state is +refused with `400 invalid_state`, `state_provider_mismatch` or `state_expired`. + +The binding cookie needs an `https` public base URL (or `localhost`). A browser +must finish the flow in the browser that launched it. + Failure modes: - `503 provider_not_configured` when the provider client id is missing @@ -168,7 +186,17 @@ Failure modes: - `401 app_auth_required` when the request body carries `entitlementPolicy` and the caller did not authenticate as that app via `x-heimdall-app-secret` (`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of - `handoff.kind` or app slug; a caller with no policy to attach needs no secret + `handoff.kind` or app slug; a caller with no policy to attach needs no secret, + unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then + every start for that app, with or without a policy, needs that app's secret. + Bifrost binds browser completion to a nonce that rides readable in the signed + state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose + browser starts are public by design leave the flag unset +- `401 app_auth_required` also when `mode` is `connect` and the body carries a + `connection` (a creator or account target): a connection target is app-backend + authority, so only the authenticated app named by `appSlug` may start it. No + secret, or another app's, is refused before any state is signed. `sign_in` + starts carrying a `connection` are `400 connection_not_allowed_in_sign_in` Launch note: @@ -179,6 +207,45 @@ Launch note: implemented yet - Repixelizer launch can configure Discord and Patreon on its provider buttons +### Google (OpenID Connect, link-only) + +`google` is an identity provider that never mints an account. It is a second +door into a person who already has one. + +- Callback redirect URI (register exactly this in the Google Cloud OAuth + client): `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`, which is + `https://heimdall.gamecult.org/v1/oauth/google/callback` in production. +- Scopes `openid email profile`. The authorization request carries `state` + (signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge, all built at the + launch hop, which is where the browser first meets the provider URL. The verifier + and the nonce are derived from the state's `jti` and Heimdall's signing key, + so nothing is stored between start and callback. +- The callback verifies the `id_token` (RS256 against Google's published + keys; `iss` is Google, `aud` is this client, `exp`, `iat`, `nonce`) and + requires a `sub` and a verified email. The account key is `sub`; the email + is a display and contact field and never a lookup key. +- `mode: "link"` needs `Authorization: Bearer ` for the + same app; the identity attaches to that token's account and to no other. + `mode: "sign_in"` signs in an account the `sub` is already linked to. +- A `sub` no account holds, arriving with `sign_in`, is refused and mints + nothing. A `sub` linked to another account is refused and never moved. + Both audit as `oauth_callback_failed` with error class `LinkRefusal` + and the code `identity_not_linked` or `link_initiator_mismatch`. +- Link is two-phase. The callback verifies the identity and records a pending + link (TTL `completionTtlSeconds`), answering `status: "link_pending"` with a + `pendingLinkId` and no session; it writes no linked identity. The app calls + `POST /v1/apps/:appSlug/links/confirm` with `{ pendingLinkId }` and the + Heimdall access token of the account that began the link; only that call + attaches the identity. A different account, an expired, used or unknown id + answers 410; a `sub` another account holds by then answers 409. A refused + confirm does not consume the link. +- A sign-in callback evaluates every linked identity of the account with the + start-time entitlement policy, so a member returning through Google keeps + what Discord or Patreon grant. +- Configure with `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID` and + `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE`. Admitted to the `bifrost` + app profile only. + ### `GET /v1/oauth/{provider}/callback` Purpose: @@ -189,7 +256,11 @@ Purpose: Current status: -- validates signed `state` +- validates signed `state` (signature, issuer, provider, expiry), requires the + binding cookie the launch hop set, and consumes the state: a second callback + is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`, + an expired state `400 state_expired`; the binding is checked before the claim, + so a browser without the cookie cannot burn the launching browser's state - returns provider errors cleanly - for Discord, exchanges the authorization code, resolves the provider identity, persists the local account/link/session/audit records, evaluates @@ -334,6 +405,11 @@ Request body: } ``` +Binding rules: + +- A completion minted for a connection target (`connection.targetId` in the signed state) is redeemed only with `targetId` equal to it; absent or different answers `403 completion_target_mismatch` and leaves the code unconsumed. +- A `link` completion is redeemed only by the authenticated app backend (`x-heimdall-app-secret`); otherwise `401 app_auth_required`, code unconsumed. `link` start itself needs that authentication and `initiatingAccountId`; the callback refuses with `403 link_initiator_mismatch` when the provider identity belongs to a different account, and attaches an unlinked identity to the initiator. + Response: ```json 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/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 4727d7f..9d23cf1 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -26,18 +26,23 @@ Do not trust this file for the exact live HEAD. Always check git. ## Active Work -Joystick H1 is the active, M1-critical cut (operator ruling 2026-10-18): -campaign `joystick:campaign:self`, target `joystick:target:r1`, branch -`eureka/joystick-h1`. Read the campaign and target in the typed mind; do not -re-derive them here. The Ghostlight KLTST canaries below stay open, behind H1. +Joystick H1 is merged and live (PR GameCult/Heimdall#1; campaign +`joystick:campaign:self`). Next: `joystick:follow_up:h1-joystick-consent-round-trip`, then +StreamPixels S0/S1 (other sessions). Open Heimdall follow-ups, by id: +`joystick:follow_up:cross-process-failed-refresh-marker`, +`joystick:follow_up:connect-start-needs-app-auth`, +`joystick:follow_up:heimdall-idunn-onboarding`, +`joystick:follow_up:joystick-viewer-signin-public-bot`. The Ghostlight KLTST +canaries below stay open, behind them. ## Current Orientation -Deploy facts below are last-recorded, from 2026-08-22 (`1086aee`); HEAD has -moved ~100 commits past it. Check Yggdrasil, not this file, for what runs. +Yggdrasil runs Heimdall `d51fa6b`, hand-deployed 2026-10-19 15:51Z; +gamecult-ops `210a1af` records backup, rollback and provenance. Facts below +about Odin, Ghostlight and CultLib pins were verified at `1086aee` (2026-08-22) +and are not re-verified at `d51fa6b`. -- Yggdrasil runs Heimdall - `1086aee01169bf60e8a492b2740db1c6f3e8cabf` with CultLib +- At `1086aee01169bf60e8a492b2740db1c6f3e8cabf` Heimdall ran with CultLib `5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9` - Heimdall publishes four redacted access records to Odin under unique keys: `eve:provider:heimdall`, `heimdall:command-boundary`, @@ -157,8 +162,7 @@ moved ~100 commits past it. Check Yggdrasil, not this file, for what runs. over `cultnet.transport.rudp.v0` when `GC_ACCESS_IDUNN_RUDP_HEALTH` is configured; HTTP `/healthz`, JWKS, discovery, systemd, and nginx routing remain compatibility witnesses -- Yggdrasil now runs Heimdall commit - `1086aee01169bf60e8a492b2740db1c6f3e8cabf`. Heimdall persists its private +- Since `1086aee` Heimdall persists its private provider-health identity at `/srv/heimdall/keys/provider-health-identity.cc` and publishes canonical signed health independently of the slower Verse snapshot cadence. Idunn owns the exact root trust binding, authenticated @@ -180,7 +184,7 @@ moved ~100 commits past it. Check Yggdrasil, not this file, for what runs. ## Next Real Move -Joystick H1 first (see Active Work). Everything below is the standing queue. +The Active Work follow-ups first. Everything below is the standing queue. Do not continue implementation automatically from a rehydrate-only request. diff --git a/package.json b/package.json index 0c6cf4f..626ab8a 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.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 005bb26..c84f4d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@fastify/cookie': + specifier: ^11.1.3 + version: 11.1.5 '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 @@ -28,8 +31,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 @@ -229,6 +232,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.1.5': + resolution: {integrity: sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==} + '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} @@ -469,6 +475,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory} engines: {node: '>=18'} @@ -532,17 +542,26 @@ 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.8.5: - resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastify-plugin@6.0.1: + resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==} + + fastify@5.12.7: + resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -556,8 +575,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: @@ -853,8 +872,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==} @@ -1180,6 +1199,11 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.0 + '@fastify/cookie@11.1.5': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.1 + '@fastify/cors@11.2.0': dependencies: fastify-plugin: 5.1.0 @@ -1387,6 +1411,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: dependencies: '@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts @@ -1475,15 +1501,28 @@ 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.8.5: + fastify-plugin@6.0.1: {} + + fastify@5.12.7: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -1491,11 +1530,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 @@ -1509,7 +1548,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 @@ -1733,7 +1772,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 @@ -1758,7 +1797,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/scripts/test-live-pg.sh b/scripts/test-live-pg.sh new file mode 100755 index 0000000..a25c3e9 --- /dev/null +++ b/scripts/test-live-pg.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Runs the whole vitest suite with the live-Postgres files enabled, against a +# throwaway local cluster that this script starts and removes. Needs the +# Postgres server binaries (initdb, pg_ctl) on PATH or in PG_BIN. +# SOUL_PG_URL's test drops and recreates schema public, so it only ever sees +# this disposable cluster. +set -euo pipefail +export PATH="${PG_BIN:-/usr/lib/postgresql/16/bin}:$PATH" +dir="$(mktemp -d)" +port="${PG_PORT:-54329}" +trap 'pg_ctl -D "$dir" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$dir"' EXIT +initdb -D "$dir" -U pgadmin --auth=trust >/dev/null +pg_ctl -D "$dir" -o "-p $port -k $dir -c listen_addresses=127.0.0.1" -l "$dir/log" -w start >/dev/null +url="postgres://pgadmin@127.0.0.1:$port/postgres" +HEIMDALL_TEST_PG_ADMIN_URL="$url" SOUL_PG_URL="$url" npx vitest run --config vitest.config.ts "$@" diff --git a/src/app-profiles.ts b/src/app-profiles.ts index 3489a48..c10f704 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -45,6 +45,17 @@ export interface AppProfile { * plane; absent for apps with no such requirement. */ requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"]; + /** + * The app binds browser completion to its own pending attempt, and the + * binding is the secrecy of the attempt nonce, which rides readable in the + * signed (not encrypted) state's return_to. If anyone could start a flow for + * this app, whoever learned a victim's nonce could mint a completion bound + * to it and sign the victim's browser in as themselves (login CSRF, finding + * known-nonce-login-csrf). So a start for this app is refused unless the + * caller is this app's authenticated caller: only the app can mint its own + * state. Absent for apps whose browser starts are public by design. + */ + startRequiresAppCaller?: boolean; } /** The membership signal shared by every app that gates on GameCult membership. */ @@ -137,11 +148,12 @@ const streampixelsProfile: AppProfile = { const bifrostProfile: AppProfile = { slug: "bifrost", displayName: "Bifrost", - profileVersion: "2026-06-08", - identityProviders: ["discord", "patreon"], + profileVersion: "2026-11-04", + identityProviders: ["discord", "patreon", "google"], 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..2707bbe 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, type FastifyRequest } from "fastify"; import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js"; import { @@ -28,6 +29,7 @@ import { renderBrowserHandoffPage } from "./browser-handoff.js"; import { type HeimdallConfig, loadConfig } from "./config.js"; import { createTokenCustody, type TokenCustody } from "./custody.js"; import { grantFact } from "./facts.js"; +import { deriveOidcSecrets } from "./google-oidc.js"; import { createOAuthRuntimeRegistry, fetchPatreonIdentity, @@ -91,10 +93,12 @@ function buildOAuthStateToken(options: { mode: OAuthMode; returnTo: string; connection: OAuthConnectionBinding | undefined; + initiatorAccountId: string | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; + requestedScopes: string[] | undefined; keys: ReturnType; -}): { token: string; expiresAt: string } { +}): { token: string; jti: string; expiresAt: string } { const issuedAt = nowEpochSeconds(); const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds; const payload: OAuthStatePayload = { @@ -111,13 +115,16 @@ function buildOAuthStateToken(options: { mode: options.mode, return_to: options.returnTo, connection: options.connection ?? null, + initiator_account_id: options.initiatorAccountId ?? null, handoff: options.handoff, entitlement_policy: options.entitlementPolicy ?? null, + requested_scopes: options.requestedScopes ?? null, }; const token = signJwt(payload as unknown as Record, options.keys); return { token, + jti: payload.jti, expiresAt: new Date(expiresAtEpoch * 1000).toISOString(), }; } @@ -151,6 +158,11 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay entitlementPolicy !== null && (entitlementPolicy as Record).kind === "patreon_membership_access" && typeof (entitlementPolicy as Record).requiredTierTitle === "string")); + const requestedScopes = payload.requested_scopes; + const requestedScopesAreValid = + requestedScopes === null || + requestedScopes === undefined || + (Array.isArray(requestedScopes) && requestedScopes.every((scope) => typeof scope === "string")); if ( payload.typ !== "heimdall_oauth_state" || typeof payload.provider !== "string" || @@ -168,7 +180,9 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay typeof payload.nbf !== "number" || typeof payload.exp !== "number" || !handoffIsValid || - !entitlementPolicyIsValid + !entitlementPolicyIsValid || + !(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") || + !requestedScopesAreValid ) { return null; } @@ -177,13 +191,105 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay if (!normalizedPayload.handoff) { normalizedPayload.handoff = { kind: "browser_completion" }; } + if (!normalizedPayload.initiator_account_id) { + normalizedPayload.initiator_account_id = null; + } 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)); } @@ -248,11 +354,17 @@ function relayedProviderText(value: string | undefined, bound: number): string | return printable ? printable : undefined; } -/** - * What may be recorded or answered about a caught error: its class and its - * code, each only when it is a short identifier. Never its message, which - * carries whatever the thrower quoted (a host, a database, a provider's body). - */ +/** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */ +export class LinkRefusal extends Error { + constructor( + readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown" | "identity_not_linked", + message: string + ) { + super(message); + this.name = "LinkRefusal"; + } +} + export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } { const code = (error as { code?: unknown } | null)?.code; return { @@ -392,6 +504,8 @@ async function evaluateStoredEntitlements(options: { appSlug: AppSlug; accountId: string; entitlementPolicies: OAuthEntitlementPolicy[]; + /** A provider the caller has already evaluated with fresh tokens. */ + skipProvider?: Provider; }): Promise { const profile = getAppProfile(options.appSlug); if (!profile) { @@ -401,7 +515,7 @@ async function evaluateStoredEntitlements(options: { const facts: string[] = []; for (const storedIdentity of linkedIdentities) { - if (!profile.entitlementSources.includes(storedIdentity.provider)) { + if (!profile.entitlementSources.includes(storedIdentity.provider) || storedIdentity.provider === options.skipProvider) { continue; } @@ -486,6 +600,13 @@ export async function startOAuthFlow( * which one reaches the signed state token. */ trustedBrowserAttemptId?: string; + /** + * The account an explicit link binds to. Like `trustedBrowserAttemptId` + * it is a function argument, never a value parsed out of the request: + * the HTTP route sets it only from a verified Heimdall access token for + * this app (`resolveLinkAccount`), so a caller cannot name an account. + */ + linkAccountId?: string; } ): Promise { const { config, keys } = ctx; @@ -501,6 +622,16 @@ export async function startOAuthFlow( }; } + // A connection target is app-backend authority: it rides only on connect + // starts that carry the app's own secret (gated below), and never on a + // sign_in start, which is open to any browser. + if (input.mode === "sign_in" && input.connection !== undefined) { + return { + statusCode: 400, + body: { error: "connection_not_allowed_in_sign_in", detail: "sign_in start must not carry a connection target." }, + }; + } + if (callbackMint(profile, provider, input.mode, input.connection) === "refused") { return { statusCode: 400, @@ -511,6 +642,48 @@ export async function startOAuthFlow( }; } + const descriptor = providerCatalog[provider]; + // A provider that never mints an account attaches an identity only through + // a bound link: the user's own access token, since the app's word does not + // name this person to Google. A link start without a verified Heimdall session would end + // in a refusal at the callback, so it is refused before the redirect. + // `sign_in` stays open: it signs in an identity already linked, and the + // callback refuses one that is not. + if (descriptor.firstLogin === "requires_link" && input.mode === "link" && !options?.linkAccountId) { + return { + statusCode: 401, + body: { + error: "link_session_required", + detail: `Linking ${descriptor.displayName} needs a Heimdall access token for ${profile.slug}: send it as a Bearer token.`, + }, + }; + } + + // Link mode binds an identity to an existing account, so the account must + // be named by someone Heimdall can believe: the authenticated app backend. + // The name rides in the signed state and the callback holds the provider's + // identity to it. No other mode carries an initiator. + if (input.mode === "link") { + if (options?.linkAccountId) { + // The user's own access token for this app names the account. + if (input.initiatingAccountId !== undefined && input.initiatingAccountId !== options.linkAccountId) { + return { statusCode: 400, body: { error: "link_initiator_conflict", detail: "initiatingAccountId differs from the bearer's account." } }; + } + } else { + if (!isAppCaller(caller) || caller.appSlug !== profile.slug) { + return { statusCode: 401, body: { error: "app_auth_required" } }; + } + if (!input.initiatingAccountId) { + return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } }; + } + if (!(await ctx.store.findAccountById(input.initiatingAccountId))) { + return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } }; + } + } + } else if (input.initiatingAccountId !== undefined) { + return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } }; + } + if (!isAllowedReturnOrigin(profile, input.returnTo)) { return { statusCode: 400, @@ -554,7 +727,7 @@ export async function startOAuthFlow( if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") { handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId }; } - if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { + if ((entitlementPolicy || profile.startRequiresAppCaller || (input.mode === "connect" && input.connection !== undefined)) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { return { statusCode: 401, body: { error: "app_auth_required" } }; } @@ -566,7 +739,6 @@ export async function startOAuthFlow( }; } - const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; const state = buildOAuthStateToken({ config, provider, @@ -574,32 +746,69 @@ export async function startOAuthFlow( mode: input.mode, returnTo: input.returnTo, connection: input.connection, + initiatorAccountId: options?.linkAccountId ?? input.initiatingAccountId, handoff, entitlementPolicy, + requestedScopes: input.requestedScopes, keys, }); - return { statusCode: 201, body: { provider, appSlug: profile.slug, mode: input.mode, - callbackUrl, - authorizationUrl: buildAuthorizationUrl({ - provider, - clientId: providerConfig.clientId, - redirectUri: callbackUrl, - state: state.token, - requestedScopes: input.requestedScopes, - }), + callbackUrl: oauthCallbackUrl(config, provider), + authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`, handoff, - stateToken: state.token, stateExpiresAt: state.expiresAt, }, }; } +/** + * The account a link start binds to: the one a live Heimdall access token for + * this app names. The token must verify under Heimdall's own key, be an access + * token for `appSlug`, be inside its validity window, and belong to a session + * the store still holds for that account at that revision. A bearer that fails + * any of these binds nothing: a link is explicit or it does not happen. + */ +async function resolveLinkAccount( + ctx: Pick, + appSlug: AppSlug, + authorization: string | undefined +): Promise { + const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1]; + if (!token) return null; + const verification = verifyJwt(token, ctx.keys.publicKey); + if (!verification.valid) return null; + const claims = verification.payload; + const now = nowEpochSeconds(); + if ( + claims.typ !== "heimdall_access" || + claims.iss !== ctx.config.issuer || + claims.aud !== appSlug || + typeof claims.account_id !== "string" || + typeof claims.sid !== "string" || + typeof claims.access_revision !== "number" || + typeof claims.exp !== "number" || + claims.exp <= now || + (typeof claims.nbf === "number" && claims.nbf > now) + ) { + return null; + } + const session = await ctx.store.findSession(appSlug, claims.sid); + if ( + !session || + session.accountId !== claims.account_id || + session.accessRevision !== claims.access_revision || + Date.parse(session.expiresAt) <= Date.now() + ) { + return null; + } + return claims.account_id; +} + /** * Session refresh, extracted for the same reason as startOAuthFlow: the HTTP * route and the plane's `refreshAuth` (which owns Ghostlight identity) call @@ -738,6 +947,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise ({ ok: true, @@ -822,6 +1032,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { const caller = resolveAppCaller(config, request.body.appSlug, request.headers); - const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body); + let linkAccountId: string | undefined; + if (request.body.mode === "link") { + const bound = await resolveLinkAccount({ config, keys, store }, request.body.appSlug, request.headers.authorization); + if (bound === null && request.headers.authorization !== undefined) { + reply.code(401); + return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." }; + } + linkAccountId = bound ?? undefined; + } + const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body, { + ...(linkAccountId !== undefined ? { linkAccountId } : {}), + }); reply.code(result.statusCode); return result.body; } ); + /** + * A refusal of a verified state. A browser (Accept: text/html) gets the + * handoff page linking back to the state's return_to, so the person is not + * left on a bare JSON body; the origin is re-checked against the app's + * allowlist here rather than trusting the signed value. Anything else gets + * the fixed JSON code. + */ + const refuseVerifiedState = ( + request: FastifyRequest, + reply: FastifyReply, + state: OAuthStatePayload, + error: "state_not_bound" | "state_already_launched" | "state_already_used" + ) => { + reply.code(400); + const profile = getAppProfile(state.app_slug); + if (prefersHtml(request.headers.accept) && profile && isAllowedReturnOrigin(profile, state.return_to)) { + reply.type("text/html; charset=utf-8"); + return reply.send( + renderBrowserHandoffPage({ + handoffKind: (state.handoff ?? { kind: "browser_completion" }).kind, + status: "error", + provider: state.provider, + appSlug: state.app_slug, + mode: state.mode, + returnTo: state.return_to, + error, + }) + ); + } + return { error }; + }; + + app.get<{ + Params: { provider: Provider }; + Querystring: { state: string }; + }>( + "/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) { + return refuseVerifiedState(request, reply, state, "state_already_launched"); + } + + const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined; + setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state), state.exp - nowEpochSeconds()); + reply.header("Cache-Control", "no-store"); + reply.header("Referrer-Policy", "no-referrer"); + return reply.redirect( + buildAuthorizationUrl({ + provider: state.provider, + clientId, + redirectUri: oauthCallbackUrl(config, state.provider), + state: request.query.state, + requestedScopes: state.requested_scopes ?? undefined, + ...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}), + }), + 302 + ); + } + ); + app.get<{ Params: { provider: Provider }; Querystring: { code?: string; state?: string; error?: string; error_description?: string }; @@ -905,17 +1226,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { if (handoff.kind !== "browser_completion" || !handoff.attemptId) return; @@ -925,6 +1241,26 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { const linkedIdentityInput: UpsertLinkedIdentityInput = { - accountId: account.id, + accountId, provider: identity.provider, providerUserId: identity.providerUserId, scopes: tokenSet.scope, @@ -1085,7 +1424,100 @@ export async function buildApp(options: BuildAppOptions = {}): Promise, @@ -1307,12 +1754,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + "/v1/apps/:appSlug/links/confirm", + { + schema: { + params: { + type: "object", + required: ["appSlug"], + additionalProperties: false, + properties: { appSlug: { type: "string", enum: [...appSlugs] } }, + }, + body: { + type: "object", + required: ["pendingLinkId"], + additionalProperties: false, + properties: { pendingLinkId: { type: "string", minLength: 1 } }, + }, + }, + }, + async (request, reply) => { + const accountId = await resolveLinkAccount({ config, keys, store }, request.params.appSlug, request.headers.authorization); + if (!accountId) { + reply.code(401); + return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." }; + } + const nowIso = new Date().toISOString(); + const pending = await store.consumePendingLink(request.params.appSlug, request.body.pendingLinkId, accountId, nowIso); + if (!pending) { + reply.code(410); + return { error: "invalid_or_expired_pending_link" }; + } + const holder = await store.findAccountByLinkedIdentity(pending.provider, pending.providerUserId); + if (holder && holder.id !== accountId) { + reply.code(409); + return { error: "identity_linked_elsewhere" }; + } + await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso }); + await store.createAuditEvent({ + accountId, + appSlug: pending.appSlug, + eventType: "link_confirmed", + eventPayloadJson: { provider: pending.provider }, + createdAt: nowIso, + }); + reply.code(201); + return { status: "linked", provider: pending.provider, accountId, returnTo: pending.returnTo }; + } + ); + app.post<{ Params: { appSlug: AppSlug }; Body: RedeemAuthCompletionRequest }>( "/v1/apps/:appSlug/auth-completions/redeem", { @@ -1388,12 +1891,38 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { const nowIso = new Date().toISOString(); + // Sign-in and connect completions are redeemed by the browser flow, so + // the route stays open to it. A link completion was minted for an + // initiator the authenticated app named at start; only that app's + // backend redeems it. Redeem is also where the connection target is + // held to what the signed state named. Both refusals read the row + // without consuming it, so a wrong caller or target cannot burn the code. + const pending = await store.findAuthCompletion(request.params.appSlug, request.body.completionCode); + if (pending && !pending.consumedAt && pending.expiresAt > nowIso) { + if (pending.mode === "link" && !resolveAppCaller(config, request.params.appSlug, request.headers)) { + reply.code(401); + return { error: "app_auth_required" }; + } + if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) { + await store.createAuditEvent({ + accountId: pending.accountId, + sessionId: pending.sessionId, + appSlug: pending.appSlug, + eventType: "auth_completion_target_refused", + eventPayloadJson: { provider: pending.provider, mode: pending.mode }, + createdAt: nowIso, + }); + reply.code(403); + return { error: "completion_target_mismatch" }; + } + } const completion = await store.consumeAuthCompletion(request.params.appSlug, request.body.completionCode, nowIso); if (!completion) { diff --git a/src/backend-handoff.ts b/src/backend-handoff.ts index 204c570..d49a125 100644 --- a/src/backend-handoff.ts +++ b/src/backend-handoff.ts @@ -39,7 +39,16 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase { errorDescription?: string; } -export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload; +/** A verified link that waits for the initiating account's confirm: no session, no tokens. */ +export interface BackendHandoffLinkPendingPayload extends BackendHandoffBase { + status: "link_pending"; + mode: OAuthMode; + pendingLinkId: string; + expiresAt: string; + confirmEndpoint: string; +} + +export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload | BackendHandoffLinkPendingPayload; export async function deliverBackendHandoff(callbackUrl: string, payload: BackendHandoffPayload): Promise { const response = await fetch(callbackUrl, { diff --git a/src/browser-handoff.ts b/src/browser-handoff.ts index e23601e..c21c01e 100644 --- a/src/browser-handoff.ts +++ b/src/browser-handoff.ts @@ -12,6 +12,8 @@ export interface BrowserHandoffSuccess extends BrowserHandoffBase { status: "success"; mode: OAuthMode; completionCode?: string; + /** A verified link waiting for its initiator's confirm; carries no session. */ + pendingLinkId?: string; attemptId?: string; } @@ -47,6 +49,8 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string { if (payload.status === "success" && payload.completionCode) { fragment.set("heimdall_completion_code", payload.completionCode); + } else if (payload.status === "success" && payload.pendingLinkId) { + fragment.set("heimdall_pending_link_id", payload.pendingLinkId); } else { if (payload.status === "error") { fragment.set("heimdall_error", payload.error); diff --git a/src/contracts.ts b/src/contracts.ts index d6cab2a..0ee7c96 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,4 +1,4 @@ -export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const; +export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick", "google"] as const; export type Provider = (typeof providers)[number]; @@ -73,6 +73,8 @@ export interface OAuthStartRequest { mode: OAuthMode; returnTo: string; connection?: OAuthConnectionBinding; + /** Link mode only: the account the authenticated app says is linking. Carried in the signed state. */ + initiatingAccountId?: string; handoff?: OAuthHandoff; requestedScopes?: string[]; entitlementPolicy?: OAuthEntitlementPolicy; @@ -92,8 +94,11 @@ export interface OAuthStatePayload { mode: OAuthMode; return_to: string; connection: OAuthConnectionBinding | null; + initiator_account_id: string | null; handoff: OAuthHandoff | null; entitlement_policy: OAuthEntitlementPolicy | null; + /** The start's requestedScopes, carried so the launch hop can build the provider URL from the signed state alone. */ + requested_scopes: string[] | null; } export interface LinkedIdentityInput { @@ -105,6 +110,8 @@ export interface LinkedIdentityInput { export interface RedeemAuthCompletionRequest { completionCode: string; + /** Required when the completion was minted for a connection target; must equal it. */ + targetId?: string; } export type HeimdallAuthAttemptStatus = "pending" | "completed" | "denied" | "consumed" | "expired"; diff --git a/src/google-oidc.ts b/src/google-oidc.ts new file mode 100644 index 0000000..b368e45 --- /dev/null +++ b/src/google-oidc.ts @@ -0,0 +1,228 @@ +import { createHash, createPublicKey, sign, verify, type KeyObject } from "node:crypto"; + +/** + * Google sign-in as OpenID Connect: the id_token is the identity, and this + * file is the one place that decides whether to believe it. The subject + * (`sub`) is the account key; the email is a claim about the subject, never + * its key. + */ + +export const GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"] as const; +export const GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs"; + +const CLOCK_LEEWAY_SECONDS = 60; +const DEFAULT_JWKS_TTL_SECONDS = 3600; +const MAX_JWKS_TTL_SECONDS = 24 * 3600; +/** An unknown `kid` refetches the key set at most this often. */ +const MIN_JWKS_REFETCH_MS = 60_000; + +export type GoogleIdTokenFailure = + | "malformed" + | "unsupported_algorithm" + | "unknown_key" + | "bad_signature" + | "bad_issuer" + | "bad_audience" + | "expired" + | "not_yet_valid" + | "bad_nonce" + | "missing_subject" + | "email_missing" + | "email_not_verified" + | "jwks_unavailable"; + +/** A refusal that names the rule that failed and quotes nothing from the token. */ +export class GoogleIdTokenError extends Error { + readonly code: GoogleIdTokenFailure; + + constructor(code: GoogleIdTokenFailure) { + super(`Google id_token refused (${code}).`); + this.name = "GoogleIdTokenError"; + this.code = code; + } +} + +export interface GoogleIdentityClaims { + sub: string; + email: string; + name?: string; + picture?: string; + hd?: string; +} + +interface Jwk { + kid?: string; + kty?: string; + alg?: string; + n?: string; + e?: string; +} + +let cachedKeys: { keys: Map; expiresAt: number; fetchedAt: number } | null = null; + +export function resetGoogleJwksCache(): void { + cachedKeys = null; +} + +async function fetchGoogleKeys(now: number): Promise<{ keys: Map; expiresAt: number; fetchedAt: number }> { + let response: Response; + try { + response = await fetch(GOOGLE_JWKS_URI, { headers: { Accept: "application/json" } }); + } catch { + throw new GoogleIdTokenError("jwks_unavailable"); + } + if (!response.ok) { + throw new GoogleIdTokenError("jwks_unavailable"); + } + let body: { keys?: Jwk[] }; + try { + body = (await response.json()) as { keys?: Jwk[] }; + } catch { + throw new GoogleIdTokenError("jwks_unavailable"); + } + const keys = new Map(); + for (const jwk of body.keys ?? []) { + if (jwk.kty !== "RSA" || !jwk.kid || (jwk.alg !== undefined && jwk.alg !== "RS256")) continue; + try { + keys.set(jwk.kid, createPublicKey({ key: jwk as never, format: "jwk" })); + } catch { + // A key Node cannot read verifies nothing; the others stand. + } + } + const maxAge = /max-age=(\d+)/.exec(response.headers.get("cache-control") ?? ""); + const ttl = Math.min(maxAge ? Number(maxAge[1]) : DEFAULT_JWKS_TTL_SECONDS, MAX_JWKS_TTL_SECONDS); + return { keys, expiresAt: now + ttl * 1000, fetchedAt: now }; +} + +async function keyForKid(kid: string, now: number): Promise { + if (cachedKeys && cachedKeys.expiresAt > now) { + const hit = cachedKeys.keys.get(kid); + if (hit) return hit; + // Unknown kid in a live cache: Google may have rotated. Refetch, but not on every bad token. + if (now - cachedKeys.fetchedAt < MIN_JWKS_REFETCH_MS) { + throw new GoogleIdTokenError("unknown_key"); + } + } + cachedKeys = await fetchGoogleKeys(now); + const key = cachedKeys.keys.get(kid); + if (!key) throw new GoogleIdTokenError("unknown_key"); + return key; +} + +function decodeSegment(segment: string): Record { + try { + const value = JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as unknown; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + return value as Record; + } + } catch { + // fallthrough + } + throw new GoogleIdTokenError("malformed"); +} + +/** + * Verifies a Google id_token for this client. Order is signature first, then + * claims, so no claim of an unauthenticated token decides anything. Refuses + * on: algorithm other than RS256, unknown key, bad signature, `iss` not + * Google, `aud` not this client (an array `aud` also needs `azp` to be this + * client), `exp` passed, `iat` or `nbf` in the future, `nonce` not the one + * this authorization request carried, no `sub`, no `email`, and an email + * Google has not verified. + */ +export async function verifyGoogleIdToken( + idToken: string, + expected: { clientId: string; nonce: string }, + nowMs: number = Date.now() +): Promise { + const parts = idToken.split("."); + if (parts.length !== 3 || parts.some((part) => part === "")) { + throw new GoogleIdTokenError("malformed"); + } + const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string]; + const header = decodeSegment(encodedHeader); + if (header.alg !== "RS256") { + throw new GoogleIdTokenError("unsupported_algorithm"); + } + if (typeof header.kid !== "string" || header.kid === "") { + throw new GoogleIdTokenError("unknown_key"); + } + + const key = await keyForKid(header.kid, nowMs); + const signatureValid = verify( + "RSA-SHA256", + Buffer.from(`${encodedHeader}.${encodedPayload}`, "utf8"), + key, + Buffer.from(encodedSignature, "base64url") + ); + if (!signatureValid) { + throw new GoogleIdTokenError("bad_signature"); + } + + const claims = decodeSegment(encodedPayload); + const nowSeconds = Math.floor(nowMs / 1000); + + if (typeof claims.iss !== "string" || !(GOOGLE_ISSUERS as readonly string[]).includes(claims.iss)) { + throw new GoogleIdTokenError("bad_issuer"); + } + const aud = claims.aud; + const audienceOk = + typeof aud === "string" + ? aud === expected.clientId + : Array.isArray(aud) && aud.includes(expected.clientId) && claims.azp === expected.clientId; + if (!audienceOk) { + throw new GoogleIdTokenError("bad_audience"); + } + if (typeof claims.exp !== "number" || claims.exp + CLOCK_LEEWAY_SECONDS <= nowSeconds) { + throw new GoogleIdTokenError("expired"); + } + if (typeof claims.iat !== "number" || claims.iat - CLOCK_LEEWAY_SECONDS > nowSeconds) { + throw new GoogleIdTokenError("not_yet_valid"); + } + if (claims.nbf !== undefined && (typeof claims.nbf !== "number" || claims.nbf - CLOCK_LEEWAY_SECONDS > nowSeconds)) { + throw new GoogleIdTokenError("not_yet_valid"); + } + if (typeof claims.nonce !== "string" || claims.nonce !== expected.nonce) { + throw new GoogleIdTokenError("bad_nonce"); + } + if (typeof claims.sub !== "string" || claims.sub === "" || claims.sub.length > 255) { + throw new GoogleIdTokenError("missing_subject"); + } + if (typeof claims.email !== "string" || claims.email === "") { + throw new GoogleIdTokenError("email_missing"); + } + if (claims.email_verified !== true && claims.email_verified !== "true") { + throw new GoogleIdTokenError("email_not_verified"); + } + + const identity: GoogleIdentityClaims = { sub: claims.sub, email: claims.email }; + if (typeof claims.name === "string" && claims.name !== "") identity.name = claims.name; + if (typeof claims.picture === "string" && claims.picture !== "") identity.picture = claims.picture; + if (typeof claims.hd === "string" && claims.hd !== "") identity.hd = claims.hd; + return identity; +} + +/** + * The PKCE verifier and the nonce of one authorization request, derived from + * the request's state `jti` and Heimdall's signing key instead of stored. The + * state token travels through the browser and carries the `jti`, so whoever + * can read the redirect can read the `jti`; they cannot compute an Ed25519 + * signature over it. The signature is deterministic, so start and callback + * derive the same pair, and nothing is persisted between them. + */ +export function deriveOidcSecrets(stateJti: string, signingKey: KeyObject): { + codeVerifier: string; + codeChallenge: string; + nonce: string; +} { + const derive = (label: string): string => + createHash("sha256") + .update(sign(null, Buffer.from(`heimdall:oidc:${label}:${stateJti}`, "utf8"), signingKey)) + .digest("base64url"); + const codeVerifier = derive("pkce-verifier"); + return { + codeVerifier, + codeChallenge: createHash("sha256").update(codeVerifier, "ascii").digest("base64url"), + nonce: derive("nonce"), + }; +} 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/oauth.ts b/src/oauth.ts index b6658e8..346726d 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -7,6 +7,7 @@ import { } from "./contracts.js"; import { type HeimdallConfig } from "./config.js"; import { entitlementFacts } from "./facts.js"; +import { verifyGoogleIdToken } from "./google-oidc.js"; import { providerCatalog } from "./providers.js"; import { type CreateEntitlementSnapshotInput } from "./store/types.js"; @@ -49,13 +50,22 @@ export interface OAuthProviderRuntime { config: HeimdallConfig; code: string; redirectUri: string; + /** PKCE verifier of an OIDC provider's request (RFC 7636). */ + codeVerifier?: string; }): Promise; refreshAccessToken?(options: { config: HeimdallConfig; refreshToken: string; signal?: AbortSignal; }): Promise; - resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; + resolveIdentity(options: { + config: HeimdallConfig; + accessToken: string; + /** The exchange's answer: an OIDC provider's identity is its id_token, not a lookup with the access token. */ + tokenSet?: OAuthTokenSet; + /** The nonce this authorization request carried; an OIDC provider's id_token must echo it. */ + oidcNonce?: string; + }): Promise; evaluateEntitlements(options: { config: HeimdallConfig; callback: OAuthCallbackContext; @@ -144,7 +154,7 @@ function normalizeScopes(scope: string | string[] | undefined): string[] { } export type TokenGrant = - | { grant_type: "authorization_code"; code: string; redirect_uri: string } + | { grant_type: "authorization_code"; code: string; redirect_uri: string; code_verifier?: string } | { grant_type: "refresh_token"; refresh_token: string }; /** @@ -249,6 +259,7 @@ function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchan grant_type: "authorization_code", code: options.code, redirect_uri: options.redirectUri, + ...(options.codeVerifier !== undefined ? { code_verifier: options.codeVerifier } : {}), }); // A rotating provider's refresh token is the only way back to the // credential: a grant that arrives without one would be stored as a row @@ -527,6 +538,31 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise return identity; } +async function resolveGoogleIdentity(options: { + config: HeimdallConfig; + tokenSet?: OAuthTokenSet; + oidcNonce?: string; +}): Promise { + const clientId = options.config.providers.google.clientId; + const idToken = options.tokenSet?.raw.id_token; + if (!clientId || typeof idToken !== "string" || !options.oidcNonce) { + throw new Error("Google sign-in needs a client id, an id_token and the request's nonce."); + } + const claims = await verifyGoogleIdToken(idToken, { clientId, nonce: options.oidcNonce }); + const identity: ResolvedIdentity = { + provider: "google", + providerUserId: claims.sub, + username: claims.email, + displayName: claims.name ?? claims.email, + primaryEmail: claims.email, + profile: { sub: claims.sub, email: claims.email, email_verified: true, name: claims.name, picture: claims.picture, hd: claims.hd }, + }; + if (claims.picture) { + identity.avatarUrl = claims.picture; + } + return identity; +} + async function resolveSpotifyIdentity(options: { accessToken: string }): Promise { const profile = await fetchJson<{ id?: string; @@ -945,6 +981,15 @@ const youtubeRuntime: OAuthProviderRuntime = { }, }; +const googleRuntime: OAuthProviderRuntime = { + exchangeAuthorizationCode: sharedRuntimeExchange("google"), + refreshAccessToken: sharedRuntimeRefresh("google"), + resolveIdentity: resolveGoogleIdentity, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, +}; + const spotifyRuntime: OAuthProviderRuntime = { exchangeAuthorizationCode: sharedRuntimeExchange("spotify"), refreshAccessToken: sharedRuntimeRefresh("spotify"), @@ -986,5 +1031,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry { youtube: youtubeRuntime, spotify: spotifyRuntime, joystick: joystickRuntime, + google: googleRuntime, }; } 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 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/providers.ts b/src/providers.ts index 77eebf4..b649ede 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -22,6 +22,18 @@ export interface ProviderDescriptor { clientIdEnv: string; clientSecretEnv: string; additionalAuthorizationParams?: Record; + /** + * What a callback does with a (provider, subject) no account holds yet. + * `mints_account` is every provider's behaviour before Google: the first + * login makes an account. `requires_link` never mints: the identity + * attaches only to the account a verified link start bound + * (`initiator_account_id`), otherwise the callback refuses. Google is a second + * door into a person who already has an account, so a first Google login + * must not make a stranger of them. + */ + firstLogin: "mints_account" | "requires_link"; + /** OpenID Connect: the callback verifies an id_token and uses PKCE (S256) and a nonce. */ + oidc?: true; } export const providerCatalog: Record = { @@ -35,6 +47,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["identify", "guilds.members.read"], roles: ["identity", "entitlement"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE", }, @@ -48,6 +61,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["identity", "identity[email]"], roles: ["identity", "entitlement"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE", }, @@ -61,6 +75,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["read:user", "user:email"], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE", }, @@ -74,6 +89,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["user:read:email"], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE", }, @@ -92,6 +108,7 @@ export const providerCatalog: Record = { "https://www.googleapis.com/auth/youtube.readonly", ], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET_FILE", additionalAuthorizationParams: { @@ -114,9 +131,26 @@ export const providerCatalog: Record = { "user-modify-playback-state", ], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE", }, + google: { + key: "google", + displayName: "Google", + authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", + tokenEndpoint: "https://oauth2.googleapis.com/token", + tokenEndpointAuthMethod: "client_secret_post", + rotatesRefreshTokens: false, + defaultAccessTokenLifetimeSeconds: 3600, + defaultScopes: ["openid", "email", "profile"], + roles: ["identity"], + firstLogin: "requires_link", + oidc: true, + clientIdEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID", + clientSecretEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE", + additionalAuthorizationParams: { prompt: "select_account" }, + }, joystick: { key: "joystick", displayName: "Joystick", @@ -127,6 +161,7 @@ export const providerCatalog: Record = { defaultAccessTokenLifetimeSeconds: 3600, defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"], roles: ["identity", "managed_credential"], + firstLogin: "mints_account", clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE", }, @@ -138,6 +173,8 @@ export function buildAuthorizationUrl(options: { redirectUri: string; state: string; requestedScopes: string[] | undefined; + /** For an OIDC provider: the nonce, and the S256 challenge of the PKCE verifier. */ + oidc?: { nonce: string; codeChallenge: string }; }): string { const descriptor = providerCatalog[options.provider]; const url = new URL(descriptor.authorizationEndpoint); @@ -148,6 +185,14 @@ export function buildAuthorizationUrl(options: { url.searchParams.set("response_type", "code"); url.searchParams.set("scope", scopes.join(" ")); url.searchParams.set("state", options.state); + if (descriptor.oidc) { + if (!options.oidc) { + throw new Error(`${descriptor.displayName} is an OIDC provider: its authorization URL needs a nonce and a PKCE challenge.`); + } + url.searchParams.set("nonce", options.oidc.nonce); + url.searchParams.set("code_challenge", options.oidc.codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + } for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) { url.searchParams.set(key, value); 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 6a53dad..eafaea5 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -3,7 +3,10 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra import { type CreateAccountInput, type CreateAuthAttemptInput, + type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, + type CreatePendingLinkInput, + type StoredPendingLink, type CreateAuditEventInput, type CreateCapabilityGrantInput, type CredentialDecision, @@ -21,6 +24,9 @@ import { type UpsertLinkedIdentityInput, } from "./types.js"; +/** The skew a signed OAuth state is still accepted for past its exp (verifyOAuthState). */ +const OAUTH_STATE_SKEW_MS = 30_000; + function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } @@ -47,7 +53,10 @@ export class InMemoryStore implements HeimdallStore { private readonly authAttempts = new Map(); private readonly privateCommandReceipts = new Map(); private readonly authCompletions = new Map(); + private readonly pendingLinks = new Map(); private readonly authCompletionsByAttempt = new Map(); + /** `${jti}:${phase}` -> the state's expiresAt. */ + private readonly oauthStateUses = new Map(); private readonly entitlementSnapshots = new Map(); private readonly auditEvents = new Map(); @@ -390,6 +399,7 @@ export class InMemoryStore implements HeimdallStore { accountId: input.accountId, sessionId: input.sessionId, returnTo: input.returnTo, + ...(input.targetId ? { targetId: input.targetId } : {}), createdAt: input.createdAt, expiresAt: input.expiresAt, payloadJson: clone(input.payloadJson), @@ -402,6 +412,11 @@ export class InMemoryStore implements HeimdallStore { return clone(completion); } + async findAuthCompletion(appSlug: AppSlug, code: string): Promise { + const completion = this.authCompletions.get(code); + return completion && completion.appSlug === appSlug ? clone(completion) : null; + } + async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise { const completion = this.authCompletions.get(code); if (!completion) { @@ -424,6 +439,37 @@ export class InMemoryStore implements HeimdallStore { return this.consumeAuthCompletion(appSlug, code, at); } + async createPendingLink(input: CreatePendingLinkInput): Promise { + const link: StoredPendingLink = { id: randomUUID(), ...clone(input) }; + this.pendingLinks.set(link.id, clone(link)); + return clone(link); + } + + async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise { + const link = this.pendingLinks.get(id); + if (!link || link.appSlug !== appSlug || link.accountId !== accountId || link.expiresAt <= at || link.consumedAt) { + return null; + } + link.consumedAt = at; + return clone(link); + } + + async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise { + // A state is still accepted until its exp + 30 s skew, so a use record must outlive that. + const usedAtMs = Date.parse(input.usedAt); + for (const [key, expiresAt] of this.oauthStateUses) { + if (Date.parse(expiresAt) + OAUTH_STATE_SKEW_MS <= usedAtMs) { + 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..e3a1c4d 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -5,7 +5,10 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f import { type CreateAccountInput, type CreateAuthAttemptInput, + type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, + type CreatePendingLinkInput, + type StoredPendingLink, type CreateAuditEventInput, type CreateCapabilityGrantInput, type CredentialDecision, @@ -82,12 +85,26 @@ interface AuthCompletionRow extends QueryResultRow { account_id: string; session_id: string; return_to: string; + target_id: string | null; payload_json: Record; created_at: string | Date; expires_at: string | Date; consumed_at: string | Date | null; } +interface PendingLinkRow extends QueryResultRow { + id: string; + app_slug: AppSlug; + account_id: string; + provider: Provider; + provider_user_id: string; + identity_json: StoredPendingLink["identity"]; + return_to: string; + created_at: string | Date; + expires_at: string | Date; + consumed_at: string | Date | null; +} + interface AuthAttemptRow extends QueryResultRow { handle: string; app_slug: AppSlug; @@ -231,6 +248,24 @@ function mapSessionRow(row: SessionRow): StoredSession { }; } +function mapPendingLinkRow(row: PendingLinkRow): StoredPendingLink { + const link: StoredPendingLink = { + id: row.id, + appSlug: row.app_slug, + accountId: row.account_id, + provider: row.provider, + providerUserId: row.provider_user_id, + identity: row.identity_json, + returnTo: row.return_to, + createdAt: normalizeTimestamp(row.created_at), + expiresAt: normalizeTimestamp(row.expires_at), + }; + if (row.consumed_at) { + link.consumedAt = normalizeTimestamp(row.consumed_at); + } + return link; +} + function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion { const completion: StoredAuthCompletion = { code: row.code, @@ -249,6 +284,10 @@ function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion { completion.attemptId = row.attempt_id; } + if (row.target_id) { + completion.targetId = row.target_id; + } + if (row.consumed_at) { completion.consumedAt = normalizeTimestamp(row.consumed_at); } @@ -378,6 +417,8 @@ export const STORE_TABLE_PRIVILEGES: Readonly { + const result = await this.pool.query( + "SELECT * FROM auth_completions WHERE code = $1 AND app_slug = $2", + [code, appSlug] + ); + return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "findAuthCompletion")) : null; + } + async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise { const result = await this.pool.query( ` @@ -1061,6 +1111,61 @@ export class PostgresStore implements HeimdallStore { return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : null; } + async createPendingLink(input: CreatePendingLinkInput): Promise { + const result = await this.pool.query( + ` + INSERT INTO pending_links ( + id, app_slug, account_id, provider, provider_user_id, identity_json, + return_to, created_at, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING * + `, + [ + randomUUID(), + input.appSlug, + input.accountId, + input.provider, + input.providerUserId, + JSON.stringify(input.identity), + input.returnTo, + input.createdAt, + input.expiresAt, + ] + ); + return mapPendingLinkRow(expectRow(result.rows[0], "createPendingLink")); + } + + async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise { + const result = await this.pool.query( + ` + UPDATE pending_links + SET consumed_at = $4 + WHERE id = $1 + AND app_slug = $2 + AND account_id = $3 + AND consumed_at IS NULL + AND expires_at > $4 + RETURNING * + `, + [id, appSlug, accountId, at] + ); + return result.rowCount ? mapPendingLinkRow(expectRow(result.rows[0], "consumePendingLink")) : null; + } + + async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise { + // 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..607445c 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -82,6 +82,19 @@ CREATE TABLE IF NOT EXISTS private_command_receipts ( CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx ON private_command_receipts(expires_at); +CREATE TABLE IF NOT EXISTS pending_links ( + id TEXT PRIMARY KEY, + app_slug TEXT NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + identity_json JSONB NOT NULL, + return_to TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); + CREATE TABLE IF NOT EXISTS auth_completions ( code TEXT PRIMARY KEY, app_slug TEXT NOT NULL, @@ -106,6 +119,10 @@ CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx -- redeem the completion it created without ever needing the code. ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT; +-- The connection target the signed state named (connection.targetId). Redeem +-- compares it against the target the redeeming app states. +ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT; + -- One attempt handle may bind at most one unconsumed completion (R21.1): a -- second completion for the same handle would let the by-attempt UPDATE in -- consumeAuthCompletionByAttempt match more than one row, silently consuming @@ -119,6 +136,20 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL; +-- One use per (state jti, phase): a signed OAuth state launches once and +-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING +-- reports every later claim as no row. The Postgres store deletes nothing, so rows +-- stay like consumed auth_completions; a state is accepted until expires_at + 30 s +-- of skew, so a row may be pruned only after that and 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..982bccc 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -159,18 +159,29 @@ export interface StoredAuthCompletion { accountId: string; sessionId: string; returnTo: string; + /** The connection target the signed state named, if any; compared at redeem. */ + targetId?: string; createdAt: string; expiresAt: string; consumedAt?: string; 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 * always mints `code` itself; a caller cannot choose it. */ attemptId?: string; + targetId?: string; appSlug: AppSlug; provider: Provider; mode: OAuthMode; @@ -182,6 +193,28 @@ export interface CreateAuthCompletionInput { payloadJson: Record; } +/** + * A link the callback has verified but not written. The identity attaches to + * `accountId` (the account whose Heimdall access token began the link) only + * when that same account confirms it; until then no linked-identity row + * exists. `identity` holds the already-encrypted row to write. + */ +export interface StoredPendingLink { + id: string; + appSlug: AppSlug; + accountId: string; + provider: Provider; + providerUserId: string; + identity: UpsertLinkedIdentityInput; + returnTo: string; + createdAt: string; + expiresAt: string; + consumedAt?: string; +} + +/** The store mints `id`; a caller cannot choose it. */ +export type CreatePendingLinkInput = Omit; + export interface StoredAuthAttempt { handle: string; appSlug: AppSlug; @@ -263,6 +296,8 @@ export interface HeimdallStore { createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise; findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise; createAuthCompletion(input: CreateAuthCompletionInput): Promise; + /** Read a completion without consuming it, so redeem can refuse a mismatch without burning the code. */ + findAuthCompletion(appSlug: AppSlug, code: string): Promise; consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise; /** * Consume a completion by its correlated attempt handle instead of its @@ -271,6 +306,23 @@ export interface HeimdallStore { * it created without ever needing to know (or forge) the code. */ consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise; + createPendingLink(input: CreatePendingLinkInput): Promise; + /** + * Single-use redemption: returns the link only for the app and the account + * it was begun by, before it expires and once. A mismatch returns null and + * leaves the link unconsumed. + */ + consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise; upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise; + /** + * Claim one use of a signed OAuth state: true only for the first claim of + * (jti, phase), false for every later one. Without it a state is a bearer + * token any number of browsers can complete, so an attacker who learns a + * victim's state completes it as themselves and hands the victim a code + * for the attacker's account. `expiresAt` is the state's own expiry; the + * state is refused at that time before any claim, so the record is dead + * after it and a store may drop it. + */ + claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise; createAuditEvent(input: CreateAuditEventInput): Promise; } diff --git a/state/branches.json b/state/branches.json index 643b64f..32cd28f 100644 --- a/state/branches.json +++ b/state/branches.json @@ -16,11 +16,11 @@ { "id": "eureka/joystick-h1", "hypothesis": "One locked refresh primitive plus a Joystick OAuth provider in Heimdall lets StreamPixels ingest Joystick without ever presenting a provider refresh token.", - "status": "active", + "status": "accepted", "artifacts": [ "state/map.yaml" ], - "notes": "Campaign joystick:campaign:self, target joystick:target:r1. Hands owns code in a separate worktree." + "notes": "Merged as PR GameCult/Heimdall#1 (d51fa6b), deployed to Yggdrasil 2026-10-19 15:51Z; gamecult-ops 210a1af. Campaign joystick:campaign:self." } ] } diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 3178947..5329d5f 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -35,3 +35,5 @@ {"ts":"2026-08-22T02:01:00+00:00","type":"deployment","status":"accepted","note":"Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9. Odin b4f9a2e95f0b41cebdeddc49223781d1d3c7b42a returns Heimdall's provider, command boundary, Eve plugin, and transport profile under unique catalog keys. Ghostlight b515ca90c25573005a616244143803b37f2d06ec accepted canonical auth.begin through the Odin-resolved boundary; only advertised Discord and Heimdall navigation origins reached the browser."} {"ts": "2026-09-06T22:12:03+00:00", "type": "design", "status": "accepted", "note": "App slugs are no longer compile-time. AppSlug is a string; membership is a registry lookup, or isAppSlug shape validation where no store is reachable (the offline verifier). Capability rules are data (anyOf over Heimdall's own fact vocabulary), replacing a per-profile evaluateSharedCapabilities function that could not be accepted from a stranger. POST /v1/apps registers in RFC 7591 shape, gated on GC_ACCESS_APP_REGISTRATION_SECRET and closed until it is set. Built-in slugs are reserved so a registration cannot shadow a shipped app's audience. Motivation: a creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server to add one slug."} {"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> .gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"} +{"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"} +{"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."} diff --git a/state/map.yaml b/state/map.yaml index 3f8772a..4aa4d5a 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -59,8 +59,8 @@ data_flow: - reject per-request auth round-trip chatter active_subgoals: - - ACTIVE, M1-critical (operator ruling 2026-10-18): Joystick H1, campaign joystick:campaign:self (branch eureka/joystick-h1). Joystick OAuth provider, joystick as a streampixels viewer provider, one locked refresh primitive for every provider replacing refreshLinkedIdentityCredentialIfNeeded. - - OPEN, behind H1: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries. + - Joystick H1 is merged and live (PR GameCult/Heimdall#1, d51fa6b; campaign joystick:campaign:self). Open follow-ups by id: joystick:follow_up:h1-joystick-consent-round-trip (next), cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot. StreamPixels S0/S1 run in other sessions. + - OPEN, behind the H1 live round-trips: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries. - Keep the shared architecture explicit and separate from app profiles. - Preserve the default boundary that app-domain data remains app-owned. - Align the plan to Heimdall as a Yggdrasil-hosted authority service. @@ -95,7 +95,7 @@ accepted_design: landed_baseline: - Heimdall publishes its redacted provider, command boundary, Eve plugin, and transport profile through Odin under globally unique record keys; Ghostlight resolves that boundary instead of owning a direct endpoint. - - Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted. + - Yggdrasil runs Heimdall d51fa6b (hand-deployed 2026-10-19 15:51Z; gamecult-ops 210a1af holds backup, rollback and provenance; prior 1086aee) with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted. - The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified. - Exact-session refresh validation and logout revocation are implemented; stale or racing refresh cannot resurrect a revoked session. - Shared architecture extracted into its own repo and renamed to Heimdall. @@ -126,5 +126,5 @@ rejected_paths: reason: reusing auth mechanics does not justify centralizing app-domain audience or product data current_status: - summary: Active work is Joystick H1 (campaign joystick:campaign:self). Last recorded Yggdrasil deploy (not verifiable from this repo; HEAD is far past it, see git): Yggdrasil runs the gamecult.heimdall.access plugin and encrypted loopback begin, complete, refresh, and logout plane at 1086aee01169bf60e8a492b2740db1c6f3e8cabf. Odin returns all four redacted discovery records, Ghostlight resolves the boundary through Odin, and live anonymous auth.begin is accepted without exposing claims or secrets. - next_action: Land Joystick H1 per campaign joystick:campaign:self and target joystick:target:r1 (worktree and branch eureka/joystick-h1). Then, still open, run separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets. + summary: Yggdrasil runs Heimdall release d51fa6b (hand-deployed 2026-10-19 15:51Z, gamecult-ops 210a1af), which includes Joystick H1 (PR #1). The plugin and encrypted loopback begin, complete, refresh and logout plane, the Odin discovery records and Ghostlight resolution were verified at 1086aee (2026-08-22) and are not re-verified at d51fa6b. + next_action: Run joystick:follow_up:h1-joystick-consent-round-trip, then StreamPixels S0/S1 (other sessions). Still open behind that: separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets. diff --git a/tests/access-plugin.test.ts b/tests/access-plugin.test.ts index 4789bf8..6da0568 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -8,6 +8,7 @@ import { entitlementFacts } from "../src/facts.js"; import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js"; import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js"; import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { launchState } from "./oauth-flow.js"; const secret = "ghostlight-private-command-secret"; const resources: Array<{ close(): Promise }> = []; @@ -107,9 +108,12 @@ describe("Heimdall Eve access plugin", () => { const begin = openPrivateEnvelope(beginWire, secret); expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1"); const handle = String(begin.handle); - const navigation = begin.navigation as { url: string }; + const navigation = begin.navigation as { url: string; allowedOrigins: string[] }; const state = new URL(navigation.url).searchParams.get("state"); expect(state).toBeTruthy(); + // The browser goes to Heimdall's launch hop first, and only Heimdall's origin is allowed. + expect(navigation.url.startsWith("https://heimdall.gamecult.org/v1/oauth/discord/launch?state=")).toBe(true); + expect(navigation.allowedOrigins).toEqual(["https://heimdall.gamecult.org"]); const pendingEnvelope = sealPrivateEnvelope({ appSlug: "ghostlight", @@ -124,9 +128,11 @@ describe("Heimdall Eve access plugin", () => { const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret); expect(pending).toMatchObject({ status: "pending", handle }); + const { cookie } = await launchState(app, state ?? ""); const callback = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`, + headers: { cookie }, }); expect(callback.statusCode, callback.body).toBe(201); // The completion code is always minted by the store now, never the @@ -291,7 +297,7 @@ function testConfig(): HeimdallConfig { storage: { backend: "memory", applySchemaOnStartup: true }, providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, - patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, + patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {}, }, }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index 7f4d96d..67d4183 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -4,7 +4,8 @@ import { join } from "node:path"; import { inspect } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; -import { buildApp, errorIdentity } from "../src/app.js"; +import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js"; +import { resolveAppCaller } from "../src/app-caller.js"; import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js"; @@ -12,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,170 @@ describe("Heimdall service", () => { expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" })); }); + describe("apps that bind browser completion to their own attempt", () => { + // Bifrost's browser binding is the secrecy of its attempt nonce, which + // travels readable in the signed state. Only Bifrost may mint a Bifrost + // state, so a known nonce cannot carry a stranger's completion + // (finding known-nonce-login-csrf). + const victimAttempt = "VICTIM-ATTEMPT-NONCE"; + const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`; + const bifrostStart = (handoff?: { kind: "browser_completion" }) => ({ + appSlug: "bifrost" as const, + mode: "sign_in" as const, + returnTo: bifrostReturn, + ...(handoff ? { handoff } : {}), + }); + + it("refuses an unauthenticated Bifrost start for browser_completion and for an omitted handoff", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + for (const payload of [bifrostStart({ kind: "browser_completion" }), bifrostStart()]) { + const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload }); + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: "app_auth_required" }); + } + }); + + it("the attack fails: a stranger cannot obtain a Bifrost state carrying a chosen return_to nonce", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + payload: bifrostStart({ kind: "browser_completion" }), + }); + expect(response.statusCode).toBe(401); + expect(response.body).not.toContain(victimAttempt); + expect(response.body).not.toContain("stateToken"); + expect(response.body).not.toContain("authorizationUrl"); + }); + + it("still starts for Bifrost's own app secret, with the requested return_to in the state", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + headers: { "x-heimdall-app-secret": "bifrost-secret" }, + payload: bifrostStart({ kind: "browser_completion" }), + }); + expect(response.statusCode).toBe(201); + const verified = verifyJwt(stateOf(response.json()), getPublicKey(app)); + expect(verified.valid).toBe(true); + if (verified.valid) { + expect(verified.payload).toEqual(expect.objectContaining({ app_slug: "bifrost", return_to: bifrostReturn })); + } + }); + + it("refuses another app's credentials on a Bifrost start", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + for (const secret of ["streampixels-secret", "repixelizer-secret"]) { + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/discord/start", + headers: { "x-heimdall-app-secret": secret }, + payload: bifrostStart({ kind: "browser_completion" }), + }); + expect(response.statusCode, secret).toBe(401); + expect(response.json()).toEqual({ error: "app_auth_required" }); + } + }); + + it("refuses another app's authenticated caller on a direct Bifrost start, with no policy attached", async () => { + // Over HTTP the caller is resolved against the body's slug, so a foreign + // secret never becomes a caller at all. The command plane and direct + // callers hand startOAuthFlow a caller of their own, so the slug match is + // decided here too. + const config = createTestConfig(); + const app = await buildApp({ config }); + apps.push(app); + const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" }); + const own = resolveAppCaller(config, "bifrost", { "x-heimdall-app-secret": "bifrost-secret" }); + + const refused = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", foreign, bifrostStart({ kind: "browser_completion" })); + expect(refused.statusCode).toBe(401); + const allowed = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", own, bifrostStart({ kind: "browser_completion" })); + expect(allowed.statusCode).toBe(201); + }); + + it("gates every mode, provider and handoff kind Bifrost admits, not one spelling of them", async () => { + // Hand mutants narrowing the gate to browser_completion or to sign_in + // survived the single-case tests (finding gate-tests-narrow). + const store = new InMemoryStore(); + const now = new Date().toISOString(); + const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now }); + const app = await buildApp({ config: createTestConfig(), store }); + apps.push(app); + const handoffs = [ + undefined, + { kind: "browser_completion" }, + { + kind: "backend_callback", + attemptId: "bifrost-attempt-1", + callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback", + }, + ]; + + let cases = 0; + for (const mode of ["sign_in", "link", "connect"] as const) { + for (const provider of ["discord", "patreon"] as const) { + for (const handoff of handoffs) { + const label = `${mode} ${provider} ${handoff?.kind ?? "omitted"}`; + const payload = { + appSlug: "bifrost", + mode, + returnTo: bifrostReturn, + ...(mode === "link" ? { initiatingAccountId: initiator.id } : {}), + ...(handoff ? { handoff } : {}), + }; + const refused = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload }); + expect(refused.statusCode, label).toBe(401); + expect(refused.json(), label).toEqual({ error: "app_auth_required" }); + + const allowed = await app.inject({ + method: "POST", + url: `/v1/oauth/${provider}/start`, + headers: { "x-heimdall-app-secret": "bifrost-secret" }, + payload, + }); + expect(allowed.statusCode, label).toBe(201); + expect(allowed.json().authorizationUrl, label).toContain(`/v1/oauth/${provider}/launch?state=`); + cases += 1; + } + } + } + expect(cases).toBe(18); + }); + + it("leaves public-by-design apps unchanged: unauthenticated browser_completion starts still succeed", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + + for (const [appSlug, provider, returnTo] of [ + ["repixelizer", "discord", "https://repixelizer.gamecult.org/app/"], + ["streampixels", "twitch", "https://streampixels.gamecult.org/app/"], + ] as const) { + const response = await app.inject({ + method: "POST", + url: `/v1/oauth/${provider}/start`, + payload: { appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } }, + }); + expect(response.statusCode, appSlug).toBe(201); + } + }); + + it("flags only Bifrost's profile", () => { + expect( + Object.values(builtInAppProfiles).filter((profile) => profile.startRequiresAppCaller).map((profile) => profile.slug) + ).toEqual(["bifrost"]); + }); + }); + it("accepts caller-owned entitlement policy for a browser completion handoff when the caller is trusted", async () => { // The other half of the same pin: a caller holding the secret may attach // policy even without a backend_callback handoff, because identity no @@ -302,9 +378,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -348,11 +424,11 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = new URL(payload.authorizationUrl as string); + const authorizationUrl = await providerRedirect(app, payload); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -396,9 +472,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); + expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -442,10 +518,10 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = new URL(payload.authorizationUrl as string); + const authorizationUrl = await providerRedirect(app, payload); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); - const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + const verified = verifyJwt(stateOf(payload), getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -473,10 +549,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(response.statusCode).toBe(201); @@ -689,10 +766,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const completionCode = callbackResponse.json().completion.code as string; @@ -731,10 +809,11 @@ describe("Heimdall service", () => { const auditEvents = () => [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string }; const created = auditEvents().filter((event) => event.eventType === "auth_completion_created"); @@ -815,10 +894,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callbackResponse.statusCode).toBe(201); const refreshToken = callbackResponse.json().refreshToken as string; @@ -862,11 +942,12 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, headers: { + cookie: flow.cookie, accept: "text/html", }, }); @@ -912,12 +993,13 @@ describe("Heimdall service", () => { }, }); expect(startResponse.statusCode).toBe(201); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, headers: { + cookie: flow.cookie, accept: "text/html", }, }); @@ -1123,11 +1205,12 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = callbackResponse.json().account.id as string; @@ -1210,11 +1293,12 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const stateToken = startResponse.json().stateToken as string; + const flow = await launchFlow(app, startResponse.json()); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = callbackResponse.json().account.id as string; @@ -1276,10 +1360,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callbackResponse.statusCode).toBe(201); const evaluationsAtSignIn = evaluations; @@ -1361,11 +1446,31 @@ describe("Heimdall service", () => { const start = await app.inject({ method: "POST", url: "/v1/oauth/joystick/start", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra }, }); return start; } + it("gates a connect start that names a creator on the app's own secret: none and a foreign app's are 401, its own is 201", async () => { + const config = createTestConfig(); + const app = await buildApp({ config }); + apps.push(app); + const body = { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", connection: creator }; + const start = (headers: Record) => app.inject({ method: "POST", url: "/v1/oauth/joystick/start", headers, payload: body }); + + for (const headers of [{}, { "x-heimdall-app-secret": "repixelizer-secret" }, { "x-heimdall-app-secret": "wrong" }]) { + const refused = await start(headers); + expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]); + } + expect((await start({ "x-heimdall-app-secret": "streampixels-secret" })).statusCode).toBe(201); + + // A direct caller (the command plane hands startOAuthFlow its own) is held to the slug too. + const input = { appSlug: "streampixels" as const, mode: "connect" as const, returnTo: body.returnTo, connection: { kind: "creator" as const, targetId: "creator-1" } }; + const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" }); + expect((await startOAuthFlow(getHeimdallRuntimeContext(app), "joystick", foreign, input)).statusCode).toBe(401); + }); + it.each([ ["no connection", {}, "creator_connection_required"], ["an account connection", { connection: { kind: "account", targetId: "victim-1" } }, "creator_connection_required"], @@ -1377,7 +1482,7 @@ describe("Heimdall service", () => { expect(start.statusCode).toBe(400); expect(start.json().error).toBe(error); expect(start.json().authorizationUrl).toBeUndefined(); - expect(start.json().stateToken).toBeUndefined(); + expect(start.json()).not.toHaveProperty("stateToken"); }); it.each([ @@ -1393,14 +1498,15 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; - const verified = verifyJwt(stateToken, keys.publicKey); + const flow = await launchFlow(app, (await connect(app)).json()); + const verified = verifyJwt(flow.state, keys.publicKey); expect(verified.valid).toBe(true); const forged = signJwt({ ...(verified as { payload: Record }).payload, connection }, keys); const callback = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(calls).toEqual([]); @@ -1432,9 +1538,11 @@ describe("Heimdall service", () => { handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl }, }); expect(start.statusCode).toBe(201); + const flow = await launchFlow(app, start.json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeLessThan(300); @@ -1453,7 +1561,7 @@ describe("Heimdall service", () => { apps.push(app); const start = await connect(app); expect(start.statusCode).toBe(201); - const url = new URL(start.json().authorizationUrl as string); + const url = await providerRedirect(app, start.json()); expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize"); expect(url.searchParams.get("client_id")).toBe("joystick-client"); expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read"); @@ -1467,10 +1575,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "deru-chan", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const connected = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const accountId = connected.json().account.id as string; const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); @@ -1518,14 +1627,15 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; - const verified = verifyJwt(stateToken, keys.publicKey); + const flow = await launchFlow(app, (await connect(app)).json()); + const verified = verifyJwt(flow.state, keys.publicKey); expect(verified.valid).toBe(true); const forged = signJwt({ ...(verified as { payload: Record }).payload, mode }, keys); const callback = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(callback.body).not.toContain("viewer_access"); @@ -1542,11 +1652,12 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeLessThan(300); @@ -1575,10 +1686,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "chan-9", username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick"); expect(row?.displayName).toBe("deru"); @@ -1589,10 +1701,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ username: "deru" }); - const stateToken = (await connect(app)).json().stateToken as string; + const flow = await launchFlow(app, (await connect(app)).json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull(); @@ -1787,11 +1900,11 @@ describe("error answers never echo an input", () => { const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } }); apps.push(app); - const stateToken = await startDiscordSignIn(app); + const flow = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, - headers: { accept: "text/html" }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie, accept: "text/html" }, }); expect(response.headers["content-type"]).toContain("text/html"); @@ -1823,9 +1936,11 @@ describe("error answers never echo an input", () => { }, }); expect(start.statusCode).toBe(201); + const flow = await launchFlow(app, start.json()); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + headers: { cookie: flow.cookie }, }); expect(response.statusCode).toBe(502); @@ -1843,16 +1958,16 @@ describe("the relayed provider error", () => { const injected = `"\\\u0007é${"x".repeat(300)}`; const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/; - function providerErrorUrl(stateToken: string): string { - const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected }); - return `/v1/oauth/discord/callback?${query.toString()}`; + function providerErrorRequest(flow: LaunchedFlow) { + const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected }); + return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } }; } it("is printable and bounded in the JSON answer", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) }); + const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app))); expect(response.statusCode).toBe(400); const { providerError, providerErrorDescription } = response.json() as Record; @@ -1866,11 +1981,8 @@ describe("the relayed provider error", () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const response = await app.inject({ - method: "GET", - url: providerErrorUrl(await startDiscordSignIn(app)), - headers: { accept: "text/html" }, - }); + const request = providerErrorRequest(await startDiscordSignIn(app)); + const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } }); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body.match(/