diff --git a/README.md b/README.md index f0e0a0e..b86dbdc 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, Google OIDC for linking), 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), 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 48c3577..f520258 100644 --- a/deployment/idunn/recipe.toml +++ b/deployment/idunn/recipe.toml @@ -157,8 +157,6 @@ 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 12fbd4f..8ae5c19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -154,11 +154,7 @@ The important split: - Heimdall owns provider OAuth, identity linking, entitlement refresh, signed sessions, claims, grants, audit surfaces, and managed provider credential - custody. It authenticates; it does not issue GameCult identity. Bifrost is - the identity provider, and the account id Heimdall mints is an authenticator - handle. Remaining scar: Heimdall still creates that account id on first - login (`store.createAccount`, `src/app.ts`), which the chat work does not - need moved + custody - the host experiment owns its workload, queue, editor, render path, viewer profile model, creator model, or other product-specific machinery - the host experiment also owns which local entity a connection attaches to, @@ -447,12 +443,13 @@ builder was not used. process, so N Heimdall processes can present one token N times after a transient failure. Needs a failed-attempt marker on the row, read under the lock. Must land before or with Idunn onboarding. -- `joystick:follow_up:connect-start-needs-app-auth` (Heimdall side done): a - connect start that names a connection target needs the app's own secret for - every managed provider, and a blank target is refused at start and at the - callback mint. What StreamPixels must do on its side is in - `notes/streampixels-connect-redeem.md`; its redeem must refuse a creator - connect whose target it did not start. +- `joystick:follow_up:connect-start-needs-app-auth`: connect starts are + unauthenticated, so anyone can name `{creator, targetId}`; the callback + re-check proves a binding exists, not that the caller owns it. It also owns + the whitespace-only `targetId` that passes both gates and the callback's + fail-open `''` fallback for a vanished profile (finding + `joystick:finding:cut-h1.s4.whitespace-targetid-passes-custody-gate`). Must + land before the public Joystick bot or a second creator. - `joystick:follow_up:heimdall-idunn-onboarding` (gamecult-ops): live Heimdall is a hand-copied release under `heimdall.service`, not an Idunn-admitted generation; the recipe's Joystick lines are declared but unproven by a @@ -616,9 +613,8 @@ Only the browser-driven OAuth flow: | Path | Why it must be public | |---|---| -| `/v1/oauth/{provider}/start` | the app's server calls it, with its app secret where the profile requires one | -| `/v1/oauth/{provider}/launch` | the user's browser goes here first; it sets the binding cookie and redirects to the provider, once per state | -| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot: it needs the binding cookie and consumes the state | +| `/v1/oauth/{provider}/start` | the user's browser goes here | +| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable | | `/.well-known/jwks.json` | *nothing external consumes it today* — see below | Everything else is internal by the design already written down: diff --git a/docs/joystick-consent-flow.md b/docs/joystick-consent-flow.md deleted file mode 100644 index 3fafec6..0000000 --- a/docs/joystick-consent-flow.md +++ /dev/null @@ -1,237 +0,0 @@ -# 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 bf8d810..e356ae2 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -119,7 +119,6 @@ Supported providers in the skeleton: - `youtube` - `spotify` - `joystick` -- `google` Request body: @@ -150,29 +149,12 @@ Response: "appSlug": "repixelizer", "mode": "sign_in", "callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback", - "authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=", + "authorizationUrl": "https://discord.com/oauth2/authorize?...", + "stateToken": "", "stateExpiresAt": "2026-04-26T12:10:00.000Z" } ``` -Send the user's browser to `authorizationUrl` unchanged. It is Heimdall's launch -hop, not the provider's URL. - -### `GET /v1/oauth/{provider}/launch?state=` - -The first thing the user's browser does with a state. Heimdall verifies the -state (signature, issuer, provider, `nbf`/`exp` with 30 s of skew), claims the -state's single launch, sets the binding cookie -`__Host-heimdall-oauth-` (`Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, -`Max-Age` until the state expires; the value is a Heimdall-signed binding for -that state) and answers `302` to the provider's authorize URL, with -`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A second launch of -the same state is `400 state_already_launched` and sets no cookie. The state is -refused with `400 invalid_state`, `state_provider_mismatch` or `state_expired`. - -The binding cookie needs an `https` public base URL (or `localhost`). A browser -must finish the flow in the browser that launched it. - Failure modes: - `503 provider_not_configured` when the provider client id is missing @@ -186,17 +168,7 @@ Failure modes: - `401 app_auth_required` when the request body carries `entitlementPolicy` and the caller did not authenticate as that app via `x-heimdall-app-secret` (`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of - `handoff.kind` or app slug; a caller with no policy to attach needs no secret, - unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then - every start for that app, with or without a policy, needs that app's secret. - Bifrost binds browser completion to a nonce that rides readable in the signed - state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose - browser starts are public by design leave the flag unset -- `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` + `handoff.kind` or app slug; a caller with no policy to attach needs no secret Launch note: @@ -207,45 +179,6 @@ 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: @@ -256,11 +189,7 @@ Purpose: Current status: -- validates signed `state` (signature, issuer, provider, expiry), requires the - binding cookie the launch hop set, and consumes the state: a second callback - is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`, - 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 +- validates signed `state` - returns provider errors cleanly - for Discord, exchanges the authorization code, resolves the provider identity, persists the local account/link/session/audit records, evaluates @@ -567,34 +496,12 @@ Important behavior: identity (`SELECT ... FOR UPDATE`), so a given refresh token is presented to the provider once across concurrent requests and across two Heimdall processes sharing one database. Within one process, concurrent refreshes of - one identity share a single attempt and its outcome (success or failure). A - failed or timed-out attempt is recorded on the identity row - (`refresh_failed_at`, written in the same transaction as the lock), and no - process presents that row's refresh token again for 60 seconds: a process - that takes the lock inside the backoff answers the same - `managed_credential_refresh_failed` without calling the provider. A stamp - more than 60 seconds in the future (a writer whose clock runs ahead) is skew, - not a fresh failure, so the wait is capped at twice the backoff. A rotation, - a revocation or a re-link clears the marker. - The marker is a best-effort write on a second connection, made after the - lock's transaction rolled back; it is not part of that transaction. When the - rotation or the revocation the provider's answer called for cannot be - committed, the fallback write leaves the marker and blanks the refresh token - only when that token is spent: on a revoke (a rotating provider answered - without a refresh token, or the grant was rejected), or when the provider - rotates refresh tokens (Joystick) and so consumed it. A provider that keeps - its refresh token valid (Twitch) keeps it, so a transient database failure - does not strand the link: the next caller after the backoff presents it again. - Residual risk: the provider may consume the refresh token as soon as the - request is sent. If a Heimdall process dies after that and before the commit - of the marker, the rotation or the revocation, or if the blanking fallback - write fails too, nothing is recorded, and the - next caller can present the refresh token the provider already consumed; for - a provider that rotates (Joystick) that reads as token theft. The Heimdall - steward ruled (#minds, 2026-11-08) that an intent log, written on its own - connection before the provider call, replaces this marker; it is tracked in - `joystick:follow_up:write-ahead-refresh-attempt-record`. - The provider call is bounded at 10 seconds. + one identity share a single attempt and its outcome (success or failure), so + nobody re-presents a token behind a failed holder in that burst. Residual + risk, tracked as follow-up `joystick:follow_up:cross-process-failed-refresh-marker`: + the guard is one presentation per process per burst, so N + Heimdall processes can present one token N times after a transient failure. + The provider call is bounded at 10 seconds - a provider whose refresh tokens rotate (Joystick) fails closed: a refresh response without a new refresh token revokes the credential instead of keeping the old token diff --git a/docs/verse-service-contract.md b/docs/verse-service-contract.md index f3c98e9..fcfa4ac 100644 --- a/docs/verse-service-contract.md +++ b/docs/verse-service-contract.md @@ -11,8 +11,7 @@ evidence only. ## Owner Map -- Owner: Heimdall owns provider identity/linking (an authenticator handle, not - GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim +- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim issuance, entitlement refresh, managed provider token custody, grants, auth-completion handoffs, refresh sessions, and audit events. - Inputs: provider OAuth callbacks, app profile requests, caller-supplied diff --git a/notes/streampixels-connect-redeem.md b/notes/streampixels-connect-redeem.md deleted file mode 100644 index 83f57a7..0000000 --- a/notes/streampixels-connect-redeem.md +++ /dev/null @@ -1,35 +0,0 @@ -# For Kiko: what StreamPixels must do for a creator connect - -Heimdall's side is done (this PR). A connect start that carries a `connection` -target is accepted only with StreamPixels' own `x-heimdall-app-secret`, so only -the StreamPixels backend can mint a state naming `{kind: "creator", targetId}`. -Heimdall cannot see StreamPixels' session; the secret proves the caller is the -backend, and the backend must have proved the creator-manage session itself -before it starts the connect. - -What StreamPixels has to change (not done here; this repo does not touch it): - -1. **Start the connect only after the creator-manage check.** The route that - calls `POST /v1/oauth/:provider/start` with `mode: "connect"` must have - verified, for the signed-in StreamPixels user, that they may manage creator - `targetId`. Never take `targetId` from the browser unchecked. -2. **Remember what you started.** Store, server-side and bound to the attempt - (the `attemptId` or browser session), the `targetId` StreamPixels asked - Heimdall to bind. -3. **Redeem with that stored target, never one read back from the request or - the completion.** `POST /v1/apps/streampixels/auth/redeem` takes `targetId`; - Heimdall answers `403 completion_target_mismatch` (code left unconsumed) when - it differs from the signed state's. That only protects you if the value you - send is the one you stored at step 2. A redeem with no stored target for this - attempt must be refused by StreamPixels before it calls Heimdall. -4. **Refuse a creator connect you did not start.** If a connect completion - arrives for a `targetId` with no matching started attempt, do not rebind that - creator's connector, whatever Heimdall's completion says. -5. **A connect completion with no creator target is not a creator connect and - not a sign-in.** Joystick is managed-connection only: a non-creator Joystick - completion is an error. (Twitch/YouTube `connect` without a `connection` is - Heimdall's viewer-session path; treat it as that, never as a connector rebind.) -6. Blank targets (`" "`, zero-width characters) are refused by Heimdall at - start with `400 invalid_connection_target`; send real ids only. - -Tracked as `joystick:follow_up:connect-start-needs-app-auth`. diff --git a/package.json b/package.json index 626ab8a..36394ef 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "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 c84f4d2..855ad01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,9 +12,6 @@ importers: .: dependencies: - '@fastify/cookie': - specifier: ^11.1.3 - version: 11.1.5 '@fastify/cors': specifier: ^11.2.0 version: 11.2.0 @@ -232,9 +229,6 @@ 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==} @@ -475,10 +469,6 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - cookie@2.0.1: - resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} - engines: {node: '>=22'} - cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory} engines: {node: '>=18'} @@ -557,9 +547,6 @@ 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==} @@ -1199,11 +1186,6 @@ 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 @@ -1411,8 +1393,6 @@ snapshots: cookie@1.1.1: {} - cookie@2.0.1: {} - cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: dependencies: '@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts @@ -1520,8 +1500,6 @@ 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 deleted file mode 100755 index e028caa..0000000 --- a/scripts/test-live-pg.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/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 c25dbb2..3489a48 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -45,17 +45,6 @@ export interface AppProfile { * plane; absent for apps with no such requirement. */ requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"]; - /** - * The app binds browser completion to its own pending attempt, and the - * binding is the secrecy of the attempt nonce, which rides readable in the - * signed (not encrypted) state's return_to. If anyone could start a flow for - * this app, whoever learned a victim's nonce could mint a completion bound - * to it and sign the victim's browser in as themselves (login CSRF, finding - * known-nonce-login-csrf). So a start for this app is refused unless the - * caller is this app's authenticated caller: only the app can mint its own - * state. Absent for apps whose browser starts are public by design. - */ - startRequiresAppCaller?: boolean; } /** The membership signal shared by every app that gates on GameCult membership. */ @@ -148,12 +137,11 @@ const streampixelsProfile: AppProfile = { const bifrostProfile: AppProfile = { slug: "bifrost", displayName: "Bifrost", - profileVersion: "2026-11-04", - identityProviders: ["discord", "patreon", "google"], + profileVersion: "2026-06-08", + identityProviders: ["discord", "patreon"], entitlementSources: ["discord", "patreon"], managedConnectionProviders: [], allowedReturnOrigins: ["https://bifrost.gamecult.org"], - startRequiresAppCaller: true, capabilities: [ { key: "member_access", @@ -263,17 +251,6 @@ export function isAllowedReturnOrigin(profile: AppProfile, returnTo: string): bo } } -/** - * A connection target names something: at least one letter or number that is - * not a default-ignorable code point (the Hangul and Khmer fillers are letters - * that render as nothing). An allowlist, so marks, private-use, unassigned and - * surrogate code points and symbols cannot stand in for a name. The one - * definition the start gate and the callback mint both use. - */ -export function isUsableConnectionTarget(targetId: string): boolean { - return /(?![\p{Default_Ignorable_Code_Point}])[\p{L}\p{N}]/u.test(targetId); -} - /** * What a completed OAuth callback may mint, decided at the callback from the * signed state's mode and the provider's role in the profile (the start gate @@ -300,7 +277,7 @@ export function callbackMint( return mode === "connect" && profile.managedConnectionProviders.includes(provider) && connection?.kind === "creator" && - isUsableConnectionTarget(connection.targetId) + connection.targetId !== "" ? "custody_only" : "refused"; } diff --git a/src/app.ts b/src/app.ts index 776d671..a1fe611 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,8 +1,6 @@ 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, type FastifyReply, type FastifyRequest } from "fastify"; +import Fastify, { type FastifyError, type FastifyInstance } from "fastify"; import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js"; import { @@ -25,13 +23,11 @@ import { type Provider, } from "./contracts.js"; import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js"; -import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint, isUsableConnectionTarget } from "./app-profiles.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, @@ -39,7 +35,7 @@ import { type OAuthRuntimeRegistry, } from "./oauth.js"; import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js"; -import { authorizationScopes, buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; +import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; import { createStore, type HeimdallStore } from "./store/index.js"; import { @@ -98,9 +94,8 @@ function buildOAuthStateToken(options: { initiatorAccountId: string | undefined; handoff: OAuthHandoff; entitlementPolicy: OAuthEntitlementPolicy | undefined; - requestedScopes: string[] | undefined; keys: ReturnType; -}): { token: string; jti: string; expiresAt: string } { +}): { token: string; expiresAt: string } { const issuedAt = nowEpochSeconds(); const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds; const payload: OAuthStatePayload = { @@ -120,13 +115,11 @@ 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(), }; } @@ -160,11 +153,6 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay entitlementPolicy !== null && (entitlementPolicy as Record).kind === "patreon_membership_access" && typeof (entitlementPolicy as Record).requiredTierTitle === "string")); - const requestedScopes = payload.requested_scopes; - const requestedScopesAreValid = - requestedScopes === null || - requestedScopes === undefined || - (Array.isArray(requestedScopes) && requestedScopes.every((scope) => typeof scope === "string")); if ( payload.typ !== "heimdall_oauth_state" || typeof payload.provider !== "string" || @@ -183,8 +171,7 @@ 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") || - !requestedScopesAreValid + !(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") ) { return null; } @@ -199,167 +186,10 @@ function parseOAuthStatePayload(payload: Record): OAuthStatePay if (!normalizedPayload.entitlement_policy) { normalizedPayload.entitlement_policy = null; } - if (!normalizedPayload.requested_scopes) { - normalizedPayload.requested_scopes = null; - } return normalizedPayload; } -type OAuthStateVerdict = - | { ok: true; payload: OAuthStatePayload } - | { ok: false; error: "invalid_state" | "state_provider_mismatch" | "state_expired" }; - -/** - * The one place a signed OAuth state becomes trusted: signature, shape, issuer, - * audience and provider, and the time window with the same 30 s skew refresh - * tokens use. Every route that turns a state into navigation or a completion - * runs this first; the refusals are fixed codes and never echo the token. - */ -function verifyOAuthState( - config: HeimdallConfig, - keys: ReturnType, - token: string, - provider: Provider -): OAuthStateVerdict { - const verification = verifyJwt(token, keys.publicKey); - if (!verification.valid) { - return { ok: false, error: "invalid_state" }; - } - - const payload = parseOAuthStatePayload(verification.payload); - if (!payload || payload.iss !== config.issuer) { - return { ok: false, error: "invalid_state" }; - } - if (payload.aud !== payload.provider || payload.provider !== provider) { - return { ok: false, error: "state_provider_mismatch" }; - } - - const now = nowEpochSeconds(); - if (payload.nbf > now + 30 || payload.exp <= now - 30) { - return { ok: false, error: "state_expired" }; - } - - return { ok: true, payload }; -} - -const oauthBindingType = "heimdall_oauth_binding"; - -function oauthBindingCookieName(jti: string): string { - return `__Host-heimdall-oauth-${jti}`; -} - -/** The signed claim that this browser launched this state: it is the cookie's value, and needs no store read to check. */ -function signOAuthBinding( - config: HeimdallConfig, - keys: ReturnType, - state: OAuthStatePayload, - 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)); } @@ -424,14 +254,18 @@ function relayedProviderText(value: string | undefined, bound: number): string | return printable ? printable : undefined; } +/** + * What may be recorded or answered about a caught error: its class and its + * code, each only when it is a short identifier. Never its message, which + * carries whatever the thrower quoted (a host, a database, a provider's body). + */ /** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */ -export class LinkRefusal extends Error { +class LinkRefusal extends Error { constructor( - readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown" | "identity_not_linked", + readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown", message: string ) { super(message); - this.name = "LinkRefusal"; } } @@ -574,8 +408,6 @@ 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) { @@ -585,7 +417,7 @@ async function evaluateStoredEntitlements(options: { const facts: string[] = []; for (const storedIdentity of linkedIdentities) { - if (!profile.entitlementSources.includes(storedIdentity.provider) || storedIdentity.provider === options.skipProvider) { + if (!profile.entitlementSources.includes(storedIdentity.provider)) { continue; } @@ -670,13 +502,6 @@ 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; @@ -693,8 +518,8 @@ export async function startOAuthFlow( } // A connection target is app-backend authority: it rides only on connect - // starts that carry the app's own secret (gated below), and never on a - // sign_in start, which is open to any browser. + // 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. if (input.mode === "sign_in" && input.connection !== undefined) { return { statusCode: 400, @@ -702,19 +527,12 @@ 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) { + if (callbackMint(profile, provider, input.mode, input.connection) === "refused") { return { - statusCode: 401, + statusCode: 400, body: { - error: "link_session_required", - detail: `Linking ${descriptor.displayName} needs a Heimdall access token for ${profile.slug}: send it as a Bearer token.`, + error: "creator_connection_required", + detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`, }, }; } @@ -724,36 +542,19 @@ export async function startOAuthFlow( // The name rides in the signed state and the callback holds the provider's // identity to it. No other mode carries an initiator. if (input.mode === "link") { - if (options?.linkAccountId) { - // The user's own access token for this app names the account. - if (input.initiatingAccountId !== undefined && input.initiatingAccountId !== options.linkAccountId) { - return { statusCode: 400, body: { error: "link_initiator_conflict", detail: "initiatingAccountId differs from the bearer's account." } }; - } - } else { - if (!isAppCaller(caller) || caller.appSlug !== profile.slug) { - return { statusCode: 401, body: { error: "app_auth_required" } }; - } - if (!input.initiatingAccountId) { - return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } }; - } - if (!(await ctx.store.findAccountById(input.initiatingAccountId))) { - return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } }; - } + 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, @@ -797,28 +598,10 @@ export async function startOAuthFlow( if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") { handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId }; } - if ((entitlementPolicy || profile.startRequiresAppCaller || (input.mode === "connect" && input.connection !== undefined)) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { + if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { return { statusCode: 401, body: { error: "app_auth_required" } }; } - // After the app-secret gate, so a caller without the secret learns nothing about what a target must look like. - if (input.connection !== undefined && !isUsableConnectionTarget(input.connection.targetId)) { - return { - statusCode: 400, - body: { error: "invalid_connection_target", detail: "connection.targetId must name a target." }, - }; - } - - if (callbackMint(profile, provider, input.mode, input.connection) === "refused") { - return { - statusCode: 400, - body: { - error: "creator_connection_required", - detail: `${provider} on ${profile.slug} is a managed creator connection: connect needs connection.kind creator and a targetId.`, - }, - }; - } - const providerConfig = config.providers[provider]; if (!providerConfig.clientId) { return { @@ -827,6 +610,7 @@ export async function startOAuthFlow( }; } + const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`; const state = buildOAuthStateToken({ config, provider, @@ -834,119 +618,33 @@ export async function startOAuthFlow( mode: input.mode, returnTo: input.returnTo, connection: input.connection, - initiatorAccountId: options?.linkAccountId ?? input.initiatingAccountId, + initiatorAccountId: input.initiatingAccountId, handoff, entitlementPolicy, - requestedScopes: input.requestedScopes, keys, }); + return { statusCode: 201, body: { provider, appSlug: profile.slug, mode: input.mode, - callbackUrl: oauthCallbackUrl(config, provider), - authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`, + callbackUrl, + authorizationUrl: buildAuthorizationUrl({ + provider, + clientId: providerConfig.clientId, + redirectUri: callbackUrl, + state: state.token, + requestedScopes: input.requestedScopes, + }), handoff, + stateToken: state.token, stateExpiresAt: state.expiresAt, }, }; } -/** - * 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 @@ -1085,11 +783,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { - done(null, Object.fromEntries(new URLSearchParams(body as string))); - }); app.get("/healthz", async () => ({ ok: true, @@ -1219,278 +912,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { const caller = resolveAppCaller(config, request.body.appSlug, request.headers); - 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 } : {}), - }); + const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body); 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 }; @@ -1524,49 +951,27 @@ export async function buildApp(options: BuildAppOptions = {}): 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"); + return { error: "invalid_state", detail: verification.error }; } - if (request.query.error === "access_denied") { - return refuseConsent(request, reply, statePayload, "provider"); + const statePayload = parseOAuthStatePayload(verification.payload); + if (!statePayload || statePayload.provider !== request.params.provider) { + reply.code(400); + return { error: "state_provider_mismatch" }; } + const handoff = statePayload.handoff ?? { kind: "browser_completion" }; + const denyBrowserAttempt = async (denialCode: string): Promise => { + if (handoff.kind !== "browser_completion" || !handoff.attemptId) return; + await store.updateAuthAttempt(statePayload.app_slug, handoff.attemptId, { + status: "denied", + at: new Date().toISOString(), + denialCode, + }); + }; + if (request.query.error) { const providerError = relayedProviderText(request.query.error, 64); const providerErrorDescription = relayedProviderText(request.query.error_description, 256); @@ -1659,139 +1064,40 @@ 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) { @@ -1820,26 +1126,32 @@ 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", { @@ -2228,7 +1470,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise nowIso) { @@ -2236,7 +1478,7 @@ 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 b26a59f..204c570 100644 --- a/src/backend-handoff.ts +++ b/src/backend-handoff.ts @@ -30,8 +30,6 @@ 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 { @@ -39,20 +37,9 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase { mode?: OAuthMode; error: string; errorDescription?: string; - /** Who said no, when `error` is consent_refused. */ - by?: "viewer" | "provider"; } -/** 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 type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload; 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 b8da567..e23601e 100644 --- a/src/browser-handoff.ts +++ b/src/browser-handoff.ts @@ -12,11 +12,7 @@ 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 { @@ -24,8 +20,6 @@ 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; @@ -51,19 +45,11 @@ 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); } @@ -87,19 +73,14 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string }); const scriptOrigin = serializeForScript(targetOrigin); const scriptFallbackUrl = serializeForScript(fallbackUrl); - 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 scriptTitle = serializeForScript( + payload.status === "success" ? "Authentication Complete" : "Authentication Failed" + ); const scriptBody = serializeForScript( - 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.` - : "") + 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." : "The authentication flow failed. Return to the app to continue." ); @@ -108,7 +89,7 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string - ${pageTitle} + ${payload.status === "success" ? "Authentication Complete" : "Authentication Failed"} - - -
-${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 0ee7c96..718523b 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,4 +1,4 @@ -export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick", "google"] as const; +export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const; export type Provider = (typeof providers)[number]; @@ -97,8 +97,6 @@ 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 deleted file mode 100644 index b368e45..0000000 --- a/src/google-oidc.ts +++ /dev/null @@ -1,228 +0,0 @@ -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/managed-credential.ts b/src/managed-credential.ts index fe60fc6..32e9f51 100644 --- a/src/managed-credential.ts +++ b/src/managed-credential.ts @@ -1,7 +1,7 @@ import { type HeimdallConfig } from "./config.js"; import { type Provider } from "./contracts.js"; import { type TokenCustody } from "./custody.js"; -import { type ExpiringTokenSet, type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js"; +import { type OAuthProviderRuntime, type OAuthRuntimeRegistry, ProviderResponseError } from "./oauth.js"; import { providerCatalog } from "./providers.js"; import { type HeimdallStore } from "./store/index.js"; import { type CredentialDecision, type StoredLinkedIdentity } from "./store/types.js"; @@ -16,14 +16,6 @@ export const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; */ export const MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS = 10_000; -/** - * After a refresh attempt fails or times out, no process presents the row's - * refresh token again until this long has passed (the failure is recorded on - * the row, read under the lock). A provider that rotates may have consumed the - * token before the attempt was aborted, and presenting it again reads as theft. - */ -export const MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS = 60 * 1000; - export interface ManagedCredentialContext { config: HeimdallConfig; store: HeimdallStore; @@ -63,17 +55,6 @@ function dueForRefresh(identity: StoredLinkedIdentity, runtime: OAuthProviderRun return Date.parse(identity.tokenExpiresAt) - now <= MANAGED_CREDENTIAL_REFRESH_SKEW_MS; } -/** - * Whether a failure stamped `failedAt` still holds this clock off. A stamp from - * a writer whose clock runs ahead reads as the future; one this far ahead or - * more is skew, not a fresh failure, so the wait stays bounded at twice the - * backoff. - */ -function withinBackoff(failedAt: string, now: number): boolean { - const elapsed = now - Date.parse(failedAt); - return elapsed > -MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS && elapsed < MANAGED_CREDENTIAL_REFRESH_BACKOFF_MS; -} - function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity { const { accessTokenEncrypted: _a, refreshTokenEncrypted: _r, tokenExpiresAt: _e, ...rest } = identity; return rest; @@ -87,8 +68,10 @@ function withoutTokens(identity: StoredLinkedIdentity): StoredLinkedIdentity { * attempt is in flight join it and receive its outcome, success or failure. * The entry lives exactly as long as the attempt: a later request retries. * - * Across processes the row owns it: a failed attempt leaves a marker on the - * row, and a process that takes the lock inside the backoff presents nothing. + * Known residual risk (docs/service-contract.md): one presentation per + * process per burst, so N processes may present one token N times. The + * cross-process failed-refresh marker that would close it is + * joystick:follow_up:cross-process-failed-refresh-marker. */ const refreshesInFlight = new WeakMap>>(); @@ -137,8 +120,28 @@ async function refreshUnderLock( const runtime = ctx.oauthRuntimes[provider]; let revocation: ManagedCredentialRevocationReason | undefined; - let failed = false; - const rotation = (locked: StoredLinkedIdentity, refreshed: ExpiringTokenSet, updatedAt: string): CredentialDecision => { + const decide = async (locked: StoredLinkedIdentity): Promise => { + if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) { + return { kind: "keep" }; + } + const updatedAt = new Date(clock()).toISOString(); + const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!); + + let refreshed; + try { + refreshed = await runtime.refreshAccessToken!({ + config: ctx.config, + refreshToken, + signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS), + }); + } catch (error) { + if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") { + revocation = "provider_revoked"; + return { kind: "revoke", updatedAt }; + } + throw new ManagedCredentialRefreshFailed(provider); + } + let refreshTokenEncrypted: string; if (refreshed.refreshToken) { refreshTokenEncrypted = ctx.tokenCustody.encrypt(refreshed.refreshToken); @@ -159,47 +162,9 @@ async function refreshUnderLock( tokenExpiresAt: refreshed.expiresAt, scopes: refreshed.scope.length ? refreshed.scope : locked.scopes, updatedAt, - consumedRefreshToken: providerCatalog[provider].rotatesRefreshTokens, }; }; - const decide = async (locked: StoredLinkedIdentity): Promise => { - if (!locked.accessTokenEncrypted || !dueForRefresh(locked, runtime, clock())) { - return { kind: "keep" }; - } - if (locked.refreshFailedAt && withinBackoff(locked.refreshFailedAt, clock())) { - failed = true; - return { kind: "keep" }; - } - const refreshToken = ctx.tokenCustody.decrypt(locked.refreshTokenEncrypted!); - - let refreshed; - try { - refreshed = await runtime.refreshAccessToken!({ - config: ctx.config, - refreshToken, - signal: AbortSignal.timeout(ctx.timeoutMs ?? MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS), - }); - } catch (error) { - if (error instanceof ProviderResponseError && error.oauthError === "invalid_grant") { - revocation = "provider_revoked"; - return { kind: "revoke", updatedAt: new Date(clock()).toISOString() }; - } - failed = true; - return { kind: "failed", failedAt: new Date(clock()).toISOString() }; - } - - // The provider has answered, and may have consumed the token: from here a throw must still leave the marker, which - // a rolled-back transaction would not (the store writes it on its own for a rotation it could not commit). - const answeredAt = new Date(clock()).toISOString(); - try { - return rotation(locked, refreshed, answeredAt); - } catch { - failed = true; - return { kind: "failed", failedAt: new Date(clock()).toISOString() }; - } - }; - const committed = await ctx.store.updateLinkedIdentityCredential(snapshot.id, decide); if (revocation) { await ctx.store.createAuditEvent({ @@ -209,9 +174,6 @@ async function refreshUnderLock( createdAt: new Date(clock()).toISOString(), }); } - if (failed) { - throw new ManagedCredentialRefreshFailed(provider); - } if (!committed) { return { kind: "revoked", identity: withoutTokens(snapshot) }; } diff --git a/src/oauth.ts b/src/oauth.ts index 346726d..b6658e8 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -7,7 +7,6 @@ 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"; @@ -50,22 +49,13 @@ 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; - /** 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; + resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise; evaluateEntitlements(options: { config: HeimdallConfig; callback: OAuthCallbackContext; @@ -154,7 +144,7 @@ function normalizeScopes(scope: string | string[] | undefined): string[] { } export type TokenGrant = - | { grant_type: "authorization_code"; code: string; redirect_uri: string; code_verifier?: string } + | { grant_type: "authorization_code"; code: string; redirect_uri: string } | { grant_type: "refresh_token"; refresh_token: string }; /** @@ -259,7 +249,6 @@ 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 @@ -538,31 +527,6 @@ 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; @@ -981,15 +945,6 @@ 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"), @@ -1031,6 +986,5 @@ 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 8e9fb8d..6d3025b 100644 --- a/src/private-command-plane.ts +++ b/src/private-command-plane.ts @@ -305,6 +305,7 @@ async function beginAuth( throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied.")); } const authorizationUrl = String(startPayload.authorizationUrl ?? ""); + const authorizationOrigin = new URL(authorizationUrl).origin; return { status: "accepted", payloadSchema: "heimdall.auth_begin_receipt.v1", @@ -315,7 +316,7 @@ async function beginAuth( expiresAt: attempt.expiresAt, navigation: { url: authorizationUrl, - allowedOrigins: [new URL(config.publicBaseUrl).origin], + allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin], }, }, }; diff --git a/src/providers.ts b/src/providers.ts index 01f749f..77eebf4 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -22,18 +22,6 @@ 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 = { @@ -47,7 +35,6 @@ 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", }, @@ -61,7 +48,6 @@ 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", }, @@ -75,7 +61,6 @@ 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", }, @@ -89,7 +74,6 @@ 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", }, @@ -108,7 +92,6 @@ 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: { @@ -131,26 +114,9 @@ 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", @@ -161,43 +127,27 @@ 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 = authorizationScopes(options.provider, options.requestedScopes); + const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes; 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 e86490d..b89e7eb 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -3,12 +3,7 @@ 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, @@ -26,9 +21,6 @@ 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; } @@ -55,13 +47,9 @@ 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; @@ -203,13 +191,7 @@ export class InMemoryStore implements HeimdallStore { if (decision.kind === "keep") { return clone(current); } - if (decision.kind === "failed") { - const marked: StoredLinkedIdentity = { ...current, refreshFailedAt: decision.failedAt }; - this.linkedIdentities.set(id, clone(marked)); - return clone(marked); - } const next: StoredLinkedIdentity = { ...current, updatedAt: decision.updatedAt }; - delete next.refreshFailedAt; delete next.accessTokenEncrypted; delete next.refreshTokenEncrypted; delete next.tokenExpiresAt; @@ -448,37 +430,6 @@ 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)); @@ -491,22 +442,4 @@ 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 6d96598..a1224dd 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -5,12 +5,7 @@ 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, @@ -47,7 +42,6 @@ interface LinkedIdentityRow extends QueryResultRow { access_token_encrypted: string | null; refresh_token_encrypted: string | null; token_expires_at: string | null; - refresh_failed_at: string | null; scopes: string; profile_json: Record; created_at: string; @@ -95,19 +89,6 @@ 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; @@ -208,10 +189,6 @@ function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity { identity.tokenExpiresAt = new Date(row.token_expires_at).toISOString(); } - if (row.refresh_failed_at) { - identity.refreshFailedAt = new Date(row.refresh_failed_at).toISOString(); - } - return identity; } @@ -255,59 +232,6 @@ 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, @@ -459,11 +383,8 @@ export const STORE_TABLE_PRIVILEGES: Readonly( "SELECT * FROM linked_identities WHERE id = $1 FOR UPDATE", @@ -827,15 +746,9 @@ export class PostgresStore implements HeimdallStore { return null; } const row = mapLinkedIdentityRow(expectRow(locked.rows[0], "updateLinkedIdentityCredential")); - decision = await decide(row); + const decision = await decide(row); let committed = row; - if (decision.kind === "failed") { - const marked = await client.query( - "UPDATE linked_identities SET refresh_failed_at = $2 WHERE id = $1 RETURNING *", - [id, decision.failedAt] - ); - committed = mapLinkedIdentityRow(expectRow(marked.rows[0], "updateLinkedIdentityCredential")); - } else if (decision.kind !== "keep") { + if (decision.kind !== "keep") { const rotated = decision.kind === "rotate" ? decision : undefined; const written = await client.query( ` @@ -843,7 +756,6 @@ export class PostgresStore implements HeimdallStore { SET access_token_encrypted = $2, refresh_token_encrypted = $3, token_expires_at = $4, - refresh_failed_at = NULL, scopes = $5, updated_at = $6 WHERE id = $1 @@ -864,21 +776,6 @@ export class PostgresStore implements HeimdallStore { return committed; } catch (error) { await client.query("ROLLBACK"); - if (decision?.kind === "rotate" || decision?.kind === "revoke") { - // The provider answered, and what its answer called for could not be committed: the rollback took the answer - // with it. In a transaction of its own, leave the marker, so no caller presents the token within the backoff. - // Blank the token only when it is spent: a revoke, or a provider that rotates (Joystick) and consumed it. - // A provider that keeps its refresh token valid (Twitch) keeps it, so a transient write failure does not - // strand the link. Best effort: if this write fails too, the original error is the one that matters, and the - // residual is named in docs/service-contract.md. - const spent = decision.kind === "revoke" || decision.consumedRefreshToken; - await client - .query( - `UPDATE linked_identities SET ${spent ? "refresh_token_encrypted = NULL, " : ""}refresh_failed_at = $2 WHERE id = $1`, - [id, decision.updatedAt] - ) - .catch(() => undefined); - } throw error; } } finally { @@ -1178,61 +1075,6 @@ export class PostgresStore implements HeimdallStore { return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : null; } - async createPendingLink(input: CreatePendingLinkInput): Promise { - const result = await this.pool.query( - ` - INSERT INTO pending_links ( - id, app_slug, account_id, provider, provider_user_id, identity_json, - return_to, created_at, expires_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING * - `, - [ - randomUUID(), - input.appSlug, - input.accountId, - input.provider, - input.providerUserId, - JSON.stringify(input.identity), - input.returnTo, - input.createdAt, - input.expiresAt, - ] - ); - return mapPendingLinkRow(expectRow(result.rows[0], "createPendingLink")); - } - - async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise { - const result = await this.pool.query( - ` - UPDATE pending_links - SET consumed_at = $4 - WHERE id = $1 - AND app_slug = $2 - AND account_id = $3 - AND consumed_at IS NULL - AND expires_at > $4 - RETURNING * - `, - [id, appSlug, accountId, at] - ); - return result.rowCount ? mapPendingLinkRow(expectRow(result.rows[0], "consumePendingLink")) : null; - } - - async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise { - // No conflict target and no RETURNING: both need SELECT, and the role has INSERT only. - const result = await this.pool.query( - ` - INSERT INTO oauth_state_uses (jti, phase, app_slug, used_at, expires_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT DO NOTHING - `, - [input.jti, input.phase, input.appSlug, input.usedAt, input.expiresAt] - ); - return result.rowCount === 1; - } - async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise { await this.pool.query( ` @@ -1263,52 +1105,6 @@ 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 7aa6823..3433ef8 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -82,19 +82,6 @@ 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,11 +110,6 @@ ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT; -- compares it against the target the redeeming app states. ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT; --- When a refresh attempt on this row last failed or timed out, read under the row --- lock so a second Heimdall process does not present the same refresh token --- right behind it. Cleared by rotation, revocation and re-link. -ALTER TABLE linked_identities ADD COLUMN IF NOT EXISTS refresh_failed_at TIMESTAMPTZ; - -- One attempt handle may bind at most one unconsumed completion (R21.1): a -- second completion for the same handle would let the by-attempt UPDATE in -- consumeAuthCompletionByAttempt match more than one row, silently consuming @@ -141,20 +123,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL; --- One use per (state jti, phase): a signed OAuth state launches once and --- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING --- reports every later claim as no row. The 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, @@ -185,34 +153,6 @@ 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, @@ -274,7 +214,7 @@ export const REQUIRED_TABLES = createdTables.map(({ table }) => table); export const REQUIRED_COLUMNS = [ ...createdTables.flatMap(({ table, lines }) => lines - .filter((line) => !/^(?:PRIMARY KEY|UNIQUE|FOREIGN KEY)\b/.test(line)) + .filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\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 9eb805b..f5b9873 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -33,8 +33,6 @@ export interface StoredLinkedIdentity { accessTokenEncrypted?: string; refreshTokenEncrypted?: string; tokenExpiresAt?: string; - /** When a refresh attempt last failed; cleared by the next rotation, revocation or re-link. */ - refreshFailedAt?: string; scopes: string[]; profileJson: Record; createdAt: string; @@ -60,9 +58,8 @@ export interface UpsertLinkedIdentityInput { /** * What the owner of a refresh decides after reading the row under its lock. - * `rotate` and `revoke` write token columns, scopes, updated_at and clear the - * failed-refresh marker; `failed` writes the marker alone. None touches - * account, profile or identity fields. + * `rotate` and `revoke` write token columns, scopes and updated_at only; they + * never touch account, profile or identity fields. */ export type CredentialDecision = | { kind: "keep" } @@ -73,11 +70,8 @@ export type CredentialDecision = tokenExpiresAt: string; scopes: string[]; updatedAt: string; - /** The provider rotates refresh tokens, so the one presented is spent whether or not this decision commits. */ - consumedRefreshToken: boolean; } - | { kind: "revoke"; updatedAt: string } - | { kind: "failed"; failedAt: string }; + | { kind: "revoke"; updatedAt: string }; export interface StoredSession { id: string; @@ -173,14 +167,6 @@ export interface StoredAuthCompletion { payloadJson: Record; } -export interface ClaimOAuthStateUseInput { - jti: string; - phase: "launch" | "callback"; - appSlug: AppSlug; - usedAt: string; - expiresAt: string; -} - export interface CreateAuthCompletionInput { /** * Correlation handle only (see StoredAuthCompletion.attemptId). The store @@ -199,28 +185,6 @@ 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; @@ -258,33 +222,6 @@ 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; @@ -339,27 +276,6 @@ 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 6da0568..4789bf8 100644 --- a/tests/access-plugin.test.ts +++ b/tests/access-plugin.test.ts @@ -8,7 +8,6 @@ import { entitlementFacts } from "../src/facts.js"; import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js"; import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js"; import { type OAuthProviderRuntime } from "../src/oauth.js"; -import { launchState } from "./oauth-flow.js"; const secret = "ghostlight-private-command-secret"; const resources: Array<{ close(): Promise }> = []; @@ -108,12 +107,9 @@ describe("Heimdall Eve access plugin", () => { const begin = openPrivateEnvelope(beginWire, secret); expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1"); const handle = String(begin.handle); - const navigation = begin.navigation as { url: string; allowedOrigins: string[] }; + const navigation = begin.navigation as { url: string }; const state = new URL(navigation.url).searchParams.get("state"); expect(state).toBeTruthy(); - // The browser goes to Heimdall's launch hop first, and only Heimdall's origin is allowed. - expect(navigation.url.startsWith("https://heimdall.gamecult.org/v1/oauth/discord/launch?state=")).toBe(true); - expect(navigation.allowedOrigins).toEqual(["https://heimdall.gamecult.org"]); const pendingEnvelope = sealPrivateEnvelope({ appSlug: "ghostlight", @@ -128,11 +124,9 @@ describe("Heimdall Eve access plugin", () => { const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret); expect(pending).toMatchObject({ status: "pending", handle }); - const { cookie } = await launchState(app, state ?? ""); const callback = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`, - headers: { cookie }, }); expect(callback.statusCode, callback.body).toBe(201); // The completion code is always minted by the store now, never the @@ -297,7 +291,7 @@ function testConfig(): HeimdallConfig { storage: { backend: "memory", applySchemaOnStartup: true }, providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, - patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {}, + patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, }, }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index 20a0191..7f4d96d 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -4,8 +4,7 @@ import { join } from "node:path"; import { inspect } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { type FastifyInstance } from "fastify"; -import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js"; -import { resolveAppCaller } from "../src/app-caller.js"; +import { buildApp, errorIdentity } from "../src/app.js"; import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js"; @@ -13,7 +12,96 @@ import { entitlementFacts, identityFacts } from "../src/facts.js"; import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; import { InMemoryStore } from "../src/store/index.js"; -import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js"; + +function createTestConfig(): HeimdallConfig { + return { + serviceName: "heimdall", + host: "127.0.0.1", + port: 4100, + workspaceRoot: "E:/Projects/Heimdall", + dataRoot: "E:/Projects/Heimdall/.heimdall-data", + cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc", + publicBaseUrl: "https://heimdall.gamecult.org", + issuer: "https://heimdall.gamecult.org", + daemonId: "yggdrasil-heimdall", + idunnRudpHealth: undefined, + idunnHealthContract: "heimdall.cultnet-rudp-provider-health", + providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc", + sessionTtlSeconds: 3600, + refreshTtlSeconds: 60 * 60 * 24 * 30, + stateTtlSeconds: 600, + completionTtlSeconds: 300, + bootstrapSigningPrivateKeyOnMissing: false, + tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), + appSharedSecrets: { + streampixels: "streampixels-secret", + repixelizer: "repixelizer-secret", + bifrost: "bifrost-secret", + }, + appBackendCallbacks: { + bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"], + repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"], + }, + storage: { + backend: "memory", + applySchemaOnStartup: true, + }, + providers: { + discord: { clientId: "discord-client", clientSecret: "discord-secret" }, + patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" }, + github: { clientId: "github-client", clientSecret: "github-secret" }, + twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" }, + youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" }, + spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" }, + joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" }, + }, + }; +} + +function createMockDiscordRuntime(): OAuthProviderRuntime { + return { + async exchangeAuthorizationCode() { + return { + accessToken: "discord-access-token", + refreshToken: "discord-refresh-token", + tokenType: "Bearer", + scope: ["identify", "guilds.members.read"], + expiresAt: "2026-04-26T13:00:00.000Z", + raw: { source: "test" }, + }; + }, + async resolveIdentity() { + return { + provider: "discord", + providerUserId: "discord-user-123", + username: "meta", + displayName: "Meta", + primaryEmail: "meta@gamecult.org", + profile: { id: "discord-user-123", username: "meta" }, + }; + }, + async evaluateEntitlements({ callback }) { + return { + facts: [entitlementFacts.appAccess], + snapshots: [ + { + accountId: callback.accountId, + provider: "discord", + scope: "repixelizer:discord_role_access:gamecult-guild", + evaluatedAt: "2026-04-26T12:00:00.000Z", + isAllowed: true, + reasonCode: "matched_role", + reasonDetail: "Matched Repixelizer access role.", + rawSummaryJson: { + guildId: "gamecult-guild", + matchedRoles: ["role-repixelizer"], + }, + }, + ], + }; + }, + }; +} const apps: Awaited>[] = []; const originalFetch = globalThis.fetch; @@ -46,7 +134,7 @@ function getHeimdallContext(app: FastifyInstance) { }).heimdallContext; } -async function startDiscordSignIn(app: FastifyInstance): Promise { +async function startDiscordSignIn(app: FastifyInstance): Promise { const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", @@ -58,7 +146,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise { }); expect(response.statusCode).toBe(201); - return launchFlow(app, response.json()); + return response.json().stateToken as string; } afterEach(async () => { @@ -119,9 +207,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); + expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -161,170 +249,6 @@ describe("Heimdall service", () => { expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" })); }); - describe("apps that bind browser completion to their own attempt", () => { - // Bifrost's browser binding is the secrecy of its attempt nonce, which - // travels readable in the signed state. Only Bifrost may mint a Bifrost - // state, so a known nonce cannot carry a stranger's completion - // (finding known-nonce-login-csrf). - const victimAttempt = "VICTIM-ATTEMPT-NONCE"; - const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`; - const bifrostStart = (handoff?: { kind: "browser_completion" }) => ({ - appSlug: "bifrost" as const, - mode: "sign_in" as const, - returnTo: bifrostReturn, - ...(handoff ? { handoff } : {}), - }); - - it("refuses an unauthenticated Bifrost start for browser_completion and for an omitted handoff", async () => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - - for (const payload of [bifrostStart({ kind: "browser_completion" }), bifrostStart()]) { - const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload }); - expect(response.statusCode).toBe(401); - expect(response.json()).toEqual({ error: "app_auth_required" }); - } - }); - - it("the attack fails: a stranger cannot obtain a Bifrost state carrying a chosen return_to nonce", async () => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - - const response = await app.inject({ - method: "POST", - url: "/v1/oauth/discord/start", - payload: bifrostStart({ kind: "browser_completion" }), - }); - expect(response.statusCode).toBe(401); - expect(response.body).not.toContain(victimAttempt); - expect(response.body).not.toContain("stateToken"); - expect(response.body).not.toContain("authorizationUrl"); - }); - - it("still starts for Bifrost's own app secret, with the requested return_to in the state", async () => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - - const response = await app.inject({ - method: "POST", - url: "/v1/oauth/discord/start", - headers: { "x-heimdall-app-secret": "bifrost-secret" }, - payload: bifrostStart({ kind: "browser_completion" }), - }); - expect(response.statusCode).toBe(201); - const verified = verifyJwt(stateOf(response.json()), getPublicKey(app)); - expect(verified.valid).toBe(true); - if (verified.valid) { - expect(verified.payload).toEqual(expect.objectContaining({ app_slug: "bifrost", return_to: bifrostReturn })); - } - }); - - it("refuses another app's credentials on a Bifrost start", async () => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - - for (const secret of ["streampixels-secret", "repixelizer-secret"]) { - const response = await app.inject({ - method: "POST", - url: "/v1/oauth/discord/start", - headers: { "x-heimdall-app-secret": secret }, - payload: bifrostStart({ kind: "browser_completion" }), - }); - expect(response.statusCode, secret).toBe(401); - expect(response.json()).toEqual({ error: "app_auth_required" }); - } - }); - - it("refuses another app's authenticated caller on a direct Bifrost start, with no policy attached", async () => { - // Over HTTP the caller is resolved against the body's slug, so a foreign - // secret never becomes a caller at all. The command plane and direct - // callers hand startOAuthFlow a caller of their own, so the slug match is - // decided here too. - const config = createTestConfig(); - const app = await buildApp({ config }); - apps.push(app); - const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" }); - const own = resolveAppCaller(config, "bifrost", { "x-heimdall-app-secret": "bifrost-secret" }); - - const refused = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", foreign, bifrostStart({ kind: "browser_completion" })); - expect(refused.statusCode).toBe(401); - const allowed = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", own, bifrostStart({ kind: "browser_completion" })); - expect(allowed.statusCode).toBe(201); - }); - - it("gates every mode, provider and handoff kind Bifrost admits, not one spelling of them", async () => { - // Hand mutants narrowing the gate to browser_completion or to sign_in - // survived the single-case tests (finding gate-tests-narrow). - const 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 @@ -378,9 +302,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); + expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -424,11 +348,11 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = await providerRedirect(app, payload); + const authorizationUrl = new URL(payload.authorizationUrl as string); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -472,9 +396,9 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize"); + expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -518,10 +442,10 @@ describe("Heimdall service", () => { expect(response.statusCode).toBe(201); const payload = response.json(); - const authorizationUrl = await providerRedirect(app, payload); + const authorizationUrl = new URL(payload.authorizationUrl as string); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); - const verified = verifyJwt(stateOf(payload), getPublicKey(app)); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); if (verified.valid) { expect(verified.payload).toEqual( @@ -549,11 +473,10 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); expect(response.statusCode).toBe(201); @@ -766,11 +689,10 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); const completionCode = callbackResponse.json().completion.code as string; @@ -809,11 +731,10 @@ describe("Heimdall service", () => { const auditEvents = () => [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string }; const created = auditEvents().filter((event) => event.eventType === "auth_completion_created"); @@ -894,11 +815,10 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); expect(callbackResponse.statusCode).toBe(201); const refreshToken = callbackResponse.json().refreshToken as string; @@ -942,12 +862,11 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, headers: { - cookie: flow.cookie, accept: "text/html", }, }); @@ -993,13 +912,12 @@ describe("Heimdall service", () => { }, }); expect(startResponse.statusCode).toBe(201); - const flow = await launchFlow(app, startResponse.json()); + const stateToken = startResponse.json().stateToken as string; const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, headers: { - cookie: flow.cookie, accept: "text/html", }, }); @@ -1205,12 +1123,11 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const flow = await launchFlow(app, startResponse.json()); + const stateToken = startResponse.json().stateToken as string; const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); const accountId = callbackResponse.json().account.id as string; @@ -1293,12 +1210,11 @@ describe("Heimdall service", () => { returnTo: "https://streampixels.gamecult.org/auth/connect", }, }); - const flow = await launchFlow(app, startResponse.json()); + const stateToken = startResponse.json().stateToken as string; const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); const accountId = callbackResponse.json().account.id as string; @@ -1360,11 +1276,10 @@ describe("Heimdall service", () => { }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const callbackResponse = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); expect(callbackResponse.statusCode).toBe(201); const evaluationsAtSignIn = evaluations; @@ -1440,136 +1355,17 @@ describe("Heimdall service", () => { expect(urls).toEqual([]); }); - // Targets made only of whitespace, format characters, controls and the invisible fillers that render as nothing. - const BLANK_TARGETS = [ - " ", "\t", "\n ", "\u200B", "\u00A0\uFEFF", - "\u2800", "\u3164", "\u115F", "\u1160", "\uFFA0", "\u034F", "\u0000", "\u0007", " \u2800\u3164 ", - // Marks, private-use, unassigned and lone-surrogate code points, and the symbols that read as nothing: none is a letter or a number. - "\u0300", "\uE000", "\u0378", "\uD800", "\u{16FE4}", "\u{1D159}", "\u{F0000}", "\u0300\uE000 ", - ]; - // A target is usable when it holds a letter or a number that is not an invisible filler. - const NAMING_TARGETS = ["creator-1", " creator-1 ", "Framgång", "42", "\u2800a\u3164", "\u3164\u0300x"]; - - describe("a connect start that names a connection target is the app backend's", () => { - // The target is app-backend authority: the StreamPixels backend proves the creator-manage session and states the - // creator it started the connect for. Anyone else naming {creator, X} would have X's connector rebound to their - // own OAuth account at StreamPixels' redeem. - const returnTo = "https://streampixels.gamecult.org/auth/connect"; - const managed = ["twitch", "youtube", "joystick"] as const; - const startFor = (app: FastifyInstance, provider: string, connection: unknown, headers: Record = {}) => - app.inject({ - method: "POST", - url: `/v1/oauth/${provider}/start`, - headers, - payload: { appSlug: "streampixels", mode: "connect", returnTo, connection }, - }); - const own = { "x-heimdall-app-secret": "streampixels-secret" }; - - it.each(managed)("%s: a creator target is refused without the app's own secret and minted for it", async (provider) => { - const config = createTestConfig(); - const app = await buildApp({ config }); - apps.push(app); - const creator = { kind: "creator", targetId: "victim-creator" }; - - for (const headers of [{}, { "x-heimdall-app-secret": "repixelizer-secret" }, { "x-heimdall-app-secret": "wrong" }]) { - const refused = await startFor(app, provider, creator, headers); - expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]); - expect(refused.body).not.toContain("authorizationUrl"); - } - const started = await startFor(app, provider, creator, own); - expect(started.statusCode).toBe(201); - const verified = verifyJwt(stateOf(started.json()), getPublicKey(app)); - expect(verified.valid && verified.payload).toEqual(expect.objectContaining({ connection: expect.objectContaining(creator) })); - - // 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, connection: { kind: "creator" as const, targetId: "victim-creator" } }; - const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" }); - expect((await startOAuthFlow(getHeimdallRuntimeContext(app), provider, foreign, input)).statusCode).toBe(401); - expect((await startOAuthFlow(getHeimdallRuntimeContext(app), provider, null, input)).statusCode).toBe(401); - }); - - it.each(["twitch", "youtube"] as const)("%s: any connection kind needs the app's secret, not only creator", async (provider) => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - const account = { kind: "account", targetId: "victim-account" }; - - const refused = await startFor(app, provider, account); - expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]); - expect((await startFor(app, provider, account, own)).statusCode).toBe(201); - }); - - it.each(managed)("%s: a blank target is refused even for the app, whatever the blank is made of", async (provider) => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - - for (const kind of ["creator", "account"]) { - for (const targetId of BLANK_TARGETS) { - const refused = await startFor(app, provider, { kind, targetId }, own); - expect([refused.statusCode, refused.json().error], `${kind} ${JSON.stringify(targetId)}`).toEqual([400, "invalid_connection_target"]); - expect(refused.body).not.toContain("authorizationUrl"); - } - } - for (const targetId of NAMING_TARGETS) { - expect((await startFor(app, provider, { kind: "creator", targetId }, own)).statusCode, JSON.stringify(targetId)).toBe(201); - } - }); - - it.each(managed)("%s: a blank target without the app's secret gets 401, not the 400 that would tell a stranger what the gate checks", async (provider) => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - - for (const headers of [{}, { "x-heimdall-app-secret": "wrong" }]) { - for (const targetId of [" ", "\u2800"]) { - const refused = await startFor(app, provider, { kind: "creator", targetId }, headers); - expect([refused.statusCode, refused.json()], JSON.stringify(targetId)).toEqual([401, { error: "app_auth_required" }]); - } - } - }); - - it("the callback mint holds a blank creator target to the same bar", () => { - const streampixels = builtInAppProfiles.streampixels!; - for (const targetId of BLANK_TARGETS) { - expect(callbackMint(streampixels, "joystick", "connect", { kind: "creator", targetId }), JSON.stringify(targetId)).toBe("refused"); - } - // A visible character among the blanks names a target. - expect(callbackMint(streampixels, "joystick", "connect", { kind: "creator", targetId: "\u2800a\u3164" })).toBe("custody_only"); - for (const targetId of NAMING_TARGETS) { - expect(callbackMint(streampixels, "joystick", "connect", { kind: "creator", targetId }), JSON.stringify(targetId)).toBe("custody_only"); - } - }); - }); - describe("joystick connect", () => { const creator = { kind: "creator", targetId: "creator-1" }; async function connect(app: FastifyInstance, extra: Record = { connection: creator }) { 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"], @@ -1581,7 +1377,7 @@ describe("Heimdall service", () => { expect(start.statusCode).toBe(400); expect(start.json().error).toBe(error); expect(start.json().authorizationUrl).toBeUndefined(); - expect(start.json()).not.toHaveProperty("stateToken"); + expect(start.json().stateToken).toBeUndefined(); }); it.each([ @@ -1597,15 +1393,14 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); - const verified = verifyJwt(flow.state, keys.publicKey); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, keys.publicKey); expect(verified.valid).toBe(true); const forged = signJwt({ ...(verified as { payload: Record }).payload, connection }, keys); const callback = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, - headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(calls).toEqual([]); @@ -1637,11 +1432,9 @@ describe("Heimdall service", () => { handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl }, }); expect(start.statusCode).toBe(201); - const flow = await launchFlow(app, start.json()); const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, }); expect(callback.statusCode).toBeLessThan(300); @@ -1660,7 +1453,7 @@ describe("Heimdall service", () => { apps.push(app); const start = await connect(app); expect(start.statusCode).toBe(201); - const url = await providerRedirect(app, start.json()); + const url = new URL(start.json().authorizationUrl as string); expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize"); expect(url.searchParams.get("client_id")).toBe("joystick-client"); expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read"); @@ -1674,11 +1467,10 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "deru-chan", username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); + const stateToken = (await connect(app)).json().stateToken as string; const connected = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`, }); const accountId = connected.json().account.id as string; const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); @@ -1726,15 +1518,14 @@ describe("Heimdall service", () => { apps.push(app); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); - const verified = verifyJwt(flow.state, keys.publicKey); + const stateToken = (await connect(app)).json().stateToken as string; + const verified = verifyJwt(stateToken, keys.publicKey); expect(verified.valid).toBe(true); const forged = signJwt({ ...(verified as { payload: Record }).payload, mode }, keys); const callback = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, - headers: { cookie: flow.cookie }, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(callback.body).not.toContain("viewer_access"); @@ -1751,12 +1542,11 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" }); - const flow = await launchFlow(app, (await connect(app)).json()); + const stateToken = (await connect(app)).json().stateToken as string; const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`, }); expect(callback.statusCode).toBeLessThan(300); @@ -1785,11 +1575,10 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ channel_id: "chan-9", username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); + const stateToken = (await connect(app)).json().stateToken as string; const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, }); const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick"); expect(row?.displayName).toBe("deru"); @@ -1800,11 +1589,10 @@ describe("Heimdall service", () => { const app = await buildApp({ config: createTestConfig(), store }); apps.push(app); stubJoystick({ username: "deru" }); - const flow = await launchFlow(app, (await connect(app)).json()); + const stateToken = (await connect(app)).json().stateToken as string; const callback = await app.inject({ method: "GET", - url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, }); expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull(); @@ -1999,11 +1787,11 @@ describe("error answers never echo an input", () => { const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } }); apps.push(app); - const flow = await startDiscordSignIn(app); + const stateToken = await startDiscordSignIn(app); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie, accept: "text/html" }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, + headers: { accept: "text/html" }, }); expect(response.headers["content-type"]).toContain("text/html"); @@ -2035,11 +1823,9 @@ describe("error answers never echo an input", () => { }, }); expect(start.statusCode).toBe(201); - const flow = await launchFlow(app, start.json()); const response = await app.inject({ method: "GET", - url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`, - headers: { cookie: flow.cookie }, + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`, }); expect(response.statusCode).toBe(502); @@ -2057,16 +1843,16 @@ describe("the relayed provider error", () => { const injected = `"\\\u0007é${"x".repeat(300)}`; const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/; - function providerErrorRequest(flow: LaunchedFlow) { - const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected }); - return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } }; + function providerErrorUrl(stateToken: string): string { + const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected }); + return `/v1/oauth/discord/callback?${query.toString()}`; } it("is printable and bounded in the JSON answer", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app))); + const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) }); expect(response.statusCode).toBe(400); const { providerError, providerErrorDescription } = response.json() as Record; @@ -2080,8 +1866,11 @@ describe("the relayed provider error", () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); - const request = providerErrorRequest(await startDiscordSignIn(app)); - const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } }); + const response = await app.inject({ + method: "GET", + url: providerErrorUrl(await startDiscordSignIn(app)), + headers: { accept: "text/html" }, + }); expect(response.headers["content-type"]).toContain("text/html"); expect(response.body.match(/