diff --git a/README.md b/README.md index b86dbdc..f0e0a0e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The point is painfully simple: ## Front Door -- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally. +- **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. 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/joystick-consent-flow.md b/docs/joystick-consent-flow.md new file mode 100644 index 0000000..3fafec6 --- /dev/null +++ b/docs/joystick-consent-flow.md @@ -0,0 +1,237 @@ +# Joystick sign-in and consent: what was missing, and the flow that fills it + +Status: built (sections 3-6 describe the code on this branch); sections 1-2 are +the state of `fefd2dd` (main) that motivated it. Follow-up +`heimdall-body:follow_up:chat-s3124-3129`, built under +`heimdall-body:follow_up:chat-mind-20261108T131253147Z-t1`. The steward's rulings +on the five open questions are in section 4. + +## 1. What a viewer sees today + +**Joystick viewer sign-in does not exist.** `streampixelsProfile` +(`src/app-profiles.ts:109-117`) lists Joystick only in +`managedConnectionProviders`. `admitsProviderForMode` (`:243`) admits `sign_in` +and `link` only for `identityProviders`, so +`POST /v1/oauth/joystick/start` with `mode: sign_in` returns +`400 provider_not_supported_for_app` as JSON before any redirect +(`startOAuthFlow`, `src/app.ts:598-603`). The viewer sees whatever the app does +with a failed start; Heimdall shows them nothing. The architecture doc records +this as deliberate until the public bot is registered +(`docs/architecture.md:461`, `joystick:follow_up:joystick-viewer-signin-public-bot`). + +**The only live Joystick path is a creator `connect`.** Walking it as the +person in the browser: + +1. The app backend starts the flow (authenticated by app secret, `connection` + `{kind: creator, targetId}`). The person's browser is sent to + `/v1/oauth/joystick/launch?state=...`. +2. `launch` (`src/app.ts:1130-1194`) claims the state, sets the binding cookie + and answers `302` straight to `joystick.tv/api/oauth/authorize`. **Heimdall + renders no page of its own.** Nothing says who is asking, for what, or what + will be kept. +3. The only ask the person sees is Joystick's own screen, listing the scopes in + `providerCatalog.joystick.defaultScopes` (`src/providers.ts:162`): + `identity:read stream:read chat:read followers:read subscribers:read`. + Joystick words that screen, not us. +4. The callback (`:1196`) exchanges the code, stores the credential encrypted + (rotating refresh token, managed custody), and returns the generic handoff + page (`src/browser-handoff.ts`): title "Authentication Complete", body "You + can return to the app now." It does not name the app, the provider, the + scopes granted, or that a long-lived refresh token is now held by Heimdall. +5. Refusal: if the person clicks Deny at Joystick, the callback gets + `error=access_denied`, and the page says "Authentication Failed ... Return to + the app to continue." The provider's own text is relayed only in + `errorDescription` in the fallback URL fragment / postMessage payload, not + shown on the page. Refusing *at Heimdall's step* is impossible: there is no + Heimdall step. +6. After: the audit rows `oauth_callback_succeeded`, `auth_completion_created`, + `managed_credential_revoked` etc. (`createAuditEvent` call sites, e.g. + `app.ts:1582`) are written for operators. There is **no route a viewer can + call to read them**, and no route a viewer can call to revoke a credential. + Revocation exists only inside the app backend plane (`managed-credential.ts:172`). + +## 2. What consent is missing + +| Gap | Today | Why it matters | +| --- | --- | --- | +| Visible ask | None from Heimdall; provider screen only | The person cannot tell Heimdall custody (refresh token kept, rotated, used for stream/chat/follower reads) from a one-off login. | +| Who is asking | Not shown | Two parties act: the app (StreamPixels) and Heimdall as custodian. Neither is named. | +| Scope truth | Provider screen lists scopes; Heimdall's `requestedScopes` / `defaultScopes` are not shown or compared with what was granted | Granted scopes can differ from requested; the stored `scopes` (`app.ts:1406`) are never shown. | +| Typed refusal | A deny is an untyped `provider_error` | The app and the audit log cannot tell "viewer said no" from "provider broke". | +| Receipt | Operator audit rows only | The viewer cannot audit what was agreed, when, or revoke it. | +| Viewer vs creator | The `connect` binding proves a binding exists, not that the browser's owner is the creator (`joystick:follow_up:connect-start-needs-app-auth`) | Consent from the wrong person is not consent. | + +## 3. The consent flow + +Applies to every completion `callbackMint` calls `custody_only`: a managed +creator connection (Joystick today; Twitch and YouTube on StreamPixels when +they are connected rather than signed in). Viewer sign-in through an identity +provider is unchanged. + +### 3.1 The ask + +`GET /v1/oauth/:provider/launch` renders a Heimdall page (`src/consent.ts`) +instead of answering `302`, after the state verifies and before any use is +claimed. The page names the app and the origin the person returns to, the +provider and the exact scopes Heimdall will send (`authorizationScopes`, the +same function that builds the authorization URL), what Heimdall keeps (an +encrypted refresh token and access token, until the person takes it back or the +provider ends it), what the app receives, and how to take it back. It carries +two POST forms to `/v1/oauth/:provider/consent`, **Continue to Joystick** and +**No, return to the app**, and runs no script. It sets the ask's own +cookie (`__Host-heimdall-ask-`), which carries the exact text shown and is +accepted only by Continue and No; an answer without it is `state_not_bound` and +spends nothing. Viewing the ask claims nothing and binds nothing: the launch +binding cookie, which decides who may complete the state, is set only when +Continue claims the launch, and it carries the text shown on to the callback. +(An earlier cut set the binding on the ask, so anyone holding the launch URL +could become the state's owner; review of 2026-11-08 found it.) + +What the app receives, stated as the code does it: not a claim. The app +backend can call `managed-credentials/resolve` and receive the current access +token; it never receives the refresh token. The earlier draft said the app +receives "no token"; that was wrong and the page does not say it. + +### 3.2 Typed refusal + +A decline, and a provider `error=access_denied` on any provider, produce the +result `consent_refused` with `by: "viewer" | "provider"`: an audit event +`consent_refused` (provider, mode, by; no account, no person), the browser +attempt denied with `denialCode consent_refused`, both uses of the state spent, +a `backend_callback` app told with the same payload, a "Not Connected" page in +the browser, and `400 {error: "consent_refused", by}` as JSON. No credential is +stored and no receipt is written. Every other provider error stays +`provider_error`. This changes the `access_denied` answer for every provider, +including Bifrost's and Repixelizer's sign-in; see the PR for the contract note. + +### 3.3 Receipt + +On a granted `custody_only` completion Heimdall writes one `consent_receipts` +row of kind `grant`: id (random), account, app, provider, scopes asked, scopes +granted (as the provider returned them), `consentText` (the lines and +button labels shown, taken from the ask and never rebuilt, so a deploy between +ask and callback cannot change what the receipt says), `consentPageDigest` +(sha-256 of that text), `createdAt`. A custody callback with no shown text +exchanges no code and stores no credential. The id +reaches the app (`consentReceiptId` in the completion and backend payloads) and +the person (the handoff page). + +Receipts are append-only: the store role has `SELECT` and `INSERT` on the table +and nothing else. A revocation is a second row of kind `revocation` naming the +grant it takes back, unique per grant (a partial unique index); a grant is live +exactly when no revocation names it, and a concurrent second revoke reports +nothing and writes nothing. The +draft's `revokedAt` slot is gone for that reason. + +### 3.4 My grants + +Signed in means a live Heimdall access token for any app (`Authorization: +Bearer`), or a page link minted from one. + +- `GET /v1/me/grants`: every grant and receipt of the account. `Accept: + text/html` gets the page, one **Take this back** form per live grant; anything + else gets `{grants, receipts}`. +- `POST /v1/me/grants/link` (bearer): `{url}` to the page, valid ten minutes. + The token is signed, purpose-bound (`heimdall_grants_page`), stateless, and + opens only this page and its revoke forms. The forms on a page opened by a link + carry the link's own expiry, so using a link never renews it. This is how a browser that holds no + session gets in; Heimdall sets no new cookie and stores nothing for it. +- `POST /v1/me/grants/:id/revoke` (bearer, or the page token in the form): + strips the stored tokens from the credential, then writes a revocation row + for every live grant on that credential. The credential is one row per account + and provider, whichever app asked, so taking it back ends every grant resting + on it. The next `managed-credentials/resolve` answers `410 + managed_credential_revoked`; nothing else needs to learn of it. Revoking a + revoked grant changes nothing. Another account's grant id is `404`. + +Heimdall can delete its copy; it cannot withdraw the permission at the +provider, and the page says so. + +## 4. The steward's rulings + +Each ruling picks the answer that gives the person the most agency and leaves +the least stored. + +1. **Who gets the ask: managed custody only.** Where Heimdall holds a credential + the person cannot see and an app can use, the person is asked and given a + receipt. A login is already asked by the provider's own screen, and a receipt + per login would store a row per sign-in for no agency the provider's screen + does not give. The line is `callbackMint === "custody_only"`, so it needs no + second list to keep in step with the profiles. +2. **The receipt is a new typed table, `consent_receipts`.** The audit table is + operator-shaped and has no read path for the person; a viewer-readable + record in it would make every audit row a potential disclosure. The table + holds ids, scopes and a digest: no IP address, no user agent, no page text. A revocation names a + receipt of its own account: a composite foreign key `(revokes_receipt_id, + account_id)` to `(id, account_id)`, which is why `(id, account_id)` is also a + unique key (`checkSchema` counts it). The in-memory store refuses the same. The table is created + whole and first-created: no deployed database had an earlier shape, so there + is no `ALTER` step. + Refusals leave no receipt, only the anonymous audit row. +3. **Viewer identity: sign-in, not the cookie.** The binding cookie dies with + the state and proves only that a browser launched a flow; it is not an + account. The receipt page and revoke require a live access token or a + ten-minute link minted from one. The cost is that a first-time creator must + hold the session the completion returned to the app; the app mints the link. + The alternative, a new long-lived Heimdall cookie, would store a session for + every person who merely looked. +4. **`consent_refused` reaches a backend callback app.** An app that started the + attempt and is waiting must be told, or it polls to expiry and cannot tell + a person's no from a fault. The person's no is also not held against them: + Heimdall stores nothing and sets no cooldown; whether and when to ask again + is the app's rule. +5. **Wording: derived, not authored.** The text is generated from facts the code + holds and states nothing the code does not do; it is a constant in + `src/consent.ts` and its digest rides every receipt. Legal review of the + wording (the CultCoin legal thread) may only narrow it, and any change moves + the digest. Until that review lands the copy is Heimdall's, signed by the + steward. +6. **Bounds on what rides the cookies.** The wording is read from a cookie + only up to 16 KB inflated, so a compressed bomb is refused, not expanded. An + app may request at most 32 scopes of 512 characters (`requested_scopes_too_large` + at the ask). A custody callback whose binding carries no wording is refused + with `consent_text_missing` before the state is claimed, so nothing is burned. + +### Refusal return contract (for app authors) + +A person's No at the ask, or a provider `access_denied`, is `consent_refused`. +No completion code is minted and the state is spent. What each side gets: + +- **Browser.** A 200 HTML page titled "Not Connected" (a non-HTML caller gets a 400 + JSON `{error, by, provider, returnTo}`). The page does not redirect: by ruling + it never navigates on its own. It posts the payload to `window.opener` + (target origin = the origin of `returnTo`), tries `window.close()` after 50 ms, + and offers a "Return to the app" link. In a plain tab the person clicks it. +- **The link** is `returnTo` with a fragment (read `location.hash`; it never + reaches a server): `heimdall_status=error`, `heimdall_error=consent_refused`, + `heimdall_refused_by=viewer|provider`, `heimdall_provider`, + `heimdall_handoff_kind`, `heimdall_app_slug`, `heimdall_mode`, and + `heimdall_attempt_id` when the flow carries an attempt. There is no + `heimdall_completion_code`; a page with none, and `heimdall_error`, is a refusal. + A fault is a different `heimdall_error` (`provider_error` and others). +- **postMessage** carries `{source: "heimdall", kind: "oauth_result", + status: "error", error: "consent_refused", by, provider, appSlug, mode, + returnTo, handoffKind, attemptId?}`. +- **Backend callback apps** get the same result POSTed to their callback URL + (`status: "error"`, `error: "consent_refused"`, `by`, `attemptId`). An auth + attempt is marked `denied` with `denialCode: "consent_refused"`, which + `completeAuth` returns as `denied`. + +`tests/joystick-consent.test.ts` ("the refusal return contract") pins the +fragment for both refusals and the attempt id. + +## 5. Out of scope + +Enabling Joystick in `identityProviders`; fixing +`joystick:follow_up:connect-start-needs-app-auth`; revoking at the provider +(Joystick offers no revocation call Heimdall uses); retention or deletion of +consent receipts and of the audit rows; consent for the creator's audience. + +## 6. Tests + +`tests/joystick-consent.test.ts` holds one behaviour per rule above. Rules were +checked by mutation, each mutant run on its own with a 300 s limit under an 8 GB +address-space cap (the PR lists them). The Postgres statements for +`consent_receipts` are exercised by `tests/store.test.ts` against the fake pool +and, when `HEIMDALL_TEST_PG_ADMIN_URL` is set, against a live database +(`scripts/test-live-pg.sh`); the live run was not possible where this was built. diff --git a/docs/service-contract.md b/docs/service-contract.md index e356ae2..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 diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index fcfa4ac..f3c98e9 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -11,7 +11,8 @@ evidence only. ## Owner Map -- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim +- Owner: Heimdall owns provider identity/linking (an authenticator handle, not + GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim issuance, entitlement refresh, managed provider token custody, grants, auth-completion handoffs, refresh sessions, and audit events. - Inputs: provider OAuth callbacks, app profile requests, caller-supplied diff --git a/package.json b/package.json index 36394ef..626ab8a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/heimdall --external:pg-native --banner:js=\"#!/usr/bin/env node\nimport{createRequire}from'node:module';const require=createRequire(import.meta.url);\"" }, "dependencies": { + "@fastify/cookie": "^11.1.3", "@fastify/cors": "^11.2.0", "@msgpack/msgpack": "^3.1.3", "@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 855ad01..c84f4d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@fastify/cookie': + specifier: ^11.1.3 + version: 11.1.5 '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 @@ -229,6 +232,9 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/cookie@11.1.5': + resolution: {integrity: sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==} + '@fastify/cors@11.2.0': resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} @@ -469,6 +475,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory} engines: {node: '>=18'} @@ -547,6 +557,9 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-plugin@6.0.1: + resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==} + fastify@5.12.7: resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} @@ -1186,6 +1199,11 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.0 + '@fastify/cookie@11.1.5': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.1 + '@fastify/cors@11.2.0': dependencies: fastify-plugin: 5.1.0 @@ -1393,6 +1411,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: dependencies: '@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts @@ -1500,6 +1520,8 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-plugin@6.0.1: {} + fastify@5.12.7: dependencies: '@fastify/ajv-compiler': 4.0.5 diff --git a/scripts/test-live-pg.sh b/scripts/test-live-pg.sh new file mode 100755 index 0000000..e028caa --- /dev/null +++ b/scripts/test-live-pg.sh @@ -0,0 +1,28 @@ +#!/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 +# Prerequisites, done here when missing (each step is skipped once it holds): +# 1. the vendor/CultLib submodule checked out, +# 2. its npm dependencies installed (the vendor packages build with its tsc), +# 3. dist/ built for cultcache-ts, cultnet-ts and cultmesh-ts (package.json +# points main at dist/index.js), +# 4. this repo's pnpm install (npm install if pnpm fails with EACCES). +cd "$(dirname "$0")/.." +[ -f vendor/CultLib/package.json ] || git submodule update --init vendor/CultLib +[ -d vendor/CultLib/node_modules/typescript ] || (cd vendor/CultLib && npm ci) +for pkg in cultcache-ts cultnet-ts cultmesh-ts; do + [ -f "vendor/CultLib/packages/$pkg/dist/index.js" ] || (cd "vendor/CultLib/packages/$pkg" && npm run build) +done +[ -d node_modules/vitest ] || pnpm install --frozen-lockfile || npm install # pnpm can fail with EACCES on its store; npm does not need it +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 a1fe611..2051499 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,8 @@ import { createHmac, randomUUID } from "node:crypto"; +import { deflateRawSync, inflateRawSync } from "node:zlib"; +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 { @@ -25,9 +27,11 @@ import { import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js"; import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.js"; import { renderBrowserHandoffPage } from "./browser-handoff.js"; +import { consentText, digestOfText, grantViews, GRANTS_PATH, renderConsentPage, renderGrantsPage, type ConsentAsk } from "./consent.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, @@ -35,7 +39,7 @@ import { type OAuthRuntimeRegistry, } from "./oauth.js"; import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js"; -import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; +import { authorizationScopes, buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; import { createStore, type HeimdallStore } from "./store/index.js"; import { @@ -94,8 +98,9 @@ function buildOAuthStateToken(options: { 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 = { @@ -115,11 +120,13 @@ function buildOAuthStateToken(options: { initiator_account_id: options.initiatorAccountId ?? null, handoff: options.handoff, entitlement_policy: options.entitlementPolicy ?? null, + requested_scopes: options.requestedScopes ?? null, }; const token = signJwt(payload as unknown as Record, options.keys); return { token, + jti: payload.jti, expiresAt: new Date(expiresAtEpoch * 1000).toISOString(), }; } @@ -153,6 +160,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" || @@ -171,7 +183,8 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay typeof payload.exp !== "number" || !handoffIsValid || !entitlementPolicyIsValid || - !(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") + !(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") || + !requestedScopesAreValid ) { return null; } @@ -186,10 +199,167 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay if (!normalizedPayload.entitlement_policy) { normalizedPayload.entitlement_policy = null; } + if (!normalizedPayload.requested_scopes) { + normalizedPayload.requested_scopes = null; + } return normalizedPayload; } +type OAuthStateVerdict = + | { ok: true; payload: OAuthStatePayload } + | { ok: false; error: "invalid_state" | "state_provider_mismatch" | "state_expired" }; + +/** + * The one place a signed OAuth state becomes trusted: signature, shape, issuer, + * audience and provider, and the time window with the same 30 s skew refresh + * tokens use. Every route that turns a state into navigation or a completion + * runs this first; the refusals are fixed codes and never echo the token. + */ +function verifyOAuthState( + config: HeimdallConfig, + keys: ReturnType, + token: string, + provider: Provider +): OAuthStateVerdict { + const verification = verifyJwt(token, keys.publicKey); + if (!verification.valid) { + return { ok: false, error: "invalid_state" }; + } + + const payload = parseOAuthStatePayload(verification.payload); + if (!payload || payload.iss !== config.issuer) { + return { ok: false, error: "invalid_state" }; + } + if (payload.aud !== payload.provider || payload.provider !== provider) { + return { ok: false, error: "state_provider_mismatch" }; + } + + const now = nowEpochSeconds(); + if (payload.nbf > now + 30 || payload.exp <= now - 30) { + return { ok: false, error: "state_expired" }; + } + + return { ok: true, payload }; +} + +const oauthBindingType = "heimdall_oauth_binding"; + +function oauthBindingCookieName(jti: string): string { + return `__Host-heimdall-oauth-${jti}`; +} + +/** The signed claim that this browser launched this state: it is the cookie's value, and needs no store read to check. */ +function signOAuthBinding( + config: HeimdallConfig, + keys: ReturnType, + state: OAuthStatePayload, + shownText?: string +): string { + return signJwt( + { + typ: oauthBindingType, + iss: config.issuer, + jti: state.jti, + iat: nowEpochSeconds(), + exp: state.exp, + // The wording the person agreed to, carried from the ask to the receipt. + ...(shownText === undefined ? {} : { ct: deflateRawSync(shownText).toString("base64url") }), + }, + keys + ); +} + +const consentAskType = "heimdall_consent_ask"; + +/** The most wording a cookie may carry, before and after compression. The ask refuses what exceeds it; the readers never inflate past it. */ +const maxConsentTextBytes = 16 * 1024; +/** Browsers drop a cookie above 4096 bytes; the ask refuses one it could not keep. */ +const maxConsentCookieChars = 3800; +/** What an app may request: more is not a scope list a person can read. */ +const maxRequestedScopes = 32; +const maxRequestedScopeChars = 512; + +function consentAskCookieName(jti: string): string { + return `__Host-heimdall-ask-${jti}`; +} + +/** + * The ask's own cookie. It proves a browser was shown the ask and carries the + * exact text shown; only Continue and No accept it. It is not the launch + * binding: that is set when Continue claims the launch, so that merely opening + * the launch URL never makes a browser the state's owner. + */ +function signConsentAsk(config: HeimdallConfig, keys: ReturnType, state: OAuthStatePayload, shownText: string): string { + return signJwt( + { typ: consentAskType, iss: config.issuer, jti: state.jti, iat: nowEpochSeconds(), exp: state.exp, ct: deflateRawSync(shownText).toString("base64url") }, + keys + ); +} + +/** The text a held cookie of the given type carries, or null when the cookie is absent, foreign, expired or carries none. */ +function cookieConsentText( + config: HeimdallConfig, + keys: ReturnType, + token: string | undefined, + type: string, + state: OAuthStatePayload +): string | null { + if (!token) return null; + const verification = verifyJwt(token, keys.publicKey); + if (!verification.valid) return null; + const { typ, iss, jti, exp, ct } = verification.payload; + if (typ !== type || iss !== config.issuer || jti !== state.jti || typeof exp !== "number" || exp <= nowEpochSeconds() || typeof ct !== "string") { + return null; + } + try { + return inflateRawSync(Buffer.from(ct, "base64url"), { maxOutputLength: maxConsentTextBytes }).toString("utf8"); + } catch { + return null; + } +} + +function setConsentAskCookie(reply: FastifyReply, state: OAuthStatePayload, value: string, maxAgeSeconds: number): void { + reply.setCookie(consentAskCookieName(state.jti), value, { + path: "/", + secure: true, + httpOnly: true, + sameSite: "lax", + maxAge: Math.max(0, maxAgeSeconds), + }); +} + +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)); } @@ -254,18 +424,14 @@ 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. */ -class LinkRefusal extends Error { +export class LinkRefusal extends Error { constructor( - readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown", + readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown" | "identity_not_linked", message: string ) { super(message); + this.name = "LinkRefusal"; } } @@ -408,6 +574,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) { @@ -417,7 +585,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; } @@ -502,6 +670,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; @@ -518,8 +693,8 @@ export async function startOAuthFlow( } // A connection target is app-backend authority: it rides only on connect - // and link starts, which are the flows gated by x-heimdall-app-secret. - // A sign_in start is open to any browser and must not carry one. + // starts that carry the app's own secret (gated below), and never on a + // sign_in start, which is open to any browser. if (input.mode === "sign_in" && input.connection !== undefined) { return { statusCode: 400, @@ -537,24 +712,58 @@ 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 (!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." } }; + 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 ( + input.requestedScopes !== undefined && + (input.requestedScopes.length > maxRequestedScopes || input.requestedScopes.some((scope) => scope.length > maxRequestedScopeChars)) + ) { + return { + statusCode: 400, + body: { error: "requested_scopes_too_large", detail: `At most ${maxRequestedScopes} scopes of ${maxRequestedScopeChars} characters each.` }, + }; + } + if (!isAllowedReturnOrigin(profile, input.returnTo)) { return { statusCode: 400, @@ -598,7 +807,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" } }; } @@ -610,7 +819,6 @@ export async function startOAuthFlow( }; } - const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; const state = buildOAuthStateToken({ config, provider, @@ -618,33 +826,119 @@ export async function startOAuthFlow( mode: input.mode, returnTo: input.returnTo, connection: input.connection, - initiatorAccountId: input.initiatingAccountId, + 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; +} + +/** The account a Heimdall access token for any app names, when the token and its session are live. */ +async function resolveAccessAccount( + ctx: Pick, + 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 audience = verification.payload.aud; + return isAppSlug(audience) ? resolveLinkAccount(ctx, audience, authorization) : null; +} + +const grantsPageTokenType = "heimdall_grants_page"; +const grantsPageTokenTtlSeconds = 600; + +/** A short, purpose-bound proof of a signed-in account, so the grants page can be opened and its forms posted by a browser that holds no session. */ +function signGrantsPageToken( + config: HeimdallConfig, + keys: ReturnType, + accountId: string, + exp: number = nowEpochSeconds() + grantsPageTokenTtlSeconds +): string { + return signJwt({ typ: grantsPageTokenType, iss: config.issuer, account_id: accountId, iat: nowEpochSeconds(), exp }, keys); +} + +/** The account and expiry a page token proves. A page's own forms are signed to this same expiry, so a link is never renewed by being used. */ +function grantsPageTokenAccount( + config: HeimdallConfig, + keys: ReturnType, + token: unknown +): { accountId: string; exp: number } | null { + if (typeof token !== "string") return null; + const verification = verifyJwt(token, keys.publicKey); + if (!verification.valid) return null; + const { typ, iss, account_id: accountId, exp } = verification.payload; + return typ === grantsPageTokenType && iss === config.issuer && typeof accountId === "string" && typeof exp === "number" && exp > nowEpochSeconds() + ? { accountId, exp } + : null; +} + +function consentAskFor(state: OAuthStatePayload): ConsentAsk { + return { + appSlug: state.app_slug, + provider: state.provider, + scopes: authorizationScopes(state.provider, state.requested_scopes ?? undefined), + returnOrigin: new URL(state.return_to).origin, + }; +} + /** * Session refresh, extracted for the same reason as startOAuthFlow: the HTTP * route and the plane's `refreshAuth` (which owns Ghostlight identity) call @@ -783,6 +1077,11 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + done(null, Object.fromEntries(new URLSearchParams(body as string))); + }); app.get("/healthz", async () => ({ ok: true, @@ -912,12 +1211,278 @@ 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" | "consent_text_missing" + ) => { + 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 }; + }; + + const denyAttempt = async (state: OAuthStatePayload, denialCode: string): Promise => { + const handoff = state.handoff ?? { kind: "browser_completion" }; + if (handoff.kind !== "browser_completion" || !handoff.attemptId) return; + await store.updateAuthAttempt(state.app_slug, handoff.attemptId, { + status: "denied", + at: new Date().toISOString(), + denialCode, + }); + }; + + /** + * The person said no, at Heimdall's ask (`viewer`) or at the provider + * (`provider`, a returned access_denied). It is its own result, never a + * provider_error: the app and the audit log can tell a refusal from a fault. + * Nothing is stored beyond an audit row that names no one. + */ + const refuseConsent = async (request: FastifyRequest, reply: FastifyReply, state: OAuthStatePayload, by: "viewer" | "provider") => { + const handoff = state.handoff ?? { kind: "browser_completion" }; + await denyAttempt(state, "consent_refused"); + await store.createAuditEvent({ + appSlug: state.app_slug, + eventType: "consent_refused", + eventPayloadJson: { provider: state.provider, mode: state.mode, by }, + createdAt: new Date().toISOString(), + }); + if (handoff.kind === "backend_callback") { + await maybeDeliverBackendHandoff(handoff, { + source: "heimdall", + kind: "oauth_result", + handoffKind: "backend_callback", + attemptId: handoff.attemptId, + status: "error", + provider: state.provider, + appSlug: state.app_slug, + mode: state.mode, + returnTo: state.return_to, + connection: state.connection, + error: "consent_refused", + by, + }); + } + if (prefersHtml(request.headers.accept)) { + reply.type("text/html; charset=utf-8"); + return reply.send( + renderBrowserHandoffPage({ + handoffKind: handoff.kind, + status: "error", + provider: state.provider, + appSlug: state.app_slug, + mode: state.mode, + returnTo: state.return_to, + ...(handoff.attemptId ? { attemptId: handoff.attemptId } : {}), + error: "consent_refused", + by, + }) + ); + } + reply.code(400); + return { error: "consent_refused", by, provider: state.provider, returnTo: state.return_to }; + }; + + const providerNotConfigured = (reply: FastifyReply, state: OAuthStatePayload) => { + reply.code(503); + return { error: "provider_not_configured", provider: state.provider, expectedEnv: providerExpectedEnv(state.provider) }; + }; + + /** Claim the launch use, bind the browser, and send it to the provider. */ + const beginProviderRedirect = async (request: FastifyRequest, reply: FastifyReply, state: OAuthStatePayload, rawState: string, shownText?: string) => { + const clientId = config.providers[state.provider].clientId; + if (!clientId) { + return providerNotConfigured(reply, state); + } + 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, shownText), 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: rawState, + requestedScopes: state.requested_scopes ?? undefined, + ...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}), + }), + 302 + ); + }; + + 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 askProfile = getAppProfile(state.app_slug); + if (askProfile && callbackMint(askProfile, state.provider, state.mode, state.connection) === "custody_only") { + if (!config.providers[state.provider].clientId) { + return providerNotConfigured(reply, state); + } + // Heimdall is about to hold a credential for the app: ask first. Viewing + // the ask claims nothing and binds nothing; only Continue spends the + // launch use and sets the launch binding. The ask's own cookie rides on + // the page so that the form's answer can only come from the browser + // that was shown it, and carries the text it was shown. + const ask = consentAskFor(state); + const shown = consentText(ask); + const askCookie = signConsentAsk(config, keys, state, shown); + // An ask the cookies cannot carry is refused, not shown: the person + // could press Continue and nothing would bind. + if (Buffer.byteLength(shown, "utf8") > maxConsentTextBytes || askCookie.length > maxConsentCookieChars) { + reply.code(400); + return { error: "consent_ask_too_large" }; + } + setConsentAskCookie(reply, state, askCookie, state.exp - nowEpochSeconds()); + reply.header("Cache-Control", "no-store"); + reply.header("Referrer-Policy", "no-referrer"); + reply.header("X-Frame-Options", "DENY"); + reply.header( + "Content-Security-Policy", + `default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self' ${new URL(providerCatalog[state.provider].authorizationEndpoint).origin}` + ); + reply.type("text/html; charset=utf-8"); + return reply.send(renderConsentPage(ask, request.query.state)); + } + return beginProviderRedirect(request, reply, state, request.query.state); + } + ); + + app.post<{ + Params: { provider: Provider }; + Body: { state: string; decision: "continue" | "decline" }; + }>( + "/v1/oauth/:provider/consent", + { + schema: { + params: { + type: "object", + required: ["provider"], + additionalProperties: false, + properties: { provider: { type: "string", enum: [...providers] } }, + }, + body: { + type: "object", + required: ["state", "decision"], + additionalProperties: false, + properties: { + state: { type: "string", minLength: 1 }, + decision: { type: "string", enum: ["continue", "decline"] }, + }, + }, + }, + }, + async (request, reply) => { + const verified = verifyOAuthState(config, keys, request.body.state, request.params.provider); + if (!verified.ok) { + reply.code(400); + return { error: verified.error }; + } + const state = verified.payload; + const profile = getAppProfile(state.app_slug); + if (!profile || callbackMint(profile, state.provider, state.mode, state.connection) !== "custody_only") { + reply.code(400); + return { error: "consent_not_required" }; + } + // Either answer must come from the browser the ask was shown to. + const shownText = cookieConsentText(config, keys, request.cookies[consentAskCookieName(state.jti)], consentAskType, state); + if (shownText === null) { + return refuseVerifiedState(request, reply, state, "state_not_bound"); + } + setConsentAskCookie(reply, state, "", 0); + if (request.body.decision === "continue") { + return beginProviderRedirect(request, reply, state, request.body.state, shownText); + } + // A decline spends both uses of the state: it can neither launch nor complete afterwards. + const usedAt = new Date().toISOString(); + const expiresAt = new Date(state.exp * 1000).toISOString(); + if (!(await store.claimOAuthStateUse({ jti: state.jti, phase: "launch", appSlug: state.app_slug, usedAt, expiresAt }))) { + return refuseVerifiedState(request, reply, state, "state_already_launched"); + } + await store.claimOAuthStateUse({ jti: state.jti, phase: "callback", appSlug: state.app_slug, usedAt, expiresAt }); + setOAuthBindingCookie(reply, state, "", 0); + return refuseConsent(request, reply, state, "viewer"); + } + ); + app.get<{ Params: { provider: Provider }; Querystring: { code?: string; state?: string; error?: string; error_description?: string }; @@ -951,27 +1516,49 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { - if (handoff.kind !== "browser_completion" || !handoff.attemptId) return; - await store.updateAuthAttempt(statePayload.app_slug, handoff.attemptId, { - status: "denied", - at: new Date().toISOString(), - denialCode, - }); - }; + const denyBrowserAttempt = (denialCode: string): Promise => denyAttempt(statePayload, denialCode); + // The cookie is checked before the claim: a stranger without it must not be + // able to burn the state that the browser that launched it still needs. + // The attempt is denied, though: a launcher whose cookie lapsed can never + // complete this state, and its page polls an attempt that would otherwise + // sit pending until it expires. + if (!oauthBindingHolds(config, keys, request.cookies[oauthBindingCookieName(statePayload.jti)], statePayload)) { + await denyBrowserAttempt("state_not_bound"); + return refuseVerifiedState(request, reply, statePayload, "state_not_bound"); + } + // A custody flow carries the wording its person was shown in the launch + // binding. A binding without it did not pass through the ask (a launch + // from before it existed): refuse here, before the state is spent and + // before any code is exchanged, so nothing is kept and nothing is burned. + const shownText = cookieConsentText(config, keys, request.cookies[oauthBindingCookieName(statePayload.jti)], oauthBindingType, statePayload); + const callbackProfile = getAppProfile(statePayload.app_slug); + const mint = callbackProfile ? callbackMint(callbackProfile, request.params.provider, statePayload.mode, statePayload.connection) : "refused"; + if (mint === "custody_only" && shownText === null) { + await denyBrowserAttempt("consent_text_missing"); + return refuseVerifiedState(request, reply, statePayload, "consent_text_missing"); + } + setOAuthBindingCookie(reply, statePayload, "", 0); + const firstCallback = await store.claimOAuthStateUse({ + jti: statePayload.jti, + phase: "callback", + appSlug: statePayload.app_slug, + usedAt: new Date().toISOString(), + expiresAt: new Date(statePayload.exp * 1000).toISOString(), + }); + if (!firstCallback) { + return refuseVerifiedState(request, reply, statePayload, "state_already_used"); + } + if (request.query.error === "access_denied") { + return refuseConsent(request, reply, statePayload, "provider"); + } if (request.query.error) { const providerError = relayedProviderText(request.query.error, 64); const providerErrorDescription = relayedProviderText(request.query.error_description, 256); @@ -1064,40 +1651,139 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + const linkedIdentityInput: UpsertLinkedIdentityInput = { + accountId, + provider: identity.provider, + providerUserId: identity.providerUserId, + scopes: tokenSet.scope, + profileJson: identity.profile, + createdAt: nowIso, + updatedAt: nowIso, + }; + if (identity.username !== undefined) { + linkedIdentityInput.username = identity.username; + } + if (identity.displayName !== undefined) { + linkedIdentityInput.displayName = identity.displayName; + } + if (identity.primaryEmail !== undefined) { + linkedIdentityInput.primaryEmail = identity.primaryEmail; + } + linkedIdentityInput.accessTokenEncrypted = tokenCustody.encrypt(tokenSet.accessToken); + if (tokenSet.refreshToken !== undefined) { + linkedIdentityInput.refreshTokenEncrypted = tokenCustody.encrypt(tokenSet.refreshToken); + } + if (tokenSet.expiresAt !== undefined) { + linkedIdentityInput.tokenExpiresAt = tokenSet.expiresAt; + } + return linkedIdentityInput; + }; + + // A link is two-phase. The callback cannot tie itself to the browser + // that began the link (start is a server-to-server POST), so whoever + // completes the Google login here may be someone the initiator sent + // the URL to. Nothing is written to the account: the verified identity + // waits as a pending link, and only a confirm bearing the access token + // of the account that began the link attaches it. + if (boundAccountId && callbackDescriptor.firstLogin === "requires_link") { + const pendingLink = await store.createPendingLink({ + appSlug: statePayload.app_slug, + accountId: boundAccountId, + provider: identity.provider, + providerUserId: identity.providerUserId, + identity: buildLinkedIdentityInput(boundAccountId), + returnTo: statePayload.return_to, + createdAt: nowIso, + expiresAt: new Date(Date.now() + config.completionTtlSeconds * 1000).toISOString(), + }); + await store.createAuditEvent({ + accountId: boundAccountId, + appSlug: statePayload.app_slug, + eventType: "link_pending", + // Never the pending link's id: it is the confirm secret. + eventPayloadJson: { provider: identity.provider, expiresAt: pendingLink.expiresAt }, + createdAt: nowIso, + }); + const pendingBody = { + status: "link_pending" as const, + provider: request.params.provider, + mode: statePayload.mode, + appSlug: statePayload.app_slug, + returnTo: statePayload.return_to, + pendingLinkId: pendingLink.id, + expiresAt: pendingLink.expiresAt, + confirmEndpoint: `${config.publicBaseUrl}/v1/apps/${statePayload.app_slug}/links/confirm`, + }; + if (handoff.kind === "backend_callback") { + await maybeDeliverBackendHandoff(handoff, { + source: "heimdall", + kind: "oauth_result", + handoffKind: "backend_callback", + attemptId: handoff.attemptId, + connection: statePayload.connection, + ...pendingBody, + }); + } + if (prefersHtml(request.headers.accept)) { + reply.type("text/html; charset=utf-8"); + return reply.send( + renderBrowserHandoffPage({ + handoffKind: handoff.kind, + status: "success", + provider: request.params.provider, + appSlug: statePayload.app_slug, + mode: statePayload.mode, + returnTo: statePayload.return_to, + pendingLinkId: pendingLink.id, + ...(handoff.attemptId ? { attemptId: handoff.attemptId } : {}), + }) + ); + } + reply.code(201); + return pendingBody; } if (!account) { @@ -1126,32 +1812,26 @@ 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", { @@ -1864,5 +2614,115 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { + const bearerAccount = await resolveAccessAccount({ config, keys, store }, request.headers.authorization); + if (bearerAccount) return { accountId: bearerAccount, exp: nowEpochSeconds() + grantsPageTokenTtlSeconds }; + return grantsPageTokenAccount(config, keys, pageToken); + }; + + const grantsReply = (reply: FastifyReply) => { + reply.header("Cache-Control", "no-store"); + reply.header("Referrer-Policy", "no-referrer"); + reply.header("X-Frame-Options", "DENY"); + reply.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); + }; + + const signInRequired = (reply: FastifyReply) => { + reply.code(401); + return { error: "sign_in_required", detail: "Send a Heimdall access token as a Bearer token, or open a link from POST /v1/me/grants/link." }; + }; + + // The signed-in surface for consent: every grant and receipt the account + // holds, and the one-click way to take each back. + app.get<{ Querystring: { t?: string } }>( + GRANTS_PATH, + { schema: { querystring: { type: "object", additionalProperties: false, properties: { t: { type: "string" } } } } }, + async (request, reply) => { + const caller = await grantsAccount(request, request.query.t); + if (!caller) return signInRequired(reply); + const { accountId } = caller; + grantsReply(reply); + const receipts = await store.listConsentReceipts(accountId); + if (prefersHtml(request.headers.accept)) { + reply.type("text/html; charset=utf-8"); + return reply.send(renderGrantsPage(grantViews(receipts), signGrantsPageToken(config, keys, accountId, caller.exp))); + } + return { grants: grantViews(receipts), receipts }; + } + ); + + // A link a browser can open: short-lived, opens the grants page and nothing else. + app.post(`${GRANTS_PATH}/link`, async (request, reply) => { + const accountId = await resolveAccessAccount({ config, keys, store }, request.headers.authorization); + if (!accountId) return signInRequired(reply); + grantsReply(reply); + reply.code(201); + return { + url: `${config.publicBaseUrl}${GRANTS_PATH}?t=${encodeURIComponent(signGrantsPageToken(config, keys, accountId))}`, + expiresInSeconds: grantsPageTokenTtlSeconds, + }; + }); + + app.post<{ Params: { grantId: string }; Body?: { t?: string } }>( + `${GRANTS_PATH}/:grantId/revoke`, + { + schema: { + params: { type: "object", required: ["grantId"], additionalProperties: false, properties: { grantId: { type: "string", minLength: 1 } } }, + body: { type: ["object", "null"], additionalProperties: false, properties: { t: { type: "string" } } }, + }, + }, + async (request, reply) => { + const caller = await grantsAccount(request, request.body?.t); + if (!caller) return signInRequired(reply); + const { accountId } = caller; + grantsReply(reply); + const receipts = await store.listConsentReceipts(accountId); + const grant = grantViews(receipts).find((view) => view.id === request.params.grantId); + if (!grant) { + reply.code(404); + return { error: "grant_not_found" }; + } + const revoked: string[] = []; + if (grant.status === "active") { + // The credential is one row per account and provider, whichever app + // asked, so taking it back ends every live grant that rests on it. + // It is stripped first: a failure after that leaves a receipt that + // still reads active over a credential that is gone, never the reverse. + const identity = await store.findStoredLinkedIdentityForAccount(accountId, grant.provider); + const at = new Date().toISOString(); + if (identity) { + await store.updateLinkedIdentityCredential(identity.id, async () => ({ kind: "revoke", updatedAt: at })); + } + for (const live of grantViews(receipts).filter((view) => view.provider === grant.provider && view.status === "active")) { + const { created } = await store.createConsentReceipt({ + kind: "revocation", + accountId, + appSlug: live.appSlug, + provider: live.provider, + createdAt: at, + revokesReceiptId: live.id, + }); + // A concurrent revoke may have written it first; only the writer reports it. + if (created) revoked.push(live.id); + } + if (revoked.length) { + await store.createAuditEvent({ + accountId, + eventType: "consent_revoked", + eventPayloadJson: { provider: grant.provider, grantIds: revoked }, + createdAt: at, + }); + } + } + if (prefersHtml(request.headers.accept) || request.headers["content-type"]?.startsWith("application/x-www-form-urlencoded")) { + reply.type("text/html; charset=utf-8"); + const views = grantViews(await store.listConsentReceipts(accountId)); + return reply.send(renderGrantsPage(views, signGrantsPageToken(config, keys, accountId, caller.exp), revoked.length ? "Taken back." : "Already taken back.")); + } + return { revoked, grants: grantViews(await store.listConsentReceipts(accountId)) }; + } + ); + return app; } diff --git a/src/backend-handoff.ts b/src/backend-handoff.ts index 204c570..b26a59f 100644 --- a/src/backend-handoff.ts +++ b/src/backend-handoff.ts @@ -30,6 +30,8 @@ export interface BackendHandoffSuccessPayload mode: OAuthMode; account: AccountSummary; entitlements: EntitlementEvaluation; + /** The receipt of the consent a managed connection rests on. */ + consentReceiptId?: string; } export interface BackendHandoffErrorPayload extends BackendHandoffBase { @@ -37,9 +39,20 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase { mode?: OAuthMode; error: string; errorDescription?: string; + /** Who said no, when `error` is consent_refused. */ + by?: "viewer" | "provider"; } -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..b8da567 100644 --- a/src/browser-handoff.ts +++ b/src/browser-handoff.ts @@ -12,7 +12,11 @@ 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; + /** The receipt of the consent a managed connection rests on. */ + consentReceiptId?: string; } export interface BrowserHandoffError extends BrowserHandoffBase { @@ -20,6 +24,8 @@ export interface BrowserHandoffError extends BrowserHandoffBase { error: string; errorDescription?: string; attemptId?: string; + /** Who said no, when `error` is consent_refused. */ + by?: "viewer" | "provider"; } export type BrowserHandoffPayload = BrowserHandoffSuccess | BrowserHandoffError; @@ -45,11 +51,19 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string { fragment.set("heimdall_attempt_id", payload.attemptId); } + if (payload.status === "success" && payload.consentReceiptId) { + fragment.set("heimdall_consent_receipt_id", payload.consentReceiptId); + } 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); + if (payload.by) { + fragment.set("heimdall_refused_by", payload.by); + } if (payload.errorDescription) { fragment.set("heimdall_error_description", payload.errorDescription); } @@ -73,14 +87,19 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string }); const scriptOrigin = serializeForScript(targetOrigin); const scriptFallbackUrl = serializeForScript(fallbackUrl); - const scriptTitle = serializeForScript( - payload.status === "success" ? "Authentication Complete" : "Authentication Failed" - ); + const refused = payload.status === "error" && payload.error === "consent_refused"; + const pageTitle = refused ? "Not Connected" : payload.status === "success" ? "Authentication Complete" : "Authentication Failed"; + const scriptTitle = serializeForScript(pageTitle); const scriptBody = serializeForScript( - payload.status === "success" - ? payload.handoffKind === "backend_callback" - ? "The app backend has the auth result already. You can return to the app now." - : "You can return to the app now." + refused + ? "Nothing was connected and nothing was kept. You can return to the app." + : payload.status === "success" + ? (payload.handoffKind === "backend_callback" + ? "The app backend has the auth result already. You can return to the app now." + : "You can return to the app now.") + + (payload.consentReceiptId + ? ` Your consent is recorded as receipt ${payload.consentReceiptId}; you can review it and take it back from your Heimdall connections page.` + : "") : "The authentication flow failed. Return to the app to continue." ); @@ -89,7 +108,7 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string - ${payload.status === "success" ? "Authentication Complete" : "Authentication Failed"} + ${pageTitle} + + +
+${body} +
+ +`; +} + +/** The ask. Two POST forms carry the signed state; the page runs no script. */ +export function renderConsentPage(ask: ConsentAsk, state: string): string { + const lines = consentLines(ask); + const provider = providerCatalog[ask.provider].displayName; + const [headline, ...rest] = lines; + const form = (decision: "continue" | "decline", label: string) => + ``; + return page( + `${appName(ask.appSlug)} wants to connect ${provider}`, + `

${escapeHtml(headline!)}

+
    +${rest.map((line) => `
  • ${escapeHtml(line)}
  • `).join("\n")} +
+
+ + ${form("continue", consentButtons(ask).go)} + ${form("decline", consentButtons(ask).no)} +
` + ); +} + +export interface GrantView { + consentText: string; + id: string; + appSlug: AppSlug; + provider: Provider; + scopesAsked: string[]; + scopesGranted: string[]; + grantedAt: string; + consentPageDigest: string; + status: "active" | "revoked"; + revokedAt?: string; + revocationReceiptId?: string; +} + +/** Every grant the receipts hold, each live unless a revocation names it. */ +export function grantViews(receipts: readonly StoredConsentReceipt[]): GrantView[] { + const revocations = new Map(); + for (const receipt of receipts) { + if (receipt.kind === "revocation") revocations.set(receipt.revokesReceiptId, receipt); + } + const views: GrantView[] = []; + for (const receipt of receipts) { + if (receipt.kind !== "grant") continue; + const revocation = revocations.get(receipt.id); + views.push({ + id: receipt.id, + appSlug: receipt.appSlug, + provider: receipt.provider, + scopesAsked: receipt.scopesAsked, + scopesGranted: receipt.scopesGranted, + grantedAt: receipt.createdAt, + consentPageDigest: receipt.consentPageDigest, + consentText: receipt.consentText, + status: revocation ? "revoked" : "active", + ...(revocation ? { revokedAt: revocation.createdAt, revocationReceiptId: revocation.id } : {}), + }); + } + return views; +} + +/** The signed-in page: every grant with its receipt, one button each to take it back. */ +export function renderGrantsPage(grants: readonly GrantView[], pageToken: string, notice?: string): string { + const cards = grants.length + ? grants + .map((grant) => { + const provider = providerCatalog[grant.provider].displayName; + const header = `${escapeHtml(appName(grant.appSlug))} → ${escapeHtml(provider)}`; + const detail = `granted ${escapeHtml(grant.grantedAt)}; permissions ${escapeHtml(grant.scopesGranted.join(", ") || "none returned")}; receipt ${escapeHtml(grant.id)}; wording ${escapeHtml(grant.consentPageDigest.slice(0, 12))}`; + if (grant.status === "revoked") { + return `
${header} taken back ${escapeHtml(grant.revokedAt ?? "")}; receipt ${escapeHtml(grant.revocationReceiptId ?? "")}
${detail}
`; + } + return `
${header} active
${detail} +
+ + +
`; + }) + .join("\n") + : `

You have not connected anything through Heimdall.

`; + return page( + "Your connections", + `

Your connections

+${notice ? `

${escapeHtml(notice)}

\n` : ""}${cards} +

Taking a connection back deletes Heimdall's copy of the tokens. The app's next request for a token is refused. The permission stays with the provider until you remove it in the provider's settings.

` + ); +} diff --git a/src/contracts.ts b/src/contracts.ts index 718523b..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]; @@ -97,6 +97,8 @@ export interface OAuthStatePayload { initiator_account_id: string | null; handoff: OAuthHandoff | null; entitlement_policy: OAuthEntitlementPolicy | null; + /** The start's requestedScopes, carried so the launch hop can build the provider URL from the signed state alone. */ + requested_scopes: string[] | null; } export interface LinkedIdentityInput { diff --git a/src/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/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/providers.ts b/src/providers.ts index 77eebf4..01f749f 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,27 +161,43 @@ 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", }, }; +/** The scopes an authorization request carries: the caller's, or the provider's defaults. */ +export function authorizationScopes(provider: Provider, requested: readonly string[] | undefined): string[] { + return requested?.length ? [...requested] : [...providerCatalog[provider].defaultScopes]; +} + export function buildAuthorizationUrl(options: { provider: Provider; clientId: string; 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); - const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes; + const scopes = authorizationScopes(options.provider, options.requestedScopes); url.searchParams.set("client_id", options.clientId); url.searchParams.set("redirect_uri", options.redirectUri); 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/store/in-memory.ts b/src/store/in-memory.ts index b89e7eb..e829900 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -3,7 +3,12 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra import { type CreateAccountInput, type CreateAuthAttemptInput, + type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, + type CreateConsentReceiptInput, + type CreatePendingLinkInput, + type StoredConsentReceipt, + type StoredPendingLink, type CreateAuditEventInput, type CreateCapabilityGrantInput, type CredentialDecision, @@ -21,6 +26,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,9 +55,13 @@ 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(); + private readonly consentReceipts: StoredConsentReceipt[] = []; async ensureSchema(): Promise { return; @@ -430,6 +442,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)); @@ -442,4 +485,22 @@ export class InMemoryStore implements HeimdallStore { }); this.auditEvents.set(event.id ?? randomUUID(), event); } + + async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> { + if (input.kind === "revocation") { + // The table's foreign key: a revocation names a receipt of the same account. + if (!this.consentReceipts.some((r) => r.id === input.revokesReceiptId && r.accountId === input.accountId)) { + throw new Error("A revocation must name a receipt of its own account."); + } + const existing = this.consentReceipts.find((r) => r.kind === "revocation" && r.revokesReceiptId === input.revokesReceiptId); + if (existing) return { receipt: clone(existing), created: false }; + } + const receipt = { id: randomUUID(), ...clone(input) } as StoredConsentReceipt; + this.consentReceipts.push(clone(receipt)); + return { receipt: clone(receipt), created: true }; + } + + async listConsentReceipts(accountId: string): Promise { + return this.consentReceipts.filter((receipt) => receipt.accountId === accountId).map(clone); + } } diff --git a/src/store/postgres.ts b/src/store/postgres.ts index a1224dd..3d34047 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -5,7 +5,12 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f import { type CreateAccountInput, type CreateAuthAttemptInput, + type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, + type CreateConsentReceiptInput, + type CreatePendingLinkInput, + type StoredConsentReceipt, + type StoredPendingLink, type CreateAuditEventInput, type CreateCapabilityGrantInput, type CredentialDecision, @@ -89,6 +94,19 @@ interface AuthCompletionRow extends QueryResultRow { 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; @@ -232,6 +250,59 @@ function mapSessionRow(row: SessionRow): StoredSession { }; } +interface ConsentReceiptRow { + id: string; + kind: "grant" | "revocation"; + account_id: string; + app_slug: AppSlug; + provider: Provider; + created_at: Date | string; + scopes_asked: string | null; + scopes_granted: string | null; + consent_page_digest: string | null; + consent_text: string | null; + revokes_receipt_id: string | null; +} + +function mapConsentReceiptRow(row: ConsentReceiptRow): StoredConsentReceipt { + const base = { + id: row.id, + accountId: row.account_id, + appSlug: row.app_slug, + provider: row.provider, + createdAt: normalizeTimestamp(row.created_at), + }; + if (row.kind === "revocation") { + return { ...base, kind: "revocation", revokesReceiptId: row.revokes_receipt_id ?? "" }; + } + return { + ...base, + kind: "grant", + scopesAsked: parseScopes(row.scopes_asked ?? ""), + scopesGranted: parseScopes(row.scopes_granted ?? ""), + consentPageDigest: row.consent_page_digest ?? "", + consentText: row.consent_text ?? "", + }; +} + +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, @@ -383,8 +454,11 @@ export const STORE_TABLE_PRIVILEGES: Readonly { + 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( ` @@ -1105,6 +1234,52 @@ export class PostgresStore implements HeimdallStore { ); } + async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> { + const grant = input.kind === "grant" ? input : null; + // No conflict target and the partial unique index decides: a second + // revocation of one grant inserts nothing and the first is read back. + const result = await this.pool.query( + ` + INSERT INTO consent_receipts ( + id, kind, account_id, app_slug, provider, created_at, + scopes_asked, scopes_granted, consent_page_digest, consent_text, revokes_receipt_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT DO NOTHING + RETURNING * + `, + [ + randomUUID(), + input.kind, + input.accountId, + input.appSlug, + input.provider, + input.createdAt, + grant ? grant.scopesAsked.join(" ") : null, + grant ? grant.scopesGranted.join(" ") : null, + grant ? grant.consentPageDigest : null, + grant ? grant.consentText : null, + input.kind === "revocation" ? input.revokesReceiptId : null, + ] + ); + if (result.rowCount) { + return { receipt: mapConsentReceiptRow(expectRow(result.rows[0], "createConsentReceipt")), created: true }; + } + const existing = await this.pool.query( + `SELECT * FROM consent_receipts WHERE revokes_receipt_id = $1`, + [input.kind === "revocation" ? input.revokesReceiptId : null] + ); + return { receipt: mapConsentReceiptRow(expectRow(existing.rows[0], "createConsentReceipt")), created: false }; + } + + async listConsentReceipts(accountId: string): Promise { + const result = await this.pool.query( + `SELECT * FROM consent_receipts WHERE account_id = $1 ORDER BY created_at, id`, + [accountId] + ); + return result.rows.map(mapConsentReceiptRow); + } + async createAuditEvent(input: CreateAuditEventInput): Promise { await this.pool.query( ` diff --git a/src/store/schema.ts b/src/store/schema.ts index 3433ef8..294ee2d 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, @@ -123,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, @@ -153,6 +180,34 @@ CREATE TABLE IF NOT EXISTS entitlement_snapshots ( UNIQUE(account_id, provider, scope) ); +-- Consent receipts are append-only (the store role has INSERT and SELECT +-- only): a revocation is a row that names the grant it takes back. +CREATE TABLE IF NOT EXISTS consent_receipts ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + app_slug TEXT NOT NULL, + provider TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + scopes_asked TEXT, + scopes_granted TEXT, + consent_page_digest TEXT, + consent_text TEXT, + revokes_receipt_id TEXT, + UNIQUE(id, account_id), + FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id) +); + +-- A revocation names a receipt of its own account (the composite foreign key; +-- NULL skips it for grants). A grant is taken back once: the second +-- revocation naming it conflicts. +CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx + ON consent_receipts(revokes_receipt_id) + WHERE revokes_receipt_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS consent_receipts_account_idx + ON consent_receipts(account_id, created_at); + CREATE TABLE IF NOT EXISTS audit_events ( id TEXT PRIMARY KEY, account_id TEXT, @@ -214,7 +269,7 @@ export const REQUIRED_TABLES = createdTables.map(({ table }) => table); export const REQUIRED_COLUMNS = [ ...createdTables.flatMap(({ table, lines }) => lines - .filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line)) + .filter((line) => !/^(?:PRIMARY KEY|UNIQUE|FOREIGN KEY)\b/.test(line)) .map((line) => { const [, column, type] = /^(\w+) (\w+)/.exec(line)!; return { table, column: column!, type: type!, nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(line) }; diff --git a/src/store/types.ts b/src/store/types.ts index f5b9873..bb2e080 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -167,6 +167,14 @@ export interface StoredAuthCompletion { payloadJson: Record; } +export interface ClaimOAuthStateUseInput { + jti: string; + phase: "launch" | "callback"; + appSlug: AppSlug; + usedAt: string; + expiresAt: string; +} + export interface CreateAuthCompletionInput { /** * Correlation handle only (see StoredAuthCompletion.attemptId). The store @@ -185,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; @@ -222,6 +252,33 @@ export interface StoredPrivateCommandReceipt { export type CreatePrivateCommandReceiptInput = StoredPrivateCommandReceipt; +/** + * What a person agreed to, or took back. Append-only: a grant row is never + * edited, and a revocation is a second row naming it, so a grant is live + * exactly when no revocation names it. `scopesAsked` is what Heimdall sent the + * provider, `scopesGranted` what the provider returned, `consentPageDigest` + * the hash of the wording the person was shown and `consentText` that wording + * itself. A grant is revoked at most once: a second revocation naming the same + * grant is not written, and the store answers with the first. + */ +export type StoredConsentReceipt = { + id: string; + accountId: string; + appSlug: AppSlug; + provider: Provider; + createdAt: string; +} & ( + | { kind: "grant"; scopesAsked: string[]; scopesGranted: string[]; consentPageDigest: string; consentText: string } + | { kind: "revocation"; revokesReceiptId: string } +); + +/** The store mints `id`; a caller cannot choose it. */ +export type CreateConsentReceiptInput = StoredConsentReceipt extends infer R + ? R extends StoredConsentReceipt + ? Omit + : never + : never; + export interface HeimdallStore { ensureSchema(): Promise; close(): Promise; @@ -276,6 +333,27 @@ 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; + /** `created` is false when a revocation for that grant already existed and is what comes back. */ + createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }>; + /** Every receipt the account holds, oldest first. */ + listConsentReceipts(accountId: string): Promise; } 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(/