Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bd5096d81 | |||
|
|
0ea6691cd3 | ||
| 61671d7fe8 | |||
|
|
b6372e5a4f | ||
|
|
f7a38a7d2a | ||
|
|
af554ca013 | ||
|
|
767852ef96 | ||
|
|
5ce8f1c14c | ||
|
|
d4f7dce098 | ||
|
|
71ce9c16e4 | ||
|
|
48207268d0 | ||
|
|
7ca4e2355f | ||
|
|
a7cdf0fea7 | ||
| fefd2ddf4a | |||
|
|
575be9cf49 | ||
|
|
70fd6396f4 | ||
| aa2edd3826 | |||
|
|
f1ddc2fffc | ||
|
|
b541e094eb | ||
| fd67d94aa3 | |||
|
|
9123f2def0 | ||
| da70416ebe | |||
|
|
c829429cd5 | ||
|
|
8e870d20a0 | ||
|
|
4185e657bb | ||
|
|
5076e04950 | ||
|
|
3def0fb426 | ||
|
|
14e0cde26c | ||
|
|
607c2b0a20 | ||
|
|
ece6ba1d62 | ||
|
|
9fd152feff | ||
|
|
ed964201d4 | ||
|
|
7e17a0e952 | ||
|
|
6d43eacd84 |
41 changed files with 4947 additions and 332 deletions
|
|
@ -14,7 +14,7 @@ The point is painfully simple:
|
|||
|
||||
## Front Door
|
||||
|
||||
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
|
||||
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify, Google OIDC for linking), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
|
||||
- **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps.
|
||||
- **Entry points:**
|
||||
- `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows.
|
||||
|
|
|
|||
|
|
@ -157,6 +157,8 @@ optional_environment = [
|
|||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
|
|
|
|||
|
|
@ -154,7 +154,11 @@ The important split:
|
|||
|
||||
- Heimdall owns provider OAuth, identity linking, entitlement refresh, signed
|
||||
sessions, claims, grants, audit surfaces, and managed provider credential
|
||||
custody
|
||||
custody. It authenticates; it does not issue GameCult identity. Bifrost is
|
||||
the identity provider, and the account id Heimdall mints is an authenticator
|
||||
handle. Remaining scar: Heimdall still creates that account id on first
|
||||
login (`store.createAccount`, `src/app.ts`), which the chat work does not
|
||||
need moved
|
||||
- the host experiment owns its workload, queue, editor, render path, viewer
|
||||
profile model, creator model, or other product-specific machinery
|
||||
- the host experiment also owns which local entity a connection attaches to,
|
||||
|
|
@ -613,8 +617,9 @@ Only the browser-driven OAuth flow:
|
|||
|
||||
| Path | Why it must be public |
|
||||
|---|---|
|
||||
| `/v1/oauth/{provider}/start` | the user's browser goes here |
|
||||
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable |
|
||||
| `/v1/oauth/{provider}/start` | the app's server calls it, with its app secret where the profile requires one |
|
||||
| `/v1/oauth/{provider}/launch` | the user's browser goes here first; it sets the binding cookie and redirects to the provider, once per state |
|
||||
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot: it needs the binding cookie and consumes the state |
|
||||
| `/.well-known/jwks.json` | *nothing external consumes it today* — see below |
|
||||
|
||||
Everything else is internal by the design already written down:
|
||||
|
|
|
|||
237
docs/joystick-consent-flow.md
Normal file
237
docs/joystick-consent-flow.md
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
# Joystick sign-in and consent: what was missing, and the flow that fills it
|
||||
|
||||
Status: built (sections 3-6 describe the code on this branch); sections 1-2 are
|
||||
the state of `fefd2dd` (main) that motivated it. Follow-up
|
||||
`heimdall-body:follow_up:chat-s3124-3129`, built under
|
||||
`heimdall-body:follow_up:chat-mind-20261108T131253147Z-t1`. The steward's rulings
|
||||
on the five open questions are in section 4.
|
||||
|
||||
## 1. What a viewer sees today
|
||||
|
||||
**Joystick viewer sign-in does not exist.** `streampixelsProfile`
|
||||
(`src/app-profiles.ts:109-117`) lists Joystick only in
|
||||
`managedConnectionProviders`. `admitsProviderForMode` (`:243`) admits `sign_in`
|
||||
and `link` only for `identityProviders`, so
|
||||
`POST /v1/oauth/joystick/start` with `mode: sign_in` returns
|
||||
`400 provider_not_supported_for_app` as JSON before any redirect
|
||||
(`startOAuthFlow`, `src/app.ts:598-603`). The viewer sees whatever the app does
|
||||
with a failed start; Heimdall shows them nothing. The architecture doc records
|
||||
this as deliberate until the public bot is registered
|
||||
(`docs/architecture.md:461`, `joystick:follow_up:joystick-viewer-signin-public-bot`).
|
||||
|
||||
**The only live Joystick path is a creator `connect`.** Walking it as the
|
||||
person in the browser:
|
||||
|
||||
1. The app backend starts the flow (authenticated by app secret, `connection`
|
||||
`{kind: creator, targetId}`). The person's browser is sent to
|
||||
`/v1/oauth/joystick/launch?state=...`.
|
||||
2. `launch` (`src/app.ts:1130-1194`) claims the state, sets the binding cookie
|
||||
and answers `302` straight to `joystick.tv/api/oauth/authorize`. **Heimdall
|
||||
renders no page of its own.** Nothing says who is asking, for what, or what
|
||||
will be kept.
|
||||
3. The only ask the person sees is Joystick's own screen, listing the scopes in
|
||||
`providerCatalog.joystick.defaultScopes` (`src/providers.ts:162`):
|
||||
`identity:read stream:read chat:read followers:read subscribers:read`.
|
||||
Joystick words that screen, not us.
|
||||
4. The callback (`:1196`) exchanges the code, stores the credential encrypted
|
||||
(rotating refresh token, managed custody), and returns the generic handoff
|
||||
page (`src/browser-handoff.ts`): title "Authentication Complete", body "You
|
||||
can return to the app now." It does not name the app, the provider, the
|
||||
scopes granted, or that a long-lived refresh token is now held by Heimdall.
|
||||
5. Refusal: if the person clicks Deny at Joystick, the callback gets
|
||||
`error=access_denied`, and the page says "Authentication Failed ... Return to
|
||||
the app to continue." The provider's own text is relayed only in
|
||||
`errorDescription` in the fallback URL fragment / postMessage payload, not
|
||||
shown on the page. Refusing *at Heimdall's step* is impossible: there is no
|
||||
Heimdall step.
|
||||
6. After: the audit rows `oauth_callback_succeeded`, `auth_completion_created`,
|
||||
`managed_credential_revoked` etc. (`createAuditEvent` call sites, e.g.
|
||||
`app.ts:1582`) are written for operators. There is **no route a viewer can
|
||||
call to read them**, and no route a viewer can call to revoke a credential.
|
||||
Revocation exists only inside the app backend plane (`managed-credential.ts:172`).
|
||||
|
||||
## 2. What consent is missing
|
||||
|
||||
| Gap | Today | Why it matters |
|
||||
| --- | --- | --- |
|
||||
| Visible ask | None from Heimdall; provider screen only | The person cannot tell Heimdall custody (refresh token kept, rotated, used for stream/chat/follower reads) from a one-off login. |
|
||||
| Who is asking | Not shown | Two parties act: the app (StreamPixels) and Heimdall as custodian. Neither is named. |
|
||||
| Scope truth | Provider screen lists scopes; Heimdall's `requestedScopes` / `defaultScopes` are not shown or compared with what was granted | Granted scopes can differ from requested; the stored `scopes` (`app.ts:1406`) are never shown. |
|
||||
| Typed refusal | A deny is an untyped `provider_error` | The app and the audit log cannot tell "viewer said no" from "provider broke". |
|
||||
| Receipt | Operator audit rows only | The viewer cannot audit what was agreed, when, or revoke it. |
|
||||
| Viewer vs creator | The `connect` binding proves a binding exists, not that the browser's owner is the creator (`joystick:follow_up:connect-start-needs-app-auth`) | Consent from the wrong person is not consent. |
|
||||
|
||||
## 3. The consent flow
|
||||
|
||||
Applies to every completion `callbackMint` calls `custody_only`: a managed
|
||||
creator connection (Joystick today; Twitch and YouTube on StreamPixels when
|
||||
they are connected rather than signed in). Viewer sign-in through an identity
|
||||
provider is unchanged.
|
||||
|
||||
### 3.1 The ask
|
||||
|
||||
`GET /v1/oauth/:provider/launch` renders a Heimdall page (`src/consent.ts`)
|
||||
instead of answering `302`, after the state verifies and before any use is
|
||||
claimed. The page names the app and the origin the person returns to, the
|
||||
provider and the exact scopes Heimdall will send (`authorizationScopes`, the
|
||||
same function that builds the authorization URL), what Heimdall keeps (an
|
||||
encrypted refresh token and access token, until the person takes it back or the
|
||||
provider ends it), what the app receives, and how to take it back. It carries
|
||||
two POST forms to `/v1/oauth/:provider/consent`, **Continue to Joystick** and
|
||||
**No, return to the app**, and runs no script. It sets the ask's own
|
||||
cookie (`__Host-heimdall-ask-<jti>`), 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.
|
||||
|
|
@ -119,6 +119,7 @@ Supported providers in the skeleton:
|
|||
- `youtube`
|
||||
- `spotify`
|
||||
- `joystick`
|
||||
- `google`
|
||||
|
||||
Request body:
|
||||
|
||||
|
|
@ -149,12 +150,29 @@ Response:
|
|||
"appSlug": "repixelizer",
|
||||
"mode": "sign_in",
|
||||
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
|
||||
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
|
||||
"stateToken": "<signed-jwt>",
|
||||
"authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=<signed-jwt>",
|
||||
"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=<signed-jwt>`
|
||||
|
||||
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-<jti>` (`Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`,
|
||||
`Max-Age` until the state expires; the value is a Heimdall-signed binding for
|
||||
that state) and answers `302` to the provider's authorize URL, with
|
||||
`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A second launch of
|
||||
the same state is `400 state_already_launched` and sets no cookie. The state is
|
||||
refused with `400 invalid_state`, `state_provider_mismatch` or `state_expired`.
|
||||
|
||||
The binding cookie needs an `https` public base URL (or `localhost`). A browser
|
||||
must finish the flow in the browser that launched it.
|
||||
|
||||
Failure modes:
|
||||
|
||||
- `503 provider_not_configured` when the provider client id is missing
|
||||
|
|
@ -168,7 +186,17 @@ Failure modes:
|
|||
- `401 app_auth_required` when the request body carries `entitlementPolicy` and
|
||||
the caller did not authenticate as that app via `x-heimdall-app-secret`
|
||||
(`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of
|
||||
`handoff.kind` or app slug; a caller with no policy to attach needs no secret
|
||||
`handoff.kind` or app slug; a caller with no policy to attach needs no secret,
|
||||
unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then
|
||||
every start for that app, with or without a policy, needs that app's secret.
|
||||
Bifrost binds browser completion to a nonce that rides readable in the signed
|
||||
state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose
|
||||
browser starts are public by design leave the flag unset
|
||||
- `401 app_auth_required` also when `mode` is `connect` and the body carries a
|
||||
`connection` (a creator or account target): a connection target is app-backend
|
||||
authority, so only the authenticated app named by `appSlug` may start it. No
|
||||
secret, or another app's, is refused before any state is signed. `sign_in`
|
||||
starts carrying a `connection` are `400 connection_not_allowed_in_sign_in`
|
||||
|
||||
Launch note:
|
||||
|
||||
|
|
@ -179,6 +207,45 @@ Launch note:
|
|||
implemented yet
|
||||
- Repixelizer launch can configure Discord and Patreon on its provider buttons
|
||||
|
||||
### Google (OpenID Connect, link-only)
|
||||
|
||||
`google` is an identity provider that never mints an account. It is a second
|
||||
door into a person who already has one.
|
||||
|
||||
- Callback redirect URI (register exactly this in the Google Cloud OAuth
|
||||
client): `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`, which is
|
||||
`https://heimdall.gamecult.org/v1/oauth/google/callback` in production.
|
||||
- Scopes `openid email profile`. The authorization request carries `state`
|
||||
(signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge, all built at the
|
||||
launch hop, which is where the browser first meets the provider URL. The verifier
|
||||
and the nonce are derived from the state's `jti` and Heimdall's signing key,
|
||||
so nothing is stored between start and callback.
|
||||
- The callback verifies the `id_token` (RS256 against Google's published
|
||||
keys; `iss` is Google, `aud` is this client, `exp`, `iat`, `nonce`) and
|
||||
requires a `sub` and a verified email. The account key is `sub`; the email
|
||||
is a display and contact field and never a lookup key.
|
||||
- `mode: "link"` needs `Authorization: Bearer <Heimdall access token>` for the
|
||||
same app; the identity attaches to that token's account and to no other.
|
||||
`mode: "sign_in"` signs in an account the `sub` is already linked to.
|
||||
- A `sub` no account holds, arriving with `sign_in`, is refused and mints
|
||||
nothing. A `sub` linked to another account is refused and never moved.
|
||||
Both audit as `oauth_callback_failed` with error class `LinkRefusal`
|
||||
and the code `identity_not_linked` or `link_initiator_mismatch`.
|
||||
- Link is two-phase. The callback verifies the identity and records a pending
|
||||
link (TTL `completionTtlSeconds`), answering `status: "link_pending"` with a
|
||||
`pendingLinkId` and no session; it writes no linked identity. The app calls
|
||||
`POST /v1/apps/:appSlug/links/confirm` with `{ pendingLinkId }` and the
|
||||
Heimdall access token of the account that began the link; only that call
|
||||
attaches the identity. A different account, an expired, used or unknown id
|
||||
answers 410; a `sub` another account holds by then answers 409. A refused
|
||||
confirm does not consume the link.
|
||||
- A sign-in callback evaluates every linked identity of the account with the
|
||||
start-time entitlement policy, so a member returning through Google keeps
|
||||
what Discord or Patreon grant.
|
||||
- Configure with `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID` and
|
||||
`GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE`. Admitted to the `bifrost`
|
||||
app profile only.
|
||||
|
||||
### `GET /v1/oauth/{provider}/callback`
|
||||
|
||||
Purpose:
|
||||
|
|
@ -189,7 +256,11 @@ Purpose:
|
|||
|
||||
Current status:
|
||||
|
||||
- validates signed `state`
|
||||
- validates signed `state` (signature, issuer, provider, expiry), requires the
|
||||
binding cookie the launch hop set, and consumes the state: a second callback
|
||||
is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`,
|
||||
an expired state `400 state_expired`; the binding is checked before the claim,
|
||||
so a browser without the cookie cannot burn the launching browser's state
|
||||
- returns provider errors cleanly
|
||||
- for Discord, exchanges the authorization code, resolves the provider
|
||||
identity, persists the local account/link/session/audit records, evaluates
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ evidence only.
|
|||
|
||||
## Owner Map
|
||||
|
||||
- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim
|
||||
- Owner: Heimdall owns provider identity/linking (an authenticator handle, not
|
||||
GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim
|
||||
issuance, entitlement refresh, managed provider token custody, grants,
|
||||
auth-completion handoffs, refresh sessions, and audit events.
|
||||
- Inputs: provider OAuth callbacks, app profile requests, caller-supplied
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/heimdall --external:pg-native --banner:js=\"#!/usr/bin/env node\nimport{createRequire}from'node:module';const require=createRequire(import.meta.url);\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts",
|
||||
|
|
|
|||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
|
|
@ -12,6 +12,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@fastify/cookie':
|
||||
specifier: ^11.1.3
|
||||
version: 11.1.5
|
||||
'@fastify/cors':
|
||||
specifier: ^11.2.0
|
||||
version: 11.2.0
|
||||
|
|
@ -229,6 +232,9 @@ packages:
|
|||
'@fastify/ajv-compiler@4.0.5':
|
||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||
|
||||
'@fastify/cookie@11.1.5':
|
||||
resolution: {integrity: sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==}
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||
|
||||
|
|
@ -469,6 +475,10 @@ packages:
|
|||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookie@2.0.1:
|
||||
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
|
||||
resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -547,6 +557,9 @@ packages:
|
|||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify-plugin@6.0.1:
|
||||
resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==}
|
||||
|
||||
fastify@5.12.7:
|
||||
resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==}
|
||||
|
||||
|
|
@ -1186,6 +1199,11 @@ snapshots:
|
|||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 3.1.0
|
||||
|
||||
'@fastify/cookie@11.1.5':
|
||||
dependencies:
|
||||
cookie: 2.0.1
|
||||
fastify-plugin: 6.0.1
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
dependencies:
|
||||
fastify-plugin: 5.1.0
|
||||
|
|
@ -1393,6 +1411,8 @@ snapshots:
|
|||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cookie@2.0.1: {}
|
||||
|
||||
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
|
||||
dependencies:
|
||||
'@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts
|
||||
|
|
@ -1500,6 +1520,8 @@ snapshots:
|
|||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify-plugin@6.0.1: {}
|
||||
|
||||
fastify@5.12.7:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
|
|
|
|||
28
scripts/test-live-pg.sh
Executable file
28
scripts/test-live-pg.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs the whole vitest suite with the live-Postgres files enabled, against a
|
||||
# throwaway local cluster that this script starts and removes. Needs the
|
||||
# Postgres server binaries (initdb, pg_ctl) on PATH or in PG_BIN.
|
||||
# SOUL_PG_URL's test drops and recreates schema public, so it only ever sees
|
||||
# this disposable cluster.
|
||||
set -euo pipefail
|
||||
# Prerequisites, done here when missing (each step is skipped once it holds):
|
||||
# 1. the vendor/CultLib submodule checked out,
|
||||
# 2. its npm dependencies installed (the vendor packages build with its tsc),
|
||||
# 3. dist/ built for cultcache-ts, cultnet-ts and cultmesh-ts (package.json
|
||||
# points main at dist/index.js),
|
||||
# 4. this repo's pnpm install (npm install if pnpm fails with EACCES).
|
||||
cd "$(dirname "$0")/.."
|
||||
[ -f vendor/CultLib/package.json ] || git submodule update --init vendor/CultLib
|
||||
[ -d vendor/CultLib/node_modules/typescript ] || (cd vendor/CultLib && npm ci)
|
||||
for pkg in cultcache-ts cultnet-ts cultmesh-ts; do
|
||||
[ -f "vendor/CultLib/packages/$pkg/dist/index.js" ] || (cd "vendor/CultLib/packages/$pkg" && npm run build)
|
||||
done
|
||||
[ -d node_modules/vitest ] || pnpm install --frozen-lockfile || npm install # pnpm can fail with EACCES on its store; npm does not need it
|
||||
export PATH="${PG_BIN:-/usr/lib/postgresql/16/bin}:$PATH"
|
||||
dir="$(mktemp -d)"
|
||||
port="${PG_PORT:-54329}"
|
||||
trap 'pg_ctl -D "$dir" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$dir"' EXIT
|
||||
initdb -D "$dir" -U pgadmin --auth=trust >/dev/null
|
||||
pg_ctl -D "$dir" -o "-p $port -k $dir -c listen_addresses=127.0.0.1" -l "$dir/log" -w start >/dev/null
|
||||
url="postgres://pgadmin@127.0.0.1:$port/postgres"
|
||||
HEIMDALL_TEST_PG_ADMIN_URL="$url" SOUL_PG_URL="$url" npx vitest run --config vitest.config.ts "$@"
|
||||
|
|
@ -45,6 +45,17 @@ export interface AppProfile {
|
|||
* plane; absent for apps with no such requirement.
|
||||
*/
|
||||
requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"];
|
||||
/**
|
||||
* The app binds browser completion to its own pending attempt, and the
|
||||
* binding is the secrecy of the attempt nonce, which rides readable in the
|
||||
* signed (not encrypted) state's return_to. If anyone could start a flow for
|
||||
* this app, whoever learned a victim's nonce could mint a completion bound
|
||||
* to it and sign the victim's browser in as themselves (login CSRF, finding
|
||||
* known-nonce-login-csrf). So a start for this app is refused unless the
|
||||
* caller is this app's authenticated caller: only the app can mint its own
|
||||
* state. Absent for apps whose browser starts are public by design.
|
||||
*/
|
||||
startRequiresAppCaller?: boolean;
|
||||
}
|
||||
|
||||
/** The membership signal shared by every app that gates on GameCult membership. */
|
||||
|
|
@ -137,11 +148,12 @@ const streampixelsProfile: AppProfile = {
|
|||
const bifrostProfile: AppProfile = {
|
||||
slug: "bifrost",
|
||||
displayName: "Bifrost",
|
||||
profileVersion: "2026-06-08",
|
||||
identityProviders: ["discord", "patreon"],
|
||||
profileVersion: "2026-11-04",
|
||||
identityProviders: ["discord", "patreon", "google"],
|
||||
entitlementSources: ["discord", "patreon"],
|
||||
managedConnectionProviders: [],
|
||||
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
|
||||
startRequiresAppCaller: true,
|
||||
capabilities: [
|
||||
{
|
||||
key: "member_access",
|
||||
|
|
|
|||
1038
src/app.ts
1038
src/app.ts
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,8 @@ export interface BackendHandoffSuccessPayload
|
|||
mode: OAuthMode;
|
||||
account: AccountSummary;
|
||||
entitlements: EntitlementEvaluation;
|
||||
/** The receipt of the consent a managed connection rests on. */
|
||||
consentReceiptId?: string;
|
||||
}
|
||||
|
||||
export interface BackendHandoffErrorPayload extends BackendHandoffBase {
|
||||
|
|
@ -37,9 +39,20 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase {
|
|||
mode?: OAuthMode;
|
||||
error: string;
|
||||
errorDescription?: string;
|
||||
/** Who said no, when `error` is consent_refused. */
|
||||
by?: "viewer" | "provider";
|
||||
}
|
||||
|
||||
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload;
|
||||
/** A verified link that waits for the initiating account's confirm: no session, no tokens. */
|
||||
export interface BackendHandoffLinkPendingPayload extends BackendHandoffBase {
|
||||
status: "link_pending";
|
||||
mode: OAuthMode;
|
||||
pendingLinkId: string;
|
||||
expiresAt: string;
|
||||
confirmEndpoint: string;
|
||||
}
|
||||
|
||||
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload | BackendHandoffLinkPendingPayload;
|
||||
|
||||
export async function deliverBackendHandoff(callbackUrl: string, payload: BackendHandoffPayload): Promise<void> {
|
||||
const response = await fetch(callbackUrl, {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ export interface BrowserHandoffSuccess extends BrowserHandoffBase {
|
|||
status: "success";
|
||||
mode: OAuthMode;
|
||||
completionCode?: string;
|
||||
/** A verified link waiting for its initiator's confirm; carries no session. */
|
||||
pendingLinkId?: string;
|
||||
attemptId?: string;
|
||||
/** The receipt of the consent a managed connection rests on. */
|
||||
consentReceiptId?: string;
|
||||
}
|
||||
|
||||
export interface BrowserHandoffError extends BrowserHandoffBase {
|
||||
|
|
@ -20,6 +24,8 @@ export interface BrowserHandoffError extends BrowserHandoffBase {
|
|||
error: string;
|
||||
errorDescription?: string;
|
||||
attemptId?: string;
|
||||
/** Who said no, when `error` is consent_refused. */
|
||||
by?: "viewer" | "provider";
|
||||
}
|
||||
|
||||
export type BrowserHandoffPayload = BrowserHandoffSuccess | BrowserHandoffError;
|
||||
|
|
@ -45,11 +51,19 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string {
|
|||
fragment.set("heimdall_attempt_id", payload.attemptId);
|
||||
}
|
||||
|
||||
if (payload.status === "success" && payload.consentReceiptId) {
|
||||
fragment.set("heimdall_consent_receipt_id", payload.consentReceiptId);
|
||||
}
|
||||
if (payload.status === "success" && payload.completionCode) {
|
||||
fragment.set("heimdall_completion_code", payload.completionCode);
|
||||
} else if (payload.status === "success" && payload.pendingLinkId) {
|
||||
fragment.set("heimdall_pending_link_id", payload.pendingLinkId);
|
||||
} else {
|
||||
if (payload.status === "error") {
|
||||
fragment.set("heimdall_error", payload.error);
|
||||
if (payload.by) {
|
||||
fragment.set("heimdall_refused_by", payload.by);
|
||||
}
|
||||
if (payload.errorDescription) {
|
||||
fragment.set("heimdall_error_description", payload.errorDescription);
|
||||
}
|
||||
|
|
@ -73,14 +87,19 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string
|
|||
});
|
||||
const scriptOrigin = serializeForScript(targetOrigin);
|
||||
const scriptFallbackUrl = serializeForScript(fallbackUrl);
|
||||
const scriptTitle = serializeForScript(
|
||||
payload.status === "success" ? "Authentication Complete" : "Authentication Failed"
|
||||
);
|
||||
const refused = payload.status === "error" && payload.error === "consent_refused";
|
||||
const pageTitle = refused ? "Not Connected" : payload.status === "success" ? "Authentication Complete" : "Authentication Failed";
|
||||
const scriptTitle = serializeForScript(pageTitle);
|
||||
const scriptBody = serializeForScript(
|
||||
payload.status === "success"
|
||||
? payload.handoffKind === "backend_callback"
|
||||
? "The app backend has the auth result already. You can return to the app now."
|
||||
: "You can return to the app now."
|
||||
refused
|
||||
? "Nothing was connected and nothing was kept. You can return to the app."
|
||||
: payload.status === "success"
|
||||
? (payload.handoffKind === "backend_callback"
|
||||
? "The app backend has the auth result already. You can return to the app now."
|
||||
: "You can return to the app now.") +
|
||||
(payload.consentReceiptId
|
||||
? ` Your consent is recorded as receipt ${payload.consentReceiptId}; you can review it and take it back from your Heimdall connections page.`
|
||||
: "")
|
||||
: "The authentication flow failed. Return to the app to continue."
|
||||
);
|
||||
|
||||
|
|
@ -89,7 +108,7 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${payload.status === "success" ? "Authentication Complete" : "Authentication Failed"}</title>
|
||||
<title>${pageTitle}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
|
|
|||
191
src/consent.ts
Normal file
191
src/consent.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { type AppSlug, type Provider } from "./contracts.js";
|
||||
import { getAppProfile } from "./app-profiles.js";
|
||||
import { providerCatalog } from "./providers.js";
|
||||
import { type StoredConsentReceipt } from "./store/index.js";
|
||||
|
||||
/**
|
||||
* What Heimdall tells a person before it holds a provider credential for an
|
||||
* app, and the page that lists what they agreed to. The wording is built from
|
||||
* the facts the code holds (the app, the provider, the scopes sent), never
|
||||
* from a promise the code does not keep. Any change to a line changes the
|
||||
* digest every later receipt carries, so a reader can tell which wording a
|
||||
* person saw.
|
||||
*/
|
||||
|
||||
export interface ConsentAsk {
|
||||
appSlug: AppSlug;
|
||||
provider: Provider;
|
||||
/** The scopes Heimdall will send the provider. */
|
||||
scopes: readonly string[];
|
||||
/** The origin the person returns to afterwards. */
|
||||
returnOrigin: string;
|
||||
}
|
||||
|
||||
export const GRANTS_PATH = "/v1/me/grants";
|
||||
|
||||
function appName(slug: AppSlug): string {
|
||||
return getAppProfile(slug)?.displayName ?? slug;
|
||||
}
|
||||
|
||||
/** The exact lines shown on the ask, apart from the two buttons. The digest is over these. */
|
||||
export function consentLines(ask: ConsentAsk): string[] {
|
||||
const app = appName(ask.appSlug);
|
||||
const provider = providerCatalog[ask.provider].displayName;
|
||||
return [
|
||||
`${app} asks Heimdall to hold a connection to your ${provider} account.`,
|
||||
`Heimdall will ask ${provider} for these permissions: ${ask.scopes.join(", ")}.`,
|
||||
`Heimdall keeps an encrypted refresh token and access token for this connection until you take it back or ${provider} ends it.`,
|
||||
`${app}'s server can ask Heimdall for a current access token and use it for those permissions. It never receives the refresh token.`,
|
||||
`If you say no, nothing is kept.`,
|
||||
`You can take this back at any time. Heimdall then deletes its copy of the tokens and stops handing out access tokens. The permission stays with ${provider} until you remove it in your ${provider} settings.`,
|
||||
`Afterwards you are returned to ${ask.returnOrigin}.`,
|
||||
];
|
||||
}
|
||||
|
||||
/** The labels on the two buttons. They are wording the person agrees to, so they are part of the text. */
|
||||
export function consentButtons(ask: ConsentAsk): { go: string; no: string } {
|
||||
return { go: `Continue to ${providerCatalog[ask.provider].displayName}`, no: "No, return to the app" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the person reads before answering, as one text: the lines and the
|
||||
* button labels. It is built once, when the ask is rendered, and that exact
|
||||
* text travels with the flow to the receipt; nothing rebuilds it later.
|
||||
*/
|
||||
export function consentText(ask: ConsentAsk): string {
|
||||
const { go, no } = consentButtons(ask);
|
||||
return [...consentLines(ask), go, no].join("\n");
|
||||
}
|
||||
|
||||
export function digestOfText(text: string): string {
|
||||
return createHash("sha256").update(text, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function consentDigest(ask: ConsentAsk): string {
|
||||
return digestOfText(consentText(ask));
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const pageStyle = `
|
||||
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #11161d; color: #f4f7fb; }
|
||||
main { width: min(38rem, calc(100vw - 2rem)); border-radius: 1rem; padding: 1.5rem; background: rgba(20, 27, 36, 0.96); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3); }
|
||||
h1 { margin: 0 0 0.75rem; font-size: 1.35rem; }
|
||||
p, li { line-height: 1.5; color: #d5dce6; }
|
||||
ul { padding-left: 1.2rem; }
|
||||
code { color: #d8eefe; }
|
||||
button { font: inherit; font-weight: 600; border: 0; border-radius: 999px; padding: 0.75rem 1rem; margin: 0.25rem 0.5rem 0.25rem 0; cursor: pointer; }
|
||||
button.go { color: #11161d; background: #d8eefe; }
|
||||
button.no { color: #f4f7fb; background: #2c3745; }
|
||||
section { border-top: 1px solid #2c3745; padding: 0.75rem 0; }
|
||||
small { color: #9aacbe; }`;
|
||||
|
||||
function page(title: string, body: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>${pageStyle}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
${body}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** 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) =>
|
||||
`<button class="${decision === "continue" ? "go" : "no"}" type="submit" name="decision" value="${decision}">${escapeHtml(label)}</button>`;
|
||||
return page(
|
||||
`${appName(ask.appSlug)} wants to connect ${provider}`,
|
||||
` <h1>${escapeHtml(headline!)}</h1>
|
||||
<ul>
|
||||
${rest.map((line) => ` <li>${escapeHtml(line)}</li>`).join("\n")}
|
||||
</ul>
|
||||
<form method="post" action="/v1/oauth/${ask.provider}/consent">
|
||||
<input type="hidden" name="state" value="${escapeHtml(state)}">
|
||||
${form("continue", consentButtons(ask).go)}
|
||||
${form("decline", consentButtons(ask).no)}
|
||||
</form>`
|
||||
);
|
||||
}
|
||||
|
||||
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<string, StoredConsentReceipt & { kind: "revocation" }>();
|
||||
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 <code>${escapeHtml(grant.id)}</code>; wording <code>${escapeHtml(grant.consentPageDigest.slice(0, 12))}</code>`;
|
||||
if (grant.status === "revoked") {
|
||||
return ` <section><strong>${header}</strong> <small>taken back ${escapeHtml(grant.revokedAt ?? "")}; receipt <code>${escapeHtml(grant.revocationReceiptId ?? "")}</code></small><br><small>${detail}</small></section>`;
|
||||
}
|
||||
return ` <section><strong>${header}</strong> <small>active</small><br><small>${detail}</small>
|
||||
<form method="post" action="${GRANTS_PATH}/${escapeHtml(grant.id)}/revoke">
|
||||
<input type="hidden" name="t" value="${escapeHtml(pageToken)}">
|
||||
<button class="no" type="submit">Take this back</button>
|
||||
</form></section>`;
|
||||
})
|
||||
.join("\n")
|
||||
: ` <p>You have not connected anything through Heimdall.</p>`;
|
||||
return page(
|
||||
"Your connections",
|
||||
` <h1>Your connections</h1>
|
||||
${notice ? ` <p><strong>${escapeHtml(notice)}</strong></p>\n` : ""}${cards}
|
||||
<p><small>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.</small></p>`
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
|
||||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick", "google"] as const;
|
||||
|
||||
export type Provider = (typeof providers)[number];
|
||||
|
||||
|
|
@ -97,6 +97,8 @@ export interface OAuthStatePayload {
|
|||
initiator_account_id: string | null;
|
||||
handoff: OAuthHandoff | null;
|
||||
entitlement_policy: OAuthEntitlementPolicy | null;
|
||||
/** The start's requestedScopes, carried so the launch hop can build the provider URL from the signed state alone. */
|
||||
requested_scopes: string[] | null;
|
||||
}
|
||||
|
||||
export interface LinkedIdentityInput {
|
||||
|
|
|
|||
228
src/google-oidc.ts
Normal file
228
src/google-oidc.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { createHash, createPublicKey, sign, verify, type KeyObject } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Google sign-in as OpenID Connect: the id_token is the identity, and this
|
||||
* file is the one place that decides whether to believe it. The subject
|
||||
* (`sub`) is the account key; the email is a claim about the subject, never
|
||||
* its key.
|
||||
*/
|
||||
|
||||
export const GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"] as const;
|
||||
export const GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs";
|
||||
|
||||
const CLOCK_LEEWAY_SECONDS = 60;
|
||||
const DEFAULT_JWKS_TTL_SECONDS = 3600;
|
||||
const MAX_JWKS_TTL_SECONDS = 24 * 3600;
|
||||
/** An unknown `kid` refetches the key set at most this often. */
|
||||
const MIN_JWKS_REFETCH_MS = 60_000;
|
||||
|
||||
export type GoogleIdTokenFailure =
|
||||
| "malformed"
|
||||
| "unsupported_algorithm"
|
||||
| "unknown_key"
|
||||
| "bad_signature"
|
||||
| "bad_issuer"
|
||||
| "bad_audience"
|
||||
| "expired"
|
||||
| "not_yet_valid"
|
||||
| "bad_nonce"
|
||||
| "missing_subject"
|
||||
| "email_missing"
|
||||
| "email_not_verified"
|
||||
| "jwks_unavailable";
|
||||
|
||||
/** A refusal that names the rule that failed and quotes nothing from the token. */
|
||||
export class GoogleIdTokenError extends Error {
|
||||
readonly code: GoogleIdTokenFailure;
|
||||
|
||||
constructor(code: GoogleIdTokenFailure) {
|
||||
super(`Google id_token refused (${code}).`);
|
||||
this.name = "GoogleIdTokenError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GoogleIdentityClaims {
|
||||
sub: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
hd?: string;
|
||||
}
|
||||
|
||||
interface Jwk {
|
||||
kid?: string;
|
||||
kty?: string;
|
||||
alg?: string;
|
||||
n?: string;
|
||||
e?: string;
|
||||
}
|
||||
|
||||
let cachedKeys: { keys: Map<string, KeyObject>; expiresAt: number; fetchedAt: number } | null = null;
|
||||
|
||||
export function resetGoogleJwksCache(): void {
|
||||
cachedKeys = null;
|
||||
}
|
||||
|
||||
async function fetchGoogleKeys(now: number): Promise<{ keys: Map<string, KeyObject>; 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<string, KeyObject>();
|
||||
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<KeyObject> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
} 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<GoogleIdentityClaims> {
|
||||
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"),
|
||||
};
|
||||
}
|
||||
50
src/oauth.ts
50
src/oauth.ts
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "./contracts.js";
|
||||
import { type HeimdallConfig } from "./config.js";
|
||||
import { entitlementFacts } from "./facts.js";
|
||||
import { verifyGoogleIdToken } from "./google-oidc.js";
|
||||
import { providerCatalog } from "./providers.js";
|
||||
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
|
||||
|
||||
|
|
@ -49,13 +50,22 @@ export interface OAuthProviderRuntime {
|
|||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
/** PKCE verifier of an OIDC provider's request (RFC 7636). */
|
||||
codeVerifier?: string;
|
||||
}): Promise<OAuthTokenSet>;
|
||||
refreshAccessToken?(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ExpiringTokenSet>;
|
||||
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
|
||||
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<ResolvedIdentity>;
|
||||
evaluateEntitlements(options: {
|
||||
config: HeimdallConfig;
|
||||
callback: OAuthCallbackContext;
|
||||
|
|
@ -144,7 +154,7 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
|
|||
}
|
||||
|
||||
export type TokenGrant =
|
||||
| { grant_type: "authorization_code"; code: string; redirect_uri: string }
|
||||
| { grant_type: "authorization_code"; code: string; redirect_uri: string; code_verifier?: string }
|
||||
| { grant_type: "refresh_token"; refresh_token: string };
|
||||
|
||||
/**
|
||||
|
|
@ -249,6 +259,7 @@ function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchan
|
|||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
redirect_uri: options.redirectUri,
|
||||
...(options.codeVerifier !== undefined ? { code_verifier: options.codeVerifier } : {}),
|
||||
});
|
||||
// A rotating provider's refresh token is the only way back to the
|
||||
// credential: a grant that arrives without one would be stored as a row
|
||||
|
|
@ -527,6 +538,31 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
|
|||
return identity;
|
||||
}
|
||||
|
||||
async function resolveGoogleIdentity(options: {
|
||||
config: HeimdallConfig;
|
||||
tokenSet?: OAuthTokenSet;
|
||||
oidcNonce?: string;
|
||||
}): Promise<ResolvedIdentity> {
|
||||
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<ResolvedIdentity> {
|
||||
const profile = await fetchJson<{
|
||||
id?: string;
|
||||
|
|
@ -945,6 +981,15 @@ const youtubeRuntime: OAuthProviderRuntime = {
|
|||
},
|
||||
};
|
||||
|
||||
const googleRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("google"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("google"),
|
||||
resolveIdentity: resolveGoogleIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const spotifyRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("spotify"),
|
||||
|
|
@ -986,5 +1031,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
|
|||
youtube: youtubeRuntime,
|
||||
spotify: spotifyRuntime,
|
||||
joystick: joystickRuntime,
|
||||
google: googleRuntime,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,7 +305,6 @@ async function beginAuth(
|
|||
throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied."));
|
||||
}
|
||||
const authorizationUrl = String(startPayload.authorizationUrl ?? "");
|
||||
const authorizationOrigin = new URL(authorizationUrl).origin;
|
||||
return {
|
||||
status: "accepted",
|
||||
payloadSchema: "heimdall.auth_begin_receipt.v1",
|
||||
|
|
@ -316,7 +315,7 @@ async function beginAuth(
|
|||
expiresAt: attempt.expiresAt,
|
||||
navigation: {
|
||||
url: authorizationUrl,
|
||||
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin],
|
||||
allowedOrigins: [new URL(config.publicBaseUrl).origin],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,18 @@ export interface ProviderDescriptor {
|
|||
clientIdEnv: string;
|
||||
clientSecretEnv: string;
|
||||
additionalAuthorizationParams?: Record<string, string>;
|
||||
/**
|
||||
* 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<Provider, ProviderDescriptor> = {
|
||||
|
|
@ -35,6 +47,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identify", "guilds.members.read"],
|
||||
roles: ["identity", "entitlement"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -48,6 +61,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identity", "identity[email]"],
|
||||
roles: ["identity", "entitlement"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -61,6 +75,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["read:user", "user:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -74,6 +89,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["user:read:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -92,6 +108,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
"https://www.googleapis.com/auth/youtube.readonly",
|
||||
],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET_FILE",
|
||||
additionalAuthorizationParams: {
|
||||
|
|
@ -114,9 +131,26 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
"user-modify-playback-state",
|
||||
],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
|
||||
},
|
||||
google: {
|
||||
key: "google",
|
||||
displayName: "Google",
|
||||
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["openid", "email", "profile"],
|
||||
roles: ["identity"],
|
||||
firstLogin: "requires_link",
|
||||
oidc: true,
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE",
|
||||
additionalAuthorizationParams: { prompt: "select_account" },
|
||||
},
|
||||
joystick: {
|
||||
key: "joystick",
|
||||
displayName: "Joystick",
|
||||
|
|
@ -127,27 +161,43 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
},
|
||||
};
|
||||
|
||||
/** The scopes an authorization request carries: the caller's, or the provider's defaults. */
|
||||
export function authorizationScopes(provider: Provider, requested: readonly string[] | undefined): string[] {
|
||||
return requested?.length ? [...requested] : [...providerCatalog[provider].defaultScopes];
|
||||
}
|
||||
|
||||
export function buildAuthorizationUrl(options: {
|
||||
provider: Provider;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
requestedScopes: string[] | undefined;
|
||||
/** For an OIDC provider: the nonce, and the S256 challenge of the PKCE verifier. */
|
||||
oidc?: { nonce: string; codeChallenge: string };
|
||||
}): string {
|
||||
const descriptor = providerCatalog[options.provider];
|
||||
const url = new URL(descriptor.authorizationEndpoint);
|
||||
const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes;
|
||||
const scopes = authorizationScopes(options.provider, options.requestedScopes);
|
||||
|
||||
url.searchParams.set("client_id", options.clientId);
|
||||
url.searchParams.set("redirect_uri", options.redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", scopes.join(" "));
|
||||
url.searchParams.set("state", options.state);
|
||||
if (descriptor.oidc) {
|
||||
if (!options.oidc) {
|
||||
throw new Error(`${descriptor.displayName} is an OIDC provider: its authorization URL needs a nonce and a PKCE challenge.`);
|
||||
}
|
||||
url.searchParams.set("nonce", options.oidc.nonce);
|
||||
url.searchParams.set("code_challenge", options.oidc.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra
|
|||
import {
|
||||
type CreateAccountInput,
|
||||
type CreateAuthAttemptInput,
|
||||
type ClaimOAuthStateUseInput,
|
||||
type CreateAuthCompletionInput,
|
||||
type CreateConsentReceiptInput,
|
||||
type CreatePendingLinkInput,
|
||||
type StoredConsentReceipt,
|
||||
type StoredPendingLink,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
|
|
@ -21,6 +26,9 @@ import {
|
|||
type UpsertLinkedIdentityInput,
|
||||
} from "./types.js";
|
||||
|
||||
/** The skew a signed OAuth state is still accepted for past its exp (verifyOAuthState). */
|
||||
const OAUTH_STATE_SKEW_MS = 30_000;
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
|
@ -47,9 +55,13 @@ export class InMemoryStore implements HeimdallStore {
|
|||
private readonly authAttempts = new Map<string, StoredAuthAttempt>();
|
||||
private readonly privateCommandReceipts = new Map<string, StoredPrivateCommandReceipt>();
|
||||
private readonly authCompletions = new Map<string, StoredAuthCompletion>();
|
||||
private readonly pendingLinks = new Map<string, StoredPendingLink>();
|
||||
private readonly authCompletionsByAttempt = new Map<string, string>();
|
||||
/** `${jti}:${phase}` -> the state's expiresAt. */
|
||||
private readonly oauthStateUses = new Map<string, string>();
|
||||
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
|
||||
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
|
||||
private readonly consentReceipts: StoredConsentReceipt[] = [];
|
||||
|
||||
async ensureSchema(): Promise<void> {
|
||||
return;
|
||||
|
|
@ -430,6 +442,37 @@ export class InMemoryStore implements HeimdallStore {
|
|||
return this.consumeAuthCompletion(appSlug, code, at);
|
||||
}
|
||||
|
||||
async createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink> {
|
||||
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<StoredPendingLink | null> {
|
||||
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<boolean> {
|
||||
// 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<void> {
|
||||
const key = `${input.accountId}:${input.provider}:${input.scope}`;
|
||||
this.entitlementSnapshots.set(key, clone(input));
|
||||
|
|
@ -442,4 +485,22 @@ export class InMemoryStore implements HeimdallStore {
|
|||
});
|
||||
this.auditEvents.set(event.id ?? randomUUID(), event);
|
||||
}
|
||||
|
||||
async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> {
|
||||
if (input.kind === "revocation") {
|
||||
// The table's foreign key: a revocation names a receipt of the same account.
|
||||
if (!this.consentReceipts.some((r) => r.id === input.revokesReceiptId && r.accountId === input.accountId)) {
|
||||
throw new Error("A revocation must name a receipt of its own account.");
|
||||
}
|
||||
const existing = this.consentReceipts.find((r) => r.kind === "revocation" && r.revokesReceiptId === input.revokesReceiptId);
|
||||
if (existing) return { receipt: clone(existing), created: false };
|
||||
}
|
||||
const receipt = { id: randomUUID(), ...clone(input) } as StoredConsentReceipt;
|
||||
this.consentReceipts.push(clone(receipt));
|
||||
return { receipt: clone(receipt), created: true };
|
||||
}
|
||||
|
||||
async listConsentReceipts(accountId: string): Promise<StoredConsentReceipt[]> {
|
||||
return this.consentReceipts.filter((receipt) => receipt.accountId === accountId).map(clone);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f
|
|||
import {
|
||||
type CreateAccountInput,
|
||||
type CreateAuthAttemptInput,
|
||||
type ClaimOAuthStateUseInput,
|
||||
type CreateAuthCompletionInput,
|
||||
type CreateConsentReceiptInput,
|
||||
type CreatePendingLinkInput,
|
||||
type StoredConsentReceipt,
|
||||
type StoredPendingLink,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
|
|
@ -89,6 +94,19 @@ interface AuthCompletionRow extends QueryResultRow {
|
|||
consumed_at: string | Date | null;
|
||||
}
|
||||
|
||||
interface PendingLinkRow extends QueryResultRow {
|
||||
id: string;
|
||||
app_slug: AppSlug;
|
||||
account_id: string;
|
||||
provider: Provider;
|
||||
provider_user_id: string;
|
||||
identity_json: StoredPendingLink["identity"];
|
||||
return_to: string;
|
||||
created_at: string | Date;
|
||||
expires_at: string | Date;
|
||||
consumed_at: string | Date | null;
|
||||
}
|
||||
|
||||
interface AuthAttemptRow extends QueryResultRow {
|
||||
handle: string;
|
||||
app_slug: AppSlug;
|
||||
|
|
@ -232,6 +250,59 @@ function mapSessionRow(row: SessionRow): StoredSession {
|
|||
};
|
||||
}
|
||||
|
||||
interface ConsentReceiptRow {
|
||||
id: string;
|
||||
kind: "grant" | "revocation";
|
||||
account_id: string;
|
||||
app_slug: AppSlug;
|
||||
provider: Provider;
|
||||
created_at: Date | string;
|
||||
scopes_asked: string | null;
|
||||
scopes_granted: string | null;
|
||||
consent_page_digest: string | null;
|
||||
consent_text: string | null;
|
||||
revokes_receipt_id: string | null;
|
||||
}
|
||||
|
||||
function mapConsentReceiptRow(row: ConsentReceiptRow): StoredConsentReceipt {
|
||||
const base = {
|
||||
id: row.id,
|
||||
accountId: row.account_id,
|
||||
appSlug: row.app_slug,
|
||||
provider: row.provider,
|
||||
createdAt: normalizeTimestamp(row.created_at),
|
||||
};
|
||||
if (row.kind === "revocation") {
|
||||
return { ...base, kind: "revocation", revokesReceiptId: row.revokes_receipt_id ?? "" };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
kind: "grant",
|
||||
scopesAsked: parseScopes(row.scopes_asked ?? ""),
|
||||
scopesGranted: parseScopes(row.scopes_granted ?? ""),
|
||||
consentPageDigest: row.consent_page_digest ?? "",
|
||||
consentText: row.consent_text ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function mapPendingLinkRow(row: PendingLinkRow): StoredPendingLink {
|
||||
const link: StoredPendingLink = {
|
||||
id: row.id,
|
||||
appSlug: row.app_slug,
|
||||
accountId: row.account_id,
|
||||
provider: row.provider,
|
||||
providerUserId: row.provider_user_id,
|
||||
identity: row.identity_json,
|
||||
returnTo: row.return_to,
|
||||
createdAt: normalizeTimestamp(row.created_at),
|
||||
expiresAt: normalizeTimestamp(row.expires_at),
|
||||
};
|
||||
if (row.consumed_at) {
|
||||
link.consumedAt = normalizeTimestamp(row.consumed_at);
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
|
||||
const completion: StoredAuthCompletion = {
|
||||
code: row.code,
|
||||
|
|
@ -383,8 +454,11 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
|
|||
auth_attempts: ["SELECT", "INSERT", "UPDATE"],
|
||||
private_command_receipts: ["SELECT", "INSERT"],
|
||||
auth_completions: ["SELECT", "INSERT", "UPDATE"],
|
||||
pending_links: ["SELECT", "INSERT", "UPDATE"],
|
||||
oauth_state_uses: ["INSERT"],
|
||||
capability_grants: ["SELECT", "INSERT"],
|
||||
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
|
||||
consent_receipts: ["SELECT", "INSERT"],
|
||||
audit_events: ["INSERT"],
|
||||
};
|
||||
|
||||
|
|
@ -1075,6 +1149,61 @@ export class PostgresStore implements HeimdallStore {
|
|||
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : null;
|
||||
}
|
||||
|
||||
async createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink> {
|
||||
const result = await this.pool.query<PendingLinkRow>(
|
||||
`
|
||||
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<StoredPendingLink | null> {
|
||||
const result = await this.pool.query<PendingLinkRow>(
|
||||
`
|
||||
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<boolean> {
|
||||
// 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<void> {
|
||||
await this.pool.query(
|
||||
`
|
||||
|
|
@ -1105,6 +1234,52 @@ export class PostgresStore implements HeimdallStore {
|
|||
);
|
||||
}
|
||||
|
||||
async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> {
|
||||
const grant = input.kind === "grant" ? input : null;
|
||||
// No conflict target and the partial unique index decides: a second
|
||||
// revocation of one grant inserts nothing and the first is read back.
|
||||
const result = await this.pool.query<ConsentReceiptRow>(
|
||||
`
|
||||
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<ConsentReceiptRow>(
|
||||
`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<StoredConsentReceipt[]> {
|
||||
const result = await this.pool.query<ConsentReceiptRow>(
|
||||
`SELECT * FROM consent_receipts WHERE account_id = $1 ORDER BY created_at, id`,
|
||||
[accountId]
|
||||
);
|
||||
return result.rows.map(mapConsentReceiptRow);
|
||||
}
|
||||
|
||||
async createAuditEvent(input: CreateAuditEventInput): Promise<void> {
|
||||
await this.pool.query(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -82,6 +82,19 @@ CREATE TABLE IF NOT EXISTS private_command_receipts (
|
|||
CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx
|
||||
ON private_command_receipts(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
app_slug TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
identity_json JSONB NOT NULL,
|
||||
return_to TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_completions (
|
||||
code TEXT PRIMARY KEY,
|
||||
app_slug TEXT NOT NULL,
|
||||
|
|
@ -123,6 +136,20 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
|
|||
ON auth_completions(app_slug, attempt_id)
|
||||
WHERE attempt_id IS NOT NULL AND consumed_at IS NULL;
|
||||
|
||||
-- One use per (state jti, phase): a signed OAuth state launches once and
|
||||
-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING
|
||||
-- reports every later claim as no row. The Postgres store deletes nothing, so rows
|
||||
-- stay like consumed auth_completions; a state is accepted until expires_at + 30 s
|
||||
-- of skew, so a row may be pruned only after that and is never consulted again.
|
||||
CREATE TABLE IF NOT EXISTS oauth_state_uses (
|
||||
jti TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
app_slug TEXT NOT NULL,
|
||||
used_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (jti, phase)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
|
|
@ -153,6 +180,34 @@ CREATE TABLE IF NOT EXISTS entitlement_snapshots (
|
|||
UNIQUE(account_id, provider, scope)
|
||||
);
|
||||
|
||||
-- Consent receipts are append-only (the store role has INSERT and SELECT
|
||||
-- only): a revocation is a row that names the grant it takes back.
|
||||
CREATE TABLE IF NOT EXISTS consent_receipts (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
app_slug TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
scopes_asked TEXT,
|
||||
scopes_granted TEXT,
|
||||
consent_page_digest TEXT,
|
||||
consent_text TEXT,
|
||||
revokes_receipt_id TEXT,
|
||||
UNIQUE(id, account_id),
|
||||
FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id)
|
||||
);
|
||||
|
||||
-- A revocation names a receipt of its own account (the composite foreign key;
|
||||
-- NULL skips it for grants). A grant is taken back once: the second
|
||||
-- revocation naming it conflicts.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx
|
||||
ON consent_receipts(revokes_receipt_id)
|
||||
WHERE revokes_receipt_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS consent_receipts_account_idx
|
||||
ON consent_receipts(account_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT,
|
||||
|
|
@ -214,7 +269,7 @@ export const REQUIRED_TABLES = createdTables.map(({ table }) => table);
|
|||
export const REQUIRED_COLUMNS = [
|
||||
...createdTables.flatMap(({ table, lines }) =>
|
||||
lines
|
||||
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line))
|
||||
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE|FOREIGN KEY)\b/.test(line))
|
||||
.map((line) => {
|
||||
const [, column, type] = /^(\w+) (\w+)/.exec(line)!;
|
||||
return { table, column: column!, type: type!, nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(line) };
|
||||
|
|
|
|||
|
|
@ -167,6 +167,14 @@ export interface StoredAuthCompletion {
|
|||
payloadJson: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ClaimOAuthStateUseInput {
|
||||
jti: string;
|
||||
phase: "launch" | "callback";
|
||||
appSlug: AppSlug;
|
||||
usedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface CreateAuthCompletionInput {
|
||||
/**
|
||||
* Correlation handle only (see StoredAuthCompletion.attemptId). The store
|
||||
|
|
@ -185,6 +193,28 @@ export interface CreateAuthCompletionInput {
|
|||
payloadJson: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<StoredPendingLink, "id" | "consumedAt">;
|
||||
|
||||
export interface StoredAuthAttempt {
|
||||
handle: string;
|
||||
appSlug: AppSlug;
|
||||
|
|
@ -222,6 +252,33 @@ export interface StoredPrivateCommandReceipt {
|
|||
|
||||
export type CreatePrivateCommandReceiptInput = StoredPrivateCommandReceipt;
|
||||
|
||||
/**
|
||||
* What a person agreed to, or took back. Append-only: a grant row is never
|
||||
* edited, and a revocation is a second row naming it, so a grant is live
|
||||
* exactly when no revocation names it. `scopesAsked` is what Heimdall sent the
|
||||
* provider, `scopesGranted` what the provider returned, `consentPageDigest`
|
||||
* the hash of the wording the person was shown and `consentText` that wording
|
||||
* itself. A grant is revoked at most once: a second revocation naming the same
|
||||
* grant is not written, and the store answers with the first.
|
||||
*/
|
||||
export type StoredConsentReceipt = {
|
||||
id: string;
|
||||
accountId: string;
|
||||
appSlug: AppSlug;
|
||||
provider: Provider;
|
||||
createdAt: string;
|
||||
} & (
|
||||
| { kind: "grant"; scopesAsked: string[]; scopesGranted: string[]; consentPageDigest: string; consentText: string }
|
||||
| { kind: "revocation"; revokesReceiptId: string }
|
||||
);
|
||||
|
||||
/** The store mints `id`; a caller cannot choose it. */
|
||||
export type CreateConsentReceiptInput = StoredConsentReceipt extends infer R
|
||||
? R extends StoredConsentReceipt
|
||||
? Omit<R, "id">
|
||||
: never
|
||||
: never;
|
||||
|
||||
export interface HeimdallStore {
|
||||
ensureSchema(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
|
|
@ -276,6 +333,27 @@ export interface HeimdallStore {
|
|||
* it created without ever needing to know (or forge) the code.
|
||||
*/
|
||||
consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise<StoredAuthCompletion | null>;
|
||||
createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink>;
|
||||
/**
|
||||
* 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<StoredPendingLink | null>;
|
||||
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
createAuditEvent(input: CreateAuditEventInput): Promise<void>;
|
||||
/** `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<StoredConsentReceipt[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { entitlementFacts } from "../src/facts.js";
|
|||
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
|
||||
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { launchState } from "./oauth-flow.js";
|
||||
|
||||
const secret = "ghostlight-private-command-secret";
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
|
@ -107,9 +108,12 @@ describe("Heimdall Eve access plugin", () => {
|
|||
const begin = openPrivateEnvelope(beginWire, secret);
|
||||
expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1");
|
||||
const handle = String(begin.handle);
|
||||
const navigation = begin.navigation as { url: string };
|
||||
const navigation = begin.navigation as { url: string; allowedOrigins: string[] };
|
||||
const state = new URL(navigation.url).searchParams.get("state");
|
||||
expect(state).toBeTruthy();
|
||||
// The browser goes to Heimdall's launch hop first, and only Heimdall's origin is allowed.
|
||||
expect(navigation.url.startsWith("https://heimdall.gamecult.org/v1/oauth/discord/launch?state=")).toBe(true);
|
||||
expect(navigation.allowedOrigins).toEqual(["https://heimdall.gamecult.org"]);
|
||||
|
||||
const pendingEnvelope = sealPrivateEnvelope({
|
||||
appSlug: "ghostlight",
|
||||
|
|
@ -124,9 +128,11 @@ describe("Heimdall Eve access plugin", () => {
|
|||
const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret);
|
||||
expect(pending).toMatchObject({ status: "pending", handle });
|
||||
|
||||
const { cookie } = await launchState(app, state ?? "");
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`,
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(callback.statusCode, callback.body).toBe(201);
|
||||
// The completion code is always minted by the store now, never the
|
||||
|
|
@ -291,7 +297,7 @@ function testConfig(): HeimdallConfig {
|
|||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import { join } from "node:path";
|
|||
import { inspect } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { buildApp, errorIdentity } from "../src/app.js";
|
||||
import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
|
||||
import { resolveAppCaller } from "../src/app-caller.js";
|
||||
import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js";
|
||||
import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
|
||||
import { type HeimdallConfig, loadConfig } from "../src/config.js";
|
||||
|
|
@ -12,96 +13,7 @@ import { entitlementFacts, identityFacts } from "../src/facts.js";
|
|||
import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
|
||||
function createTestConfig(): HeimdallConfig {
|
||||
return {
|
||||
serviceName: "heimdall",
|
||||
host: "127.0.0.1",
|
||||
port: 4100,
|
||||
workspaceRoot: "E:/Projects/Heimdall",
|
||||
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
|
||||
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
|
||||
publicBaseUrl: "https://heimdall.gamecult.org",
|
||||
issuer: "https://heimdall.gamecult.org",
|
||||
daemonId: "yggdrasil-heimdall",
|
||||
idunnRudpHealth: undefined,
|
||||
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
|
||||
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
|
||||
sessionTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 60 * 60 * 24 * 30,
|
||||
stateTtlSeconds: 600,
|
||||
completionTtlSeconds: 300,
|
||||
bootstrapSigningPrivateKeyOnMissing: false,
|
||||
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: {
|
||||
streampixels: "streampixels-secret",
|
||||
repixelizer: "repixelizer-secret",
|
||||
bifrost: "bifrost-secret",
|
||||
},
|
||||
appBackendCallbacks: {
|
||||
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
|
||||
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
|
||||
},
|
||||
storage: {
|
||||
backend: "memory",
|
||||
applySchemaOnStartup: true,
|
||||
},
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
|
||||
github: { clientId: "github-client", clientSecret: "github-secret" },
|
||||
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
||||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockDiscordRuntime(): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return {
|
||||
accessToken: "discord-access-token",
|
||||
refreshToken: "discord-refresh-token",
|
||||
tokenType: "Bearer",
|
||||
scope: ["identify", "guilds.members.read"],
|
||||
expiresAt: "2026-04-26T13:00:00.000Z",
|
||||
raw: { source: "test" },
|
||||
};
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return {
|
||||
provider: "discord",
|
||||
providerUserId: "discord-user-123",
|
||||
username: "meta",
|
||||
displayName: "Meta",
|
||||
primaryEmail: "meta@gamecult.org",
|
||||
profile: { id: "discord-user-123", username: "meta" },
|
||||
};
|
||||
},
|
||||
async evaluateEntitlements({ callback }) {
|
||||
return {
|
||||
facts: [entitlementFacts.appAccess],
|
||||
snapshots: [
|
||||
{
|
||||
accountId: callback.accountId,
|
||||
provider: "discord",
|
||||
scope: "repixelizer:discord_role_access:gamecult-guild",
|
||||
evaluatedAt: "2026-04-26T12:00:00.000Z",
|
||||
isAllowed: true,
|
||||
reasonCode: "matched_role",
|
||||
reasonDetail: "Matched Repixelizer access role.",
|
||||
rawSummaryJson: {
|
||||
guildId: "gamecult-guild",
|
||||
matchedRoles: ["role-repixelizer"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
|
@ -134,7 +46,7 @@ function getHeimdallContext(app: FastifyInstance) {
|
|||
}).heimdallContext;
|
||||
}
|
||||
|
||||
async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
|
||||
async function startDiscordSignIn(app: FastifyInstance): Promise<LaunchedFlow> {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
|
|
@ -146,7 +58,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
return response.json().stateToken as string;
|
||||
return launchFlow(app, response.json());
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -207,9 +119,9 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
||||
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
|
||||
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -249,6 +161,170 @@ describe("Heimdall service", () => {
|
|||
expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" }));
|
||||
});
|
||||
|
||||
describe("apps that bind browser completion to their own attempt", () => {
|
||||
// Bifrost's browser binding is the secrecy of its attempt nonce, which
|
||||
// travels readable in the signed state. Only Bifrost may mint a Bifrost
|
||||
// state, so a known nonce cannot carry a stranger's completion
|
||||
// (finding known-nonce-login-csrf).
|
||||
const victimAttempt = "VICTIM-ATTEMPT-NONCE";
|
||||
const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`;
|
||||
const bifrostStart = (handoff?: { kind: "browser_completion" }) => ({
|
||||
appSlug: "bifrost" as const,
|
||||
mode: "sign_in" as const,
|
||||
returnTo: bifrostReturn,
|
||||
...(handoff ? { handoff } : {}),
|
||||
});
|
||||
|
||||
it("refuses an unauthenticated Bifrost start for browser_completion and for an omitted handoff", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
for (const payload of [bifrostStart({ kind: "browser_completion" }), bifrostStart()]) {
|
||||
const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload });
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json()).toEqual({ error: "app_auth_required" });
|
||||
}
|
||||
});
|
||||
|
||||
it("the attack fails: a stranger cannot obtain a Bifrost state carrying a chosen return_to nonce", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: bifrostStart({ kind: "browser_completion" }),
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.body).not.toContain(victimAttempt);
|
||||
expect(response.body).not.toContain("stateToken");
|
||||
expect(response.body).not.toContain("authorizationUrl");
|
||||
});
|
||||
|
||||
it("still starts for Bifrost's own app secret, with the requested return_to in the state", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
||||
payload: bifrostStart({ kind: "browser_completion" }),
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const verified = verifyJwt(stateOf(response.json()), getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(expect.objectContaining({ app_slug: "bifrost", return_to: bifrostReturn }));
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses another app's credentials on a Bifrost start", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
for (const secret of ["streampixels-secret", "repixelizer-secret"]) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
headers: { "x-heimdall-app-secret": secret },
|
||||
payload: bifrostStart({ kind: "browser_completion" }),
|
||||
});
|
||||
expect(response.statusCode, secret).toBe(401);
|
||||
expect(response.json()).toEqual({ error: "app_auth_required" });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses another app's authenticated caller on a direct Bifrost start, with no policy attached", async () => {
|
||||
// Over HTTP the caller is resolved against the body's slug, so a foreign
|
||||
// secret never becomes a caller at all. The command plane and direct
|
||||
// callers hand startOAuthFlow a caller of their own, so the slug match is
|
||||
// decided here too.
|
||||
const config = createTestConfig();
|
||||
const app = await buildApp({ config });
|
||||
apps.push(app);
|
||||
const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
|
||||
const own = resolveAppCaller(config, "bifrost", { "x-heimdall-app-secret": "bifrost-secret" });
|
||||
|
||||
const refused = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", foreign, bifrostStart({ kind: "browser_completion" }));
|
||||
expect(refused.statusCode).toBe(401);
|
||||
const allowed = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", own, bifrostStart({ kind: "browser_completion" }));
|
||||
expect(allowed.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("gates every mode, provider and handoff kind Bifrost admits, not one spelling of them", async () => {
|
||||
// Hand mutants narrowing the gate to browser_completion or to sign_in
|
||||
// survived the single-case tests (finding gate-tests-narrow).
|
||||
const store = new InMemoryStore();
|
||||
const now = new Date().toISOString();
|
||||
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
const handoffs = [
|
||||
undefined,
|
||||
{ kind: "browser_completion" },
|
||||
{
|
||||
kind: "backend_callback",
|
||||
attemptId: "bifrost-attempt-1",
|
||||
callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback",
|
||||
},
|
||||
];
|
||||
|
||||
let cases = 0;
|
||||
for (const mode of ["sign_in", "link", "connect"] as const) {
|
||||
for (const provider of ["discord", "patreon"] as const) {
|
||||
for (const handoff of handoffs) {
|
||||
const label = `${mode} ${provider} ${handoff?.kind ?? "omitted"}`;
|
||||
const payload = {
|
||||
appSlug: "bifrost",
|
||||
mode,
|
||||
returnTo: bifrostReturn,
|
||||
...(mode === "link" ? { initiatingAccountId: initiator.id } : {}),
|
||||
...(handoff ? { handoff } : {}),
|
||||
};
|
||||
const refused = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload });
|
||||
expect(refused.statusCode, label).toBe(401);
|
||||
expect(refused.json(), label).toEqual({ error: "app_auth_required" });
|
||||
|
||||
const allowed = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/oauth/${provider}/start`,
|
||||
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
||||
payload,
|
||||
});
|
||||
expect(allowed.statusCode, label).toBe(201);
|
||||
expect(allowed.json().authorizationUrl, label).toContain(`/v1/oauth/${provider}/launch?state=`);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(cases).toBe(18);
|
||||
});
|
||||
|
||||
it("leaves public-by-design apps unchanged: unauthenticated browser_completion starts still succeed", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
for (const [appSlug, provider, returnTo] of [
|
||||
["repixelizer", "discord", "https://repixelizer.gamecult.org/app/"],
|
||||
["streampixels", "twitch", "https://streampixels.gamecult.org/app/"],
|
||||
] as const) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/oauth/${provider}/start`,
|
||||
payload: { appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } },
|
||||
});
|
||||
expect(response.statusCode, appSlug).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
it("flags only Bifrost's profile", () => {
|
||||
expect(
|
||||
Object.values(builtInAppProfiles).filter((profile) => profile.startRequiresAppCaller).map((profile) => profile.slug)
|
||||
).toEqual(["bifrost"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts caller-owned entitlement policy for a browser completion handoff when the caller is trusted", async () => {
|
||||
// The other half of the same pin: a caller holding the secret may attach
|
||||
// policy even without a backend_callback handoff, because identity no
|
||||
|
|
@ -302,9 +378,9 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
||||
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
|
||||
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -348,11 +424,11 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
const authorizationUrl = new URL(payload.authorizationUrl as string);
|
||||
const authorizationUrl = await providerRedirect(app, payload);
|
||||
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
|
||||
expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]");
|
||||
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -396,9 +472,9 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
||||
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
|
||||
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -442,10 +518,10 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
const authorizationUrl = new URL(payload.authorizationUrl as string);
|
||||
const authorizationUrl = await providerRedirect(app, payload);
|
||||
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
|
||||
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -473,10 +549,11 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
|
|
@ -689,10 +766,11 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
const completionCode = callbackResponse.json().completion.code as string;
|
||||
|
||||
|
|
@ -731,10 +809,11 @@ describe("Heimdall service", () => {
|
|||
const auditEvents = () =>
|
||||
[...(store as unknown as { auditEvents: Map<string, { eventType: string; sessionId?: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string };
|
||||
const created = auditEvents().filter((event) => event.eventType === "auth_completion_created");
|
||||
|
|
@ -815,10 +894,11 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(201);
|
||||
const refreshToken = callbackResponse.json().refreshToken as string;
|
||||
|
|
@ -862,11 +942,12 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: {
|
||||
cookie: flow.cookie,
|
||||
accept: "text/html",
|
||||
},
|
||||
});
|
||||
|
|
@ -912,12 +993,13 @@ describe("Heimdall service", () => {
|
|||
},
|
||||
});
|
||||
expect(startResponse.statusCode).toBe(201);
|
||||
const stateToken = startResponse.json().stateToken as string;
|
||||
const flow = await launchFlow(app, startResponse.json());
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: {
|
||||
cookie: flow.cookie,
|
||||
accept: "text/html",
|
||||
},
|
||||
});
|
||||
|
|
@ -1123,11 +1205,12 @@ describe("Heimdall service", () => {
|
|||
returnTo: "https://streampixels.gamecult.org/auth/connect",
|
||||
},
|
||||
});
|
||||
const stateToken = startResponse.json().stateToken as string;
|
||||
const flow = await launchFlow(app, startResponse.json());
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
const accountId = callbackResponse.json().account.id as string;
|
||||
|
||||
|
|
@ -1210,11 +1293,12 @@ describe("Heimdall service", () => {
|
|||
returnTo: "https://streampixels.gamecult.org/auth/connect",
|
||||
},
|
||||
});
|
||||
const stateToken = startResponse.json().stateToken as string;
|
||||
const flow = await launchFlow(app, startResponse.json());
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
const accountId = callbackResponse.json().account.id as string;
|
||||
|
||||
|
|
@ -1276,10 +1360,11 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(201);
|
||||
const evaluationsAtSignIn = evaluations;
|
||||
|
|
@ -1361,11 +1446,31 @@ describe("Heimdall service", () => {
|
|||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/joystick/start",
|
||||
headers: { "x-heimdall-app-secret": "streampixels-secret" },
|
||||
payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra },
|
||||
});
|
||||
return start;
|
||||
}
|
||||
|
||||
it("gates a connect start that names a creator on the app's own secret: none and a foreign app's are 401, its own is 201", async () => {
|
||||
const config = createTestConfig();
|
||||
const app = await buildApp({ config });
|
||||
apps.push(app);
|
||||
const body = { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", connection: creator };
|
||||
const start = (headers: Record<string, string>) => app.inject({ method: "POST", url: "/v1/oauth/joystick/start", headers, payload: body });
|
||||
|
||||
for (const headers of [{}, { "x-heimdall-app-secret": "repixelizer-secret" }, { "x-heimdall-app-secret": "wrong" }]) {
|
||||
const refused = await start(headers);
|
||||
expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]);
|
||||
}
|
||||
expect((await start({ "x-heimdall-app-secret": "streampixels-secret" })).statusCode).toBe(201);
|
||||
|
||||
// A direct caller (the command plane hands startOAuthFlow its own) is held to the slug too.
|
||||
const input = { appSlug: "streampixels" as const, mode: "connect" as const, returnTo: body.returnTo, connection: { kind: "creator" as const, targetId: "creator-1" } };
|
||||
const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
|
||||
expect((await startOAuthFlow(getHeimdallRuntimeContext(app), "joystick", foreign, input)).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no connection", {}, "creator_connection_required"],
|
||||
["an account connection", { connection: { kind: "account", targetId: "victim-1" } }, "creator_connection_required"],
|
||||
|
|
@ -1377,7 +1482,7 @@ describe("Heimdall service", () => {
|
|||
expect(start.statusCode).toBe(400);
|
||||
expect(start.json().error).toBe(error);
|
||||
expect(start.json().authorizationUrl).toBeUndefined();
|
||||
expect(start.json().stateToken).toBeUndefined();
|
||||
expect(start.json()).not.toHaveProperty("stateToken");
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -1393,14 +1498,15 @@ describe("Heimdall service", () => {
|
|||
apps.push(app);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const verified = verifyJwt(stateToken, keys.publicKey);
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const verified = verifyJwt(flow.state, keys.publicKey);
|
||||
expect(verified.valid).toBe(true);
|
||||
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, connection }, keys);
|
||||
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(calls).toEqual([]);
|
||||
|
|
@ -1432,9 +1538,11 @@ describe("Heimdall service", () => {
|
|||
handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl },
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBeLessThan(300);
|
||||
|
||||
|
|
@ -1453,7 +1561,7 @@ describe("Heimdall service", () => {
|
|||
apps.push(app);
|
||||
const start = await connect(app);
|
||||
expect(start.statusCode).toBe(201);
|
||||
const url = new URL(start.json().authorizationUrl as string);
|
||||
const url = await providerRedirect(app, start.json());
|
||||
expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize");
|
||||
expect(url.searchParams.get("client_id")).toBe("joystick-client");
|
||||
expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read");
|
||||
|
|
@ -1467,10 +1575,11 @@ describe("Heimdall service", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
stubJoystick({ channel_id: "deru-chan", username: "deru" });
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const connected = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
const accountId = connected.json().account.id as string;
|
||||
const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
|
||||
|
|
@ -1518,14 +1627,15 @@ describe("Heimdall service", () => {
|
|||
apps.push(app);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const verified = verifyJwt(stateToken, keys.publicKey);
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const verified = verifyJwt(flow.state, keys.publicKey);
|
||||
expect(verified.valid).toBe(true);
|
||||
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, mode }, keys);
|
||||
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(callback.body).not.toContain("viewer_access");
|
||||
|
|
@ -1542,11 +1652,12 @@ describe("Heimdall service", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" });
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBeLessThan(300);
|
||||
|
||||
|
|
@ -1575,10 +1686,11 @@ describe("Heimdall service", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
stubJoystick({ channel_id: "chan-9", username: "deru" });
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
|
||||
expect(row?.displayName).toBe("deru");
|
||||
|
|
@ -1589,10 +1701,11 @@ describe("Heimdall service", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
stubJoystick({ username: "deru" });
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
|
||||
|
|
@ -1787,11 +1900,11 @@ describe("error answers never echo an input", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } });
|
||||
apps.push(app);
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
headers: { accept: "text/html" },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie, accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(response.headers["content-type"]).toContain("text/html");
|
||||
|
|
@ -1823,9 +1936,11 @@ describe("error answers never echo an input", () => {
|
|||
},
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
|
|
@ -1843,16 +1958,16 @@ describe("the relayed provider error", () => {
|
|||
const injected = `<script>alert(1)</script>"\\\u0007é${"x".repeat(300)}`;
|
||||
const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/;
|
||||
|
||||
function providerErrorUrl(stateToken: string): string {
|
||||
const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected });
|
||||
return `/v1/oauth/discord/callback?${query.toString()}`;
|
||||
function providerErrorRequest(flow: LaunchedFlow) {
|
||||
const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected });
|
||||
return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } };
|
||||
}
|
||||
|
||||
it("is printable and bounded in the JSON answer", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) });
|
||||
const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app)));
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const { providerError, providerErrorDescription } = response.json() as Record<string, string>;
|
||||
|
|
@ -1866,11 +1981,8 @@ describe("the relayed provider error", () => {
|
|||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: providerErrorUrl(await startDiscordSignIn(app)),
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
const request = providerErrorRequest(await startDiscordSignIn(app));
|
||||
const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } });
|
||||
|
||||
expect(response.headers["content-type"]).toContain("text/html");
|
||||
expect(response.body.match(/<script/gi)).toHaveLength(1);
|
||||
|
|
@ -1902,7 +2014,7 @@ describe("the relayed provider error", () => {
|
|||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
|
||||
await app.inject({ method: "GET", url: providerErrorUrl(start.json().stateToken as string) });
|
||||
await app.inject(providerErrorRequest(await launchFlow(app, start.json())));
|
||||
|
||||
expect(deliveries).toHaveLength(1);
|
||||
const description = String(deliveries[0]!.errorDescription);
|
||||
|
|
@ -1928,10 +2040,11 @@ describe("the failed-callback audit event", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(response.statusCode).toBe(502);
|
||||
|
||||
|
|
@ -1955,10 +2068,11 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
|
|||
const store = new InMemoryStore();
|
||||
const app = await buildApp({ config: createTestConfig(), store, oauthRuntimes: createOAuthRuntimeRegistry() });
|
||||
apps.push(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBe(502);
|
||||
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
||||
|
|
@ -2012,8 +2126,12 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
|
|||
},
|
||||
});
|
||||
apps.push(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}` });
|
||||
const flow = await startDiscordSignIn(app);
|
||||
await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
|
||||
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: Record<string, unknown> }> }).auditEvents.values()];
|
||||
const failed = events.find((event) => event.eventType === "oauth_callback_failed");
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { InMemoryStore } from "../src/store/index.js";
|
|||
import { PostgresStore } from "../src/store/postgres.js";
|
||||
import { CREATE_SCHEMA_SQL } from "../src/store/schema.js";
|
||||
import { type CreateAuthCompletionInput, type HeimdallStore } from "../src/store/types.js";
|
||||
import { launchState, stateOf } from "./oauth-flow.js";
|
||||
|
||||
const ghostlightSecret = "ghostlight-private-command-secret";
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
|
@ -66,6 +67,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -102,10 +104,11 @@ async function start(app: App, payload: unknown, headers: Record<string, string>
|
|||
}
|
||||
|
||||
async function completeAs(app: App, stateToken: string, who: string) {
|
||||
const { cookie } = await launchState(app, stateToken);
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`,
|
||||
headers: { accept: "application/json" },
|
||||
headers: { accept: "application/json", cookie },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +216,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
|
|||
});
|
||||
expect(response.statusCode, response.body).toBe(201);
|
||||
expect(response.json().handoff).toEqual({ kind: "browser_completion" });
|
||||
const state = jwtPayload(response.json().stateToken as string);
|
||||
const state = jwtPayload(stateOf(response.json()));
|
||||
expect(state.handoff).toEqual({ kind: "browser_completion" });
|
||||
expect(JSON.stringify(state)).not.toContain(attempt.handle);
|
||||
});
|
||||
|
|
@ -237,7 +240,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
|
|||
expect(response.body).not.toContain(planted);
|
||||
if (response.statusCode === 201) {
|
||||
expect(response.json().handoff).toEqual({ kind: "browser_completion" });
|
||||
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
|
||||
expect(jwtPayload(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
|
||||
} else {
|
||||
expect(response.statusCode, response.body).toBe(400);
|
||||
}
|
||||
|
|
@ -249,7 +252,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
|
|||
const response = await start(app, { appSlug: "ghostlight", mode: "sign_in", returnTo: legitReturn, ...extra });
|
||||
expect(response.statusCode, response.body).toBe(201);
|
||||
expect(response.body).not.toContain(planted);
|
||||
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
|
||||
expect(jwtPayload(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -264,8 +267,8 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
|
|||
const calls = source.match(/(?<!function )startOAuthFlow\(/g) ?? [];
|
||||
if (calls.length) callers.push(`${file}:${calls.length}`);
|
||||
if (file.endsWith("app.ts")) {
|
||||
// The HTTP route's call ends at the body; no fifth argument.
|
||||
expect(source).toMatch(/startOAuthFlow\(\{ config, keys, store \}, request\.params\.provider, caller, request\.body\);/);
|
||||
// The HTTP route passes only the bearer-derived linkAccountId; never an attempt handle.
|
||||
expect(source).toMatch(/startOAuthFlow\(\{ config, keys, store \}, request\.params\.provider, caller, request\.body, \{\s*\.\.\.\(linkAccountId !== undefined \? \{ linkAccountId \} : \{\}\),\s*\}\);/);
|
||||
}
|
||||
}
|
||||
expect(callers.sort()).toEqual(["app.ts:1", "private-command-plane.ts:1"]);
|
||||
|
|
@ -306,7 +309,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
|
|||
handoff: { kind: "browser_completion", attemptId: handle },
|
||||
});
|
||||
expect(attackerStart.statusCode).toBe(201);
|
||||
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker");
|
||||
const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
|
||||
expect(attackerCallback.statusCode, attackerCallback.body).toBe(201);
|
||||
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("pending");
|
||||
|
||||
|
|
@ -340,7 +343,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
|
|||
expect(response.statusCode, response.body).toBe(201);
|
||||
// Delivery to port 1 fails; whatever the callback returns, the attempt
|
||||
// must be untouched and no completion may be bound to it.
|
||||
await completeAs(app, response.json().stateToken as string, "attacker");
|
||||
await completeAs(app, stateOf(response.json()), "attacker");
|
||||
expect((await store.findAuthAttempt("ghostlight", attempt.handle))?.status).toBe("pending");
|
||||
expect(await store.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, new Date().toISOString())).toBeNull();
|
||||
});
|
||||
|
|
@ -493,26 +496,27 @@ partialUniqueIndexContract("PostgresStore over pg-mem", async () => {
|
|||
});
|
||||
|
||||
describe("duplicate-handle error surfacing through the callback route", () => {
|
||||
it("state tokens are replayable, and a replay while the handle's completion is unconsumed echoes the store error in a 502", async () => {
|
||||
it("a replayed state is refused as already used before any exchange, and leaves the real completion intact", async () => {
|
||||
const { app, store, begin } = await planeHarness();
|
||||
const receipt = await begin("begin-replay");
|
||||
const handle = String(receipt.handle);
|
||||
const state = new URL((receipt.navigation as { url: string }).url).searchParams.get("state") ?? "";
|
||||
const first = await completeAs(app, state, "victim");
|
||||
const { cookie } = await launchState(app, state);
|
||||
const callbackWith = () =>
|
||||
app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=victim&state=${encodeURIComponent(state)}`,
|
||||
headers: { accept: "application/json", cookie },
|
||||
});
|
||||
const first = await callbackWith();
|
||||
expect(first.statusCode).toBe(201);
|
||||
const replay = await completeAs(app, state, "victim");
|
||||
// Nothing consumed the state's jti, so the second exchange still runs and
|
||||
// reaches createAuthCompletion, where the unique index refuses it. The
|
||||
// route's catch (app.ts ~1290) now answers with a fixed detail rather than
|
||||
// the store's own text, so a Postgres 23505 cannot hand the caller a
|
||||
// constraint name. The full text stays in the audit event.
|
||||
expect(replay.statusCode, replay.body).toBe(502);
|
||||
expect(replay.json()).toMatchObject({
|
||||
error: "oauth_callback_failed",
|
||||
detail: "The provider callback could not be completed.",
|
||||
});
|
||||
expect(JSON.stringify(replay.json())).not.toContain("unconsumed completion");
|
||||
// The failed replay must not have damaged the real completion.
|
||||
// The state's jti was consumed by the first callback, so the second never
|
||||
// reaches createAuthCompletion, whose unique index used to be the only
|
||||
// thing refusing a replay (and whose Postgres 23505 text the route had to
|
||||
// keep out of the answer).
|
||||
const replay = await callbackWith();
|
||||
expect(replay.statusCode, replay.body).toBe(400);
|
||||
expect(replay.json()).toEqual({ error: "state_already_used" });
|
||||
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("completed");
|
||||
expect(await store.consumeAuthCompletionByAttempt("ghostlight", handle, new Date().toISOString())).toBeTruthy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { type HeimdallConfig } from "../src/config.js";
|
|||
import { entitlementFacts } from "../src/facts.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { launchState, stateOf } from "./oauth-flow.js";
|
||||
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ function testConfig(): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -86,10 +88,11 @@ async function start(app: App, payload: Record<string, unknown>, headers: Record
|
|||
}
|
||||
|
||||
async function completeAs(app: App, stateToken: string, who: string) {
|
||||
const { cookie } = await launchState(app, stateToken);
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`,
|
||||
headers: { accept: "application/json" },
|
||||
headers: { accept: "application/json", cookie },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +113,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
|
|||
});
|
||||
expect(started.statusCode).toBe(201);
|
||||
|
||||
const callback = await completeAs(app, started.json().stateToken as string, "victim");
|
||||
const callback = await completeAs(app, stateOf(started.json()), "victim");
|
||||
expect(callback.statusCode).toBe(201);
|
||||
const minted = callback.json().completion.code as string;
|
||||
expect(minted).not.toBe("attacker-fixed");
|
||||
|
|
@ -125,7 +128,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
|
|||
const { app, store } = await harness();
|
||||
const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
|
||||
expect(started.statusCode).toBe(201);
|
||||
const callback = await completeAs(app, started.json().stateToken as string, "victim");
|
||||
const callback = await completeAs(app, stateOf(started.json()), "victim");
|
||||
expect(callback.statusCode).toBe(201);
|
||||
const code = callback.json().completion.code as string;
|
||||
expect(code).toMatch(uuidV4);
|
||||
|
|
@ -159,7 +162,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
|
|||
it("attemptId set to a real victim completion code does not let the attacker's flow touch the victim's completion", async () => {
|
||||
const { app } = await harness();
|
||||
const victimStart = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
|
||||
const victimCallback = await completeAs(app, victimStart.json().stateToken as string, "victim");
|
||||
const victimCallback = await completeAs(app, stateOf(victimStart.json()), "victim");
|
||||
const victimCode = victimCallback.json().completion.code as string;
|
||||
|
||||
const attackerStart = await start(app, {
|
||||
|
|
@ -168,7 +171,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
|
|||
returnTo: "https://repixelizer.gamecult.org/app/",
|
||||
handoff: { kind: "browser_completion", attemptId: victimCode },
|
||||
});
|
||||
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker");
|
||||
const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
|
||||
expect(attackerCallback.statusCode).toBe(201);
|
||||
expect(attackerCallback.json().completion.code).not.toBe(victimCode);
|
||||
|
||||
|
|
@ -204,7 +207,7 @@ describe("consumeAuthCompletion: single-use, scoped, expiring", () => {
|
|||
it("a code redeems exactly once under concurrent redemption, and never for another app", async () => {
|
||||
const { app } = await harness();
|
||||
const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
|
||||
const callback = await completeAs(app, started.json().stateToken as string, "victim");
|
||||
const callback = await completeAs(app, stateOf(started.json()), "victim");
|
||||
const code = callback.json().completion.code as string;
|
||||
|
||||
const crossApp = await redeem(app, "bifrost", code);
|
||||
|
|
@ -265,7 +268,7 @@ describe("attempt-handle binding: the public start route may attach a completion
|
|||
handoff: { kind: "browser_completion", attemptId: attempt.handle },
|
||||
});
|
||||
expect(attackerStart.statusCode).toBe(201);
|
||||
await completeAs(app, attackerStart.json().stateToken as string, "attacker");
|
||||
await completeAs(app, stateOf(attackerStart.json()), "attacker");
|
||||
|
||||
const after = await store.findAuthAttempt("ghostlight", attempt.handle);
|
||||
expect(after?.status).toBe("pending");
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { type OAuthProviderRuntime } from "../src/oauth.js";
|
|||
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
|
||||
import { sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { launchFlow } from "./oauth-flow.js";
|
||||
|
||||
const ghostlightSecret = "ghostlight-private-command-secret";
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
|
@ -60,6 +61,7 @@ function testConfig(): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -104,10 +106,11 @@ describe("caller identity: unauthenticated mint paths", () => {
|
|||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
headers: { accept: "text/html" },
|
||||
url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { accept: "text/html", cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBe(200);
|
||||
|
||||
|
|
|
|||
65
tests/consent-drift.test.ts
Normal file
65
tests/consent-drift.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
|
||||
// The receipt records the wording the person was shown. If the wording changes
|
||||
// between the ask and the callback (a deploy inside the state's lifetime), the
|
||||
// receipt must still hold what they saw.
|
||||
|
||||
const drift = vi.hoisted(() => ({ on: false }));
|
||||
vi.mock("../src/consent.js", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("../src/consent.js")>();
|
||||
return { ...real, consentText: (ask: Parameters<typeof real.consentText>[0]) => real.consentText(ask) + (drift.on ? "\nREWORDED AFTER THE ASK" : "") };
|
||||
});
|
||||
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js";
|
||||
|
||||
const apps: FastifyInstance[] = [];
|
||||
afterEach(async () => {
|
||||
drift.on = false;
|
||||
while (apps.length) await apps.pop()?.close();
|
||||
});
|
||||
|
||||
it("a receipt holds the wording of the ask, not wording rebuilt at the callback", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const app = await buildApp({
|
||||
config: createTestConfig(),
|
||||
store,
|
||||
oauthRuntimes: {
|
||||
joystick: {
|
||||
async exchangeAuthorizationCode() {
|
||||
return { accessToken: "joy-access-code-a", refreshToken: "r", tokenType: "Bearer", scope: ["identity:read"], expiresAt: "2099-01-01T00:00:00.000Z", raw: {} };
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return { provider: "joystick", providerUserId: "joy-a", username: "a", profile: {} };
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
apps.push(app);
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/joystick/start",
|
||||
headers: { "x-heimdall-app-secret": "streampixels-secret" },
|
||||
payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" } },
|
||||
});
|
||||
const state = stateOf(start.json());
|
||||
const ask = await app.inject({ method: "GET", url: `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`, headers: { accept: "text/html" } });
|
||||
const askCookie = setCookieOf(ask).find((c) => c.startsWith("__Host-heimdall-ask-"))!.split(";")[0]!;
|
||||
const go = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/joystick/consent",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: askCookie },
|
||||
payload: new URLSearchParams({ state, decision: "continue" }).toString(),
|
||||
});
|
||||
const binding = setCookieOf(go).find((c) => c.startsWith("__Host-heimdall-oauth-"))!.split(";")[0]!;
|
||||
drift.on = true; // the wording changes after the person has agreed
|
||||
const done = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?state=${encodeURIComponent(state)}&code=code-a`, headers: { cookie: binding, accept: "application/json" } });
|
||||
expect(done.statusCode).toBe(201);
|
||||
const [receipt] = await store.listConsentReceipts(done.json().account.id);
|
||||
expect(receipt?.kind === "grant" && receipt.consentText).not.toContain("REWORDED");
|
||||
});
|
||||
525
tests/google-link.test.ts
Normal file
525
tests/google-link.test.ts
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
import { createHash, createSign, generateKeyPairSync } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig, type HeimdallConfig } from "../src/config.js";
|
||||
import { resetGoogleJwksCache } from "../src/google-oidc.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { launchFlow, launchWithRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
|
||||
|
||||
const CLIENT_ID = "google-client";
|
||||
const REDIRECT = "https://heimdall.gamecult.org/v1/oauth/google/callback";
|
||||
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const apps: FastifyInstance[] = [];
|
||||
|
||||
function config(): HeimdallConfig {
|
||||
const base = loadConfig({
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
||||
GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID: CLIENT_ID,
|
||||
GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID: "discord-client",
|
||||
} as NodeJS.ProcessEnv);
|
||||
return {
|
||||
...base,
|
||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
bootstrapSigningPrivateKeyOnMissing: true,
|
||||
appSharedSecrets: { ...base.appSharedSecrets, bifrost: "bifrost-secret" },
|
||||
providers: {
|
||||
...base.providers,
|
||||
google: { clientId: CLIENT_ID, clientSecret: "google-secret" },
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function discordRuntime(providerUserId: string, name: string): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return { accessToken: "d-at", refreshToken: "d-rt", tokenType: "Bearer", scope: ["identify"], expiresAt: "2099-01-01T00:00:00.000Z", raw: {} };
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return { provider: "discord", providerUserId, username: name, displayName: name, profile: { id: providerUserId } };
|
||||
},
|
||||
// Discord grants app access only under a policy the app named at start.
|
||||
async evaluateEntitlements(input) {
|
||||
return { facts: input.callback.entitlementPolicy ? ["entitlement.app_access"] : [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Bifrost refuses an unauthenticated start: its backend holds the app secret.
|
||||
const BIFROST_APP = { "x-heimdall-app-secret": "bifrost-secret" };
|
||||
|
||||
function b64(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
function signedIdToken(claims: Record<string, unknown>): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const body = `${b64({ alg: "RS256", kid: "k1" })}.${b64({
|
||||
iss: "https://accounts.google.com",
|
||||
aud: CLIENT_ID,
|
||||
azp: CLIENT_ID,
|
||||
sub: "g-sub-1",
|
||||
email: "person@example.org",
|
||||
email_verified: true,
|
||||
name: "Person",
|
||||
iat: now,
|
||||
exp: now + 600,
|
||||
...claims,
|
||||
})}`;
|
||||
return `${body}.${createSign("RSA-SHA256").update(body).sign(rsa.privateKey).toString("base64url")}`;
|
||||
}
|
||||
|
||||
interface GoogleLeg {
|
||||
tokenRequests: URLSearchParams[];
|
||||
}
|
||||
|
||||
/** Google's two endpoints: the key set, and a token endpoint whose id_token echoes the nonce it was told. */
|
||||
function fakeGoogle(options: { nonceFrom: () => string | undefined; claims?: Record<string, unknown> }): GoogleLeg {
|
||||
const leg: GoogleLeg = { tokenRequests: [] };
|
||||
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "https://www.googleapis.com/oauth2/v3/certs") {
|
||||
return new Response(
|
||||
JSON.stringify({ keys: [{ ...(rsa.publicKey.export({ format: "jwk" }) as object), kid: "k1", alg: "RS256" }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url === "https://oauth2.googleapis.com/token") {
|
||||
leg.tokenRequests.push(new URLSearchParams(String(init?.body)));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "g-at",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
scope: "openid email profile",
|
||||
id_token: signedIdToken({ nonce: options.nonceFrom(), ...options.claims }),
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
}) as typeof fetch;
|
||||
return leg;
|
||||
}
|
||||
|
||||
function accountCount(store: InMemoryStore): number {
|
||||
return (store as unknown as { accounts: Map<string, unknown> }).accounts.size;
|
||||
}
|
||||
|
||||
async function signInWithDiscord(app: FastifyInstance, providerUserId = "discord-1"): Promise<{ accessToken: string; accountId: string }> {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
headers: BIFROST_APP,
|
||||
payload: { appSlug: "bifrost", mode: "sign_in", returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" },
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBe(201);
|
||||
const body = callback.json();
|
||||
return { accessToken: body.accessToken as string, accountId: body.account.id as string };
|
||||
}
|
||||
|
||||
async function build(store = new InMemoryStore(), discordUser = "discord-1") {
|
||||
const app = await buildApp({ config: config(), store, oauthRuntimes: { discord: discordRuntime(discordUser, "Person") } });
|
||||
apps.push(app);
|
||||
return { app, store };
|
||||
}
|
||||
|
||||
function startGoogle(app: FastifyInstance, accessToken?: string, mode: "link" | "sign_in" = "link") {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/google/start",
|
||||
headers: { ...BIFROST_APP, ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}) },
|
||||
payload: { appSlug: "bifrost", mode, returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" },
|
||||
});
|
||||
}
|
||||
|
||||
function callbackGoogle(app: FastifyInstance, flow: LaunchedFlow) {
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/google/callback?code=g-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
}
|
||||
|
||||
const DISCORD_POLICY = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["member"] } as const;
|
||||
|
||||
/** Begin a link with `token`, let Google answer as `claims` says, and return the callback's answer. */
|
||||
async function linkGoogleCallback(app: FastifyInstance, token: string, claims?: Record<string, unknown>) {
|
||||
const start = await startGoogle(app, token);
|
||||
expect(start.statusCode).toBe(201);
|
||||
const startFlow = await launchWithRedirect(app, start.json());
|
||||
const url = startFlow.url;
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, ...(claims ? { claims } : {}) });
|
||||
return callbackGoogle(app, startFlow);
|
||||
}
|
||||
|
||||
function confirmLink(app: FastifyInstance, token: string | undefined, pendingLinkId: string) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/bifrost/links/confirm",
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
payload: { pendingLinkId },
|
||||
});
|
||||
}
|
||||
|
||||
/** The whole link: callback, then the initiator's confirm. */
|
||||
async function linkGoogle(app: FastifyInstance, token: string, claims?: Record<string, unknown>) {
|
||||
const done = await linkGoogleCallback(app, token, claims);
|
||||
expect(done.statusCode).toBe(201);
|
||||
const confirmed = await confirmLink(app, token, done.json().pendingLinkId);
|
||||
expect(confirmed.statusCode).toBe(201);
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetGoogleJwksCache();
|
||||
while (apps.length) await apps.pop()?.close();
|
||||
});
|
||||
|
||||
describe("Google as a Heimdall OIDC provider", () => {
|
||||
it("builds the authorization request: openid email profile, the exact callback, state, nonce and an S256 challenge", async () => {
|
||||
const { app } = await build();
|
||||
const { accessToken } = await signInWithDiscord(app);
|
||||
const start = await startGoogle(app, accessToken);
|
||||
|
||||
expect(start.statusCode).toBe(201);
|
||||
expect(start.json().callbackUrl).toBe(REDIRECT);
|
||||
const startFlow = await launchWithRedirect(app, start.json());
|
||||
const url = startFlow.url;
|
||||
expect(`${url.origin}${url.pathname}`).toBe("https://accounts.google.com/o/oauth2/v2/auth");
|
||||
expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(REDIRECT);
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("scope")).toBe("openid email profile");
|
||||
expect(url.searchParams.get("state")).toBe(stateOf(start.json()));
|
||||
expect(url.searchParams.get("nonce")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("links Google to the account that asked, with PKCE proven end to end and the id_token's nonce matched", async () => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken, accountId } = await signInWithDiscord(app);
|
||||
const start = await startGoogle(app, accessToken);
|
||||
const startFlow = await launchWithRedirect(app, start.json());
|
||||
const url = startFlow.url;
|
||||
const leg = fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
|
||||
|
||||
const done = await callbackGoogle(app, startFlow);
|
||||
|
||||
// Phase one verified the identity and recorded it; it linked nothing and issued no session.
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().status).toBe("link_pending");
|
||||
expect(done.json().accessToken).toBeUndefined();
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
const confirmed = await confirmLink(app, accessToken, done.json().pendingLinkId);
|
||||
expect(confirmed.statusCode).toBe(201);
|
||||
expect(confirmed.json().accountId).toBe(accountId);
|
||||
expect(accountCount(store)).toBe(1);
|
||||
const sent = leg.tokenRequests[0]!;
|
||||
expect(sent.get("grant_type")).toBe("authorization_code");
|
||||
expect(sent.get("redirect_uri")).toBe(REDIRECT);
|
||||
// The verifier Heimdall sent hashes to the challenge it put in the authorization URL.
|
||||
expect(createHash("sha256").update(sent.get("code_verifier") ?? "", "ascii").digest("base64url")).toBe(
|
||||
url.searchParams.get("code_challenge")
|
||||
);
|
||||
const stored = await store.findStoredLinkedIdentity("google", "g-sub-1");
|
||||
expect(stored?.accountId).toBe(accountId);
|
||||
expect(stored?.primaryEmail).toBe("person@example.org");
|
||||
});
|
||||
|
||||
it("signs in the account Google is linked to, with no session, and mints nothing", async () => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken, accountId } = await signInWithDiscord(app);
|
||||
await linkGoogle(app, accessToken);
|
||||
|
||||
const signIn = await startGoogle(app, undefined, "sign_in");
|
||||
expect(signIn.statusCode).toBe(201);
|
||||
const signInFlow = await launchWithRedirect(app, signIn.json());
|
||||
const signInUrl = signInFlow.url;
|
||||
fakeGoogle({ nonceFrom: () => signInUrl.searchParams.get("nonce") ?? undefined });
|
||||
const done = await callbackGoogle(app, signInFlow);
|
||||
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().account.id).toBe(accountId);
|
||||
expect(accountCount(store)).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a link start whose named initiator is not the bearer's account", async () => {
|
||||
const { app, store } = await build();
|
||||
const a = await signInWithDiscord(app, "discord-1");
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/google/start",
|
||||
headers: { ...BIFROST_APP, authorization: `Bearer ${a.accessToken}` },
|
||||
payload: {
|
||||
appSlug: "bifrost",
|
||||
mode: "link",
|
||||
returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete",
|
||||
initiatingAccountId: `${a.accountId}-other`,
|
||||
},
|
||||
});
|
||||
expect([start.statusCode, start.json().error]).toEqual([400, "link_initiator_conflict"]);
|
||||
expect(accountCount(store)).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a link start with no Heimdall session", async () => {
|
||||
const { app, store } = await build();
|
||||
const start = await startGoogle(app);
|
||||
expect(start.statusCode).toBe(401);
|
||||
expect(start.json().error).toBe("link_session_required");
|
||||
expect(accountCount(store)).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a garbage bearer", () => "not-a-token"],
|
||||
["a token for another app", async (app: FastifyInstance) => {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
|
||||
});
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie } });
|
||||
return cb.json().accessToken as string;
|
||||
}],
|
||||
])("refuses a link start carrying %s", async (_name, token) => {
|
||||
const { app, store } = await build();
|
||||
const bearer = await token(app);
|
||||
const before = accountCount(store);
|
||||
const start = await startGoogle(app, bearer);
|
||||
expect(start.statusCode).toBe(401);
|
||||
expect(start.json().error).toBe("invalid_link_session");
|
||||
expect(accountCount(store)).toBe(before);
|
||||
});
|
||||
|
||||
it("refuses a first Google login from a subject no account holds, and mints no account for it", async () => {
|
||||
// The person who has a Discord account arrives with Google for the first time, signing in rather than linking.
|
||||
const { app, store } = await build();
|
||||
await signInWithDiscord(app);
|
||||
const start = await startGoogle(app, undefined, "sign_in");
|
||||
expect(start.statusCode).toBe(201);
|
||||
const startFlow = await launchWithRedirect(app, start.json());
|
||||
const url = startFlow.url;
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
|
||||
|
||||
const before = accountCount(store);
|
||||
const done = await callbackGoogle(app, startFlow);
|
||||
|
||||
expect(done.statusCode).toBe(403);
|
||||
expect(done.json().error).toBe("identity_not_linked");
|
||||
expect(accountCount(store)).toBe(before);
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("never moves or merges: a Google subject already linked to one account cannot be linked to another", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const first = await build(store, "discord-1");
|
||||
const a = await signInWithDiscord(first.app, "discord-1");
|
||||
await linkGoogle(first.app, a.accessToken);
|
||||
|
||||
// A second person, a second account, the same Google subject.
|
||||
const second = await build(store, "discord-2");
|
||||
const b = await signInWithDiscord(second.app, "discord-2");
|
||||
expect(b.accountId).not.toBe(a.accountId);
|
||||
const startB = await startGoogle(second.app, b.accessToken);
|
||||
const startBFlow = await launchWithRedirect(second.app, startB.json());
|
||||
const urlB = startBFlow.url;
|
||||
fakeGoogle({ nonceFrom: () => urlB.searchParams.get("nonce") ?? undefined });
|
||||
const done = await callbackGoogle(second.app, startBFlow);
|
||||
|
||||
expect(done.statusCode).toBe(403);
|
||||
expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unverified email", { email_verified: false }],
|
||||
["another client's audience", { aud: "other-client", azp: "other-client" }],
|
||||
["a nonce this request did not carry", { nonce: "replayed" }],
|
||||
["an expired token", { exp: Math.floor(Date.now() / 1000) - 3600 }],
|
||||
])("links nothing when the id_token has %s", async (_name, claims) => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken } = await signInWithDiscord(app);
|
||||
const start = await startGoogle(app, accessToken);
|
||||
const startFlow = await launchWithRedirect(app, start.json());
|
||||
const url = startFlow.url;
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims });
|
||||
|
||||
const done = await callbackGoogle(app, startFlow);
|
||||
|
||||
expect(done.statusCode).toBe(502);
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
expect(accountCount(store)).toBe(1);
|
||||
});
|
||||
|
||||
it("keys the identity by sub: the same email under a different sub is a different, unlinkable identity", async () => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken, accountId } = await signInWithDiscord(app);
|
||||
await linkGoogle(app, accessToken, { sub: "g-sub-2" });
|
||||
expect((await store.findStoredLinkedIdentity("google", "g-sub-2"))?.accountId).toBe(accountId);
|
||||
expect(await store.findStoredLinkedIdentity("google", "person@example.org")).toBeNull();
|
||||
});
|
||||
|
||||
describe("link is two-phase", () => {
|
||||
it("a victim completing an attacker's link URL attaches the victim's Google sub to no account", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const attacker = await build(store, "discord-attacker");
|
||||
const a = await signInWithDiscord(attacker.app, "discord-attacker");
|
||||
const victim = await build(store, "discord-victim");
|
||||
const v = await signInWithDiscord(victim.app, "discord-victim");
|
||||
|
||||
// The attacker begins a link and sends the Google URL to the victim, who finishes it as themselves.
|
||||
const done = await linkGoogleCallback(attacker.app, a.accessToken, { sub: "victim-google-sub" });
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
|
||||
|
||||
// The victim's app holds the victim's token: Heimdall refuses it, and the link stays unwritten.
|
||||
const asVictim = await confirmLink(victim.app, v.accessToken, done.json().pendingLinkId);
|
||||
expect(asVictim.statusCode).toBe(410);
|
||||
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
|
||||
|
||||
// No token at all, and a garbage token, are refused the same way.
|
||||
expect((await confirmLink(attacker.app, undefined, done.json().pendingLinkId)).statusCode).toBe(401);
|
||||
expect((await confirmLink(attacker.app, "garbage", done.json().pendingLinkId)).statusCode).toBe(401);
|
||||
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
|
||||
|
||||
// The refused attempts did not burn the pending link: its initiator can still confirm it.
|
||||
expect((await confirmLink(attacker.app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("signing in with Google cannot reach a Google sub the attacker's pending link recorded", async () => {
|
||||
const { app } = await build();
|
||||
const a = await signInWithDiscord(app);
|
||||
await linkGoogleCallback(app, a.accessToken, { sub: "victim-google-sub" });
|
||||
|
||||
const signIn = await startGoogle(app, undefined, "sign_in");
|
||||
const signInFlow = await launchWithRedirect(app, signIn.json());
|
||||
const url = signInFlow.url;
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims: { sub: "victim-google-sub" } });
|
||||
expect((await callbackGoogle(app, signInFlow)).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("a pending link is single-use", async () => {
|
||||
const { app } = await build();
|
||||
const a = await signInWithDiscord(app);
|
||||
const done = await linkGoogleCallback(app, a.accessToken);
|
||||
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(201);
|
||||
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(410);
|
||||
});
|
||||
|
||||
it("a pending link expires after the completion TTL", async () => {
|
||||
const { app, store } = await build();
|
||||
const a = await signInWithDiscord(app);
|
||||
const done = await linkGoogleCallback(app, a.accessToken);
|
||||
const ttlMs = Date.parse(done.json().expiresAt) - Date.now();
|
||||
expect(ttlMs).toBeGreaterThan(0);
|
||||
expect(ttlMs).toBeLessThanOrEqual(300_000);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 301_000 });
|
||||
try {
|
||||
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(410);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("a pending link is bound to its app: another app's session cannot confirm it", async () => {
|
||||
const { app, store } = await build();
|
||||
const a = await signInWithDiscord(app);
|
||||
const done = await linkGoogleCallback(app, a.accessToken);
|
||||
const other = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
|
||||
});
|
||||
const flow = await launchFlow(app, other.json());
|
||||
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie } });
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/repixelizer/links/confirm",
|
||||
headers: { authorization: `Bearer ${cb.json().accessToken}` },
|
||||
payload: { pendingLinkId: done.json().pendingLinkId },
|
||||
});
|
||||
expect(res.statusCode).toBe(410);
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses at confirm a sub another account linked in between, and never moves it", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const first = await build(store, "discord-1");
|
||||
const a = await signInWithDiscord(first.app, "discord-1");
|
||||
const second = await build(store, "discord-2");
|
||||
const b = await signInWithDiscord(second.app, "discord-2");
|
||||
|
||||
const pendingA = await linkGoogleCallback(first.app, a.accessToken);
|
||||
const pendingB = await linkGoogleCallback(second.app, b.accessToken);
|
||||
expect((await confirmLink(first.app, a.accessToken, pendingA.json().pendingLinkId)).statusCode).toBe(201);
|
||||
const lost = await confirmLink(second.app, b.accessToken, pendingB.json().pendingLinkId);
|
||||
|
||||
expect(lost.statusCode).toBe(409);
|
||||
expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entitlements at the callback", () => {
|
||||
it("a Discord-origin member returning through Google keeps entitlement.app_access under the start-time policy", async () => {
|
||||
const { app } = await build();
|
||||
const member = await signInWithDiscord(app);
|
||||
await linkGoogle(app, member.accessToken);
|
||||
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/google/start",
|
||||
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
||||
payload: {
|
||||
appSlug: "bifrost",
|
||||
mode: "sign_in",
|
||||
returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete",
|
||||
entitlementPolicy: DISCORD_POLICY,
|
||||
},
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const startFlow = await launchWithRedirect(app, start.json());
|
||||
const url = startFlow.url;
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
|
||||
const done = await callbackGoogle(app, startFlow);
|
||||
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().claimSet.facts).toContain("entitlement.app_access");
|
||||
});
|
||||
|
||||
it("without a start-time policy the other identities grant nothing", async () => {
|
||||
const { app } = await build();
|
||||
const member = await signInWithDiscord(app);
|
||||
await linkGoogle(app, member.accessToken);
|
||||
|
||||
const start = await startGoogle(app, undefined, "sign_in");
|
||||
const startFlow = await launchWithRedirect(app, start.json());
|
||||
const url = startFlow.url;
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
|
||||
const done = await callbackGoogle(app, startFlow);
|
||||
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().claimSet.facts).not.toContain("entitlement.app_access");
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the six existing providers minting on first login", async () => {
|
||||
const { app, store } = await build();
|
||||
await signInWithDiscord(app);
|
||||
expect(accountCount(store)).toBe(1);
|
||||
});
|
||||
});
|
||||
197
tests/google-oidc.test.ts
Normal file
197
tests/google-oidc.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveOidcSecrets,
|
||||
GoogleIdTokenError,
|
||||
resetGoogleJwksCache,
|
||||
verifyGoogleIdToken,
|
||||
type GoogleIdTokenFailure,
|
||||
} from "../src/google-oidc.js";
|
||||
|
||||
const CLIENT_ID = "google-client.apps.googleusercontent.com";
|
||||
const NONCE = "the-nonce";
|
||||
const NOW_MS = Date.parse("2026-11-04T12:00:00.000Z");
|
||||
const NOW_S = Math.floor(NOW_MS / 1000);
|
||||
|
||||
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const otherRsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let jwksFetches = 0;
|
||||
|
||||
function jwks(kidKeys: Record<string, KeyObject>) {
|
||||
return {
|
||||
keys: Object.entries(kidKeys).map(([kid, key]) => ({
|
||||
...(key.export({ format: "jwk" }) as Record<string, string>),
|
||||
kid,
|
||||
alg: "RS256",
|
||||
use: "sig",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function serveJwks(kidKeys: Record<string, KeyObject>) {
|
||||
globalThis.fetch = (async (input: string | URL) => {
|
||||
expect(String(input)).toBe("https://www.googleapis.com/oauth2/v3/certs");
|
||||
jwksFetches += 1;
|
||||
return new Response(JSON.stringify(jwks(kidKeys)), { status: 200, headers: { "cache-control": "public, max-age=3600" } });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function b64(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
function idToken(
|
||||
claims: Record<string, unknown> = {},
|
||||
options: { kid?: string; alg?: string; key?: KeyObject; omit?: string[] } = {}
|
||||
): string {
|
||||
const payload: Record<string, unknown> = {
|
||||
iss: "https://accounts.google.com",
|
||||
aud: CLIENT_ID,
|
||||
azp: CLIENT_ID,
|
||||
sub: "110169484474386276334",
|
||||
email: "meta@example.org",
|
||||
email_verified: true,
|
||||
name: "Meta",
|
||||
picture: "https://lh3.googleusercontent.com/a/x",
|
||||
nonce: NONCE,
|
||||
iat: NOW_S - 10,
|
||||
exp: NOW_S + 3000,
|
||||
...claims,
|
||||
};
|
||||
for (const key of options.omit ?? []) delete payload[key];
|
||||
const header = b64({ alg: options.alg ?? "RS256", typ: "JWT", kid: options.kid ?? "k1" });
|
||||
const body = `${header}.${b64(payload)}`;
|
||||
const signature = createSign("RSA-SHA256").update(body).sign(options.key ?? rsa.privateKey).toString("base64url");
|
||||
return `${body}.${signature}`;
|
||||
}
|
||||
|
||||
async function refusal(token: string, nonce = NONCE): Promise<GoogleIdTokenFailure> {
|
||||
try {
|
||||
await verifyGoogleIdToken(token, { clientId: CLIENT_ID, nonce }, NOW_MS);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(GoogleIdTokenError);
|
||||
return (error as GoogleIdTokenError).code;
|
||||
}
|
||||
throw new Error("the token was accepted");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetGoogleJwksCache();
|
||||
jwksFetches = 0;
|
||||
serveJwks({ k1: rsa.publicKey });
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("verifyGoogleIdToken", () => {
|
||||
it("accepts a good token and returns the subject as the identity", async () => {
|
||||
const claims = await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
|
||||
expect(claims).toEqual({
|
||||
sub: "110169484474386276334",
|
||||
email: "meta@example.org",
|
||||
name: "Meta",
|
||||
picture: "https://lh3.googleusercontent.com/a/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the bare-host issuer Google also uses", async () => {
|
||||
await expect(verifyGoogleIdToken(idToken({ iss: "accounts.google.com" }), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS)).resolves.toMatchObject({
|
||||
sub: "110169484474386276334",
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, () => string, GoogleIdTokenFailure]>([
|
||||
["a foreign issuer", () => idToken({ iss: "https://evil.example" }), "bad_issuer"],
|
||||
["an issuer that merely contains Google's", () => idToken({ iss: "https://accounts.google.com.evil.example" }), "bad_issuer"],
|
||||
["another client's audience", () => idToken({ aud: "other-client", azp: "other-client" }), "bad_audience"],
|
||||
["an array audience whose azp is not this client", () => idToken({ aud: [CLIENT_ID, "other"], azp: "other" }), "bad_audience"],
|
||||
["an array audience with no azp", () => idToken({ aud: [CLIENT_ID], azp: undefined }), "bad_audience"],
|
||||
["an expired token", () => idToken({ exp: NOW_S - 61 }), "expired"],
|
||||
["a token with no exp", () => idToken({}, { omit: ["exp"] }), "expired"],
|
||||
["a token issued in the future", () => idToken({ iat: NOW_S + 3600 }), "not_yet_valid"],
|
||||
["a token with no iat", () => idToken({}, { omit: ["iat"] }), "not_yet_valid"],
|
||||
["a nonce this request did not carry", () => idToken({ nonce: "someone-elses" }), "bad_nonce"],
|
||||
["a token with no nonce", () => idToken({}, { omit: ["nonce"] }), "bad_nonce"],
|
||||
["no subject", () => idToken({}, { omit: ["sub"] }), "missing_subject"],
|
||||
["an empty subject", () => idToken({ sub: "" }), "missing_subject"],
|
||||
["no email", () => idToken({}, { omit: ["email"] }), "email_missing"],
|
||||
["an unverified email", () => idToken({ email_verified: false }), "email_not_verified"],
|
||||
["no email_verified claim", () => idToken({}, { omit: ["email_verified"] }), "email_not_verified"],
|
||||
["a string email_verified other than true", () => idToken({ email_verified: "yes" }), "email_not_verified"],
|
||||
["alg none", () => idToken({}, { alg: "none" }), "unsupported_algorithm"],
|
||||
["alg HS256", () => idToken({}, { alg: "HS256" }), "unsupported_algorithm"],
|
||||
["a key Google does not publish", () => idToken({}, { kid: "unknown" }), "unknown_key"],
|
||||
["a signature by another key under Google's kid", () => idToken({}, { key: otherRsa.privateKey }), "bad_signature"],
|
||||
["three empty parts", () => "..", "malformed"],
|
||||
["two parts", () => "a.b", "malformed"],
|
||||
])("refuses %s", async (_name, make, code) => {
|
||||
expect(await refusal(make())).toBe(code);
|
||||
});
|
||||
|
||||
it("checks the signature before it believes any claim", async () => {
|
||||
// A forged payload with every claim right is still refused as a bad signature.
|
||||
const forged = idToken({}, { key: otherRsa.privateKey });
|
||||
expect(await refusal(forged)).toBe("bad_signature");
|
||||
});
|
||||
|
||||
it("takes email_verified string 'true' as Google's older tokens spell it", async () => {
|
||||
await expect(verifyGoogleIdToken(idToken({ email_verified: "true" }), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS)).resolves.toMatchObject({
|
||||
email: "meta@example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("caches Google's keys and refetches once for a rotated kid, not for every bad token", async () => {
|
||||
await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
|
||||
await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 1000);
|
||||
expect(jwksFetches).toBe(1);
|
||||
serveJwks({ k1: rsa.publicKey, k2: otherRsa.publicKey });
|
||||
const rotated = idToken({}, { kid: "k2", key: otherRsa.privateKey });
|
||||
// Inside the refetch floor the unknown kid is refused without a fetch.
|
||||
await expect(verifyGoogleIdToken(rotated, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 2000)).rejects.toMatchObject({ code: "unknown_key" });
|
||||
expect(jwksFetches).toBe(1);
|
||||
await expect(verifyGoogleIdToken(rotated, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 120_000)).resolves.toMatchObject({ sub: "110169484474386276334" });
|
||||
expect(jwksFetches).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses with jwks_unavailable when Google's key endpoint fails", async () => {
|
||||
resetGoogleJwksCache();
|
||||
globalThis.fetch = (async () => new Response("down", { status: 503 })) as typeof fetch;
|
||||
expect(await refusal(idToken())).toBe("jwks_unavailable");
|
||||
});
|
||||
|
||||
it("never quotes the token in its refusal", async () => {
|
||||
const token = idToken({ email: "secret-person@example.org", nonce: "wrong" });
|
||||
try {
|
||||
await verifyGoogleIdToken(token, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
|
||||
} catch (error) {
|
||||
expect(String((error as Error).message)).not.toContain("secret-person");
|
||||
expect(String((error as Error).message)).not.toContain(token.slice(0, 20));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveOidcSecrets", () => {
|
||||
const key = generateKeyPairSync("ed25519").privateKey;
|
||||
const otherKey = generateKeyPairSync("ed25519").privateKey;
|
||||
|
||||
it("is the same pair for the same state and Heimdall key, so nothing is stored between start and callback", () => {
|
||||
expect(deriveOidcSecrets("jti-1", key)).toEqual(deriveOidcSecrets("jti-1", key));
|
||||
});
|
||||
|
||||
it("differs per state, per Heimdall key, and between verifier and nonce", () => {
|
||||
const a = deriveOidcSecrets("jti-1", key);
|
||||
expect(deriveOidcSecrets("jti-2", key).codeVerifier).not.toBe(a.codeVerifier);
|
||||
expect(deriveOidcSecrets("jti-2", key).nonce).not.toBe(a.nonce);
|
||||
expect(deriveOidcSecrets("jti-1", otherKey).codeVerifier).not.toBe(a.codeVerifier);
|
||||
expect(a.codeVerifier).not.toBe(a.nonce);
|
||||
});
|
||||
|
||||
it("yields an RFC 7636 verifier (43-128 unreserved characters) and its S256 challenge", () => {
|
||||
const { codeVerifier, codeChallenge } = deriveOidcSecrets("jti-1", key);
|
||||
expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
|
||||
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(codeChallenge).not.toBe(codeVerifier);
|
||||
});
|
||||
});
|
||||
669
tests/joystick-consent.test.ts
Normal file
669
tests/joystick-consent.test.ts
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { buildApp, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
|
||||
import { resolveAppCaller } from "../src/app-caller.js";
|
||||
import { consentDigest, consentText, digestOfText, type ConsentAsk } from "../src/consent.js";
|
||||
import { decodeJwt, signJwt } from "../src/signing.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { providerCatalog } from "../src/providers.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js";
|
||||
|
||||
// The consent cut for managed custody (docs/joystick-consent-flow.md): the ask,
|
||||
// the typed refusal, the receipt, and the signed-in page that lists and revokes.
|
||||
|
||||
const apps: FastifyInstance[] = [];
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
while (apps.length) await apps.pop()?.close();
|
||||
});
|
||||
|
||||
const defaultScopes = providerCatalog.joystick.defaultScopes;
|
||||
const secret = { "x-heimdall-app-secret": "streampixels-secret" };
|
||||
const form = { "content-type": "application/x-www-form-urlencoded" };
|
||||
|
||||
/** A Joystick that returns whatever `code-<user>` names, granting only `granted`. */
|
||||
function joystick(granted: string[] = ["identity:read", "stream:read"]): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode({ code }) {
|
||||
return { accessToken: `joy-access-${code}`, refreshToken: `joy-refresh-${code}`, tokenType: "Bearer", scope: granted, expiresAt: "2099-01-01T00:00:00.000Z", raw: {} };
|
||||
},
|
||||
async resolveIdentity({ accessToken }) {
|
||||
const id = accessToken.replace("joy-access-code-", "");
|
||||
return { provider: "joystick", providerUserId: `joy-${id}`, username: id, profile: { id } };
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function newApp(options: { granted?: string[]; callbacks?: string[] } = {}) {
|
||||
const store = new InMemoryStore();
|
||||
const config = createTestConfig();
|
||||
if (options.callbacks) config.appBackendCallbacks = { streampixels: options.callbacks };
|
||||
const app = await buildApp({ config, store, oauthRuntimes: { joystick: joystick(options.granted) } });
|
||||
apps.push(app);
|
||||
return { app, store };
|
||||
}
|
||||
|
||||
async function startConnect(app: FastifyInstance, handoff: Record<string, unknown> = { kind: "browser_completion" }) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/joystick/start",
|
||||
headers: secret,
|
||||
payload: {
|
||||
appSlug: "streampixels",
|
||||
mode: "connect",
|
||||
connection: { kind: "creator", targetId: "creator-1" },
|
||||
returnTo: "https://streampixels.gamecult.org/settings",
|
||||
handoff,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
return stateOf(response.json());
|
||||
}
|
||||
|
||||
const launchUrl = (state: string) => `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`;
|
||||
const cookieNamed = (prefix: string) => (response: { headers: Record<string, unknown> }) =>
|
||||
setCookieOf(response).find((value) => value.startsWith(prefix))?.split(";")[0];
|
||||
const askCookieOf = cookieNamed("__Host-heimdall-ask-");
|
||||
const bindingCookieOf = cookieNamed("__Host-heimdall-oauth-");
|
||||
|
||||
/** The browser is shown the ask. The cookie it keeps is the ask's, which binds nothing. */
|
||||
async function seeAsk(app: FastifyInstance, state: string) {
|
||||
const response = await app.inject({ method: "GET", url: launchUrl(state), headers: { accept: "text/html" } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(bindingCookieOf(response), "the ask must not set the launch binding").toBeUndefined();
|
||||
return { page: response, cookie: askCookieOf(response)! };
|
||||
}
|
||||
|
||||
function answer(app: FastifyInstance, state: string, decision: "continue" | "decline", cookie?: string, accept = "text/html") {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/joystick/consent",
|
||||
headers: { ...form, accept, ...(cookie ? { cookie } : {}) },
|
||||
payload: new URLSearchParams({ state, decision }).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
function callback(app: FastifyInstance, state: string, cookie: string, query: string, accept = "application/json") {
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?state=${encodeURIComponent(state)}&${query}`,
|
||||
headers: { cookie, accept },
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask, accept, and complete as Joystick user `user`; returns the callback's JSON. */
|
||||
async function grantAs(app: FastifyInstance, user: string) {
|
||||
const state = await startConnect(app);
|
||||
const asked = await seeAsk(app, state);
|
||||
const go = await answer(app, state, "continue", asked.cookie);
|
||||
expect(go.statusCode).toBe(302);
|
||||
const done = await callback(app, state, bindingCookieOf(go)!, `code=code-${user}`);
|
||||
expect(done.statusCode).toBe(201);
|
||||
return done.json() as { accessToken: string; consentReceiptId: string; account: { id: string }; session: { accountId: string } };
|
||||
}
|
||||
|
||||
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
||||
const grantsOf = async (app: FastifyInstance, token: string) =>
|
||||
(await app.inject({ method: "GET", url: "/v1/me/grants", headers: bearer(token) })).json() as { grants: Array<Record<string, any>>; receipts: Array<Record<string, any>> };
|
||||
const resolveCredential = (app: FastifyInstance, accountId: string) =>
|
||||
app.inject({ method: "POST", url: "/v1/apps/streampixels/managed-credentials/resolve", headers: secret, payload: { accountId, provider: "joystick" } });
|
||||
|
||||
describe("the ask", () => {
|
||||
it("launch shows a Heimdall page naming app, provider, scopes and custody, and does not redirect", async () => {
|
||||
const { app } = await newApp();
|
||||
const { page } = await seeAsk(app, await startConnect(app));
|
||||
expect(page.headers.location).toBeUndefined();
|
||||
for (const needle of ["StreamPixels", "Joystick", ...defaultScopes, "refresh token", "streampixels.gamecult.org", "Continue to Joystick", "No, return to the app"]) {
|
||||
expect(page.body).toContain(needle);
|
||||
}
|
||||
expect(page.body).not.toContain("<script");
|
||||
expect(page.headers["content-security-policy"]).toContain("form-action 'self' https://joystick.tv");
|
||||
});
|
||||
|
||||
it("viewing the ask claims nothing: the launch is still spendable once after any number of views", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
await seeAsk(app, state);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302);
|
||||
const again = await answer(app, state, "continue", cookie, "application/json");
|
||||
expect(again.statusCode).toBe(400);
|
||||
expect(again.json()).toEqual({ error: "state_already_launched" });
|
||||
});
|
||||
|
||||
it("Continue sends exactly the scopes the page listed", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
const go = await answer(app, state, "continue", cookie);
|
||||
expect(go.statusCode).toBe(302);
|
||||
const target = new URL(go.headers.location as string);
|
||||
expect(target.origin).toBe("https://joystick.tv");
|
||||
expect(target.searchParams.get("scope")).toBe(defaultScopes.join(" "));
|
||||
});
|
||||
|
||||
it("a flow with no custody (sign-in through an identity provider) gets no ask and redirects as before", async () => {
|
||||
const { app } = await newApp();
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/twitch/start",
|
||||
headers: secret,
|
||||
payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/", handoff: { kind: "browser_completion" } },
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const launch = await app.inject({ method: "GET", url: `/v1/oauth/twitch/launch?state=${encodeURIComponent(stateOf(start.json()))}`, headers: { accept: "text/html" } });
|
||||
expect(launch.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it("an answer without the cookie the ask set is refused and spends nothing", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
for (const decision of ["continue", "decline"] as const) {
|
||||
const stranger = await answer(app, state, decision, undefined, "application/json");
|
||||
expect(stranger.statusCode, decision).toBe(400);
|
||||
expect(stranger.json(), decision).toEqual({ error: "state_not_bound" });
|
||||
}
|
||||
expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it("a stranger who only opens the launch URL is not bound to the state and cannot complete it", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const victim = await seeAsk(app, state);
|
||||
await answer(app, state, "continue", victim.cookie);
|
||||
// A fresh browser that holds the URL loads the ask and calls back with its own provider code.
|
||||
const stranger = await seeAsk(app, state);
|
||||
for (const cookie of [stranger.cookie, ""]) {
|
||||
const done = await callback(app, state, cookie, "code=code-attacker");
|
||||
expect(done.statusCode).toBe(400);
|
||||
expect(done.json()).toEqual({ error: "state_not_bound" });
|
||||
}
|
||||
});
|
||||
|
||||
it("only Continue sets the launch binding, and the ask's cookie is no binding: it cannot complete a callback", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const asked = await seeAsk(app, state);
|
||||
const done = await callback(app, state, asked.cookie, "code=code-a");
|
||||
expect(done.json()).toEqual({ error: "state_not_bound" });
|
||||
const go = await answer(app, state, "continue", asked.cookie);
|
||||
expect(bindingCookieOf(go)).toBeDefined();
|
||||
expect(bindingCookieOf(go)!.length).toBeLessThan(3500);
|
||||
});
|
||||
|
||||
it("a binding without the shown wording is refused with consent_text_missing before the state is spent", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const state = await startConnect(app);
|
||||
const { jti, exp } = decodeJwt(state).payload as { jti: string; exp: number };
|
||||
// A binding that names this state but carries no wording: what a launch from before the ask would leave.
|
||||
const bare = signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti, iat: exp - 600, exp }, ctx.keys);
|
||||
const done = await callback(app, state, `__Host-heimdall-oauth-${jti}=${bare}`, "code=code-a");
|
||||
expect(done.statusCode).toBe(400);
|
||||
expect(done.json()).toEqual({ error: "consent_text_missing" });
|
||||
expect(await store.findStoredLinkedIdentity("joystick", "joy-a"), "no credential is kept").toBeNull();
|
||||
// Neither use of the state was spent by the refusal.
|
||||
const usedAt = new Date().toISOString();
|
||||
const expiresAt = new Date(exp * 1000).toISOString();
|
||||
expect(await store.claimOAuthStateUse({ jti, phase: "callback", appSlug: "streampixels", usedAt, expiresAt })).toBe(true);
|
||||
});
|
||||
|
||||
describe("the wording a cookie may carry", () => {
|
||||
const signedAsk = (ctx: ReturnType<typeof getHeimdallRuntimeContext>, jti: string, exp: number, text: Buffer) =>
|
||||
signJwt({ typ: "heimdall_consent_ask", iss: ctx.config.issuer, jti, iat: exp - 600, exp, ct: deflateRawSync(text).toString("base64url") }, ctx.keys);
|
||||
|
||||
it("is read up to 16 KB and no further: one byte over is refused, and a bomb is refused quickly", async () => {
|
||||
const { app } = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const state = await startConnect(app);
|
||||
const { jti, exp } = decodeJwt(state).payload as { jti: string; exp: number };
|
||||
const answerWith = (text: Buffer, decision: "decline" | "continue" = "decline") =>
|
||||
answer(app, state, decision, `__Host-heimdall-ask-${jti}=${signedAsk(ctx, jti, exp, text)}`, "application/json");
|
||||
expect((await answerWith(Buffer.alloc(16 * 1024 + 1, 97))).json()).toEqual({ error: "state_not_bound" });
|
||||
const t0 = Date.now();
|
||||
expect((await answerWith(Buffer.alloc(300 * 1024 * 1024))).json()).toEqual({ error: "state_not_bound" });
|
||||
expect(Date.now() - t0).toBeLessThan(1500);
|
||||
// At the cap it is read.
|
||||
expect((await answerWith(Buffer.alloc(16 * 1024, 97))).json()).toMatchObject({ error: "consent_refused" });
|
||||
});
|
||||
|
||||
it("requested scopes are capped at 32 of 512 characters, at start", async () => {
|
||||
const { app } = await newApp();
|
||||
const startWith = (requestedScopes: string[]) =>
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/joystick/start",
|
||||
headers: secret,
|
||||
payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" }, requestedScopes },
|
||||
});
|
||||
const scopes = (n: number, len: number) => Array.from({ length: n }, (_, i) => `${i}`.padEnd(len, "x"));
|
||||
expect((await startWith(scopes(33, 10))).json()).toMatchObject({ error: "requested_scopes_too_large" });
|
||||
expect((await startWith(scopes(1, 513))).json()).toMatchObject({ error: "requested_scopes_too_large" });
|
||||
expect((await startWith(scopes(32, 512))).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("an ask the cookies could not carry is refused, not shown", async () => {
|
||||
const { app } = await newApp();
|
||||
const noise = Array.from({ length: 32 }, () => randomBytes(256).toString("hex")); // 32 x 512 incompressible
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/joystick/start",
|
||||
headers: secret,
|
||||
payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" }, requestedScopes: noise },
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const page = await app.inject({ method: "GET", url: launchUrl(stateOf(start.json())), headers: { accept: "text/html" } });
|
||||
expect(page.statusCode).toBe(400);
|
||||
expect(page.json()).toEqual({ error: "consent_ask_too_large" });
|
||||
expect(askCookieOf(page)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("the consent route refuses a flow that has no ask", async () => {
|
||||
const { app } = await newApp();
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/twitch/start",
|
||||
headers: secret,
|
||||
payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/", handoff: { kind: "browser_completion" } },
|
||||
});
|
||||
const state = stateOf(start.json());
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/twitch/consent",
|
||||
headers: form,
|
||||
payload: new URLSearchParams({ state, decision: "continue" }).toString(),
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "consent_not_required" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the wording digest", () => {
|
||||
const ask: ConsentAsk = { appSlug: "streampixels", provider: "joystick", scopes: ["identity:read"], returnOrigin: "https://streampixels.gamecult.org" };
|
||||
it("is the sha-256 of the text shown, and moves with the app, provider, scopes and origin", () => {
|
||||
expect(consentDigest(ask)).toBe(createHash("sha256").update(consentText(ask), "utf8").digest("hex"));
|
||||
const variants: ConsentAsk[] = [
|
||||
{ ...ask, appSlug: "bifrost" },
|
||||
{ ...ask, provider: "twitch" },
|
||||
{ ...ask, scopes: ["identity:read", "chat:read"] },
|
||||
{ ...ask, returnOrigin: "https://elsewhere.example" },
|
||||
];
|
||||
for (const variant of variants) expect(consentDigest(variant)).not.toBe(consentDigest(ask));
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusal", () => {
|
||||
it("declining yields consent_refused by the viewer, stores nothing, and spends the state", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
const no = await answer(app, state, "decline", cookie, "application/json");
|
||||
expect(no.statusCode).toBe(400);
|
||||
expect(no.json()).toMatchObject({ error: "consent_refused", by: "viewer", provider: "joystick" });
|
||||
|
||||
// Nothing launches or completes afterwards, and nothing was kept.
|
||||
expect((await answer(app, state, "continue", cookie, "application/json")).json()).toEqual({ error: "state_already_launched" });
|
||||
expect((await callback(app, state, cookie, "code=code-a")).json()).toEqual({ error: "state_not_bound" });
|
||||
expect(await store.findStoredLinkedIdentity("joystick", "joy-a")).toBeNull();
|
||||
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
||||
expect(events.filter((event) => event.eventType === "consent_refused").map((event) => event.eventPayloadJson)).toEqual([
|
||||
{ provider: "joystick", mode: "connect", by: "viewer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("the browser sees a 'Not Connected' page, not an authentication failure", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
const no = await answer(app, state, "decline", cookie);
|
||||
expect(no.statusCode).toBe(200);
|
||||
expect(no.body).toContain("Not Connected");
|
||||
expect(no.body).toContain("consent_refused");
|
||||
expect(no.body).not.toContain("Authentication Failed");
|
||||
});
|
||||
|
||||
it("an access_denied from the provider is consent_refused by the provider, distinct from provider_error", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const denied = await newFlow(app);
|
||||
const response = await callback(app, denied.state, denied.cookie, "error=access_denied");
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({ error: "consent_refused", by: "provider" });
|
||||
|
||||
const faulty = await newFlow(app);
|
||||
expect((await callback(app, faulty.state, faulty.cookie, "error=server_error")).json().error).toBe("provider_error");
|
||||
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string }> }).auditEvents.values()];
|
||||
expect(events.filter((event) => event.eventType === "consent_refused")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a backend_callback app is told, with who refused", async () => {
|
||||
const callbackUrl = "https://streampixels.gamecult.org/api/heimdall/callback";
|
||||
const { app } = await newApp({ callbacks: [callbackUrl] });
|
||||
const delivered: Array<Record<string, unknown>> = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
|
||||
delivered.push(JSON.parse(String(init?.body)));
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
const handoff = { kind: "backend_callback", callbackUrl, attemptId: "attempt-1" };
|
||||
|
||||
const byViewer = await startConnect(app, handoff);
|
||||
const { cookie } = await seeAsk(app, byViewer);
|
||||
await answer(app, byViewer, "decline", cookie);
|
||||
const byProvider = await startConnect(app, handoff);
|
||||
const second = await seeAsk(app, byProvider);
|
||||
const proceeded = await answer(app, byProvider, "continue", second.cookie);
|
||||
await callback(app, byProvider, bindingCookieOf(proceeded)!, "error=access_denied");
|
||||
|
||||
expect(delivered.map((payload) => [payload.status, payload.error, payload.by])).toEqual([
|
||||
["error", "consent_refused", "viewer"],
|
||||
["error", "consent_refused", "provider"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** What an app's landing page can read: the fragment on the page's "Return to the app" link. */
|
||||
function returnFragment(page: { body: string }): URLSearchParams {
|
||||
const href = /id="return-link" href="([^"]+)"/.exec(page.body)?.[1];
|
||||
expect(href, "the page carries a return link").toBeDefined();
|
||||
const url = new URL(href!);
|
||||
expect(url.origin + url.pathname).toBe("https://streampixels.gamecult.org/settings");
|
||||
return new URLSearchParams(url.hash.slice(1));
|
||||
}
|
||||
|
||||
describe("the refusal return contract", () => {
|
||||
const refusedFragment = (by: "viewer" | "provider") => ({
|
||||
heimdall_status: "error",
|
||||
heimdall_provider: "joystick",
|
||||
heimdall_handoff_kind: "browser_completion",
|
||||
heimdall_app_slug: "streampixels",
|
||||
heimdall_mode: "connect",
|
||||
heimdall_error: "consent_refused",
|
||||
heimdall_refused_by: by,
|
||||
});
|
||||
|
||||
it("a decline returns a fragment that says the viewer refused, with no completion code", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
const fragment = returnFragment(await answer(app, state, "decline", cookie));
|
||||
expect(Object.fromEntries(fragment)).toEqual(refusedFragment("viewer"));
|
||||
expect(fragment.has("heimdall_completion_code")).toBe(false);
|
||||
});
|
||||
|
||||
it("a provider access_denied returns a fragment that says the provider refused, with no completion code", async () => {
|
||||
const { app } = await newApp();
|
||||
const flow = await newFlow(app);
|
||||
const fragment = returnFragment(await callback(app, flow.state, flow.cookie, "error=access_denied", "text/html"));
|
||||
expect(Object.fromEntries(fragment)).toEqual(refusedFragment("provider"));
|
||||
expect(fragment.has("heimdall_completion_code")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["viewer", "provider"] as const)("a browser_completion attempt refused by the %s is named in the fragment", async (by) => {
|
||||
const { app, store } = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const attempt = await store.createAuthAttempt({
|
||||
handle: "attempt-refused",
|
||||
appSlug: "streampixels",
|
||||
provider: "joystick",
|
||||
mode: "connect",
|
||||
returnTo: "https://streampixels.gamecult.org/settings",
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
});
|
||||
const caller = resolveAppCaller(ctx.config, "streampixels", secret);
|
||||
const started = await startOAuthFlow(
|
||||
ctx,
|
||||
"joystick",
|
||||
caller,
|
||||
{ appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "creator-1" }, returnTo: "https://streampixels.gamecult.org/settings", handoff: { kind: "browser_completion" } },
|
||||
{ trustedBrowserAttemptId: attempt.handle }
|
||||
);
|
||||
expect(started.statusCode).toBe(201);
|
||||
const state = stateOf(started.body as Parameters<typeof stateOf>[0]);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
let page;
|
||||
if (by === "viewer") {
|
||||
page = await answer(app, state, "decline", cookie);
|
||||
} else {
|
||||
const go = await answer(app, state, "continue", cookie);
|
||||
page = await callback(app, state, bindingCookieOf(go)!, "error=access_denied", "text/html");
|
||||
}
|
||||
expect(Object.fromEntries(returnFragment(page))).toEqual({ ...refusedFragment(by), heimdall_attempt_id: "attempt-refused" });
|
||||
});
|
||||
});
|
||||
|
||||
async function newFlow(app: FastifyInstance) {
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
const go = await answer(app, state, "continue", cookie);
|
||||
expect(go.statusCode).toBe(302);
|
||||
return { state, cookie: bindingCookieOf(go)! };
|
||||
}
|
||||
|
||||
describe("the receipt", () => {
|
||||
it("a granted completion writes one receipt with asked and granted scopes and the digest of the wording shown", async () => {
|
||||
const { app, store } = await newApp({ granted: ["identity:read", "stream:read"] });
|
||||
const done = await grantAs(app, "a");
|
||||
const receipts = await store.listConsentReceipts(done.account.id);
|
||||
expect(receipts).toHaveLength(1);
|
||||
const ask: ConsentAsk = { appSlug: "streampixels", provider: "joystick", scopes: defaultScopes, returnOrigin: "https://streampixels.gamecult.org" };
|
||||
expect(receipts[0]).toMatchObject({
|
||||
id: done.consentReceiptId,
|
||||
kind: "grant",
|
||||
accountId: done.account.id,
|
||||
appSlug: "streampixels",
|
||||
provider: "joystick",
|
||||
scopesAsked: defaultScopes,
|
||||
scopesGranted: ["identity:read", "stream:read"],
|
||||
consentPageDigest: consentDigest(ask),
|
||||
});
|
||||
});
|
||||
|
||||
it("the receipt holds the wording the person was shown, and its digest is that wording's hash", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const asked = await seeAsk(app, state);
|
||||
const go = await answer(app, state, "continue", asked.cookie);
|
||||
const done = await callback(app, state, bindingCookieOf(go)!, "code=code-a");
|
||||
const [receipt] = await store.listConsentReceipts(done.json().account.id);
|
||||
if (receipt?.kind !== "grant") throw new Error("no grant");
|
||||
expect(receipt.consentPageDigest).toBe(digestOfText(receipt.consentText));
|
||||
const shown = asked.page.body.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
||||
for (const line of receipt.consentText.split("\n")) expect(shown, line).toContain(line);
|
||||
expect(receipt.consentText).toContain("Continue to Joystick");
|
||||
expect(receipt.consentText).toContain("No, return to the app");
|
||||
});
|
||||
|
||||
it("a refusal leaves no receipt", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
await answer(app, state, "decline", cookie);
|
||||
expect(await store.listConsentReceipts("anyone")).toEqual([]);
|
||||
expect((await store.findStoredLinkedIdentity("joystick", "joy-a"))).toBeNull();
|
||||
});
|
||||
|
||||
it("the success page names the receipt", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
const go = await answer(app, state, "continue", cookie);
|
||||
const page = await callback(app, state, bindingCookieOf(go)!, "code=code-a", "text/html");
|
||||
expect(page.statusCode).toBe(200);
|
||||
expect(page.body).toMatch(/receipt [0-9a-f-]{36}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("my grants", () => {
|
||||
it("is closed to anyone without a live Heimdall access token", async () => {
|
||||
const { app } = await newApp();
|
||||
for (const headers of [{}, bearer("not-a-token")]) {
|
||||
const response = await app.inject({ method: "GET", url: "/v1/me/grants", headers });
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().error).toBe("sign_in_required");
|
||||
}
|
||||
const link = await app.inject({ method: "POST", url: "/v1/me/grants/link" });
|
||||
expect(link.statusCode).toBe(401);
|
||||
const revoke = await app.inject({ method: "POST", url: "/v1/me/grants/x/revoke" });
|
||||
expect(revoke.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("lists the signed-in account's grants and receipts, and no one else's", async () => {
|
||||
const { app } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const b = await grantAs(app, "b");
|
||||
const mine = await grantsOf(app, a.accessToken);
|
||||
expect(mine.grants).toHaveLength(1);
|
||||
expect(mine.grants[0]).toMatchObject({ id: a.consentReceiptId, status: "active", provider: "joystick", appSlug: "streampixels", scopesGranted: ["identity:read", "stream:read"] });
|
||||
expect(JSON.stringify(mine)).not.toContain(b.consentReceiptId);
|
||||
expect(mine.receipts.map((receipt) => receipt.accountId)).toEqual([a.account.id]);
|
||||
});
|
||||
|
||||
it("revoke takes effect on the next token use, edits no row, and receipts the revocation", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200);
|
||||
const before = await store.listConsentReceipts(a.account.id);
|
||||
|
||||
const revoke = await app.inject({ method: "POST", url: `/v1/me/grants/${a.consentReceiptId}/revoke`, headers: bearer(a.accessToken) });
|
||||
expect(revoke.statusCode).toBe(200);
|
||||
expect(revoke.json().revoked).toEqual([a.consentReceiptId]);
|
||||
|
||||
const next = await resolveCredential(app, a.account.id);
|
||||
expect(next.statusCode).toBe(410);
|
||||
expect(next.json().error).toBe("managed_credential_revoked");
|
||||
|
||||
const after = await store.listConsentReceipts(a.account.id);
|
||||
expect(after).toHaveLength(2);
|
||||
expect(after[0]).toEqual(before[0]);
|
||||
expect(after[1]).toMatchObject({ kind: "revocation", revokesReceiptId: a.consentReceiptId, accountId: a.account.id });
|
||||
const view = (await grantsOf(app, a.accessToken)).grants[0]!;
|
||||
expect(view).toMatchObject({ status: "revoked", revocationReceiptId: after[1]!.id });
|
||||
expect(view.revokedAt).toBe(after[1]!.createdAt);
|
||||
});
|
||||
|
||||
it("revoke is the owner's alone: another account's grant id is not found and its credential is untouched", async () => {
|
||||
const { app } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const b = await grantAs(app, "b");
|
||||
const attempt = await app.inject({ method: "POST", url: `/v1/me/grants/${a.consentReceiptId}/revoke`, headers: bearer(b.accessToken) });
|
||||
expect(attempt.statusCode).toBe(404);
|
||||
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200);
|
||||
expect((await resolveCredential(app, b.account.id)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("revoking twice changes nothing the second time", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const url = `/v1/me/grants/${a.consentReceiptId}/revoke`;
|
||||
await app.inject({ method: "POST", url, headers: bearer(a.accessToken) });
|
||||
const second = await app.inject({ method: "POST", url, headers: bearer(a.accessToken) });
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.json().revoked).toEqual([]);
|
||||
expect(await store.listConsentReceipts(a.account.id)).toHaveLength(2);
|
||||
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string }> }).auditEvents.values()];
|
||||
expect(events.filter((event) => event.eventType === "consent_revoked")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a fresh connection after a revoke is a new grant, live again, and the old one stays revoked", async () => {
|
||||
const { app } = await newApp();
|
||||
const first = await grantAs(app, "a");
|
||||
await app.inject({ method: "POST", url: `/v1/me/grants/${first.consentReceiptId}/revoke`, headers: bearer(first.accessToken) });
|
||||
const second = await grantAs(app, "a");
|
||||
expect(second.consentReceiptId).not.toBe(first.consentReceiptId);
|
||||
expect((await resolveCredential(app, second.account.id)).statusCode).toBe(200);
|
||||
const grants = (await grantsOf(app, second.accessToken)).grants;
|
||||
expect(grants.map((grant) => [grant.id, grant.status])).toEqual([
|
||||
[first.consentReceiptId, "revoked"],
|
||||
[second.consentReceiptId, "active"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("a browser with no session opens the page from a minted link and revokes with one form post", async () => {
|
||||
const { app } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const link = await app.inject({ method: "POST", url: "/v1/me/grants/link", headers: bearer(a.accessToken) });
|
||||
expect(link.statusCode).toBe(201);
|
||||
const url = new URL(link.json().url as string);
|
||||
const page = await app.inject({ method: "GET", url: `${url.pathname}${url.search}`, headers: { accept: "text/html" } });
|
||||
expect(page.statusCode).toBe(200);
|
||||
expect(page.body).toContain(a.consentReceiptId);
|
||||
expect(page.body).toContain("Take this back");
|
||||
expect(page.body).not.toContain("<script");
|
||||
|
||||
const token = url.searchParams.get("t")!;
|
||||
const post = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/me/grants/${a.consentReceiptId}/revoke`,
|
||||
headers: { ...form, accept: "text/html" },
|
||||
payload: new URLSearchParams({ t: token }).toString(),
|
||||
});
|
||||
expect(post.statusCode).toBe(200);
|
||||
expect(post.body).toContain("Taken back.");
|
||||
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(410);
|
||||
});
|
||||
|
||||
it("a page link is never renewed by being used: the forms on its page expire when the link does", async () => {
|
||||
const { app } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const link = await app.inject({ method: "POST", url: "/v1/me/grants/link", headers: bearer(a.accessToken) });
|
||||
const url = new URL(link.json().url as string);
|
||||
const linkToken = url.searchParams.get("t")!;
|
||||
// Two minutes on, so a form token minted afresh would differ from the link's.
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 120_000 });
|
||||
const page = await app.inject({ method: "GET", url: `${url.pathname}${url.search}`, headers: { accept: "text/html" } });
|
||||
const formToken = /name="t" value="([^"]+)"/.exec(page.body)![1]!;
|
||||
expect(decodeJwt(formToken).payload.exp).toBe(decodeJwt(linkToken).payload.exp);
|
||||
// The same holds one hop on: the page a form post answers with.
|
||||
const post = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/me/grants/nope/revoke",
|
||||
headers: { ...form, accept: "text/html" },
|
||||
payload: new URLSearchParams({ t: formToken }).toString(),
|
||||
});
|
||||
expect(post.statusCode).toBe(404);
|
||||
// A bearer, by contrast, opens a fresh window.
|
||||
const fresh = await app.inject({ method: "GET", url: "/v1/me/grants", headers: { ...bearer(a.accessToken), accept: "text/html" } });
|
||||
const freshToken = /name="t" value="([^"]+)"/.exec(fresh.body)![1]!;
|
||||
expect(decodeJwt(freshToken).payload.exp).toBeGreaterThanOrEqual(decodeJwt(linkToken).payload.exp as number);
|
||||
});
|
||||
|
||||
it("two revokes at once take the grant back once: one revocation row, one audit event, one reporter", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const url = `/v1/me/grants/${a.consentReceiptId}/revoke`;
|
||||
const [one, two] = await Promise.all([
|
||||
app.inject({ method: "POST", url, headers: bearer(a.accessToken) }),
|
||||
app.inject({ method: "POST", url, headers: bearer(a.accessToken) }),
|
||||
]);
|
||||
expect([one.json().revoked.length, two.json().revoked.length].sort()).toEqual([0, 1]);
|
||||
expect((await store.listConsentReceipts(a.account.id)).filter((r) => r.kind === "revocation")).toHaveLength(1);
|
||||
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string }> }).auditEvents.values()];
|
||||
expect(events.filter((event) => event.eventType === "consent_revoked")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a page link proves only what it was minted for: an access token, a foreign token or none opens nothing", async () => {
|
||||
const { app } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
for (const t of [a.accessToken, "garbage", ""]) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/me/grants?t=${encodeURIComponent(t)}` });
|
||||
expect(response.statusCode, t).toBe(401);
|
||||
const revoke = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/me/grants/${a.consentReceiptId}/revoke`,
|
||||
headers: form,
|
||||
payload: new URLSearchParams({ t }).toString(),
|
||||
});
|
||||
expect(revoke.statusCode, t).toBe(401);
|
||||
}
|
||||
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import { type HeimdallConfig } from "../src/config.js";
|
|||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { launchFlow } from "./oauth-flow.js";
|
||||
|
||||
function createTestConfig(): HeimdallConfig {
|
||||
return {
|
||||
|
|
@ -49,6 +50,7 @@ function createTestConfig(): HeimdallConfig {
|
|||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
||||
google: { clientId: "google-client", clientSecret: "google-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -97,8 +99,15 @@ function start(r: Rig, payload: Record<string, unknown>, headers: Record<string,
|
|||
return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } });
|
||||
}
|
||||
|
||||
function callback(r: Rig, stateToken: string) {
|
||||
return r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(stateToken)}` });
|
||||
/** The browser follows authorizationUrl through the launch hop, then returns with the state, or with `forged` in its place, and the cookie it was handed. */
|
||||
async function callback(r: Rig, started: { authorizationUrl: string }, forged?: (state: string) => string) {
|
||||
const flow = await launchFlow(r.app, started);
|
||||
const state = forged ? forged(flow.state) : flow.state;
|
||||
return r.app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
}
|
||||
|
||||
function redeem(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
|
||||
|
|
@ -137,7 +146,7 @@ describe("link callback holds the provider identity to the initiator", () => {
|
|||
const r = await rig("d-new");
|
||||
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
|
||||
expect(started.statusCode).toBe(201);
|
||||
const done = await callback(r, started.json().stateToken);
|
||||
const done = await callback(r, started.json());
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().account.id).toBe(r.initiator.id);
|
||||
expect((await r.store.findAccountByLinkedIdentity("discord", "d-new"))?.id).toBe(r.initiator.id);
|
||||
|
|
@ -149,7 +158,7 @@ describe("link callback holds the provider identity to the initiator", () => {
|
|||
const other = await r.store.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
await r.store.upsertLinkedIdentity({ accountId: other.id, provider: "discord", providerUserId: "d-held", scopes: [], profileJson: {}, createdAt: now, updatedAt: now });
|
||||
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
|
||||
const done = await callback(r, started.json().stateToken);
|
||||
const done = await callback(r, started.json());
|
||||
expect(done.statusCode).toBe(403);
|
||||
expect(done.json().error).toBe("link_initiator_mismatch");
|
||||
expect(done.json().completion).toBeUndefined();
|
||||
|
|
@ -164,10 +173,11 @@ describe("link callback holds the provider identity to the initiator", () => {
|
|||
const r = await rig("d-forged", config);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
|
||||
const verified = verifyJwt(started.json().stateToken, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
|
||||
expect(verified.valid).toBe(true);
|
||||
const stripped = signJwt({ ...verified.payload, initiator_account_id: null }, keys);
|
||||
const done = await callback(r, stripped);
|
||||
const done = await callback(r, started.json(), (state) => {
|
||||
const verified = verifyJwt(state, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
|
||||
expect(verified.valid).toBe(true);
|
||||
return signJwt({ ...verified.payload, initiator_account_id: null }, keys);
|
||||
});
|
||||
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]);
|
||||
expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull();
|
||||
} finally {
|
||||
|
|
@ -183,7 +193,7 @@ describe("redeem binds caller, target and replay", () => {
|
|||
initiatingAccountId: r.initiator.id,
|
||||
...(targetId ? { connection: { kind: "account", targetId } } : {}),
|
||||
});
|
||||
const done = await callback(r, started.json().stateToken);
|
||||
const done = await callback(r, started.json());
|
||||
expect(done.statusCode).toBe(201);
|
||||
return done.json().completion.code as string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { createOAuthRuntimeRegistry } from "../src/oauth.js";
|
|||
import { providerCatalog } from "../src/providers.js";
|
||||
import { InMemoryStore, PostgresStore, type HeimdallStore } from "../src/store/index.js";
|
||||
import { type StoredLinkedIdentity } from "../src/store/types.js";
|
||||
import { launchFlow } from "./oauth-flow.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
|
@ -579,11 +580,13 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
|
||||
async function flow(rig: Rig, provider: "joystick" | "twitch" | "youtube", mode: "connect" | "sign_in") {
|
||||
const app = rig.apps[0]!;
|
||||
const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } });
|
||||
const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, headers: mode === "connect" ? { "x-heimdall-app-secret": "streampixels-secret" } : {}, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } });
|
||||
expect(start.statusCode).toBe(201);
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBe(201);
|
||||
const redeem = await app.inject({
|
||||
|
|
@ -630,12 +633,15 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
|
|||
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, connection: { kind: "creator", targetId: "creator-1" } },
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
});
|
||||
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(callback.body).not.toContain("tok-access");
|
||||
|
|
|
|||
158
tests/oauth-flow.ts
Normal file
158
tests/oauth-flow.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { expect } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { entitlementFacts } from "../src/facts.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { decodeJwt } from "../src/signing.js";
|
||||
|
||||
export 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" },
|
||||
google: { clientId: "google-client", clientSecret: "google-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export 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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a start returns that the browser needs: the launch URL, whose query carries the signed state. */
|
||||
export interface StartBody {
|
||||
authorizationUrl: string;
|
||||
}
|
||||
|
||||
export function stateOf(start: StartBody): string {
|
||||
return new URL(start.authorizationUrl).searchParams.get("state") ?? "";
|
||||
}
|
||||
|
||||
export interface LaunchedFlow {
|
||||
state: string;
|
||||
/** The `name=value` pair a browser sends back to the callback. */
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
export function setCookieOf(response: { headers: Record<string, unknown> }): string[] {
|
||||
return [response.headers["set-cookie"]].flat().filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
|
||||
async function followLaunch(app: FastifyInstance, state: string): Promise<LaunchedFlow & { providerUrl: URL }> {
|
||||
const provider = decodeJwt(state).payload.provider as string;
|
||||
let response = await app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` });
|
||||
if (response.statusCode === 200) {
|
||||
// Heimdall's ask (managed custody): the person says Continue, with the cookie the ask set.
|
||||
const asked = setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-ask-"));
|
||||
expect(asked, "the ask sets its own cookie").toBeDefined();
|
||||
response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/oauth/${provider}/consent`,
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: (asked as string).split(";")[0] as string },
|
||||
payload: new URLSearchParams({ state, decision: "continue" }).toString(),
|
||||
});
|
||||
}
|
||||
expect(response.statusCode, "launch").toBe(302);
|
||||
const set = setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-oauth-"));
|
||||
expect(set, "launch sets the binding cookie").toBeDefined();
|
||||
return { state, cookie: (set as string).split(";")[0] as string, providerUrl: new URL(response.headers.location as string) };
|
||||
}
|
||||
|
||||
/** What a browser does with a state it was handed: open the launch hop for the state's provider and keep the cookie it sets. */
|
||||
export async function launchState(app: FastifyInstance, state: string): Promise<LaunchedFlow> {
|
||||
const { providerUrl: _providerUrl, ...flow } = await followLaunch(app, state);
|
||||
return flow;
|
||||
}
|
||||
|
||||
/** Drives what a user's browser does after a start: follow authorizationUrl to the launch hop and keep its cookie. */
|
||||
export function launchFlow(app: FastifyInstance, start: StartBody): Promise<LaunchedFlow> {
|
||||
return launchState(app, stateOf(start));
|
||||
}
|
||||
|
||||
/** The launch hop's answer in full: the cookie the browser keeps and the provider URL it is redirected to. */
|
||||
export async function launchWithRedirect(app: FastifyInstance, start: StartBody): Promise<LaunchedFlow & { url: URL }> {
|
||||
const { providerUrl, ...flow } = await followLaunch(app, stateOf(start));
|
||||
return { ...flow, url: providerUrl };
|
||||
}
|
||||
|
||||
/** The provider URL a browser is redirected to once it has followed authorizationUrl through the launch hop. */
|
||||
export async function providerRedirect(app: FastifyInstance, start: StartBody): Promise<URL> {
|
||||
return (await followLaunch(app, stateOf(start))).providerUrl;
|
||||
}
|
||||
549
tests/oauth-state-binding.test.ts
Normal file
549
tests/oauth-state-binding.test.ts
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { buildApp, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
|
||||
import { resolveAppCaller } from "../src/app-caller.js";
|
||||
import { decodeJwt, signJwt } from "../src/signing.js";
|
||||
import { createMockDiscordRuntime, createTestConfig, launchFlow, setCookieOf, stateOf } from "./oauth-flow.js";
|
||||
|
||||
// Heimdall's OAuth states complete once, only in the browser that launched
|
||||
// them (finding state-replay). Soul's attack: a Bifrost state is readable in
|
||||
// the URL the victim's browser is sent to, so an attacker who sees it opens it
|
||||
// in their own browser, finishes the provider login as themselves, and hands
|
||||
// the victim the resulting completion code.
|
||||
|
||||
const apps: FastifyInstance[] = [];
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
while (apps.length) {
|
||||
await apps.pop()?.close();
|
||||
}
|
||||
});
|
||||
|
||||
const victimAttempt = "VICTIM-ATTEMPT-NONCE";
|
||||
const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`;
|
||||
|
||||
async function newApp() {
|
||||
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: createMockDiscordRuntime() } });
|
||||
apps.push(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function bifrostStart(app: FastifyInstance, extra: Record<string, unknown> = {}) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
||||
payload: { appSlug: "bifrost", mode: "sign_in", returnTo: bifrostReturn, handoff: { kind: "browser_completion" }, ...extra },
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
return response.json() as { authorizationUrl: string };
|
||||
}
|
||||
|
||||
function launchUrl(start: { authorizationUrl: string }): string {
|
||||
const url = new URL(start.authorizationUrl);
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
function callback(app: FastifyInstance, state: string, cookie?: string, query = "code=attacker-code") {
|
||||
return app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?${query}&state=${encodeURIComponent(state)}`,
|
||||
...(cookie ? { headers: { cookie } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("OAuth state binding", () => {
|
||||
it("Soul's replay fails: one state, at most one completion code, and only for the browser that launched it", async () => {
|
||||
const app = await newApp();
|
||||
const start = await bifrostStart(app);
|
||||
const state = stateOf(start);
|
||||
|
||||
const victim = await launchFlow(app, start);
|
||||
|
||||
// The attacker holds the same state. A second launch yields no cookie and no redirect.
|
||||
const attackerLaunch = await app.inject({ method: "GET", url: launchUrl(start) });
|
||||
expect(attackerLaunch.statusCode).toBe(400);
|
||||
expect(attackerLaunch.json()).toEqual({ error: "state_already_launched" });
|
||||
expect(setCookieOf(attackerLaunch)).toEqual([]);
|
||||
expect(attackerLaunch.headers.location).toBeUndefined();
|
||||
|
||||
// Without the cookie the callback refuses and mints nothing.
|
||||
const attackerCallback = await callback(app, state);
|
||||
expect(attackerCallback.statusCode).toBe(400);
|
||||
expect(attackerCallback.json()).toEqual({ error: "state_not_bound" });
|
||||
expect(attackerCallback.body).not.toContain("completion");
|
||||
|
||||
// The victim's callback still completes, once.
|
||||
const victimCallback = await callback(app, state, victim.cookie, "code=victim-code");
|
||||
expect(victimCallback.statusCode).toBe(201);
|
||||
const completionCode = victimCallback.json().completion.code as string;
|
||||
const redeem = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/bifrost/auth-completions/redeem",
|
||||
payload: { completionCode },
|
||||
});
|
||||
expect(redeem.statusCode).toBe(201);
|
||||
expect(redeem.json()).toEqual(expect.objectContaining({ status: "success", appSlug: "bifrost" }));
|
||||
|
||||
const second = await callback(app, state, victim.cookie, "code=victim-code");
|
||||
expect(second.statusCode).toBe(400);
|
||||
expect(second.json()).toEqual({ error: "state_already_used" });
|
||||
expect(second.body).not.toContain("completion");
|
||||
});
|
||||
|
||||
it("a stranger's callback needs no launch to be refused: no cookie, no completion code", async () => {
|
||||
const app = await newApp();
|
||||
const start = await bifrostStart(app);
|
||||
|
||||
const response = await callback(app, stateOf(start));
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "state_not_bound" });
|
||||
expect(response.body).not.toContain("completion");
|
||||
});
|
||||
|
||||
it("a cookie for another flow, another key, another type, or the wrong lifetime does not bind", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const own = await bifrostStart(app);
|
||||
const attackerFlow = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
|
||||
});
|
||||
const attackerStart = attackerFlow.json() as { authorizationUrl: string };
|
||||
const attackerCookie = (await launchFlow(app, attackerStart)).cookie;
|
||||
const ownState = stateOf(own);
|
||||
const ownJti = decodeJwt(ownState).payload.jti as string;
|
||||
const name = `__Host-heimdall-oauth-${ownJti}`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const binding = (overrides: Record<string, unknown>, keys = ctx.keys) =>
|
||||
signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti: ownJti, iat: now, exp: now + 300, ...overrides }, keys);
|
||||
const foreignKeys = (() => {
|
||||
const pair = generateKeyPairSync("ed25519");
|
||||
return { ...ctx.keys, privateKey: pair.privateKey, publicKey: pair.publicKey };
|
||||
})();
|
||||
|
||||
const forged: Record<string, string> = {
|
||||
"another flow's binding under this flow's cookie name": `${name}=${attackerCookie.split("=")[1]}`,
|
||||
"a binding signed with a different key": `${name}=${binding({}, foreignKeys)}`,
|
||||
"a binding for a different jti": `${name}=${binding({ jti: "someone-else" })}`,
|
||||
"a binding of another type": `${name}=${binding({ typ: "heimdall_oauth_state" })}`,
|
||||
"a binding from another issuer": `${name}=${binding({ iss: "https://elsewhere.example" })}`,
|
||||
"an expired binding": `${name}=${binding({ exp: now - 5 })}`,
|
||||
"the state token used as the cookie": `${name}=${ownState}`,
|
||||
"a binding whose exp is a string": `${name}=${binding({ exp: String(now + 300) })}`,
|
||||
"an empty cookie": `${name}=`,
|
||||
};
|
||||
for (const [label, cookie] of Object.entries(forged)) {
|
||||
const response = await callback(app, ownState, cookie);
|
||||
expect(response.statusCode, label).toBe(400);
|
||||
expect(response.json(), label).toEqual({ error: "state_not_bound" });
|
||||
}
|
||||
|
||||
// A correctly signed binding for this jti is the one thing that binds.
|
||||
const good = await callback(app, ownState, `${name}=${binding({})}`);
|
||||
expect(good.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("the cookie is checked before the state is consumed: a stranger cannot burn or deny the victim's flow", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const attempt = await ctx.store.createAuthAttempt({
|
||||
handle: "attempt-1",
|
||||
appSlug: "repixelizer",
|
||||
provider: "discord",
|
||||
mode: "sign_in",
|
||||
returnTo: "https://repixelizer.gamecult.org/app/",
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
});
|
||||
const caller = resolveAppCaller(ctx.config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
|
||||
const started = await startOAuthFlow(
|
||||
ctx,
|
||||
"discord",
|
||||
caller,
|
||||
{ appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/", handoff: { kind: "browser_completion" } },
|
||||
{ trustedBrowserAttemptId: attempt.handle }
|
||||
);
|
||||
expect(started.statusCode).toBe(201);
|
||||
const start = started.body as unknown as { authorizationUrl: string };
|
||||
const victim = await launchFlow(app, start);
|
||||
|
||||
// A cookieless provider error, which would deny the attempt, and a cookieless code: both refused.
|
||||
for (const query of ["error=access_denied", "code=attacker-code", ""]) {
|
||||
const stranger = await callback(app, victim.state, undefined, query);
|
||||
expect(stranger.statusCode, query).toBe(400);
|
||||
expect(stranger.json(), query).toEqual({ error: "state_not_bound" });
|
||||
}
|
||||
|
||||
// The state is still unclaimed, so the launcher's cookie still completes it...
|
||||
const response = await callback(app, victim.state, victim.cookie, "code=victim-code");
|
||||
expect(response.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("a callback with no binding denies the attempt its state names, once, and never a completed one", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const make = async (handle: string) => {
|
||||
await ctx.store.createAuthAttempt({
|
||||
handle,
|
||||
appSlug: "repixelizer",
|
||||
provider: "discord",
|
||||
mode: "sign_in",
|
||||
returnTo: "https://repixelizer.gamecult.org/app/",
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
});
|
||||
const caller = resolveAppCaller(ctx.config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
|
||||
const started = await startOAuthFlow(
|
||||
ctx,
|
||||
"discord",
|
||||
caller,
|
||||
{ appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/", handoff: { kind: "browser_completion" } },
|
||||
{ trustedBrowserAttemptId: handle }
|
||||
);
|
||||
return started.body as unknown as { authorizationUrl: string };
|
||||
};
|
||||
|
||||
// Lapsed cookie: the launcher launched, then its cookie is gone at the callback.
|
||||
const lapsed = await make("lapsed");
|
||||
await launchFlow(app, lapsed);
|
||||
const refused = await callback(app, stateOf(lapsed), undefined, "code=c");
|
||||
expect(refused.json()).toEqual({ error: "state_not_bound" });
|
||||
const denied = await ctx.store.findAuthAttempt("repixelizer", "lapsed");
|
||||
expect(denied?.status).toBe("denied");
|
||||
expect(denied?.denialCode).toBe("state_not_bound");
|
||||
|
||||
// A completed attempt stays completed under a later cookieless callback.
|
||||
const done = await make("done");
|
||||
const launched = await launchFlow(app, done);
|
||||
expect((await callback(app, launched.state, launched.cookie, "code=c")).statusCode).toBe(201);
|
||||
await callback(app, launched.state, undefined, "code=c");
|
||||
expect(["completed", "consumed"]).toContain((await ctx.store.findAuthAttempt("repixelizer", "done"))?.status);
|
||||
});
|
||||
|
||||
it("a state with no jti is refused as invalid and names no attempt to deny", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const original = decodeJwt(stateOf(await bifrostStart(app))).payload;
|
||||
const { jti: _jti, ...rest } = original;
|
||||
const token = signJwt(rest, ctx.keys);
|
||||
const response = await callback(app, token, undefined, "code=c");
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "invalid_state" });
|
||||
});
|
||||
|
||||
describe("a browser refused for its binding gets a page back to the app, not bare JSON", () => {
|
||||
const html = { accept: "text/html" };
|
||||
const get = (app: FastifyInstance, url: string, cookie?: string) =>
|
||||
app.inject({ method: "GET", url, headers: { ...html, ...(cookie ? { cookie } : {}) } });
|
||||
|
||||
it("on a cookieless callback, a lapsed cookie and a reloaded launch", async () => {
|
||||
const app = await newApp();
|
||||
const start = await bifrostStart(app);
|
||||
const state = stateOf(start);
|
||||
|
||||
const reloadBeforeLaunch = await get(app, `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(state)}`);
|
||||
expect(reloadBeforeLaunch.statusCode).toBe(400);
|
||||
expect(reloadBeforeLaunch.headers["content-type"]).toContain("text/html");
|
||||
expect(reloadBeforeLaunch.body).toContain("state_not_bound");
|
||||
expect(reloadBeforeLaunch.body).toContain("https://bifrost.gamecult.org/auth/heimdall/complete");
|
||||
|
||||
const victim = await launchFlow(app, start);
|
||||
const reloaded = await get(app, launchUrl(start));
|
||||
expect(reloaded.statusCode).toBe(400);
|
||||
expect(reloaded.headers["content-type"]).toContain("text/html");
|
||||
expect(reloaded.body).toContain("state_already_launched");
|
||||
expect(setCookieOf(reloaded)).toEqual([]);
|
||||
|
||||
// Handoff reload: the page after a completed callback, loaded again.
|
||||
expect((await get(app, `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(victim.state)}`, victim.cookie)).statusCode).toBeLessThan(300);
|
||||
const reload = await get(app, `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(victim.state)}`, victim.cookie);
|
||||
expect(reload.statusCode).toBe(400);
|
||||
expect(reload.headers["content-type"]).toContain("text/html");
|
||||
expect(reload.body).toContain("state_already_used");
|
||||
});
|
||||
|
||||
it("never links to an origin the app does not own, and JSON stays JSON without Accept", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const original = decodeJwt(stateOf(await bifrostStart(app))).payload;
|
||||
const token = signJwt({ ...original, return_to: "https://evil.example/steal" }, ctx.keys);
|
||||
const evil = await get(app, `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(token)}`);
|
||||
expect(evil.statusCode).toBe(400);
|
||||
expect(evil.json()).toEqual({ error: "state_not_bound" });
|
||||
expect(evil.body).not.toContain("evil.example");
|
||||
|
||||
const plain = await callback(app, stateOf(await bifrostStart(app)));
|
||||
expect(plain.json()).toEqual({ error: "state_not_bound" });
|
||||
});
|
||||
});
|
||||
|
||||
it("the memory store keeps a use record until the state's exp + 30 s skew", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const claim = (jti: string, usedAt: number) =>
|
||||
ctx.store.claimOAuthStateUse({
|
||||
jti,
|
||||
phase: "launch",
|
||||
appSlug: "bifrost",
|
||||
usedAt: new Date(usedAt).toISOString(),
|
||||
expiresAt: new Date(1_000_000).toISOString(),
|
||||
});
|
||||
expect(await claim("a", 900_000)).toBe(true);
|
||||
// Past exp but inside the skew a state still verifies, so its record must still refuse a replay.
|
||||
expect(await claim("b", 1_000_000 + 29_999)).toBe(true);
|
||||
expect(await claim("a", 1_000_000 + 29_999)).toBe(false);
|
||||
expect(await claim("c", 1_000_000 + 30_000)).toBe(true);
|
||||
expect(await claim("a", 1_000_000 + 30_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("a provider refusal from the launching browser still denies the attempt, once", async () => {
|
||||
const app = await newApp();
|
||||
const start = await bifrostStart(app);
|
||||
const victim = await launchFlow(app, start);
|
||||
|
||||
const denied = await callback(app, victim.state, victim.cookie, "error=access_denied");
|
||||
expect(denied.statusCode).toBe(400);
|
||||
expect(denied.json().error).toBe("consent_refused");
|
||||
expect(denied.json().by).toBe("provider");
|
||||
const replay = await callback(app, victim.state, victim.cookie, "error=access_denied");
|
||||
expect(replay.json()).toEqual({ error: "state_already_used" });
|
||||
});
|
||||
|
||||
// The private command plane's completeAuth answers a denied attempt with
|
||||
// attempt.denialCode as its error (private-command-plane.ts, completeAuth), so
|
||||
// these two codes are what Bifrost, Repixelizer and Ghostlight poll for.
|
||||
it.each([
|
||||
["bifrost", "https://bifrost.gamecult.org/auth/heimdall/complete?attempt=a1", "access_denied", "consent_refused"],
|
||||
["repixelizer", "https://repixelizer.gamecult.org/app/", "access_denied", "consent_refused"],
|
||||
["repixelizer", "https://repixelizer.gamecult.org/app/", "server_error", "provider_error"],
|
||||
] as const)("a %s attempt whose provider answers %s is denied with %s", async (appSlug, returnTo, providerError, denialCode) => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const attempt = await ctx.store.createAuthAttempt({
|
||||
handle: `attempt-${providerError}`,
|
||||
appSlug,
|
||||
provider: "discord",
|
||||
mode: "sign_in",
|
||||
returnTo,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
});
|
||||
const caller = resolveAppCaller(ctx.config, appSlug, { "x-heimdall-app-secret": `${appSlug}-secret` });
|
||||
const started = await startOAuthFlow(
|
||||
ctx,
|
||||
"discord",
|
||||
caller,
|
||||
{ appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } },
|
||||
{ trustedBrowserAttemptId: attempt.handle }
|
||||
);
|
||||
const flow = await launchFlow(app, started.body as unknown as { authorizationUrl: string });
|
||||
await callback(app, flow.state, flow.cookie, `error=${providerError}`);
|
||||
expect(await ctx.store.findAuthAttempt(appSlug, attempt.handle)).toMatchObject({ status: "denied", denialCode });
|
||||
});
|
||||
|
||||
describe("a state that is expired, foreign or tampered is refused at launch and at callback", () => {
|
||||
it.each([
|
||||
["expired beyond the skew", { exp: Math.floor(Date.now() / 1000) - 100 }, "state_expired"],
|
||||
["not yet valid", { nbf: Math.floor(Date.now() / 1000) + 100 }, "state_expired"],
|
||||
["another issuer", { iss: "https://elsewhere.example" }, "invalid_state"],
|
||||
["another provider in aud", { aud: "patreon" }, "state_provider_mismatch"],
|
||||
["another provider in provider", { provider: "patreon", aud: "patreon" }, "state_provider_mismatch"],
|
||||
["a wrong type", { typ: "heimdall_refresh" }, "invalid_state"],
|
||||
])("%s", async (_label, change, error) => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const start = await bifrostStart(app);
|
||||
const original = decodeJwt(stateOf(start)).payload;
|
||||
const token = signJwt({ ...original, ...change }, ctx.keys);
|
||||
const launch = await app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
|
||||
expect(launch.statusCode).toBe(400);
|
||||
expect(launch.json()).toEqual({ error });
|
||||
expect(setCookieOf(launch)).toEqual([]);
|
||||
|
||||
const cookie = `__Host-heimdall-oauth-${original.jti as string}=${signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti: original.jti, iat: 0, exp: 4_000_000_000 }, ctx.keys)}`;
|
||||
const response = await callback(app, token, cookie);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error });
|
||||
expect(response.body).not.toContain(token.slice(20, 60));
|
||||
});
|
||||
|
||||
it("a state inside the 30 s skew still works, as a refresh token does", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const start = await bifrostStart(app);
|
||||
const original = decodeJwt(stateOf(start)).payload;
|
||||
const token = signJwt({ ...original, exp: Math.floor(Date.now() / 1000) - 10 }, ctx.keys);
|
||||
const launch = await app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
|
||||
expect(launch.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it("a tampered signature, a malformed token and a provider that is not the route's", async () => {
|
||||
const app = await newApp();
|
||||
const start = await bifrostStart(app);
|
||||
const state = stateOf(start);
|
||||
const tampered = `${state.slice(0, -4)}AAAA`;
|
||||
for (const token of [tampered, "not-a-jwt", "a.b"]) {
|
||||
const launch = await app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
|
||||
expect(launch.statusCode).toBe(400);
|
||||
expect(launch.json()).toEqual({ error: "invalid_state" });
|
||||
expect(launch.body).not.toContain(token);
|
||||
}
|
||||
const wrongRoute = await app.inject({ method: "GET", url: `/v1/oauth/patreon/launch?state=${encodeURIComponent(state)}` });
|
||||
expect(wrongRoute.statusCode).toBe(400);
|
||||
expect(wrongRoute.json()).toEqual({ error: "state_provider_mismatch" });
|
||||
});
|
||||
});
|
||||
|
||||
it("the launch response and the binding cookie carry the protections the binding relies on", async () => {
|
||||
const app = await newApp();
|
||||
const start = await bifrostStart(app, { requestedScopes: ["identify", "email"] });
|
||||
const jti = decodeJwt(stateOf(start)).payload.jti as string;
|
||||
|
||||
const launch = await app.inject({ method: "GET", url: launchUrl(start) });
|
||||
expect(launch.statusCode).toBe(302);
|
||||
const cookies = setCookieOf(launch);
|
||||
expect(cookies).toHaveLength(1);
|
||||
const cookie = cookies[0] as string;
|
||||
expect(cookie.startsWith(`__Host-heimdall-oauth-${jti}=`)).toBe(true);
|
||||
const attributes = cookie.split(";").slice(1).map((part) => part.trim());
|
||||
expect(attributes).toEqual(expect.arrayContaining(["Path=/", "Secure", "HttpOnly", "SameSite=Lax"]));
|
||||
expect(attributes.some((attribute) => /^domain=/i.test(attribute))).toBe(false);
|
||||
const maxAge = Number(attributes.find((attribute) => attribute.startsWith("Max-Age="))?.slice("Max-Age=".length));
|
||||
expect(maxAge).toBeGreaterThan(0);
|
||||
expect(maxAge).toBeLessThanOrEqual(600);
|
||||
|
||||
const location = new URL(launch.headers.location as string);
|
||||
expect(location.origin + location.pathname).toBe("https://discord.com/oauth2/authorize");
|
||||
expect(location.searchParams.get("state")).toBe(stateOf(start));
|
||||
expect(location.searchParams.get("scope")).toBe("identify email");
|
||||
expect(location.searchParams.get("client_id")).toBe("discord-client");
|
||||
expect(location.searchParams.get("redirect_uri")).toBe("https://heimdall.gamecult.org/v1/oauth/discord/callback");
|
||||
expect(launch.headers["cache-control"]).toBe("no-store");
|
||||
expect(launch.headers["referrer-policy"]).toBe("no-referrer");
|
||||
|
||||
const done = await callback(app, stateOf(start), cookie.split(";")[0], "code=c");
|
||||
expect(done.statusCode).toBe(201);
|
||||
const cleared = setCookieOf(done).find((value) => value.startsWith(`__Host-heimdall-oauth-${jti}=`));
|
||||
expect(cleared).toBeDefined();
|
||||
expect(cleared).toMatch(/Max-Age=0/);
|
||||
expect(cleared?.startsWith(`__Host-heimdall-oauth-${jti}=;`)).toBe(true);
|
||||
});
|
||||
|
||||
it("a launch for a provider with no client id is 503 and burns nothing", async () => {
|
||||
const config = { ...createTestConfig(), providers: { ...createTestConfig().providers } };
|
||||
const app = await buildApp({ config, oauthRuntimes: { discord: createMockDiscordRuntime() } });
|
||||
apps.push(app);
|
||||
const start = await bifrostStart(app);
|
||||
config.providers.discord = { clientId: undefined, clientSecret: undefined } as unknown as typeof config.providers.discord;
|
||||
const refused = await app.inject({ method: "GET", url: launchUrl(start) });
|
||||
expect(refused.statusCode).toBe(503);
|
||||
expect(refused.json().error).toBe("provider_not_configured");
|
||||
expect(setCookieOf(refused)).toEqual([]);
|
||||
});
|
||||
|
||||
it("every start returns Heimdall's launch URL and no state token", async () => {
|
||||
const app = await newApp();
|
||||
for (const [headers, payload] of [
|
||||
[{}, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }],
|
||||
[
|
||||
{ "x-heimdall-app-secret": "bifrost-secret" },
|
||||
{ appSlug: "bifrost", mode: "sign_in", returnTo: bifrostReturn },
|
||||
],
|
||||
] as const) {
|
||||
const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload });
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = response.json();
|
||||
expect(body.stateToken).toBeUndefined();
|
||||
expect(new URL(body.authorizationUrl).origin + new URL(body.authorizationUrl).pathname).toBe(
|
||||
"https://heimdall.gamecult.org/v1/oauth/discord/launch"
|
||||
);
|
||||
expect(body.stateExpiresAt).toEqual(expect.any(String));
|
||||
}
|
||||
});
|
||||
|
||||
it("claims the launch and the callback of a state under their own phases, until the state's expiry", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const spy = vi.spyOn(ctx.store, "claimOAuthStateUse");
|
||||
const start = await bifrostStart(app);
|
||||
const { jti, exp } = decodeJwt(stateOf(start)).payload as { jti: string; exp: number };
|
||||
|
||||
const victim = await launchFlow(app, start);
|
||||
expect((await callback(app, victim.state, victim.cookie, "code=victim-code")).statusCode).toBe(201);
|
||||
|
||||
const expiresAt = new Date(exp * 1000).toISOString();
|
||||
expect(spy.mock.calls.map(([claim]) => claim)).toEqual([
|
||||
{ jti, phase: "launch", appSlug: "bifrost", usedAt: expect.any(String), expiresAt },
|
||||
{ jti, phase: "callback", appSlug: "bifrost", usedAt: expect.any(String), expiresAt },
|
||||
]);
|
||||
});
|
||||
|
||||
it("the launch route validates its provider and its state parameter", async () => {
|
||||
const app = await newApp();
|
||||
for (const url of ["/v1/oauth/discord/launch", "/v1/oauth/discord/launch?state=", "/v1/oauth/bogus/launch?state=abc"]) {
|
||||
const response = await app.inject({ method: "GET", url });
|
||||
expect(response.statusCode, url).toBe(400);
|
||||
expect(response.json().error, url).toBe("invalid_request");
|
||||
}
|
||||
});
|
||||
|
||||
it("a state's requested scopes are null, absent, or a list of strings, and nothing else", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const start = await bifrostStart(app);
|
||||
const original = decodeJwt(stateOf(start)).payload;
|
||||
const launchWith = async (change: Record<string, unknown>) => {
|
||||
const rest = { ...original };
|
||||
delete rest.requested_scopes;
|
||||
const token = signJwt({ ...rest, jti: randomUUID(), ...change }, ctx.keys);
|
||||
return app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
|
||||
};
|
||||
|
||||
for (const [label, change] of [
|
||||
["absent", {}],
|
||||
["null", { requested_scopes: null }],
|
||||
["a list", { requested_scopes: ["identify"] }],
|
||||
] as const) {
|
||||
expect((await launchWith(change)).statusCode, label).toBe(302);
|
||||
}
|
||||
for (const [label, change] of [
|
||||
["a string", { requested_scopes: "identify" }],
|
||||
["a list with a number", { requested_scopes: ["identify", 7] }],
|
||||
["a list of numbers", { requested_scopes: [1, 2] }],
|
||||
["an object", { requested_scopes: { 0: "identify" } }],
|
||||
] as const) {
|
||||
const response = await launchWith(change);
|
||||
expect(response.statusCode, label).toBe(400);
|
||||
expect(response.json(), label).toEqual({ error: "invalid_state" });
|
||||
}
|
||||
});
|
||||
|
||||
it("the time windows hold to the second: a state from nbf - 30 s to exp + 30 s, a binding until its exp", async () => {
|
||||
const app = await newApp();
|
||||
const ctx = getHeimdallRuntimeContext(app);
|
||||
const start = await bifrostStart(app);
|
||||
const original = decodeJwt(stateOf(start)).payload;
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const now = 1_900_000_000;
|
||||
vi.setSystemTime(now * 1000);
|
||||
const stateWith = (change: Record<string, unknown>) =>
|
||||
signJwt({ ...original, jti: randomUUID(), nbf: now - 1, exp: now + 600, ...change }, ctx.keys);
|
||||
const launch = (token: string) =>
|
||||
app.inject({ method: "GET", url: `/v1/oauth/discord/launch?state=${encodeURIComponent(token)}` });
|
||||
|
||||
expect((await launch(stateWith({ exp: now - 30 }))).json()).toEqual({ error: "state_expired" });
|
||||
expect((await launch(stateWith({ exp: now - 29 }))).statusCode).toBe(302);
|
||||
expect((await launch(stateWith({ nbf: now + 31 }))).json()).toEqual({ error: "state_expired" });
|
||||
expect((await launch(stateWith({ nbf: now + 30 }))).statusCode).toBe(302);
|
||||
|
||||
const token = stateWith({});
|
||||
const jti = decodeJwt(token).payload.jti as string;
|
||||
const bindingWith = (exp: number) =>
|
||||
`__Host-heimdall-oauth-${jti}=${signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti, iat: now - 5, exp }, ctx.keys)}`;
|
||||
expect((await callback(app, token, bindingWith(now))).json()).toEqual({ error: "state_not_bound" });
|
||||
expect((await callback(app, token, bindingWith(now + 1))).statusCode).toBe(201);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import { type HeimdallConfig } from "../src/config.js";
|
|||
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
|
||||
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { launchState } from "./oauth-flow.js";
|
||||
|
||||
const secret = "ghostlight-private-command-secret";
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
|
@ -30,7 +31,7 @@ function config(): HeimdallConfig {
|
|||
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
|
||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} },
|
||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {} },
|
||||
} as HeimdallConfig;
|
||||
}
|
||||
|
||||
|
|
@ -73,10 +74,11 @@ async function refreshAgainst(refreshAnswer: () => Response) {
|
|||
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
|
||||
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
|
||||
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
|
||||
const { cookie } = await launchState(app, state);
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
|
||||
headers: { accept: "application/json" },
|
||||
headers: { accept: "application/json", cookie },
|
||||
});
|
||||
expect(callback.statusCode, callback.body).toBe(201);
|
||||
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));
|
||||
|
|
@ -133,10 +135,11 @@ it("keeps the completion code out of the audit trail when the plane redeems by a
|
|||
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1",
|
||||
{ provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy }));
|
||||
const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
|
||||
const { cookie } = await launchState(app, state);
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
|
||||
headers: { accept: "application/json" },
|
||||
headers: { accept: "application/json", cookie },
|
||||
});
|
||||
const code = (callback.json().completion as { code: string }).code;
|
||||
const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Pool } from "pg";
|
|||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { newDb } from "pg-mem";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { createStore } from "../src/store/index.js";
|
||||
import { createStore, InMemoryStore } from "../src/store/index.js";
|
||||
import {
|
||||
createPostgresStore,
|
||||
PostgresStore,
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
samePredicate,
|
||||
STORE_TABLE_PRIVILEGES,
|
||||
} from "../src/store/postgres.js";
|
||||
import { REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
|
||||
import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js";
|
||||
|
||||
describe("PostgresStore", () => {
|
||||
it("round-trips core auth records through postgres", async () => {
|
||||
|
|
@ -198,6 +198,99 @@ describe("PostgresStore", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("consent_receipts foreign key", () => {
|
||||
it("a revocation names a receipt of its own account: the schema says so and the memory store enforces it", async () => {
|
||||
expect(CREATE_SCHEMA_SQL).toContain("FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id)");
|
||||
const store = new InMemoryStore();
|
||||
const base = { appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: "2026-11-08T00:00:00.000Z" };
|
||||
const { receipt: grant } = await store.createConsentReceipt({ ...base, accountId: "a", kind: "grant", scopesAsked: [], scopesGranted: [], consentPageDigest: "d", consentText: "t" });
|
||||
await expect(store.createConsentReceipt({ ...base, accountId: "b", kind: "revocation", revokesReceiptId: grant.id })).rejects.toThrow(/own account/);
|
||||
await expect(store.createConsentReceipt({ ...base, accountId: "a", kind: "revocation", revokesReceiptId: "no-such" })).rejects.toThrow(/own account/);
|
||||
expect(await store.listConsentReceipts("a")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PostgresStore.createConsentReceipt revocation conflict", () => {
|
||||
async function granted() {
|
||||
const adapter = newDb().adapters.createPg();
|
||||
const store = new PostgresStore(new adapter.Pool());
|
||||
await store.ensureSchema();
|
||||
const now = "2026-11-08T00:00:00.000Z";
|
||||
const account = await store.createAccount({ createdAt: now, lastSeenAt: now, displayName: "Probe" });
|
||||
const base = { accountId: account.id, appSlug: "streampixels" as const, provider: "joystick" as const };
|
||||
const { receipt: grant } = await store.createConsentReceipt({ ...base, kind: "grant", createdAt: now, scopesAsked: ["a"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" });
|
||||
return { store, base, grant };
|
||||
}
|
||||
|
||||
it("a second revocation of one grant inserts nothing: created:false, the first row's id", async () => {
|
||||
const { store, base, grant } = await granted();
|
||||
const first = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:00:00.000Z", revokesReceiptId: grant.id });
|
||||
const second = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T02:00:00.000Z", revokesReceiptId: grant.id });
|
||||
expect(first.created).toBe(true);
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.receipt.id).toBe(first.receipt.id);
|
||||
expect(second.receipt.createdAt).toBe("2026-11-08T01:00:00.000Z");
|
||||
expect((await store.listConsentReceipts(base.accountId)).filter((r) => r.kind === "revocation")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("the read-back returns the revocation of this grant, not another's or the grant", async () => {
|
||||
const { store, base, grant } = await granted();
|
||||
const other = (await store.createConsentReceipt({ ...base, kind: "grant", createdAt: "2026-11-08T00:30:00.000Z", scopesAsked: [], scopesGranted: [], consentPageDigest: "d2", consentText: "t2" })).receipt;
|
||||
const otherRevocation = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:00:00.000Z", revokesReceiptId: other.id });
|
||||
const first = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:10:00.000Z", revokesReceiptId: grant.id });
|
||||
const second = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T02:00:00.000Z", revokesReceiptId: grant.id });
|
||||
expect(second.receipt.id).toBe(first.receipt.id);
|
||||
expect(second.receipt.id).not.toBe(otherRevocation.receipt.id);
|
||||
expect(second.receipt).toMatchObject({ kind: "revocation", revokesReceiptId: grant.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryStore.createConsentReceipt", () => {
|
||||
it("writes one revocation per grant: the second names the first and is not created", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const base = { accountId: "a", appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: "2026-11-08T00:00:00.000Z" };
|
||||
const { receipt: grant } = await store.createConsentReceipt({ ...base, kind: "grant", scopesAsked: [], scopesGranted: [], consentPageDigest: "d", consentText: "t" });
|
||||
const first = await store.createConsentReceipt({ ...base, kind: "revocation", revokesReceiptId: grant.id });
|
||||
const second = await store.createConsentReceipt({ ...base, createdAt: "2026-11-09T00:00:00.000Z", kind: "revocation", revokesReceiptId: grant.id });
|
||||
expect([first.created, second.created]).toEqual([true, false]);
|
||||
expect(second.receipt).toEqual(first.receipt);
|
||||
expect(await store.listConsentReceipts("a")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryStore.claimOAuthStateUse", () => {
|
||||
const use = {
|
||||
jti: "state-1",
|
||||
phase: "launch" as const,
|
||||
appSlug: "repixelizer" as const,
|
||||
usedAt: "2026-04-26T12:00:00.000Z",
|
||||
expiresAt: "2026-04-26T12:10:00.000Z",
|
||||
};
|
||||
|
||||
it("is true once per (jti, phase)", async () => {
|
||||
const store = new InMemoryStore();
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
||||
expect(await store.claimOAuthStateUse({ ...use, phase: "callback" })).toBe(true);
|
||||
expect(await store.claimOAuthStateUse({ ...use, phase: "callback" })).toBe(false);
|
||||
expect(await store.claimOAuthStateUse({ ...use, jti: "state-2" })).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a record once its state's exp + 30 s skew has passed, and keeps it until then", async () => {
|
||||
const store = new InMemoryStore();
|
||||
await store.claimOAuthStateUse(use);
|
||||
// Before expiry a later claim of another state leaves the record in force.
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-2", usedAt: "2026-04-26T12:09:59.000Z" });
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
||||
// Inside the 30 s skew past expiry the state still verifies, so the record still refuses a replay.
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-3", usedAt: "2026-04-26T12:10:29.999Z" });
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(false);
|
||||
// At exp + 30 s the state is refused before any claim and the record is dead weight.
|
||||
await store.claimOAuthStateUse({ ...use, jti: "state-4", usedAt: "2026-04-26T12:10:30.000Z" });
|
||||
expect(await store.claimOAuthStateUse(use)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPostgresStore", () => {
|
||||
// pg emits an idle client's connection loss on the pool. With no listener
|
||||
// that event kills the process, and Node prints the pg Client with its
|
||||
|
|
@ -232,12 +325,15 @@ describe("PostgresStore.checkSchema", () => {
|
|||
"sessions",
|
||||
"auth_attempts",
|
||||
"private_command_receipts",
|
||||
"pending_links",
|
||||
"auth_completions",
|
||||
"oauth_state_uses",
|
||||
"capability_grants",
|
||||
"entitlement_snapshots",
|
||||
"consent_receipts",
|
||||
"audit_events",
|
||||
]);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(86);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(112);
|
||||
expect(REQUIRED_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
|
||||
|
|
@ -248,11 +344,14 @@ describe("PostgresStore.checkSchema", () => {
|
|||
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
|
||||
])
|
||||
);
|
||||
expect(REQUIRED_KEYS).toHaveLength(12);
|
||||
expect(REQUIRED_KEYS).toHaveLength(17);
|
||||
expect(REQUIRED_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
|
||||
{ table: "private_command_receipts", columns: "app_slug,idempotency_key", predicate: "" },
|
||||
{ table: "oauth_state_uses", columns: "jti,phase", predicate: "" },
|
||||
{ table: "consent_receipts", columns: "revokes_receipt_id", predicate: "revokes_receipt_id IS NOT NULL" },
|
||||
{ table: "consent_receipts", columns: "account_id,id", predicate: "" },
|
||||
{ table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" },
|
||||
{ table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" },
|
||||
])
|
||||
|
|
@ -362,9 +461,35 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
|
|||
await s.consumeAuthCompletion("ghostlight", byCode.code, now);
|
||||
await s.createAuthCompletion({ ...completion, attemptId: attempt.handle });
|
||||
await s.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, now);
|
||||
const found = await s.createAuthCompletion(completion);
|
||||
await s.findAuthCompletion("ghostlight", found.code);
|
||||
const pending = await s.createPendingLink({
|
||||
appSlug: "ghostlight",
|
||||
accountId: account.id,
|
||||
provider: "discord",
|
||||
providerUserId: "u2",
|
||||
identity: { ...identity, providerUserId: "u2" },
|
||||
returnTo: "https://x.test/",
|
||||
createdAt: now,
|
||||
expiresAt: later,
|
||||
});
|
||||
await s.consumePendingLink("ghostlight", pending.id, account.id, now);
|
||||
const snapshot = { accountId: account.id, provider: "discord" as const, scope: "ghostlight:probe", evaluatedAt: now, isAllowed: true, reasonCode: "ok", rawSummaryJson: {} };
|
||||
await s.upsertEntitlementSnapshot(snapshot);
|
||||
await s.upsertEntitlementSnapshot({ ...snapshot, isAllowed: false });
|
||||
const stateUse = { jti: randomUUID(), phase: "launch" as const, appSlug: "ghostlight" as const, usedAt: now, expiresAt: later };
|
||||
expect(await s.claimOAuthStateUse(stateUse)).toBe(true);
|
||||
expect(await s.claimOAuthStateUse(stateUse)).toBe(false);
|
||||
expect(await s.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true);
|
||||
const { receipt: grant } = await s.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: now, scopesAsked: ["a", "b"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" });
|
||||
const revocation = { kind: "revocation" as const, accountId: account.id, appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: later, revokesReceiptId: grant.id };
|
||||
const first = await s.createConsentReceipt(revocation);
|
||||
// The second revocation of one grant hits ON CONFLICT DO NOTHING and reads the first back.
|
||||
const second = await s.createConsentReceipt(revocation);
|
||||
expect(first.created).toBe(true);
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.receipt.id).toBe(first.receipt.id);
|
||||
expect((await s.listConsentReceipts(account.id)).map((r) => r.kind)).toEqual(["grant", "revocation"]);
|
||||
await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
|
||||
return called;
|
||||
}
|
||||
|
|
@ -524,12 +649,28 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
|
|||
it("prints each declared predicate exactly as Postgres prints the applied index's", async () => {
|
||||
const { owner } = await appliedDatabase();
|
||||
const printed = await printedDeclaredPredicates(owner, HEIMDALL_SCHEMA_REQUIREMENTS);
|
||||
const applied = await owner.query<{ predicate: string }>(
|
||||
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'auth_completions_attempt_unconsumed_unique_idx'::regclass"
|
||||
);
|
||||
const attemptKey = REQUIRED_KEYS.findIndex((key) => key.predicate !== "");
|
||||
expect(printed[attemptKey]).toBe(applied.rows[0]!.predicate);
|
||||
expect(printed.filter((predicate) => predicate !== "")).toHaveLength(1);
|
||||
const appliedByIndex = {
|
||||
auth_completions_attempt_unconsumed_unique_idx: "auth_completions",
|
||||
consent_receipts_revokes_unique_idx: "consent_receipts",
|
||||
};
|
||||
const partialKeys = REQUIRED_KEYS.flatMap((key, index) => (key.predicate === "" ? [] : [{ key, index }]));
|
||||
expect(partialKeys.map(({ key }) => key.table).sort()).toEqual(Object.values(appliedByIndex).sort());
|
||||
for (const [indexName, table] of Object.entries(appliedByIndex)) {
|
||||
const applied = await owner.query<{ predicate: string }>(
|
||||
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = $1::regclass",
|
||||
[indexName]
|
||||
);
|
||||
const { index } = partialKeys.find(({ key }) => key.table === table)!;
|
||||
expect(printed[index], indexName).toBe(applied.rows[0]!.predicate);
|
||||
}
|
||||
expect(printed.filter((predicate) => predicate !== "")).toHaveLength(partialKeys.length);
|
||||
});
|
||||
|
||||
it("refuses to boot with consent_receipts missing when the schema is not applied on startup", async () => {
|
||||
const { name, owner } = await appliedDatabase();
|
||||
await owner.query("DROP TABLE consent_receipts");
|
||||
|
||||
expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING"));
|
||||
});
|
||||
|
||||
it("refuses a table whose primary key was dropped, even after applying the schema", async () => {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => {
|
|||
expect(bySchema.get("idunn.daemon_health")).toEqual([
|
||||
"yggdrasil-heimdall",
|
||||
"active",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/8; apps=4; healthTransport=CultNet/RUDP",
|
||||
"2026-06-16T17:10:00.000Z",
|
||||
"heimdall.cultnet-rudp-provider-health",
|
||||
"daemon-published",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ describe("Heimdall Verse witness advertisement", () => {
|
|||
"youtube",
|
||||
"spotify",
|
||||
"joystick",
|
||||
"google",
|
||||
]);
|
||||
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
||||
"repixelizer",
|
||||
|
|
|
|||
Loading…
Reference in a new issue