Compare commits
60 changed files with 523 additions and 4475 deletions
13
README.md
13
README.md
|
|
@ -12,19 +12,6 @@ The point is painfully simple:
|
|||
become "everything phones home for every request"
|
||||
- keep app-domain data separate so "shared auth" does not become "shared swamp"
|
||||
|
||||
## Front Door
|
||||
|
||||
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify, Google OIDC for linking), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
|
||||
- **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps.
|
||||
- **Entry points:**
|
||||
- `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows.
|
||||
- `src/verifier.ts`: the local verifier helper an app backend imports to validate Heimdall JWTs without phoning home.
|
||||
- `src/odin-publication.ts` and `plugins/gamecult.heimdall.access`: the redacted Odin/Eve provider advertisement and the `gamecult.heimdall.access` plugin (`docs/eve-access-plugin.md`).
|
||||
- `docs/architecture.md`, `docs/service-contract.md`, `docs/verse-service-contract.md`, `docs/app-profiles/`: the contracts.
|
||||
- `pnpm install`, `pnpm dev`, `pnpm test` (see Dev Commands).
|
||||
- **Live:** a stateful first slice: signing and JWKS, OAuth state for six providers, in-memory and Postgres stores, a real Discord callback path for the Repixelizer flow, sealed provider tokens, the loopback CultNet operation boundary, Idunn health publication, and the deployed Ghostlight Eve host consuming the discovered boundary.
|
||||
- **Planned:** the two-account and eight-account Ghostlight human canaries, refresh and revocation flows, admin surfaces, and broader inspection projections beyond the redacted access boundary.
|
||||
|
||||
## Current Shape
|
||||
|
||||
This repo now has a stateful first slice instead of a decorative skeleton.
|
||||
|
|
|
|||
|
|
@ -157,8 +157,6 @@ optional_environment = [
|
|||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
|
|
|
|||
|
|
@ -154,11 +154,7 @@ The important split:
|
|||
|
||||
- Heimdall owns provider OAuth, identity linking, entitlement refresh, signed
|
||||
sessions, claims, grants, audit surfaces, and managed provider credential
|
||||
custody. It authenticates; it does not issue GameCult identity. Bifrost is
|
||||
the identity provider, and the account id Heimdall mints is an authenticator
|
||||
handle. Remaining scar: Heimdall still creates that account id on first
|
||||
login (`store.createAccount`, `src/app.ts`), which the chat work does not
|
||||
need moved
|
||||
custody
|
||||
- the host experiment owns its workload, queue, editor, render path, viewer
|
||||
profile model, creator model, or other product-specific machinery
|
||||
- the host experiment also owns which local entity a connection attaches to,
|
||||
|
|
@ -617,9 +613,8 @@ Only the browser-driven OAuth flow:
|
|||
|
||||
| Path | Why it must be public |
|
||||
|---|---|
|
||||
| `/v1/oauth/{provider}/start` | the app's server calls it, with its app secret where the profile requires one |
|
||||
| `/v1/oauth/{provider}/launch` | the user's browser goes here first; it sets the binding cookie and redirects to the provider, once per state |
|
||||
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot: it needs the binding cookie and consumes the state |
|
||||
| `/v1/oauth/{provider}/start` | the user's browser goes here |
|
||||
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable |
|
||||
| `/.well-known/jwks.json` | *nothing external consumes it today* — see below |
|
||||
|
||||
Everything else is internal by the design already written down:
|
||||
|
|
|
|||
|
|
@ -119,7 +119,6 @@ Supported providers in the skeleton:
|
|||
- `youtube`
|
||||
- `spotify`
|
||||
- `joystick`
|
||||
- `google`
|
||||
|
||||
Request body:
|
||||
|
||||
|
|
@ -150,29 +149,12 @@ Response:
|
|||
"appSlug": "repixelizer",
|
||||
"mode": "sign_in",
|
||||
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
|
||||
"authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=<signed-jwt>",
|
||||
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
|
||||
"stateToken": "<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
|
||||
|
|
@ -186,17 +168,7 @@ Failure modes:
|
|||
- `401 app_auth_required` when the request body carries `entitlementPolicy` and
|
||||
the caller did not authenticate as that app via `x-heimdall-app-secret`
|
||||
(`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of
|
||||
`handoff.kind` or app slug; a caller with no policy to attach needs no secret,
|
||||
unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then
|
||||
every start for that app, with or without a policy, needs that app's secret.
|
||||
Bifrost binds browser completion to a nonce that rides readable in the signed
|
||||
state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose
|
||||
browser starts are public by design leave the flag unset
|
||||
- `401 app_auth_required` also when `mode` is `connect` and the body carries a
|
||||
`connection` (a creator or account target): a connection target is app-backend
|
||||
authority, so only the authenticated app named by `appSlug` may start it. No
|
||||
secret, or another app's, is refused before any state is signed. `sign_in`
|
||||
starts carrying a `connection` are `400 connection_not_allowed_in_sign_in`
|
||||
`handoff.kind` or app slug; a caller with no policy to attach needs no secret
|
||||
|
||||
Launch note:
|
||||
|
||||
|
|
@ -207,45 +179,6 @@ Launch note:
|
|||
implemented yet
|
||||
- Repixelizer launch can configure Discord and Patreon on its provider buttons
|
||||
|
||||
### Google (OpenID Connect, link-only)
|
||||
|
||||
`google` is an identity provider that never mints an account. It is a second
|
||||
door into a person who already has one.
|
||||
|
||||
- Callback redirect URI (register exactly this in the Google Cloud OAuth
|
||||
client): `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`, which is
|
||||
`https://heimdall.gamecult.org/v1/oauth/google/callback` in production.
|
||||
- Scopes `openid email profile`. The authorization request carries `state`
|
||||
(signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge, all built at the
|
||||
launch hop, which is where the browser first meets the provider URL. The verifier
|
||||
and the nonce are derived from the state's `jti` and Heimdall's signing key,
|
||||
so nothing is stored between start and callback.
|
||||
- The callback verifies the `id_token` (RS256 against Google's published
|
||||
keys; `iss` is Google, `aud` is this client, `exp`, `iat`, `nonce`) and
|
||||
requires a `sub` and a verified email. The account key is `sub`; the email
|
||||
is a display and contact field and never a lookup key.
|
||||
- `mode: "link"` needs `Authorization: Bearer <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:
|
||||
|
|
@ -256,11 +189,7 @@ Purpose:
|
|||
|
||||
Current status:
|
||||
|
||||
- validates signed `state` (signature, issuer, provider, expiry), requires the
|
||||
binding cookie the launch hop set, and consumes the state: a second callback
|
||||
is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`,
|
||||
an expired state `400 state_expired`; the binding is checked before the claim,
|
||||
so a browser without the cookie cannot burn the launching browser's state
|
||||
- validates signed `state`
|
||||
- returns provider errors cleanly
|
||||
- for Discord, exchanges the authorization code, resolves the provider
|
||||
identity, persists the local account/link/session/audit records, evaluates
|
||||
|
|
@ -405,11 +334,6 @@ Request body:
|
|||
}
|
||||
```
|
||||
|
||||
Binding rules:
|
||||
|
||||
- A completion minted for a connection target (`connection.targetId` in the signed state) is redeemed only with `targetId` equal to it; absent or different answers `403 completion_target_mismatch` and leaves the code unconsumed.
|
||||
- A `link` completion is redeemed only by the authenticated app backend (`x-heimdall-app-secret`); otherwise `401 app_auth_required`, code unconsumed. `link` start itself needs that authentication and `initiatingAccountId`; the callback refuses with `403 link_initiator_mismatch` when the provider identity belongs to a different account, and attaches an unlinked identity to the initiator.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ evidence only.
|
|||
|
||||
## Owner Map
|
||||
|
||||
- Owner: Heimdall owns provider identity/linking (an authenticator handle, not
|
||||
GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim
|
||||
- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim
|
||||
issuance, entitlement refresh, managed provider token custody, grants,
|
||||
auth-completion handoffs, refresh sessions, and audit events.
|
||||
- Inputs: provider OAuth callbacks, app profile requests, caller-supplied
|
||||
|
|
|
|||
|
|
@ -26,23 +26,18 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
|
||||
## Active Work
|
||||
|
||||
Joystick H1 is merged and live (PR GameCult/Heimdall#1; campaign
|
||||
`joystick:campaign:self`). Next: `joystick:follow_up:h1-joystick-consent-round-trip`, then
|
||||
StreamPixels S0/S1 (other sessions). Open Heimdall follow-ups, by id:
|
||||
`joystick:follow_up:cross-process-failed-refresh-marker`,
|
||||
`joystick:follow_up:connect-start-needs-app-auth`,
|
||||
`joystick:follow_up:heimdall-idunn-onboarding`,
|
||||
`joystick:follow_up:joystick-viewer-signin-public-bot`. The Ghostlight KLTST
|
||||
canaries below stay open, behind them.
|
||||
Joystick H1 is the active, M1-critical cut (operator ruling 2026-10-18):
|
||||
campaign `joystick:campaign:self`, target `joystick:target:r1`, branch
|
||||
`eureka/joystick-h1`. Read the campaign and target in the typed mind; do not
|
||||
re-derive them here. The Ghostlight KLTST canaries below stay open, behind H1.
|
||||
|
||||
## Current Orientation
|
||||
|
||||
Yggdrasil runs Heimdall `d51fa6b`, hand-deployed 2026-10-19 15:51Z;
|
||||
gamecult-ops `210a1af` records backup, rollback and provenance. Facts below
|
||||
about Odin, Ghostlight and CultLib pins were verified at `1086aee` (2026-08-22)
|
||||
and are not re-verified at `d51fa6b`.
|
||||
Deploy facts below are last-recorded, from 2026-08-22 (`1086aee`); HEAD has
|
||||
moved ~100 commits past it. Check Yggdrasil, not this file, for what runs.
|
||||
|
||||
- At `1086aee01169bf60e8a492b2740db1c6f3e8cabf` Heimdall ran with CultLib
|
||||
- Yggdrasil runs Heimdall
|
||||
`1086aee01169bf60e8a492b2740db1c6f3e8cabf` with CultLib
|
||||
`5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9`
|
||||
- Heimdall publishes four redacted access records to Odin under unique keys:
|
||||
`eve:provider:heimdall`, `heimdall:command-boundary`,
|
||||
|
|
@ -162,7 +157,8 @@ and are not re-verified at `d51fa6b`.
|
|||
over `cultnet.transport.rudp.v0` when `GC_ACCESS_IDUNN_RUDP_HEALTH` is
|
||||
configured; HTTP `/healthz`, JWKS, discovery, systemd, and nginx routing
|
||||
remain compatibility witnesses
|
||||
- Since `1086aee` Heimdall persists its private
|
||||
- Yggdrasil now runs Heimdall commit
|
||||
`1086aee01169bf60e8a492b2740db1c6f3e8cabf`. Heimdall persists its private
|
||||
provider-health identity at `/srv/heimdall/keys/provider-health-identity.cc`
|
||||
and publishes canonical signed health independently of the slower Verse
|
||||
snapshot cadence. Idunn owns the exact root trust binding, authenticated
|
||||
|
|
@ -184,7 +180,7 @@ and are not re-verified at `d51fa6b`.
|
|||
|
||||
## Next Real Move
|
||||
|
||||
The Active Work follow-ups first. Everything below is the standing queue.
|
||||
Joystick H1 first (see Active Work). Everything below is the standing queue.
|
||||
|
||||
Do not continue implementation automatically from a rehydrate-only request.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,12 @@
|
|||
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/heimdall --external:pg-native --banner:js=\"#!/usr/bin/env node\nimport{createRequire}from'node:module';const require=createRequire(import.meta.url);\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts",
|
||||
"cultmesh-ts": "file:vendor/CultLib/packages/cultmesh-ts",
|
||||
"cultnet-ts": "file:vendor/CultLib/packages/cultnet-ts",
|
||||
"fastify": "^5.12.6",
|
||||
"fastify": "^5.8.5",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
|
|
|||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
|
|
@ -12,9 +12,6 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@fastify/cookie':
|
||||
specifier: ^11.1.3
|
||||
version: 11.1.5
|
||||
'@fastify/cors':
|
||||
specifier: ^11.2.0
|
||||
version: 11.2.0
|
||||
|
|
@ -31,8 +28,8 @@ importers:
|
|||
specifier: file:vendor/CultLib/packages/cultnet-ts
|
||||
version: file:vendor/CultLib/packages/cultnet-ts
|
||||
fastify:
|
||||
specifier: ^5.12.6
|
||||
version: 5.12.7
|
||||
specifier: ^5.8.5
|
||||
version: 5.8.5
|
||||
pg:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
|
|
@ -232,9 +229,6 @@ packages:
|
|||
'@fastify/ajv-compiler@4.0.5':
|
||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||
|
||||
'@fastify/cookie@11.1.5':
|
||||
resolution: {integrity: sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==}
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||
|
||||
|
|
@ -475,10 +469,6 @@ packages:
|
|||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cookie@2.0.1:
|
||||
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
|
||||
resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -542,26 +532,17 @@ packages:
|
|||
fast-json-stringify@6.3.0:
|
||||
resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==}
|
||||
|
||||
fast-json-stringify@7.1.3:
|
||||
resolution: {integrity: sha512-5TDM1yOF/BDFOclKz2G6rs1RsIl5HXMCrg/npmdNIPNQCezshR5kIHjhwBNuMY67jhkTUAm9DguL8xkL/x0Wtw==}
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fast-uri@4.2.3:
|
||||
resolution: {integrity: sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==}
|
||||
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify-plugin@6.0.1:
|
||||
resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==}
|
||||
|
||||
fastify@5.12.7:
|
||||
resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==}
|
||||
fastify@5.8.5:
|
||||
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
|
@ -575,8 +556,8 @@ packages:
|
|||
picomatch:
|
||||
optional: true
|
||||
|
||||
find-my-way@9.10.0:
|
||||
resolution: {integrity: sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==}
|
||||
find-my-way@9.5.0:
|
||||
resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
|
|
@ -872,8 +853,8 @@ packages:
|
|||
process-warning@4.0.1:
|
||||
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
||||
|
||||
process-warning@5.1.1:
|
||||
resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==}
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
|
@ -1199,11 +1180,6 @@ snapshots:
|
|||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 3.1.0
|
||||
|
||||
'@fastify/cookie@11.1.5':
|
||||
dependencies:
|
||||
cookie: 2.0.1
|
||||
fastify-plugin: 6.0.1
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
dependencies:
|
||||
fastify-plugin: 5.1.0
|
||||
|
|
@ -1411,8 +1387,6 @@ snapshots:
|
|||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cookie@2.0.1: {}
|
||||
|
||||
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
|
||||
dependencies:
|
||||
'@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts
|
||||
|
|
@ -1501,28 +1475,15 @@ snapshots:
|
|||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-json-stringify@7.1.3:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.2.1
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 4.2.3
|
||||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
dependencies:
|
||||
fast-decode-uri-component: 1.0.1
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-uri@4.2.3: {}
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify-plugin@6.0.1: {}
|
||||
|
||||
fastify@5.12.7:
|
||||
fastify@5.8.5:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
'@fastify/error': 4.2.0
|
||||
|
|
@ -1530,11 +1491,11 @@ snapshots:
|
|||
'@fastify/proxy-addr': 5.1.0
|
||||
abstract-logging: 2.0.1
|
||||
avvio: 9.2.0
|
||||
fast-json-stringify: 7.1.3
|
||||
find-my-way: 9.10.0
|
||||
fast-json-stringify: 6.3.0
|
||||
find-my-way: 9.5.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 10.3.1
|
||||
process-warning: 5.1.1
|
||||
process-warning: 5.0.0
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.1.0
|
||||
semver: 7.7.4
|
||||
|
|
@ -1548,7 +1509,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
find-my-way@9.10.0:
|
||||
find-my-way@9.5.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-querystring: 1.1.2
|
||||
|
|
@ -1772,7 +1733,7 @@ snapshots:
|
|||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.1.1
|
||||
process-warning: 5.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
|
|
@ -1797,7 +1758,7 @@ snapshots:
|
|||
|
||||
process-warning@4.0.1: {}
|
||||
|
||||
process-warning@5.1.1: {}
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs the whole vitest suite with the live-Postgres files enabled, against a
|
||||
# throwaway local cluster that this script starts and removes. Needs the
|
||||
# Postgres server binaries (initdb, pg_ctl) on PATH or in PG_BIN.
|
||||
# SOUL_PG_URL's test drops and recreates schema public, so it only ever sees
|
||||
# this disposable cluster.
|
||||
set -euo pipefail
|
||||
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,17 +45,6 @@ export interface AppProfile {
|
|||
* plane; absent for apps with no such requirement.
|
||||
*/
|
||||
requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"];
|
||||
/**
|
||||
* The app binds browser completion to its own pending attempt, and the
|
||||
* binding is the secrecy of the attempt nonce, which rides readable in the
|
||||
* signed (not encrypted) state's return_to. If anyone could start a flow for
|
||||
* this app, whoever learned a victim's nonce could mint a completion bound
|
||||
* to it and sign the victim's browser in as themselves (login CSRF, finding
|
||||
* known-nonce-login-csrf). So a start for this app is refused unless the
|
||||
* caller is this app's authenticated caller: only the app can mint its own
|
||||
* state. Absent for apps whose browser starts are public by design.
|
||||
*/
|
||||
startRequiresAppCaller?: boolean;
|
||||
}
|
||||
|
||||
/** The membership signal shared by every app that gates on GameCult membership. */
|
||||
|
|
@ -148,12 +137,11 @@ const streampixelsProfile: AppProfile = {
|
|||
const bifrostProfile: AppProfile = {
|
||||
slug: "bifrost",
|
||||
displayName: "Bifrost",
|
||||
profileVersion: "2026-11-04",
|
||||
identityProviders: ["discord", "patreon", "google"],
|
||||
profileVersion: "2026-06-08",
|
||||
identityProviders: ["discord", "patreon"],
|
||||
entitlementSources: ["discord", "patreon"],
|
||||
managedConnectionProviders: [],
|
||||
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
|
||||
startRequiresAppCaller: true,
|
||||
capabilities: [
|
||||
{
|
||||
key: "member_access",
|
||||
|
|
|
|||
653
src/app.ts
653
src/app.ts
|
|
@ -1,7 +1,6 @@
|
|||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
|
||||
import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js";
|
||||
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
|
||||
import {
|
||||
|
|
@ -29,7 +28,6 @@ import { renderBrowserHandoffPage } from "./browser-handoff.js";
|
|||
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||
import { createTokenCustody, type TokenCustody } from "./custody.js";
|
||||
import { grantFact } from "./facts.js";
|
||||
import { deriveOidcSecrets } from "./google-oidc.js";
|
||||
import {
|
||||
createOAuthRuntimeRegistry,
|
||||
fetchPatreonIdentity,
|
||||
|
|
@ -93,12 +91,10 @@ function buildOAuthStateToken(options: {
|
|||
mode: OAuthMode;
|
||||
returnTo: string;
|
||||
connection: OAuthConnectionBinding | undefined;
|
||||
initiatorAccountId: string | undefined;
|
||||
handoff: OAuthHandoff;
|
||||
entitlementPolicy: OAuthEntitlementPolicy | undefined;
|
||||
requestedScopes: string[] | undefined;
|
||||
keys: ReturnType<typeof createRuntimeKeyMaterial>;
|
||||
}): { token: string; jti: string; expiresAt: string } {
|
||||
}): { token: string; expiresAt: string } {
|
||||
const issuedAt = nowEpochSeconds();
|
||||
const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds;
|
||||
const payload: OAuthStatePayload = {
|
||||
|
|
@ -115,16 +111,13 @@ function buildOAuthStateToken(options: {
|
|||
mode: options.mode,
|
||||
return_to: options.returnTo,
|
||||
connection: options.connection ?? null,
|
||||
initiator_account_id: options.initiatorAccountId ?? null,
|
||||
handoff: options.handoff,
|
||||
entitlement_policy: options.entitlementPolicy ?? null,
|
||||
requested_scopes: options.requestedScopes ?? null,
|
||||
};
|
||||
const token = signJwt(payload as unknown as Record<string, unknown>, options.keys);
|
||||
|
||||
return {
|
||||
token,
|
||||
jti: payload.jti,
|
||||
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -158,11 +151,6 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
|
|||
entitlementPolicy !== null &&
|
||||
(entitlementPolicy as Record<string, unknown>).kind === "patreon_membership_access" &&
|
||||
typeof (entitlementPolicy as Record<string, unknown>).requiredTierTitle === "string"));
|
||||
const requestedScopes = payload.requested_scopes;
|
||||
const requestedScopesAreValid =
|
||||
requestedScopes === null ||
|
||||
requestedScopes === undefined ||
|
||||
(Array.isArray(requestedScopes) && requestedScopes.every((scope) => typeof scope === "string"));
|
||||
if (
|
||||
payload.typ !== "heimdall_oauth_state" ||
|
||||
typeof payload.provider !== "string" ||
|
||||
|
|
@ -180,9 +168,7 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
|
|||
typeof payload.nbf !== "number" ||
|
||||
typeof payload.exp !== "number" ||
|
||||
!handoffIsValid ||
|
||||
!entitlementPolicyIsValid ||
|
||||
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") ||
|
||||
!requestedScopesAreValid
|
||||
!entitlementPolicyIsValid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -191,105 +177,13 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
|
|||
if (!normalizedPayload.handoff) {
|
||||
normalizedPayload.handoff = { kind: "browser_completion" };
|
||||
}
|
||||
if (!normalizedPayload.initiator_account_id) {
|
||||
normalizedPayload.initiator_account_id = null;
|
||||
}
|
||||
if (!normalizedPayload.entitlement_policy) {
|
||||
normalizedPayload.entitlement_policy = null;
|
||||
}
|
||||
if (!normalizedPayload.requested_scopes) {
|
||||
normalizedPayload.requested_scopes = null;
|
||||
}
|
||||
|
||||
return normalizedPayload;
|
||||
}
|
||||
|
||||
type OAuthStateVerdict =
|
||||
| { ok: true; payload: OAuthStatePayload }
|
||||
| { ok: false; error: "invalid_state" | "state_provider_mismatch" | "state_expired" };
|
||||
|
||||
/**
|
||||
* The one place a signed OAuth state becomes trusted: signature, shape, issuer,
|
||||
* audience and provider, and the time window with the same 30 s skew refresh
|
||||
* tokens use. Every route that turns a state into navigation or a completion
|
||||
* runs this first; the refusals are fixed codes and never echo the token.
|
||||
*/
|
||||
function verifyOAuthState(
|
||||
config: HeimdallConfig,
|
||||
keys: ReturnType<typeof createRuntimeKeyMaterial>,
|
||||
token: string,
|
||||
provider: Provider
|
||||
): OAuthStateVerdict {
|
||||
const verification = verifyJwt(token, keys.publicKey);
|
||||
if (!verification.valid) {
|
||||
return { ok: false, error: "invalid_state" };
|
||||
}
|
||||
|
||||
const payload = parseOAuthStatePayload(verification.payload);
|
||||
if (!payload || payload.iss !== config.issuer) {
|
||||
return { ok: false, error: "invalid_state" };
|
||||
}
|
||||
if (payload.aud !== payload.provider || payload.provider !== provider) {
|
||||
return { ok: false, error: "state_provider_mismatch" };
|
||||
}
|
||||
|
||||
const now = nowEpochSeconds();
|
||||
if (payload.nbf > now + 30 || payload.exp <= now - 30) {
|
||||
return { ok: false, error: "state_expired" };
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
}
|
||||
|
||||
const oauthBindingType = "heimdall_oauth_binding";
|
||||
|
||||
function oauthBindingCookieName(jti: string): string {
|
||||
return `__Host-heimdall-oauth-${jti}`;
|
||||
}
|
||||
|
||||
/** The signed claim that this browser launched this state: it is the cookie's value, and needs no store read to check. */
|
||||
function signOAuthBinding(
|
||||
config: HeimdallConfig,
|
||||
keys: ReturnType<typeof createRuntimeKeyMaterial>,
|
||||
state: OAuthStatePayload
|
||||
): string {
|
||||
return signJwt(
|
||||
{ typ: oauthBindingType, iss: config.issuer, jti: state.jti, iat: nowEpochSeconds(), exp: state.exp },
|
||||
keys
|
||||
);
|
||||
}
|
||||
|
||||
function oauthBindingHolds(
|
||||
config: HeimdallConfig,
|
||||
keys: ReturnType<typeof createRuntimeKeyMaterial>,
|
||||
token: string | undefined,
|
||||
state: OAuthStatePayload
|
||||
): boolean {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const verification = verifyJwt(token, keys.publicKey);
|
||||
if (!verification.valid) {
|
||||
return false;
|
||||
}
|
||||
const { typ, iss, jti, exp } = verification.payload;
|
||||
return typ === oauthBindingType && iss === config.issuer && jti === state.jti && typeof exp === "number" && exp > nowEpochSeconds();
|
||||
}
|
||||
|
||||
function setOAuthBindingCookie(reply: FastifyReply, state: OAuthStatePayload, value: string, maxAgeSeconds: number): void {
|
||||
reply.setCookie(oauthBindingCookieName(state.jti), value, {
|
||||
path: "/",
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: Math.max(0, maxAgeSeconds),
|
||||
});
|
||||
}
|
||||
|
||||
function oauthCallbackUrl(config: HeimdallConfig, provider: Provider): string {
|
||||
return `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
|
||||
}
|
||||
|
||||
function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] {
|
||||
return grants.map((grant) => grantFact(grant.capability));
|
||||
}
|
||||
|
|
@ -354,17 +248,11 @@ function relayedProviderText(value: string | undefined, bound: number): string |
|
|||
return printable ? printable : undefined;
|
||||
}
|
||||
|
||||
/** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */
|
||||
export class LinkRefusal extends Error {
|
||||
constructor(
|
||||
readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown" | "identity_not_linked",
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LinkRefusal";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What may be recorded or answered about a caught error: its class and its
|
||||
* code, each only when it is a short identifier. Never its message, which
|
||||
* carries whatever the thrower quoted (a host, a database, a provider's body).
|
||||
*/
|
||||
export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } {
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return {
|
||||
|
|
@ -504,8 +392,6 @@ async function evaluateStoredEntitlements(options: {
|
|||
appSlug: AppSlug;
|
||||
accountId: string;
|
||||
entitlementPolicies: OAuthEntitlementPolicy[];
|
||||
/** A provider the caller has already evaluated with fresh tokens. */
|
||||
skipProvider?: Provider;
|
||||
}): Promise<string[]> {
|
||||
const profile = getAppProfile(options.appSlug);
|
||||
if (!profile) {
|
||||
|
|
@ -515,7 +401,7 @@ async function evaluateStoredEntitlements(options: {
|
|||
const facts: string[] = [];
|
||||
|
||||
for (const storedIdentity of linkedIdentities) {
|
||||
if (!profile.entitlementSources.includes(storedIdentity.provider) || storedIdentity.provider === options.skipProvider) {
|
||||
if (!profile.entitlementSources.includes(storedIdentity.provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -600,13 +486,6 @@ export async function startOAuthFlow(
|
|||
* which one reaches the signed state token.
|
||||
*/
|
||||
trustedBrowserAttemptId?: string;
|
||||
/**
|
||||
* The account an explicit link binds to. Like `trustedBrowserAttemptId`
|
||||
* it is a function argument, never a value parsed out of the request:
|
||||
* the HTTP route sets it only from a verified Heimdall access token for
|
||||
* this app (`resolveLinkAccount`), so a caller cannot name an account.
|
||||
*/
|
||||
linkAccountId?: string;
|
||||
}
|
||||
): Promise<HandlerResult> {
|
||||
const { config, keys } = ctx;
|
||||
|
|
@ -622,16 +501,6 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
// A connection target is app-backend authority: it rides only on connect
|
||||
// starts that carry the app's own secret (gated below), and never on a
|
||||
// sign_in start, which is open to any browser.
|
||||
if (input.mode === "sign_in" && input.connection !== undefined) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
body: { error: "connection_not_allowed_in_sign_in", detail: "sign_in start must not carry a connection target." },
|
||||
};
|
||||
}
|
||||
|
||||
if (callbackMint(profile, provider, input.mode, input.connection) === "refused") {
|
||||
return {
|
||||
statusCode: 400,
|
||||
|
|
@ -642,48 +511,6 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
const descriptor = providerCatalog[provider];
|
||||
// A provider that never mints an account attaches an identity only through
|
||||
// a bound link: the user's own access token, since the app's word does not
|
||||
// name this person to Google. A link start without a verified Heimdall session would end
|
||||
// in a refusal at the callback, so it is refused before the redirect.
|
||||
// `sign_in` stays open: it signs in an identity already linked, and the
|
||||
// callback refuses one that is not.
|
||||
if (descriptor.firstLogin === "requires_link" && input.mode === "link" && !options?.linkAccountId) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
body: {
|
||||
error: "link_session_required",
|
||||
detail: `Linking ${descriptor.displayName} needs a Heimdall access token for ${profile.slug}: send it as a Bearer token.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Link mode binds an identity to an existing account, so the account must
|
||||
// be named by someone Heimdall can believe: the authenticated app backend.
|
||||
// The name rides in the signed state and the callback holds the provider's
|
||||
// identity to it. No other mode carries an initiator.
|
||||
if (input.mode === "link") {
|
||||
if (options?.linkAccountId) {
|
||||
// The user's own access token for this app names the account.
|
||||
if (input.initiatingAccountId !== undefined && input.initiatingAccountId !== options.linkAccountId) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_conflict", detail: "initiatingAccountId differs from the bearer's account." } };
|
||||
}
|
||||
} else {
|
||||
if (!isAppCaller(caller) || caller.appSlug !== profile.slug) {
|
||||
return { statusCode: 401, body: { error: "app_auth_required" } };
|
||||
}
|
||||
if (!input.initiatingAccountId) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } };
|
||||
}
|
||||
if (!(await ctx.store.findAccountById(input.initiatingAccountId))) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } };
|
||||
}
|
||||
}
|
||||
} else if (input.initiatingAccountId !== undefined) {
|
||||
return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } };
|
||||
}
|
||||
|
||||
if (!isAllowedReturnOrigin(profile, input.returnTo)) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
|
|
@ -727,7 +554,7 @@ export async function startOAuthFlow(
|
|||
if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") {
|
||||
handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId };
|
||||
}
|
||||
if ((entitlementPolicy || profile.startRequiresAppCaller || (input.mode === "connect" && input.connection !== undefined)) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
|
||||
if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
|
||||
return { statusCode: 401, body: { error: "app_auth_required" } };
|
||||
}
|
||||
|
||||
|
|
@ -739,6 +566,7 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
|
||||
const state = buildOAuthStateToken({
|
||||
config,
|
||||
provider,
|
||||
|
|
@ -746,69 +574,32 @@ export async function startOAuthFlow(
|
|||
mode: input.mode,
|
||||
returnTo: input.returnTo,
|
||||
connection: input.connection,
|
||||
initiatorAccountId: options?.linkAccountId ?? input.initiatingAccountId,
|
||||
handoff,
|
||||
entitlementPolicy,
|
||||
requestedScopes: input.requestedScopes,
|
||||
keys,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 201,
|
||||
body: {
|
||||
provider,
|
||||
appSlug: profile.slug,
|
||||
mode: input.mode,
|
||||
callbackUrl: oauthCallbackUrl(config, provider),
|
||||
authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`,
|
||||
callbackUrl,
|
||||
authorizationUrl: buildAuthorizationUrl({
|
||||
provider,
|
||||
clientId: providerConfig.clientId,
|
||||
redirectUri: callbackUrl,
|
||||
state: state.token,
|
||||
requestedScopes: input.requestedScopes,
|
||||
}),
|
||||
handoff,
|
||||
stateToken: state.token,
|
||||
stateExpiresAt: state.expiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a link start binds to: the one a live Heimdall access token for
|
||||
* this app names. The token must verify under Heimdall's own key, be an access
|
||||
* token for `appSlug`, be inside its validity window, and belong to a session
|
||||
* the store still holds for that account at that revision. A bearer that fails
|
||||
* any of these binds nothing: a link is explicit or it does not happen.
|
||||
*/
|
||||
async function resolveLinkAccount(
|
||||
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
|
||||
appSlug: AppSlug,
|
||||
authorization: string | undefined
|
||||
): Promise<string | null> {
|
||||
const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1];
|
||||
if (!token) return null;
|
||||
const verification = verifyJwt(token, ctx.keys.publicKey);
|
||||
if (!verification.valid) return null;
|
||||
const claims = verification.payload;
|
||||
const now = nowEpochSeconds();
|
||||
if (
|
||||
claims.typ !== "heimdall_access" ||
|
||||
claims.iss !== ctx.config.issuer ||
|
||||
claims.aud !== appSlug ||
|
||||
typeof claims.account_id !== "string" ||
|
||||
typeof claims.sid !== "string" ||
|
||||
typeof claims.access_revision !== "number" ||
|
||||
typeof claims.exp !== "number" ||
|
||||
claims.exp <= now ||
|
||||
(typeof claims.nbf === "number" && claims.nbf > now)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const session = await ctx.store.findSession(appSlug, claims.sid);
|
||||
if (
|
||||
!session ||
|
||||
session.accountId !== claims.account_id ||
|
||||
session.accessRevision !== claims.access_revision ||
|
||||
Date.parse(session.expiresAt) <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return claims.account_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session refresh, extracted for the same reason as startOAuthFlow: the HTTP
|
||||
* route and the plane's `refreshAuth` (which owns Ghostlight identity) call
|
||||
|
|
@ -947,7 +738,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
await app.register(cookie);
|
||||
|
||||
app.get("/healthz", async () => ({
|
||||
ok: true,
|
||||
|
|
@ -1032,7 +822,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
summary: { type: "string" },
|
||||
},
|
||||
},
|
||||
initiatingAccountId: { type: "string", minLength: 1 },
|
||||
handoff: {
|
||||
type: "object",
|
||||
required: ["kind"],
|
||||
|
|
@ -1077,122 +866,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
},
|
||||
async (request, reply) => {
|
||||
const caller = resolveAppCaller(config, request.body.appSlug, request.headers);
|
||||
let linkAccountId: string | undefined;
|
||||
if (request.body.mode === "link") {
|
||||
const bound = await resolveLinkAccount({ config, keys, store }, request.body.appSlug, request.headers.authorization);
|
||||
if (bound === null && request.headers.authorization !== undefined) {
|
||||
reply.code(401);
|
||||
return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." };
|
||||
}
|
||||
linkAccountId = bound ?? undefined;
|
||||
}
|
||||
const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body, {
|
||||
...(linkAccountId !== undefined ? { linkAccountId } : {}),
|
||||
});
|
||||
const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body);
|
||||
reply.code(result.statusCode);
|
||||
return result.body;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A refusal of a verified state. A browser (Accept: text/html) gets the
|
||||
* handoff page linking back to the state's return_to, so the person is not
|
||||
* left on a bare JSON body; the origin is re-checked against the app's
|
||||
* allowlist here rather than trusting the signed value. Anything else gets
|
||||
* the fixed JSON code.
|
||||
*/
|
||||
const refuseVerifiedState = (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
state: OAuthStatePayload,
|
||||
error: "state_not_bound" | "state_already_launched" | "state_already_used"
|
||||
) => {
|
||||
reply.code(400);
|
||||
const profile = getAppProfile(state.app_slug);
|
||||
if (prefersHtml(request.headers.accept) && profile && isAllowedReturnOrigin(profile, state.return_to)) {
|
||||
reply.type("text/html; charset=utf-8");
|
||||
return reply.send(
|
||||
renderBrowserHandoffPage({
|
||||
handoffKind: (state.handoff ?? { kind: "browser_completion" }).kind,
|
||||
status: "error",
|
||||
provider: state.provider,
|
||||
appSlug: state.app_slug,
|
||||
mode: state.mode,
|
||||
returnTo: state.return_to,
|
||||
error,
|
||||
})
|
||||
);
|
||||
}
|
||||
return { error };
|
||||
};
|
||||
|
||||
app.get<{
|
||||
Params: { provider: Provider };
|
||||
Querystring: { state: string };
|
||||
}>(
|
||||
"/v1/oauth/:provider/launch",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["provider"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
provider: { type: "string", enum: [...providers] },
|
||||
},
|
||||
},
|
||||
querystring: {
|
||||
type: "object",
|
||||
required: ["state"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
state: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const verified = verifyOAuthState(config, keys, request.query.state, request.params.provider);
|
||||
if (!verified.ok) {
|
||||
reply.code(400);
|
||||
return { error: verified.error };
|
||||
}
|
||||
const state = verified.payload;
|
||||
const clientId = config.providers[state.provider].clientId;
|
||||
if (!clientId) {
|
||||
reply.code(503);
|
||||
return { error: "provider_not_configured", provider: state.provider, expectedEnv: providerExpectedEnv(state.provider) };
|
||||
}
|
||||
|
||||
const first = await store.claimOAuthStateUse({
|
||||
jti: state.jti,
|
||||
phase: "launch",
|
||||
appSlug: state.app_slug,
|
||||
usedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(state.exp * 1000).toISOString(),
|
||||
});
|
||||
if (!first) {
|
||||
return refuseVerifiedState(request, reply, state, "state_already_launched");
|
||||
}
|
||||
|
||||
const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
|
||||
setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state), state.exp - nowEpochSeconds());
|
||||
reply.header("Cache-Control", "no-store");
|
||||
reply.header("Referrer-Policy", "no-referrer");
|
||||
return reply.redirect(
|
||||
buildAuthorizationUrl({
|
||||
provider: state.provider,
|
||||
clientId,
|
||||
redirectUri: oauthCallbackUrl(config, state.provider),
|
||||
state: request.query.state,
|
||||
requestedScopes: state.requested_scopes ?? undefined,
|
||||
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
|
||||
}),
|
||||
302
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{
|
||||
Params: { provider: Provider };
|
||||
Querystring: { code?: string; state?: string; error?: string; error_description?: string };
|
||||
|
|
@ -1226,12 +905,17 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
return { error: "missing_state" };
|
||||
}
|
||||
|
||||
const verified = verifyOAuthState(config, keys, request.query.state, request.params.provider);
|
||||
if (!verified.ok) {
|
||||
const verification = verifyJwt(request.query.state, keys.publicKey);
|
||||
if (!verification.valid) {
|
||||
reply.code(400);
|
||||
return { error: verified.error };
|
||||
return { error: "invalid_state", detail: verification.error };
|
||||
}
|
||||
|
||||
const statePayload = parseOAuthStatePayload(verification.payload);
|
||||
if (!statePayload || statePayload.provider !== request.params.provider) {
|
||||
reply.code(400);
|
||||
return { error: "state_provider_mismatch" };
|
||||
}
|
||||
const statePayload = verified.payload;
|
||||
const handoff = statePayload.handoff ?? { kind: "browser_completion" };
|
||||
const denyBrowserAttempt = async (denialCode: string): Promise<void> => {
|
||||
if (handoff.kind !== "browser_completion" || !handoff.attemptId) return;
|
||||
|
|
@ -1241,26 +925,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
denialCode,
|
||||
});
|
||||
};
|
||||
// The cookie is checked before the claim: a stranger without it must not be
|
||||
// able to burn the state that the browser that launched it still needs.
|
||||
// The attempt is denied, though: a launcher whose cookie lapsed can never
|
||||
// complete this state, and its page polls an attempt that would otherwise
|
||||
// sit pending until it expires.
|
||||
if (!oauthBindingHolds(config, keys, request.cookies[oauthBindingCookieName(statePayload.jti)], statePayload)) {
|
||||
await denyBrowserAttempt("state_not_bound");
|
||||
return refuseVerifiedState(request, reply, statePayload, "state_not_bound");
|
||||
}
|
||||
setOAuthBindingCookie(reply, statePayload, "", 0);
|
||||
const firstCallback = await store.claimOAuthStateUse({
|
||||
jti: statePayload.jti,
|
||||
phase: "callback",
|
||||
appSlug: statePayload.app_slug,
|
||||
usedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(statePayload.exp * 1000).toISOString(),
|
||||
});
|
||||
if (!firstCallback) {
|
||||
return refuseVerifiedState(request, reply, statePayload, "state_already_used");
|
||||
}
|
||||
|
||||
if (request.query.error) {
|
||||
const providerError = relayedProviderText(request.query.error, 64);
|
||||
|
|
@ -1359,48 +1023,45 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
if (mint === "refused") {
|
||||
throw new Error("The signed state names a mode the provider has no role for in this app.");
|
||||
}
|
||||
const callbackDescriptor = providerCatalog[request.params.provider];
|
||||
const oidc = callbackDescriptor.oidc ? deriveOidcSecrets(statePayload.jti, keys.privateKey) : undefined;
|
||||
const tokenSet = await runtime.exchangeAuthorizationCode({
|
||||
config,
|
||||
code: request.query.code,
|
||||
redirectUri: callbackUrl,
|
||||
...(oidc ? { codeVerifier: oidc.codeVerifier } : {}),
|
||||
});
|
||||
const identity = await runtime.resolveIdentity({
|
||||
config,
|
||||
accessToken: tokenSet.accessToken,
|
||||
tokenSet,
|
||||
...(oidc ? { oidcNonce: oidc.nonce } : {}),
|
||||
});
|
||||
const nowIso = new Date().toISOString();
|
||||
let account = await store.findAccountByLinkedIdentity(identity.provider, identity.providerUserId);
|
||||
// One person, one account. A link attaches the provider identity to the
|
||||
// account the signed state names, and to no other: an identity another
|
||||
// account holds is a refusal, never a quiet sign-in as that account or a
|
||||
// move. A provider that never mints an account refuses an identity no
|
||||
// account holds.
|
||||
const boundAccountId = statePayload.mode === "link" ? statePayload.initiator_account_id : null;
|
||||
if (statePayload.mode === "link") {
|
||||
if (!boundAccountId) {
|
||||
throw new LinkRefusal("link_initiator_missing", "The signed link state names no initiating account.");
|
||||
|
||||
if (!account) {
|
||||
const createAccountInput: CreateAccountInput = {
|
||||
createdAt: nowIso,
|
||||
lastSeenAt: nowIso,
|
||||
};
|
||||
const displayName = identity.displayName ?? identity.username;
|
||||
if (displayName !== undefined) {
|
||||
createAccountInput.displayName = displayName;
|
||||
}
|
||||
if (account && account.id !== boundAccountId) {
|
||||
throw new LinkRefusal("link_initiator_mismatch", "This identity belongs to a different account than the one that started the link.");
|
||||
if (identity.primaryEmail !== undefined) {
|
||||
createAccountInput.primaryEmail = identity.primaryEmail;
|
||||
}
|
||||
if (!account) {
|
||||
account = await store.findAccountById(boundAccountId);
|
||||
if (!account) {
|
||||
throw new LinkRefusal("link_initiator_unknown", "The initiating account no longer exists.");
|
||||
}
|
||||
account = await store.createAccount(createAccountInput);
|
||||
} else {
|
||||
const accountUpdates: { displayName?: string; primaryEmail?: string } = {};
|
||||
const displayName = identity.displayName ?? identity.username;
|
||||
if (displayName !== undefined) {
|
||||
accountUpdates.displayName = displayName;
|
||||
}
|
||||
} else if (!account && callbackDescriptor.firstLogin === "requires_link") {
|
||||
throw new LinkRefusal("identity_not_linked", "No account holds this identity; sign in another way and link it first.");
|
||||
if (identity.primaryEmail !== undefined) {
|
||||
accountUpdates.primaryEmail = identity.primaryEmail;
|
||||
}
|
||||
await store.touchAccount(account.id, nowIso, accountUpdates);
|
||||
}
|
||||
|
||||
const buildLinkedIdentityInput = (accountId: string): UpsertLinkedIdentityInput => {
|
||||
const linkedIdentityInput: UpsertLinkedIdentityInput = {
|
||||
accountId,
|
||||
accountId: account.id,
|
||||
provider: identity.provider,
|
||||
providerUserId: identity.providerUserId,
|
||||
scopes: tokenSet.scope,
|
||||
|
|
@ -1424,100 +1085,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
if (tokenSet.expiresAt !== undefined) {
|
||||
linkedIdentityInput.tokenExpiresAt = tokenSet.expiresAt;
|
||||
}
|
||||
return linkedIdentityInput;
|
||||
};
|
||||
|
||||
// A link is two-phase. The callback cannot tie itself to the browser
|
||||
// that began the link (start is a server-to-server POST), so whoever
|
||||
// completes the Google login here may be someone the initiator sent
|
||||
// the URL to. Nothing is written to the account: the verified identity
|
||||
// waits as a pending link, and only a confirm bearing the access token
|
||||
// of the account that began the link attaches it.
|
||||
if (boundAccountId && callbackDescriptor.firstLogin === "requires_link") {
|
||||
const pendingLink = await store.createPendingLink({
|
||||
appSlug: statePayload.app_slug,
|
||||
accountId: boundAccountId,
|
||||
provider: identity.provider,
|
||||
providerUserId: identity.providerUserId,
|
||||
identity: buildLinkedIdentityInput(boundAccountId),
|
||||
returnTo: statePayload.return_to,
|
||||
createdAt: nowIso,
|
||||
expiresAt: new Date(Date.now() + config.completionTtlSeconds * 1000).toISOString(),
|
||||
});
|
||||
await store.createAuditEvent({
|
||||
accountId: boundAccountId,
|
||||
appSlug: statePayload.app_slug,
|
||||
eventType: "link_pending",
|
||||
// Never the pending link's id: it is the confirm secret.
|
||||
eventPayloadJson: { provider: identity.provider, expiresAt: pendingLink.expiresAt },
|
||||
createdAt: nowIso,
|
||||
});
|
||||
const pendingBody = {
|
||||
status: "link_pending" as const,
|
||||
provider: request.params.provider,
|
||||
mode: statePayload.mode,
|
||||
appSlug: statePayload.app_slug,
|
||||
returnTo: statePayload.return_to,
|
||||
pendingLinkId: pendingLink.id,
|
||||
expiresAt: pendingLink.expiresAt,
|
||||
confirmEndpoint: `${config.publicBaseUrl}/v1/apps/${statePayload.app_slug}/links/confirm`,
|
||||
};
|
||||
if (handoff.kind === "backend_callback") {
|
||||
await maybeDeliverBackendHandoff(handoff, {
|
||||
source: "heimdall",
|
||||
kind: "oauth_result",
|
||||
handoffKind: "backend_callback",
|
||||
attemptId: handoff.attemptId,
|
||||
connection: statePayload.connection,
|
||||
...pendingBody,
|
||||
});
|
||||
}
|
||||
if (prefersHtml(request.headers.accept)) {
|
||||
reply.type("text/html; charset=utf-8");
|
||||
return reply.send(
|
||||
renderBrowserHandoffPage({
|
||||
handoffKind: handoff.kind,
|
||||
status: "success",
|
||||
provider: request.params.provider,
|
||||
appSlug: statePayload.app_slug,
|
||||
mode: statePayload.mode,
|
||||
returnTo: statePayload.return_to,
|
||||
pendingLinkId: pendingLink.id,
|
||||
...(handoff.attemptId ? { attemptId: handoff.attemptId } : {}),
|
||||
})
|
||||
);
|
||||
}
|
||||
reply.code(201);
|
||||
return pendingBody;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
const createAccountInput: CreateAccountInput = {
|
||||
createdAt: nowIso,
|
||||
lastSeenAt: nowIso,
|
||||
};
|
||||
const displayName = identity.displayName ?? identity.username;
|
||||
if (displayName !== undefined) {
|
||||
createAccountInput.displayName = displayName;
|
||||
}
|
||||
if (identity.primaryEmail !== undefined) {
|
||||
createAccountInput.primaryEmail = identity.primaryEmail;
|
||||
}
|
||||
account = await store.createAccount(createAccountInput);
|
||||
} else {
|
||||
const accountUpdates: { displayName?: string; primaryEmail?: string } = {};
|
||||
const displayName = identity.displayName ?? identity.username;
|
||||
// A link adds an identity; it does not rewrite the account's profile.
|
||||
if (displayName !== undefined && statePayload.mode !== "link") {
|
||||
accountUpdates.displayName = displayName;
|
||||
}
|
||||
if (identity.primaryEmail !== undefined && statePayload.mode !== "link") {
|
||||
accountUpdates.primaryEmail = identity.primaryEmail;
|
||||
}
|
||||
await store.touchAccount(account.id, nowIso, accountUpdates);
|
||||
}
|
||||
|
||||
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
|
||||
await store.upsertLinkedIdentity(linkedIdentityInput);
|
||||
|
||||
const entitlementEvaluation = await runtime.evaluateEntitlements({
|
||||
config,
|
||||
|
|
@ -1533,27 +1101,13 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
for (const snapshot of entitlementEvaluation.snapshots) {
|
||||
await store.upsertEntitlementSnapshot(snapshot);
|
||||
}
|
||||
// The account's other linked identities count too: a member who
|
||||
// returns through a provider that grants nothing (Google) keeps what
|
||||
// Discord or Patreon grant. They are evaluated with the app's
|
||||
// start-time policy, the same call the refresh route makes.
|
||||
const linkedFacts = await evaluateStoredEntitlements({
|
||||
config,
|
||||
store,
|
||||
tokenCustody,
|
||||
oauthRuntimes,
|
||||
appSlug: statePayload.app_slug,
|
||||
accountId: account.id,
|
||||
entitlementPolicies: statePayload.entitlement_policy ? [statePayload.entitlement_policy] : [],
|
||||
skipProvider: identity.provider,
|
||||
});
|
||||
|
||||
const grants = await store.listActiveGrants(account.id, statePayload.app_slug, nowIso);
|
||||
const issueInput: IssueAccessClaimInput = {
|
||||
appSlug: statePayload.app_slug,
|
||||
accountId: account.id,
|
||||
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
|
||||
facts: [...new Set([...entitlementEvaluation.facts, ...linkedFacts]), ...buildGrantFacts(grants)],
|
||||
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
|
||||
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
|
||||
};
|
||||
if (account.displayName !== undefined) {
|
||||
|
|
@ -1692,7 +1246,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
accountId: account.id,
|
||||
sessionId: issued.session.sessionId,
|
||||
returnTo: statePayload.return_to,
|
||||
...(statePayload.connection ? { targetId: statePayload.connection.targetId } : {}),
|
||||
createdAt: nowIso,
|
||||
expiresAt: completionExpiresAt,
|
||||
payloadJson: completionPayload as unknown as Record<string, unknown>,
|
||||
|
|
@ -1754,16 +1307,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
// JSON.parse quotes the provider's response body. The audit event
|
||||
// keeps the error's class, its code, and a provider's HTTP status.
|
||||
const status = (error as { status?: unknown }).status;
|
||||
// A link refusal is a typed decision with a static message; every
|
||||
// other failure keeps the opaque answer above.
|
||||
const refusal = error instanceof LinkRefusal ? error : null;
|
||||
const failureCode = refusal?.code ?? "oauth_callback_failed";
|
||||
const failure = refusal?.message ?? "The provider callback could not be completed.";
|
||||
const failure = "The provider callback could not be completed.";
|
||||
const nowIso = new Date().toISOString();
|
||||
await denyBrowserAttempt(failureCode);
|
||||
await denyBrowserAttempt("oauth_callback_failed");
|
||||
await store.createAuditEvent({
|
||||
appSlug: statePayload.app_slug,
|
||||
eventType: refusal ? "oauth_link_refused" : "oauth_callback_failed",
|
||||
eventType: "oauth_callback_failed",
|
||||
eventPayloadJson: {
|
||||
provider: request.params.provider,
|
||||
mode: statePayload.mode,
|
||||
|
|
@ -1786,7 +1335,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
mode: statePayload.mode,
|
||||
returnTo: statePayload.return_to,
|
||||
connection: statePayload.connection,
|
||||
error: failureCode,
|
||||
error: "oauth_callback_failed",
|
||||
errorDescription: failure,
|
||||
});
|
||||
} catch {}
|
||||
|
|
@ -1803,15 +1352,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
mode: statePayload.mode,
|
||||
returnTo: statePayload.return_to,
|
||||
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
|
||||
error: failureCode,
|
||||
error: "oauth_callback_failed",
|
||||
errorDescription: failure,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
reply.code(refusal ? 403 : 502);
|
||||
reply.code(502);
|
||||
return {
|
||||
error: failureCode,
|
||||
error: "oauth_callback_failed",
|
||||
detail: failure,
|
||||
provider: request.params.provider,
|
||||
appSlug: statePayload.app_slug,
|
||||
|
|
@ -1821,58 +1370,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
}
|
||||
);
|
||||
|
||||
// Second phase of a link. The callback only recorded the verified identity;
|
||||
// this writes it, and only for the account whose access token began the
|
||||
// link. The bearer is the same proof start demanded, so a stranger who
|
||||
// completed the provider login (or holds the id) attaches nothing.
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: { pendingLinkId: string } }>(
|
||||
"/v1/apps/:appSlug/links/confirm",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["appSlug"],
|
||||
additionalProperties: false,
|
||||
properties: { appSlug: { type: "string", enum: [...appSlugs] } },
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["pendingLinkId"],
|
||||
additionalProperties: false,
|
||||
properties: { pendingLinkId: { type: "string", minLength: 1 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const accountId = await resolveLinkAccount({ config, keys, store }, request.params.appSlug, request.headers.authorization);
|
||||
if (!accountId) {
|
||||
reply.code(401);
|
||||
return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." };
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
const pending = await store.consumePendingLink(request.params.appSlug, request.body.pendingLinkId, accountId, nowIso);
|
||||
if (!pending) {
|
||||
reply.code(410);
|
||||
return { error: "invalid_or_expired_pending_link" };
|
||||
}
|
||||
const holder = await store.findAccountByLinkedIdentity(pending.provider, pending.providerUserId);
|
||||
if (holder && holder.id !== accountId) {
|
||||
reply.code(409);
|
||||
return { error: "identity_linked_elsewhere" };
|
||||
}
|
||||
await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso });
|
||||
await store.createAuditEvent({
|
||||
accountId,
|
||||
appSlug: pending.appSlug,
|
||||
eventType: "link_confirmed",
|
||||
eventPayloadJson: { provider: pending.provider },
|
||||
createdAt: nowIso,
|
||||
});
|
||||
reply.code(201);
|
||||
return { status: "linked", provider: pending.provider, accountId, returnTo: pending.returnTo };
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: RedeemAuthCompletionRequest }>(
|
||||
"/v1/apps/:appSlug/auth-completions/redeem",
|
||||
{
|
||||
|
|
@ -1891,38 +1388,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
additionalProperties: false,
|
||||
properties: {
|
||||
completionCode: { type: "string", minLength: 1 },
|
||||
targetId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const nowIso = new Date().toISOString();
|
||||
// Sign-in and connect completions are redeemed by the browser flow, so
|
||||
// the route stays open to it. A link completion was minted for an
|
||||
// initiator the authenticated app named at start; only that app's
|
||||
// backend redeems it. Redeem is also where the connection target is
|
||||
// held to what the signed state named. Both refusals read the row
|
||||
// without consuming it, so a wrong caller or target cannot burn the code.
|
||||
const pending = await store.findAuthCompletion(request.params.appSlug, request.body.completionCode);
|
||||
if (pending && !pending.consumedAt && pending.expiresAt > nowIso) {
|
||||
if (pending.mode === "link" && !resolveAppCaller(config, request.params.appSlug, request.headers)) {
|
||||
reply.code(401);
|
||||
return { error: "app_auth_required" };
|
||||
}
|
||||
if (pending.targetId !== undefined && pending.targetId !== request.body.targetId) {
|
||||
await store.createAuditEvent({
|
||||
accountId: pending.accountId,
|
||||
sessionId: pending.sessionId,
|
||||
appSlug: pending.appSlug,
|
||||
eventType: "auth_completion_target_refused",
|
||||
eventPayloadJson: { provider: pending.provider, mode: pending.mode },
|
||||
createdAt: nowIso,
|
||||
});
|
||||
reply.code(403);
|
||||
return { error: "completion_target_mismatch" };
|
||||
}
|
||||
}
|
||||
const completion = await store.consumeAuthCompletion(request.params.appSlug, request.body.completionCode, nowIso);
|
||||
|
||||
if (!completion) {
|
||||
|
|
|
|||
|
|
@ -39,16 +39,7 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase {
|
|||
errorDescription?: string;
|
||||
}
|
||||
|
||||
/** A verified link that waits for the initiating account's confirm: no session, no tokens. */
|
||||
export interface BackendHandoffLinkPendingPayload extends BackendHandoffBase {
|
||||
status: "link_pending";
|
||||
mode: OAuthMode;
|
||||
pendingLinkId: string;
|
||||
expiresAt: string;
|
||||
confirmEndpoint: string;
|
||||
}
|
||||
|
||||
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload | BackendHandoffLinkPendingPayload;
|
||||
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload;
|
||||
|
||||
export async function deliverBackendHandoff(callbackUrl: string, payload: BackendHandoffPayload): Promise<void> {
|
||||
const response = await fetch(callbackUrl, {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -49,8 +47,6 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string {
|
|||
|
||||
if (payload.status === "success" && payload.completionCode) {
|
||||
fragment.set("heimdall_completion_code", payload.completionCode);
|
||||
} else if (payload.status === "success" && payload.pendingLinkId) {
|
||||
fragment.set("heimdall_pending_link_id", payload.pendingLinkId);
|
||||
} else {
|
||||
if (payload.status === "error") {
|
||||
fragment.set("heimdall_error", payload.error);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick", "google"] as const;
|
||||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
|
||||
|
||||
export type Provider = (typeof providers)[number];
|
||||
|
||||
|
|
@ -73,8 +73,6 @@ export interface OAuthStartRequest {
|
|||
mode: OAuthMode;
|
||||
returnTo: string;
|
||||
connection?: OAuthConnectionBinding;
|
||||
/** Link mode only: the account the authenticated app says is linking. Carried in the signed state. */
|
||||
initiatingAccountId?: string;
|
||||
handoff?: OAuthHandoff;
|
||||
requestedScopes?: string[];
|
||||
entitlementPolicy?: OAuthEntitlementPolicy;
|
||||
|
|
@ -94,11 +92,8 @@ export interface OAuthStatePayload {
|
|||
mode: OAuthMode;
|
||||
return_to: string;
|
||||
connection: OAuthConnectionBinding | null;
|
||||
initiator_account_id: string | null;
|
||||
handoff: OAuthHandoff | null;
|
||||
entitlement_policy: OAuthEntitlementPolicy | null;
|
||||
/** The start's requestedScopes, carried so the launch hop can build the provider URL from the signed state alone. */
|
||||
requested_scopes: string[] | null;
|
||||
}
|
||||
|
||||
export interface LinkedIdentityInput {
|
||||
|
|
@ -110,8 +105,6 @@ export interface LinkedIdentityInput {
|
|||
|
||||
export interface RedeemAuthCompletionRequest {
|
||||
completionCode: string;
|
||||
/** Required when the completion was minted for a connection target; must equal it. */
|
||||
targetId?: string;
|
||||
}
|
||||
|
||||
export type HeimdallAuthAttemptStatus = "pending" | "completed" | "denied" | "consumed" | "expired";
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
import { createHash, createPublicKey, sign, verify, type KeyObject } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Google sign-in as OpenID Connect: the id_token is the identity, and this
|
||||
* file is the one place that decides whether to believe it. The subject
|
||||
* (`sub`) is the account key; the email is a claim about the subject, never
|
||||
* its key.
|
||||
*/
|
||||
|
||||
export const GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"] as const;
|
||||
export const GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs";
|
||||
|
||||
const CLOCK_LEEWAY_SECONDS = 60;
|
||||
const DEFAULT_JWKS_TTL_SECONDS = 3600;
|
||||
const MAX_JWKS_TTL_SECONDS = 24 * 3600;
|
||||
/** An unknown `kid` refetches the key set at most this often. */
|
||||
const MIN_JWKS_REFETCH_MS = 60_000;
|
||||
|
||||
export type GoogleIdTokenFailure =
|
||||
| "malformed"
|
||||
| "unsupported_algorithm"
|
||||
| "unknown_key"
|
||||
| "bad_signature"
|
||||
| "bad_issuer"
|
||||
| "bad_audience"
|
||||
| "expired"
|
||||
| "not_yet_valid"
|
||||
| "bad_nonce"
|
||||
| "missing_subject"
|
||||
| "email_missing"
|
||||
| "email_not_verified"
|
||||
| "jwks_unavailable";
|
||||
|
||||
/** A refusal that names the rule that failed and quotes nothing from the token. */
|
||||
export class GoogleIdTokenError extends Error {
|
||||
readonly code: GoogleIdTokenFailure;
|
||||
|
||||
constructor(code: GoogleIdTokenFailure) {
|
||||
super(`Google id_token refused (${code}).`);
|
||||
this.name = "GoogleIdTokenError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GoogleIdentityClaims {
|
||||
sub: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
hd?: string;
|
||||
}
|
||||
|
||||
interface Jwk {
|
||||
kid?: string;
|
||||
kty?: string;
|
||||
alg?: string;
|
||||
n?: string;
|
||||
e?: string;
|
||||
}
|
||||
|
||||
let cachedKeys: { keys: Map<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"),
|
||||
};
|
||||
}
|
||||
45
src/index.ts
45
src/index.ts
|
|
@ -1,3 +1,4 @@
|
|||
import path from "node:path";
|
||||
import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js";
|
||||
import { buildApp } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
|
|
@ -6,16 +7,14 @@ import { publishHeimdallOdinState } from "./odin-publication.js";
|
|||
import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js";
|
||||
import type { CultNetOperationServer } from "cultnet-ts";
|
||||
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
|
||||
import { openPresenceAuthority } from "./runtime-presence.js";
|
||||
import {
|
||||
buildPresenceStatement,
|
||||
nextPublisherSequence,
|
||||
readRuntimeBundleFacts,
|
||||
type RuntimeBundleFacts,
|
||||
} from "./runtime-presence.js";
|
||||
|
||||
const config = loadConfig();
|
||||
// Under Idunn the launch's presence keys, bundle facts and publisher sequence
|
||||
// open here, before anything listens. A launch-contract fault throws, uncaught,
|
||||
// and the process exits non-zero with no listener: Idunn fails Warming in
|
||||
// seconds rather than waiting on a candidate that can never sign.
|
||||
const presenceAuthority = config.idunnRuntimeBundlePath
|
||||
? await openPresenceAuthority(config)
|
||||
: undefined;
|
||||
const app = await buildApp({ config });
|
||||
const versePulseIntervalMs = 60_000;
|
||||
const healthPulseIntervalMs = 10_000;
|
||||
|
|
@ -71,6 +70,13 @@ async function publishVerseState(): Promise<void> {
|
|||
await publishHeimdallOdinState(config, pulse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read once and keep: the bundle is Idunn's immutable record of this launch and
|
||||
* cannot change while the process lives, so re-reading it every pulse would be
|
||||
* work that can only ever return the same answer or fail.
|
||||
*/
|
||||
let runtimeBundleFacts: RuntimeBundleFacts | undefined;
|
||||
|
||||
async function publishHealthState(): Promise<void> {
|
||||
const pulse = createHeimdallRuntimePulse(config);
|
||||
|
||||
|
|
@ -88,17 +94,32 @@ async function publishHealthState(): Promise<void> {
|
|||
? buildHeimdallHealthDetail(config, pulse)
|
||||
: `Heimdall candidate warming; ${lease.reason}`;
|
||||
|
||||
if (!presenceAuthority) {
|
||||
if (!config.idunnRuntimeBundlePath) {
|
||||
// Outside Idunn there is no launch to attest and nothing that admits the
|
||||
// statement; publishing is simply not part of that world.
|
||||
return;
|
||||
}
|
||||
const state = lease.mayWrite ? "active" : "warming";
|
||||
const presence = presenceAuthority.sign(state, detail, Date.parse(pulse.updatedAt));
|
||||
runtimeBundleFacts ??= await readRuntimeBundleFacts(config.idunnRuntimeBundlePath);
|
||||
|
||||
const observedAtUnixMillis = Date.parse(pulse.updatedAt);
|
||||
const presence = await buildPresenceStatement({
|
||||
bundle: runtimeBundleFacts,
|
||||
state: lease.mayWrite ? "active" : "warming",
|
||||
detail,
|
||||
boundEndpoint: `http://${config.host}:${config.port}`,
|
||||
// A warming candidate holds no lease and must not claim one; the contract
|
||||
// refuses the pair, and Idunn needs the warming statement to promote it.
|
||||
writeLeaseSha256: null,
|
||||
observedAtUnixMillis,
|
||||
publisherSequence: await nextPublisherSequence(
|
||||
path.join(config.dataRoot, "runtime-presence-sequence"),
|
||||
),
|
||||
providerHealthIdentityPath: config.providerHealthIdentityPath,
|
||||
});
|
||||
|
||||
await publishIdunnRudpHealth(config, {
|
||||
daemonId: config.daemonId,
|
||||
state,
|
||||
state: lease.mayWrite ? "active" : "warming",
|
||||
detail,
|
||||
observedAt: pulse.updatedAt,
|
||||
presence,
|
||||
|
|
|
|||
50
src/oauth.ts
50
src/oauth.ts
|
|
@ -7,7 +7,6 @@ import {
|
|||
} from "./contracts.js";
|
||||
import { type HeimdallConfig } from "./config.js";
|
||||
import { entitlementFacts } from "./facts.js";
|
||||
import { verifyGoogleIdToken } from "./google-oidc.js";
|
||||
import { providerCatalog } from "./providers.js";
|
||||
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
|
||||
|
||||
|
|
@ -50,22 +49,13 @@ export interface OAuthProviderRuntime {
|
|||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
/** PKCE verifier of an OIDC provider's request (RFC 7636). */
|
||||
codeVerifier?: string;
|
||||
}): Promise<OAuthTokenSet>;
|
||||
refreshAccessToken?(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ExpiringTokenSet>;
|
||||
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>;
|
||||
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
|
||||
evaluateEntitlements(options: {
|
||||
config: HeimdallConfig;
|
||||
callback: OAuthCallbackContext;
|
||||
|
|
@ -154,7 +144,7 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
|
|||
}
|
||||
|
||||
export type TokenGrant =
|
||||
| { grant_type: "authorization_code"; code: string; redirect_uri: string; code_verifier?: string }
|
||||
| { grant_type: "authorization_code"; code: string; redirect_uri: string }
|
||||
| { grant_type: "refresh_token"; refresh_token: string };
|
||||
|
||||
/**
|
||||
|
|
@ -259,7 +249,6 @@ function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchan
|
|||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
redirect_uri: options.redirectUri,
|
||||
...(options.codeVerifier !== undefined ? { code_verifier: options.codeVerifier } : {}),
|
||||
});
|
||||
// A rotating provider's refresh token is the only way back to the
|
||||
// credential: a grant that arrives without one would be stored as a row
|
||||
|
|
@ -538,31 +527,6 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
|
|||
return identity;
|
||||
}
|
||||
|
||||
async function resolveGoogleIdentity(options: {
|
||||
config: HeimdallConfig;
|
||||
tokenSet?: OAuthTokenSet;
|
||||
oidcNonce?: string;
|
||||
}): Promise<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;
|
||||
|
|
@ -981,15 +945,6 @@ const youtubeRuntime: OAuthProviderRuntime = {
|
|||
},
|
||||
};
|
||||
|
||||
const googleRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("google"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("google"),
|
||||
resolveIdentity: resolveGoogleIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const spotifyRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("spotify"),
|
||||
|
|
@ -1031,6 +986,5 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
|
|||
youtube: youtubeRuntime,
|
||||
spotify: spotifyRuntime,
|
||||
joystick: joystickRuntime,
|
||||
google: googleRuntime,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ async function beginAuth(
|
|||
throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied."));
|
||||
}
|
||||
const authorizationUrl = String(startPayload.authorizationUrl ?? "");
|
||||
const authorizationOrigin = new URL(authorizationUrl).origin;
|
||||
return {
|
||||
status: "accepted",
|
||||
payloadSchema: "heimdall.auth_begin_receipt.v1",
|
||||
|
|
@ -315,7 +316,7 @@ async function beginAuth(
|
|||
expiresAt: attempt.expiresAt,
|
||||
navigation: {
|
||||
url: authorizationUrl,
|
||||
allowedOrigins: [new URL(config.publicBaseUrl).origin],
|
||||
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
sign,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { closeSync, fstatSync, readSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import { SingleFileMessagePackBackingStore, type CultCacheEnvelope } from "@gamecult/cultcache-ts";
|
||||
import { readPrivateStoreRecord } from "./idunn-store.js";
|
||||
|
|
@ -71,7 +71,7 @@ export async function openOrEnrollProviderHealthIdentity(path: string): Promise<
|
|||
if (existing.length !== 1) {
|
||||
throw new Error("Provider-health identity store must contain exactly one record.");
|
||||
}
|
||||
return decodePrivateIdentity(existing[0]!.payload, await readMachineId(), "bin");
|
||||
return decodePrivateIdentity(existing[0]!.payload, await readMachineId());
|
||||
}
|
||||
|
||||
const PRESENCE_IDENTITY_FD_NAME = "gamecult-runtime-presence-identity";
|
||||
|
|
@ -124,50 +124,19 @@ export function passedDescriptor(
|
|||
*/
|
||||
export async function openProviderHealthIdentity(
|
||||
path: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
machineId: () => Promise<string> = readMachineId
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): Promise<PrivateIdentity> {
|
||||
const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env);
|
||||
if (descriptor === undefined) {
|
||||
if (env.GAMECULT_IDUNN_RUNTIME_BUNDLE) {
|
||||
// Inside an Idunn generation a self-enrolled key is one Idunn does not
|
||||
// trust, and its private store would land in a state root the next
|
||||
// generation must share. The descriptor is the only identity there.
|
||||
throw new Error(
|
||||
`Idunn runtime bundle is set but the ${PRESENCE_IDENTITY_FD_NAME} descriptor was not passed; refusing to self-enroll`
|
||||
);
|
||||
}
|
||||
return openOrEnrollProviderHealthIdentity(path);
|
||||
}
|
||||
|
||||
const payload = readPrivateStoreRecord(
|
||||
readPassedDescriptor(descriptor),
|
||||
readFileSync(descriptor),
|
||||
"Idunn runtime presence identity"
|
||||
);
|
||||
|
||||
// The descriptor's writer is the Rust owner, whose key fields are integer arrays.
|
||||
return decodePrivateIdentity(payload, await machineId(), "int-array");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a passed descriptor whole, from offset 0, and close it. A descriptor is
|
||||
* a one-shot input: reading it again starts at the offset the last read left,
|
||||
* which is the end, so the owner reads once at open and keeps what it decoded.
|
||||
*/
|
||||
export function readPassedDescriptor(descriptor: number): Buffer {
|
||||
try {
|
||||
const size = fstatSync(descriptor).size;
|
||||
const bytes = Buffer.alloc(size);
|
||||
let read = 0;
|
||||
while (read < size) {
|
||||
const count = readSync(descriptor, bytes, read, size - read, read);
|
||||
if (count === 0) break;
|
||||
read += count;
|
||||
}
|
||||
return bytes.subarray(0, read);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
return decodePrivateIdentity(payload, await readMachineId());
|
||||
}
|
||||
|
||||
export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array {
|
||||
|
|
@ -199,58 +168,23 @@ export function providerHealthPublicIdentity(identity: PrivateIdentity): {
|
|||
return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which writer produced the record. The self-enrolling store this process
|
||||
* writes keeps key fields as MessagePack bin; the Rust owner (cultnet-rs
|
||||
* service_identity.rs) serialises a Vec<u8> without serde_bytes, so its key
|
||||
* fields are arrays of integers. Each source is read in its own shape and the
|
||||
* other is refused.
|
||||
*/
|
||||
type KeyShape = "bin" | "int-array";
|
||||
|
||||
function keyBytes(value: unknown, shape: KeyShape, field: string): Uint8Array {
|
||||
const bytes =
|
||||
shape === "bin"
|
||||
? value instanceof Uint8Array
|
||||
? value
|
||||
: undefined
|
||||
: Array.isArray(value) &&
|
||||
value.every((byte) => typeof byte === "number" && Number.isInteger(byte) && byte >= 0 && byte <= 255)
|
||||
? Uint8Array.from(value as number[])
|
||||
: undefined;
|
||||
if (bytes === undefined || bytes.length !== 32) {
|
||||
throw new Error(
|
||||
`Provider-health identity key shape check failed: ${field} is not a 32-byte ${shape === "bin" ? "binary" : "integer-array"} key field.`
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodePrivateIdentity(payload: Uint8Array, machineId: string, shape: KeyShape): PrivateIdentity {
|
||||
function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity {
|
||||
const value = decode(payload);
|
||||
if (!Array.isArray(value) || value.length !== 10) {
|
||||
throw new Error("Provider-health identity schema check failed: payload is not a 10-field record.");
|
||||
throw new Error("Provider-health identity payload is malformed.");
|
||||
}
|
||||
const [schema, identityId, publicKeyField, protectedSeedField, protector, binding, version] = value;
|
||||
if (schema !== PRIVATE_SCHEMA) {
|
||||
throw new Error("Provider-health identity schema check failed: unexpected schema id.");
|
||||
}
|
||||
if (typeof identityId !== "string") {
|
||||
throw new Error("Provider-health identity id check failed: id is not a string.");
|
||||
}
|
||||
const publicKey = keyBytes(publicKeyField, shape, "public key");
|
||||
const protectedSeed = keyBytes(protectedSeedField, shape, "protected seed");
|
||||
if (protector !== "linux_file_mode_machine_id_binding") {
|
||||
throw new Error("Provider-health identity protector check failed: unexpected protector.");
|
||||
}
|
||||
if (binding !== protectorBinding(machineId)) {
|
||||
throw new Error("Provider-health identity binding check failed: not bound to this host's machine id.");
|
||||
}
|
||||
if (version !== "v1") {
|
||||
throw new Error("Provider-health identity version check failed: unexpected version.");
|
||||
}
|
||||
if (identityId !== deriveIdentityId(publicKey)) {
|
||||
throw new Error("Provider-health identity id check failed: id does not derive from the public key.");
|
||||
const [schema, identityId, publicKey, protectedSeed, protector, binding, version] = value;
|
||||
if (
|
||||
schema !== PRIVATE_SCHEMA ||
|
||||
typeof identityId !== "string" ||
|
||||
!(publicKey instanceof Uint8Array) || publicKey.length !== 32 ||
|
||||
!(protectedSeed instanceof Uint8Array) || protectedSeed.length !== 32 ||
|
||||
protector !== "linux_file_mode_machine_id_binding" ||
|
||||
binding !== protectorBinding(machineId) ||
|
||||
version !== "v1" ||
|
||||
identityId !== deriveIdentityId(publicKey)
|
||||
) {
|
||||
throw new Error("Provider-health identity does not belong to this host or profile.");
|
||||
}
|
||||
const seed = xorSeed(protectedSeed, binding);
|
||||
const privateKey = privateKeyFromSeed(seed);
|
||||
|
|
|
|||
|
|
@ -22,18 +22,6 @@ 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> = {
|
||||
|
|
@ -47,7 +35,6 @@ 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",
|
||||
},
|
||||
|
|
@ -61,7 +48,6 @@ 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",
|
||||
},
|
||||
|
|
@ -75,7 +61,6 @@ 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",
|
||||
},
|
||||
|
|
@ -89,7 +74,6 @@ 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",
|
||||
},
|
||||
|
|
@ -108,7 +92,6 @@ 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: {
|
||||
|
|
@ -131,26 +114,9 @@ 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",
|
||||
|
|
@ -161,7 +127,6 @@ 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",
|
||||
},
|
||||
|
|
@ -173,8 +138,6 @@ export function buildAuthorizationUrl(options: {
|
|||
redirectUri: string;
|
||||
state: string;
|
||||
requestedScopes: string[] | undefined;
|
||||
/** For an OIDC provider: the nonce, and the S256 challenge of the PKCE verifier. */
|
||||
oidc?: { nonce: string; codeChallenge: string };
|
||||
}): string {
|
||||
const descriptor = providerCatalog[options.provider];
|
||||
const url = new URL(descriptor.authorizationEndpoint);
|
||||
|
|
@ -185,14 +148,6 @@ export function buildAuthorizationUrl(options: {
|
|||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", scopes.join(" "));
|
||||
url.searchParams.set("state", options.state);
|
||||
if (descriptor.oidc) {
|
||||
if (!options.oidc) {
|
||||
throw new Error(`${descriptor.displayName} is an OIDC provider: its authorization URL needs a nonce and a PKCE challenge.`);
|
||||
}
|
||||
url.searchParams.set("nonce", options.oidc.nonce);
|
||||
url.searchParams.set("code_challenge", options.oidc.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
|
||||
import path from "node:path";
|
||||
import { createHash, createPrivateKey, type KeyObject } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile, writeFile, rename } from "node:fs/promises";
|
||||
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import {
|
||||
|
|
@ -32,14 +34,11 @@ import {
|
|||
type RuntimePresenceState,
|
||||
} from "cultnet-ts";
|
||||
|
||||
import type { HeimdallConfig } from "./config.js";
|
||||
import { readIdunnStoreRecord } from "./idunn-store.js";
|
||||
import {
|
||||
openProviderHealthIdentity,
|
||||
passedDescriptor,
|
||||
readPassedDescriptor,
|
||||
signWithIdentity,
|
||||
type PrivateIdentity,
|
||||
} from "./provider-health-identity.js";
|
||||
|
||||
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
|
@ -179,37 +178,70 @@ export async function readRuntimeBundleFacts(bundlePath: string): Promise<Runtim
|
|||
}
|
||||
|
||||
/**
|
||||
* The publisher sequence a process stamps on its presence statements.
|
||||
* Open the activation signing key Idunn passed as a descriptor.
|
||||
*
|
||||
* Odin compares `publisher_sequence` only against stored presence of the same
|
||||
* activation, and Idunn mints a fresh activation for every start, continuity
|
||||
* restarts included. So the sequence need only strictly increase within this
|
||||
* process; nothing has to survive a restart, and nothing is written to the
|
||||
* state root. Muninn derives its sequence the same way: the observation clock,
|
||||
* never below the previous value plus one.
|
||||
* The credential is exactly 32 bytes: the raw Ed25519 seed. It is passed as a
|
||||
* parent-only descriptor and not as a path, so only the process Idunn launched
|
||||
* can produce this half of the proof.
|
||||
*/
|
||||
export class PublisherSequence {
|
||||
private last = 0;
|
||||
|
||||
next(observedAtUnixMillis: number): number {
|
||||
if (!Number.isSafeInteger(observedAtUnixMillis)) {
|
||||
throw new Error("observedAtUnixMillis is not a safe integer");
|
||||
}
|
||||
this.last = Math.max(observedAtUnixMillis, this.last + 1);
|
||||
return this.last;
|
||||
export function openActivationSigner(env: NodeJS.ProcessEnv = process.env): KeyObject | undefined {
|
||||
const descriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env);
|
||||
if (descriptor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const seed = readFileSync(descriptor);
|
||||
if (seed.length !== 32) {
|
||||
throw new Error("Idunn runtime activation credential is not a 32-byte seed");
|
||||
}
|
||||
return createPrivateKey({
|
||||
key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The publisher sequence must strictly increase for the life of a signer
|
||||
* identity, because Idunn treats a repeat or a regression as a replay and
|
||||
* refuses the observation.
|
||||
*
|
||||
* An in-process counter is therefore not enough: a restart would reset it and
|
||||
* every statement afterwards would be refused until the count climbed past the
|
||||
* old high-water mark. It lives beside the target's state instead, and is
|
||||
* advanced before use.
|
||||
*/
|
||||
export async function nextPublisherSequence(sequencePath: string): Promise<number> {
|
||||
let current = 0;
|
||||
try {
|
||||
const raw = (await readFile(sequencePath, "utf8")).trim();
|
||||
const parsed = Number(raw);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) {
|
||||
current = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const next = current + 1;
|
||||
// Written through a temporary file: a torn write here would look like a
|
||||
// regression and cost the target every future observation.
|
||||
const temporary = `${sequencePath}.next`;
|
||||
await writeFile(temporary, `${next}\n`, { mode: 0o600 });
|
||||
await rename(temporary, sequencePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface PresenceStatementInputs {
|
||||
bundle: RuntimeBundleFacts;
|
||||
identity: PrivateIdentity;
|
||||
activationKey: KeyObject;
|
||||
state: RuntimePresenceState;
|
||||
detail: string;
|
||||
boundEndpoint: string | null;
|
||||
writeLeaseSha256: string | null;
|
||||
observedAtUnixMillis: number;
|
||||
publisherSequence: number;
|
||||
providerHealthIdentityPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface PresenceStatement {
|
||||
|
|
@ -218,14 +250,24 @@ export interface PresenceStatement {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build and dual-sign one presence statement from keys and facts already open.
|
||||
* Build and dual-sign one presence statement.
|
||||
*
|
||||
* Returns the target alongside the bytes because the document key must equal
|
||||
* the signed target; Odin refuses the pair when they differ, and that is the
|
||||
* one part of the envelope the transport cannot infer.
|
||||
*/
|
||||
export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement {
|
||||
const { identity } = inputs;
|
||||
export async function buildPresenceStatement(
|
||||
inputs: PresenceStatementInputs,
|
||||
): Promise<PresenceStatement> {
|
||||
const env = inputs.env ?? process.env;
|
||||
const activationKey = openActivationSigner(env);
|
||||
if (activationKey === undefined) {
|
||||
throw new Error(
|
||||
"Idunn passed no runtime activation credential, so this process cannot prove it is the " +
|
||||
"incarnation Idunn started",
|
||||
);
|
||||
}
|
||||
const identity = await openProviderHealthIdentity(inputs.providerHealthIdentityPath, env);
|
||||
|
||||
const presence: RuntimePresenceHealth = {
|
||||
target: inputs.bundle.target,
|
||||
|
|
@ -253,7 +295,7 @@ export function buildPresenceStatement(inputs: PresenceStatementInputs): Presenc
|
|||
const proof = runtimePresenceProofPayload(presence);
|
||||
const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof));
|
||||
const activationSignature = signWithIdentity(
|
||||
{ ...identity, privateKey: inputs.activationKey },
|
||||
{ ...identity, privateKey: activationKey },
|
||||
runtimePresenceActivationSigningMessage(proof),
|
||||
);
|
||||
|
||||
|
|
@ -262,72 +304,3 @@ export function buildPresenceStatement(inputs: PresenceStatementInputs): Presenc
|
|||
payload: encodeRuntimePresenceHealth(presence, signature, activationSignature),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PresenceAuthority {
|
||||
sign(
|
||||
state: RuntimePresenceState,
|
||||
detail: string,
|
||||
observedAtUnixMillis: number,
|
||||
): PresenceStatement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open everything this launch's presence statements stand on, once, before the
|
||||
* process listens: Idunn's bundle facts, the activation seed and the presence
|
||||
* identity (each descriptor is read whole and closed), and the one publisher
|
||||
* sequence. It owns them for the life of the process; a pulse supplies only its
|
||||
* state, detail and observation time.
|
||||
*
|
||||
* A launch-contract fault (a missing or malformed bundle, descriptor or
|
||||
* identity) throws here, so the process exits before it accepts a request and
|
||||
* Idunn fails Warming at once, instead of a listener that can never sign.
|
||||
*/
|
||||
export async function openPresenceAuthority(
|
||||
config: Pick<HeimdallConfig, "idunnRuntimeBundlePath" | "host" | "port" | "providerHealthIdentityPath">,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
machineId?: () => Promise<string>,
|
||||
): Promise<PresenceAuthority> {
|
||||
if (!config.idunnRuntimeBundlePath) {
|
||||
throw new Error("no Idunn runtime bundle path: presence has no launch to attest");
|
||||
}
|
||||
const bundle = await readRuntimeBundleFacts(config.idunnRuntimeBundlePath);
|
||||
|
||||
const activationDescriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env);
|
||||
if (activationDescriptor === undefined) {
|
||||
throw new Error(
|
||||
"Idunn passed no runtime activation credential, so this process cannot prove it is the " +
|
||||
"incarnation Idunn started",
|
||||
);
|
||||
}
|
||||
const seed = readPassedDescriptor(activationDescriptor);
|
||||
if (seed.length !== 32) {
|
||||
throw new Error("Idunn runtime activation credential is not a 32-byte seed");
|
||||
}
|
||||
const activationKey = createPrivateKey({
|
||||
key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
});
|
||||
|
||||
const identity = await openProviderHealthIdentity(config.providerHealthIdentityPath, env, machineId);
|
||||
const boundEndpoint = `http://${config.host}:${config.port}`;
|
||||
const sequence = new PublisherSequence();
|
||||
|
||||
return {
|
||||
sign: (state, detail, observedAtUnixMillis) =>
|
||||
buildPresenceStatement({
|
||||
bundle,
|
||||
identity,
|
||||
activationKey,
|
||||
state,
|
||||
detail,
|
||||
boundEndpoint,
|
||||
// A warming candidate holds no lease and must not claim one; the
|
||||
// contract refuses the pair, and Idunn needs the warming statement to
|
||||
// promote it.
|
||||
writeLeaseSha256: null,
|
||||
observedAtUnixMillis,
|
||||
publisherSequence: sequence.next(observedAtUnixMillis),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra
|
|||
import {
|
||||
type CreateAccountInput,
|
||||
type CreateAuthAttemptInput,
|
||||
type ClaimOAuthStateUseInput,
|
||||
type CreateAuthCompletionInput,
|
||||
type CreatePendingLinkInput,
|
||||
type StoredPendingLink,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
|
|
@ -24,9 +21,6 @@ import {
|
|||
type UpsertLinkedIdentityInput,
|
||||
} from "./types.js";
|
||||
|
||||
/** The skew a signed OAuth state is still accepted for past its exp (verifyOAuthState). */
|
||||
const OAUTH_STATE_SKEW_MS = 30_000;
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
|
@ -53,10 +47,7 @@ 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>();
|
||||
|
||||
|
|
@ -399,7 +390,6 @@ export class InMemoryStore implements HeimdallStore {
|
|||
accountId: input.accountId,
|
||||
sessionId: input.sessionId,
|
||||
returnTo: input.returnTo,
|
||||
...(input.targetId ? { targetId: input.targetId } : {}),
|
||||
createdAt: input.createdAt,
|
||||
expiresAt: input.expiresAt,
|
||||
payloadJson: clone(input.payloadJson),
|
||||
|
|
@ -412,11 +402,6 @@ export class InMemoryStore implements HeimdallStore {
|
|||
return clone(completion);
|
||||
}
|
||||
|
||||
async findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null> {
|
||||
const completion = this.authCompletions.get(code);
|
||||
return completion && completion.appSlug === appSlug ? clone(completion) : null;
|
||||
}
|
||||
|
||||
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
|
||||
const completion = this.authCompletions.get(code);
|
||||
if (!completion) {
|
||||
|
|
@ -439,37 +424,6 @@ 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));
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f
|
|||
import {
|
||||
type CreateAccountInput,
|
||||
type CreateAuthAttemptInput,
|
||||
type ClaimOAuthStateUseInput,
|
||||
type CreateAuthCompletionInput,
|
||||
type CreatePendingLinkInput,
|
||||
type StoredPendingLink,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
|
|
@ -85,26 +82,12 @@ interface AuthCompletionRow extends QueryResultRow {
|
|||
account_id: string;
|
||||
session_id: string;
|
||||
return_to: string;
|
||||
target_id: string | null;
|
||||
payload_json: Record<string, unknown>;
|
||||
created_at: string | Date;
|
||||
expires_at: string | Date;
|
||||
consumed_at: string | Date | null;
|
||||
}
|
||||
|
||||
interface PendingLinkRow extends QueryResultRow {
|
||||
id: string;
|
||||
app_slug: AppSlug;
|
||||
account_id: string;
|
||||
provider: Provider;
|
||||
provider_user_id: string;
|
||||
identity_json: StoredPendingLink["identity"];
|
||||
return_to: string;
|
||||
created_at: string | Date;
|
||||
expires_at: string | Date;
|
||||
consumed_at: string | Date | null;
|
||||
}
|
||||
|
||||
interface AuthAttemptRow extends QueryResultRow {
|
||||
handle: string;
|
||||
app_slug: AppSlug;
|
||||
|
|
@ -248,24 +231,6 @@ function mapSessionRow(row: SessionRow): StoredSession {
|
|||
};
|
||||
}
|
||||
|
||||
function mapPendingLinkRow(row: PendingLinkRow): StoredPendingLink {
|
||||
const link: StoredPendingLink = {
|
||||
id: row.id,
|
||||
appSlug: row.app_slug,
|
||||
accountId: row.account_id,
|
||||
provider: row.provider,
|
||||
providerUserId: row.provider_user_id,
|
||||
identity: row.identity_json,
|
||||
returnTo: row.return_to,
|
||||
createdAt: normalizeTimestamp(row.created_at),
|
||||
expiresAt: normalizeTimestamp(row.expires_at),
|
||||
};
|
||||
if (row.consumed_at) {
|
||||
link.consumedAt = normalizeTimestamp(row.consumed_at);
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
|
||||
const completion: StoredAuthCompletion = {
|
||||
code: row.code,
|
||||
|
|
@ -284,10 +249,6 @@ function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
|
|||
completion.attemptId = row.attempt_id;
|
||||
}
|
||||
|
||||
if (row.target_id) {
|
||||
completion.targetId = row.target_id;
|
||||
}
|
||||
|
||||
if (row.consumed_at) {
|
||||
completion.consumedAt = normalizeTimestamp(row.consumed_at);
|
||||
}
|
||||
|
|
@ -417,8 +378,6 @@ 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"],
|
||||
audit_events: ["INSERT"],
|
||||
|
|
@ -1045,9 +1004,9 @@ export class PostgresStore implements HeimdallStore {
|
|||
`
|
||||
INSERT INTO auth_completions (
|
||||
code, attempt_id, app_slug, provider, mode, account_id, session_id,
|
||||
return_to, target_id, payload_json, created_at, expires_at
|
||||
return_to, payload_json, created_at, expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
|
|
@ -1059,7 +1018,6 @@ export class PostgresStore implements HeimdallStore {
|
|||
input.accountId,
|
||||
input.sessionId,
|
||||
input.returnTo,
|
||||
input.targetId ?? null,
|
||||
JSON.stringify(input.payloadJson),
|
||||
input.createdAt,
|
||||
input.expiresAt,
|
||||
|
|
@ -1069,14 +1027,6 @@ export class PostgresStore implements HeimdallStore {
|
|||
return mapAuthCompletionRow(expectRow(result.rows[0], "createAuthCompletion"));
|
||||
}
|
||||
|
||||
async findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null> {
|
||||
const result = await this.pool.query<AuthCompletionRow>(
|
||||
"SELECT * FROM auth_completions WHERE code = $1 AND app_slug = $2",
|
||||
[code, appSlug]
|
||||
);
|
||||
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "findAuthCompletion")) : null;
|
||||
}
|
||||
|
||||
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
|
||||
const result = await this.pool.query<AuthCompletionRow>(
|
||||
`
|
||||
|
|
@ -1111,61 +1061,6 @@ 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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -82,19 +82,6 @@ CREATE TABLE IF NOT EXISTS private_command_receipts (
|
|||
CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx
|
||||
ON private_command_receipts(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
app_slug TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
identity_json JSONB NOT NULL,
|
||||
return_to TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_completions (
|
||||
code TEXT PRIMARY KEY,
|
||||
app_slug TEXT NOT NULL,
|
||||
|
|
@ -119,10 +106,6 @@ CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx
|
|||
-- redeem the completion it created without ever needing the code.
|
||||
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT;
|
||||
|
||||
-- The connection target the signed state named (connection.targetId). Redeem
|
||||
-- compares it against the target the redeeming app states.
|
||||
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT;
|
||||
|
||||
-- One attempt handle may bind at most one unconsumed completion (R21.1): a
|
||||
-- second completion for the same handle would let the by-attempt UPDATE in
|
||||
-- consumeAuthCompletionByAttempt match more than one row, silently consuming
|
||||
|
|
@ -136,20 +119,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
|
|||
ON auth_completions(app_slug, attempt_id)
|
||||
WHERE attempt_id IS NOT NULL AND consumed_at IS NULL;
|
||||
|
||||
-- One use per (state jti, phase): a signed OAuth state launches once and
|
||||
-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING
|
||||
-- reports every later claim as no row. The 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,
|
||||
|
|
|
|||
|
|
@ -159,29 +159,18 @@ export interface StoredAuthCompletion {
|
|||
accountId: string;
|
||||
sessionId: string;
|
||||
returnTo: string;
|
||||
/** The connection target the signed state named, if any; compared at redeem. */
|
||||
targetId?: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
consumedAt?: string;
|
||||
payloadJson: Record<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
|
||||
* always mints `code` itself; a caller cannot choose it.
|
||||
*/
|
||||
attemptId?: string;
|
||||
targetId?: string;
|
||||
appSlug: AppSlug;
|
||||
provider: Provider;
|
||||
mode: OAuthMode;
|
||||
|
|
@ -193,28 +182,6 @@ 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;
|
||||
|
|
@ -296,8 +263,6 @@ export interface HeimdallStore {
|
|||
createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise<StoredPrivateCommandReceipt>;
|
||||
findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null>;
|
||||
createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion>;
|
||||
/** Read a completion without consuming it, so redeem can refuse a mismatch without burning the code. */
|
||||
findAuthCompletion(appSlug: AppSlug, code: string): Promise<StoredAuthCompletion | null>;
|
||||
consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null>;
|
||||
/**
|
||||
* Consume a completion by its correlated attempt handle instead of its
|
||||
|
|
@ -306,23 +271,6 @@ 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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@
|
|||
{
|
||||
"id": "eureka/joystick-h1",
|
||||
"hypothesis": "One locked refresh primitive plus a Joystick OAuth provider in Heimdall lets StreamPixels ingest Joystick without ever presenting a provider refresh token.",
|
||||
"status": "accepted",
|
||||
"status": "active",
|
||||
"artifacts": [
|
||||
"state/map.yaml"
|
||||
],
|
||||
"notes": "Merged as PR GameCult/Heimdall#1 (d51fa6b), deployed to Yggdrasil 2026-10-19 15:51Z; gamecult-ops 210a1af. Campaign joystick:campaign:self."
|
||||
"notes": "Campaign joystick:campaign:self, target joystick:target:r1. Hands owns code in a separate worktree."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,5 +35,3 @@
|
|||
{"ts":"2026-08-22T02:01:00+00:00","type":"deployment","status":"accepted","note":"Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9. Odin b4f9a2e95f0b41cebdeddc49223781d1d3c7b42a returns Heimdall's provider, command boundary, Eve plugin, and transport profile under unique catalog keys. Ghostlight b515ca90c25573005a616244143803b37f2d06ec accepted canonical auth.begin through the Odin-resolved boundary; only advertised Discord and Heimdall navigation origins reached the browser."}
|
||||
{"ts": "2026-09-06T22:12:03+00:00", "type": "design", "status": "accepted", "note": "App slugs are no longer compile-time. AppSlug is a string; membership is a registry lookup, or isAppSlug shape validation where no store is reachable (the offline verifier). Capability rules are data (anyOf over Heimdall's own fact vocabulary), replacing a per-profile evaluateSharedCapabilities function that could not be accepted from a stranger. POST /v1/apps registers in RFC 7591 shape, gated on GC_ACCESS_APP_REGISTRATION_SECRET and closed until it is set. Built-in slugs are reserved so a registration cannot shadow a shipped app's audience. Motivation: a creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server to add one slug."}
|
||||
{"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> <slug>.gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"}
|
||||
{"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"}
|
||||
{"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."}
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ data_flow:
|
|||
- reject per-request auth round-trip chatter
|
||||
|
||||
active_subgoals:
|
||||
- Joystick H1 is merged and live (PR GameCult/Heimdall#1, d51fa6b; campaign joystick:campaign:self). Open follow-ups by id: joystick:follow_up:h1-joystick-consent-round-trip (next), cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot. StreamPixels S0/S1 run in other sessions.
|
||||
- OPEN, behind the H1 live round-trips: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries.
|
||||
- ACTIVE, M1-critical (operator ruling 2026-10-18): Joystick H1, campaign joystick:campaign:self (branch eureka/joystick-h1). Joystick OAuth provider, joystick as a streampixels viewer provider, one locked refresh primitive for every provider replacing refreshLinkedIdentityCredentialIfNeeded.
|
||||
- OPEN, behind H1: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries.
|
||||
- Keep the shared architecture explicit and separate from app profiles.
|
||||
- Preserve the default boundary that app-domain data remains app-owned.
|
||||
- Align the plan to Heimdall as a Yggdrasil-hosted authority service.
|
||||
|
|
@ -95,7 +95,7 @@ accepted_design:
|
|||
|
||||
landed_baseline:
|
||||
- Heimdall publishes its redacted provider, command boundary, Eve plugin, and transport profile through Odin under globally unique record keys; Ghostlight resolves that boundary instead of owning a direct endpoint.
|
||||
- Yggdrasil runs Heimdall d51fa6b (hand-deployed 2026-10-19 15:51Z; gamecult-ops 210a1af holds backup, rollback and provenance; prior 1086aee) with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted.
|
||||
- Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted.
|
||||
- The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified.
|
||||
- Exact-session refresh validation and logout revocation are implemented; stale or racing refresh cannot resurrect a revoked session.
|
||||
- Shared architecture extracted into its own repo and renamed to Heimdall.
|
||||
|
|
@ -126,5 +126,5 @@ rejected_paths:
|
|||
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
|
||||
|
||||
current_status:
|
||||
summary: Yggdrasil runs Heimdall release d51fa6b (hand-deployed 2026-10-19 15:51Z, gamecult-ops 210a1af), which includes Joystick H1 (PR #1). The plugin and encrypted loopback begin, complete, refresh and logout plane, the Odin discovery records and Ghostlight resolution were verified at 1086aee (2026-08-22) and are not re-verified at d51fa6b.
|
||||
next_action: Run joystick:follow_up:h1-joystick-consent-round-trip, then StreamPixels S0/S1 (other sessions). Still open behind that: separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets.
|
||||
summary: Active work is Joystick H1 (campaign joystick:campaign:self). Last recorded Yggdrasil deploy (not verifiable from this repo; HEAD is far past it, see git): Yggdrasil runs the gamecult.heimdall.access plugin and encrypted loopback begin, complete, refresh, and logout plane at 1086aee01169bf60e8a492b2740db1c6f3e8cabf. Odin returns all four redacted discovery records, Ghostlight resolves the boundary through Odin, and live anonymous auth.begin is accepted without exposing claims or secrets.
|
||||
next_action: Land Joystick H1 per campaign joystick:campaign:self and target joystick:target:r1 (worktree and branch eureka/joystick-h1). Then, still open, run separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { entitlementFacts } from "../src/facts.js";
|
|||
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
|
||||
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { launchState } from "./oauth-flow.js";
|
||||
|
||||
const secret = "ghostlight-private-command-secret";
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
|
|
@ -108,12 +107,9 @@ describe("Heimdall Eve access plugin", () => {
|
|||
const begin = openPrivateEnvelope(beginWire, secret);
|
||||
expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1");
|
||||
const handle = String(begin.handle);
|
||||
const navigation = begin.navigation as { url: string; allowedOrigins: string[] };
|
||||
const navigation = begin.navigation as { url: string };
|
||||
const state = new URL(navigation.url).searchParams.get("state");
|
||||
expect(state).toBeTruthy();
|
||||
// The browser goes to Heimdall's launch hop first, and only Heimdall's origin is allowed.
|
||||
expect(navigation.url.startsWith("https://heimdall.gamecult.org/v1/oauth/discord/launch?state=")).toBe(true);
|
||||
expect(navigation.allowedOrigins).toEqual(["https://heimdall.gamecult.org"]);
|
||||
|
||||
const pendingEnvelope = sealPrivateEnvelope({
|
||||
appSlug: "ghostlight",
|
||||
|
|
@ -128,11 +124,9 @@ describe("Heimdall Eve access plugin", () => {
|
|||
const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret);
|
||||
expect(pending).toMatchObject({ status: "pending", handle });
|
||||
|
||||
const { cookie } = await launchState(app, state ?? "");
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`,
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(callback.statusCode, callback.body).toBe(201);
|
||||
// The completion code is always minted by the store now, never the
|
||||
|
|
@ -297,7 +291,7 @@ function testConfig(): HeimdallConfig {
|
|||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {},
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { join } from "node:path";
|
|||
import { inspect } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
|
||||
import { resolveAppCaller } from "../src/app-caller.js";
|
||||
import { buildApp, errorIdentity } from "../src/app.js";
|
||||
import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js";
|
||||
import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
|
||||
import { type HeimdallConfig, loadConfig } from "../src/config.js";
|
||||
|
|
@ -13,7 +12,96 @@ import { entitlementFacts, identityFacts } from "../src/facts.js";
|
|||
import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
|
||||
|
||||
function createTestConfig(): HeimdallConfig {
|
||||
return {
|
||||
serviceName: "heimdall",
|
||||
host: "127.0.0.1",
|
||||
port: 4100,
|
||||
workspaceRoot: "E:/Projects/Heimdall",
|
||||
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
|
||||
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
|
||||
publicBaseUrl: "https://heimdall.gamecult.org",
|
||||
issuer: "https://heimdall.gamecult.org",
|
||||
daemonId: "yggdrasil-heimdall",
|
||||
idunnRudpHealth: undefined,
|
||||
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
|
||||
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
|
||||
sessionTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 60 * 60 * 24 * 30,
|
||||
stateTtlSeconds: 600,
|
||||
completionTtlSeconds: 300,
|
||||
bootstrapSigningPrivateKeyOnMissing: false,
|
||||
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: {
|
||||
streampixels: "streampixels-secret",
|
||||
repixelizer: "repixelizer-secret",
|
||||
bifrost: "bifrost-secret",
|
||||
},
|
||||
appBackendCallbacks: {
|
||||
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
|
||||
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
|
||||
},
|
||||
storage: {
|
||||
backend: "memory",
|
||||
applySchemaOnStartup: true,
|
||||
},
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
|
||||
github: { clientId: "github-client", clientSecret: "github-secret" },
|
||||
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
||||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockDiscordRuntime(): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return {
|
||||
accessToken: "discord-access-token",
|
||||
refreshToken: "discord-refresh-token",
|
||||
tokenType: "Bearer",
|
||||
scope: ["identify", "guilds.members.read"],
|
||||
expiresAt: "2026-04-26T13:00:00.000Z",
|
||||
raw: { source: "test" },
|
||||
};
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return {
|
||||
provider: "discord",
|
||||
providerUserId: "discord-user-123",
|
||||
username: "meta",
|
||||
displayName: "Meta",
|
||||
primaryEmail: "meta@gamecult.org",
|
||||
profile: { id: "discord-user-123", username: "meta" },
|
||||
};
|
||||
},
|
||||
async evaluateEntitlements({ callback }) {
|
||||
return {
|
||||
facts: [entitlementFacts.appAccess],
|
||||
snapshots: [
|
||||
{
|
||||
accountId: callback.accountId,
|
||||
provider: "discord",
|
||||
scope: "repixelizer:discord_role_access:gamecult-guild",
|
||||
evaluatedAt: "2026-04-26T12:00:00.000Z",
|
||||
isAllowed: true,
|
||||
reasonCode: "matched_role",
|
||||
reasonDetail: "Matched Repixelizer access role.",
|
||||
rawSummaryJson: {
|
||||
guildId: "gamecult-guild",
|
||||
matchedRoles: ["role-repixelizer"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
|
@ -46,7 +134,7 @@ function getHeimdallContext(app: FastifyInstance) {
|
|||
}).heimdallContext;
|
||||
}
|
||||
|
||||
async function startDiscordSignIn(app: FastifyInstance): Promise<LaunchedFlow> {
|
||||
async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
|
|
@ -58,7 +146,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise<LaunchedFlow> {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
return launchFlow(app, response.json());
|
||||
return response.json().stateToken as string;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -119,9 +207,9 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
|
||||
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
||||
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -161,170 +249,6 @@ describe("Heimdall service", () => {
|
|||
expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" }));
|
||||
});
|
||||
|
||||
describe("apps that bind browser completion to their own attempt", () => {
|
||||
// Bifrost's browser binding is the secrecy of its attempt nonce, which
|
||||
// travels readable in the signed state. Only Bifrost may mint a Bifrost
|
||||
// state, so a known nonce cannot carry a stranger's completion
|
||||
// (finding known-nonce-login-csrf).
|
||||
const victimAttempt = "VICTIM-ATTEMPT-NONCE";
|
||||
const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`;
|
||||
const bifrostStart = (handoff?: { kind: "browser_completion" }) => ({
|
||||
appSlug: "bifrost" as const,
|
||||
mode: "sign_in" as const,
|
||||
returnTo: bifrostReturn,
|
||||
...(handoff ? { handoff } : {}),
|
||||
});
|
||||
|
||||
it("refuses an unauthenticated Bifrost start for browser_completion and for an omitted handoff", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
for (const payload of [bifrostStart({ kind: "browser_completion" }), bifrostStart()]) {
|
||||
const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload });
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json()).toEqual({ error: "app_auth_required" });
|
||||
}
|
||||
});
|
||||
|
||||
it("the attack fails: a stranger cannot obtain a Bifrost state carrying a chosen return_to nonce", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: bifrostStart({ kind: "browser_completion" }),
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.body).not.toContain(victimAttempt);
|
||||
expect(response.body).not.toContain("stateToken");
|
||||
expect(response.body).not.toContain("authorizationUrl");
|
||||
});
|
||||
|
||||
it("still starts for Bifrost's own app secret, with the requested return_to in the state", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
||||
payload: bifrostStart({ kind: "browser_completion" }),
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
const verified = verifyJwt(stateOf(response.json()), getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(expect.objectContaining({ app_slug: "bifrost", return_to: bifrostReturn }));
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses another app's credentials on a Bifrost start", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
for (const secret of ["streampixels-secret", "repixelizer-secret"]) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
headers: { "x-heimdall-app-secret": secret },
|
||||
payload: bifrostStart({ kind: "browser_completion" }),
|
||||
});
|
||||
expect(response.statusCode, secret).toBe(401);
|
||||
expect(response.json()).toEqual({ error: "app_auth_required" });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses another app's authenticated caller on a direct Bifrost start, with no policy attached", async () => {
|
||||
// Over HTTP the caller is resolved against the body's slug, so a foreign
|
||||
// secret never becomes a caller at all. The command plane and direct
|
||||
// callers hand startOAuthFlow a caller of their own, so the slug match is
|
||||
// decided here too.
|
||||
const config = createTestConfig();
|
||||
const app = await buildApp({ config });
|
||||
apps.push(app);
|
||||
const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
|
||||
const own = resolveAppCaller(config, "bifrost", { "x-heimdall-app-secret": "bifrost-secret" });
|
||||
|
||||
const refused = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", foreign, bifrostStart({ kind: "browser_completion" }));
|
||||
expect(refused.statusCode).toBe(401);
|
||||
const allowed = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", own, bifrostStart({ kind: "browser_completion" }));
|
||||
expect(allowed.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("gates every mode, provider and handoff kind Bifrost admits, not one spelling of them", async () => {
|
||||
// Hand mutants narrowing the gate to browser_completion or to sign_in
|
||||
// survived the single-case tests (finding gate-tests-narrow).
|
||||
const store = new InMemoryStore();
|
||||
const now = new Date().toISOString();
|
||||
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
const handoffs = [
|
||||
undefined,
|
||||
{ kind: "browser_completion" },
|
||||
{
|
||||
kind: "backend_callback",
|
||||
attemptId: "bifrost-attempt-1",
|
||||
callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback",
|
||||
},
|
||||
];
|
||||
|
||||
let cases = 0;
|
||||
for (const mode of ["sign_in", "link", "connect"] as const) {
|
||||
for (const provider of ["discord", "patreon"] as const) {
|
||||
for (const handoff of handoffs) {
|
||||
const label = `${mode} ${provider} ${handoff?.kind ?? "omitted"}`;
|
||||
const payload = {
|
||||
appSlug: "bifrost",
|
||||
mode,
|
||||
returnTo: bifrostReturn,
|
||||
...(mode === "link" ? { initiatingAccountId: initiator.id } : {}),
|
||||
...(handoff ? { handoff } : {}),
|
||||
};
|
||||
const refused = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload });
|
||||
expect(refused.statusCode, label).toBe(401);
|
||||
expect(refused.json(), label).toEqual({ error: "app_auth_required" });
|
||||
|
||||
const allowed = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/oauth/${provider}/start`,
|
||||
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
||||
payload,
|
||||
});
|
||||
expect(allowed.statusCode, label).toBe(201);
|
||||
expect(allowed.json().authorizationUrl, label).toContain(`/v1/oauth/${provider}/launch?state=`);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(cases).toBe(18);
|
||||
});
|
||||
|
||||
it("leaves public-by-design apps unchanged: unauthenticated browser_completion starts still succeed", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
for (const [appSlug, provider, returnTo] of [
|
||||
["repixelizer", "discord", "https://repixelizer.gamecult.org/app/"],
|
||||
["streampixels", "twitch", "https://streampixels.gamecult.org/app/"],
|
||||
] as const) {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/v1/oauth/${provider}/start`,
|
||||
payload: { appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } },
|
||||
});
|
||||
expect(response.statusCode, appSlug).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
it("flags only Bifrost's profile", () => {
|
||||
expect(
|
||||
Object.values(builtInAppProfiles).filter((profile) => profile.startRequiresAppCaller).map((profile) => profile.slug)
|
||||
).toEqual(["bifrost"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts caller-owned entitlement policy for a browser completion handoff when the caller is trusted", async () => {
|
||||
// The other half of the same pin: a caller holding the secret may attach
|
||||
// policy even without a backend_callback handoff, because identity no
|
||||
|
|
@ -378,9 +302,9 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
|
||||
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
||||
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -424,11 +348,11 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
const authorizationUrl = await providerRedirect(app, payload);
|
||||
const authorizationUrl = new URL(payload.authorizationUrl as string);
|
||||
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
|
||||
expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]");
|
||||
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -472,9 +396,9 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
|
||||
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
||||
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -518,10 +442,10 @@ describe("Heimdall service", () => {
|
|||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
const authorizationUrl = await providerRedirect(app, payload);
|
||||
const authorizationUrl = new URL(payload.authorizationUrl as string);
|
||||
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
|
||||
|
||||
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
|
||||
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
if (verified.valid) {
|
||||
expect(verified.payload).toEqual(
|
||||
|
|
@ -549,11 +473,10 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
|
|
@ -766,11 +689,10 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
const completionCode = callbackResponse.json().completion.code as string;
|
||||
|
||||
|
|
@ -809,11 +731,10 @@ describe("Heimdall service", () => {
|
|||
const auditEvents = () =>
|
||||
[...(store as unknown as { auditEvents: Map<string, { eventType: string; sessionId?: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
||||
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string };
|
||||
const created = auditEvents().filter((event) => event.eventType === "auth_completion_created");
|
||||
|
|
@ -894,11 +815,10 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(201);
|
||||
const refreshToken = callbackResponse.json().refreshToken as string;
|
||||
|
|
@ -942,12 +862,11 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
headers: {
|
||||
cookie: flow.cookie,
|
||||
accept: "text/html",
|
||||
},
|
||||
});
|
||||
|
|
@ -993,13 +912,12 @@ describe("Heimdall service", () => {
|
|||
},
|
||||
});
|
||||
expect(startResponse.statusCode).toBe(201);
|
||||
const flow = await launchFlow(app, startResponse.json());
|
||||
const stateToken = startResponse.json().stateToken as string;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
headers: {
|
||||
cookie: flow.cookie,
|
||||
accept: "text/html",
|
||||
},
|
||||
});
|
||||
|
|
@ -1205,12 +1123,11 @@ describe("Heimdall service", () => {
|
|||
returnTo: "https://streampixels.gamecult.org/auth/connect",
|
||||
},
|
||||
});
|
||||
const flow = await launchFlow(app, startResponse.json());
|
||||
const stateToken = startResponse.json().stateToken as string;
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
const accountId = callbackResponse.json().account.id as string;
|
||||
|
||||
|
|
@ -1293,12 +1210,11 @@ describe("Heimdall service", () => {
|
|||
returnTo: "https://streampixels.gamecult.org/auth/connect",
|
||||
},
|
||||
});
|
||||
const flow = await launchFlow(app, startResponse.json());
|
||||
const stateToken = startResponse.json().stateToken as string;
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
const accountId = callbackResponse.json().account.id as string;
|
||||
|
||||
|
|
@ -1360,11 +1276,10 @@ describe("Heimdall service", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const callbackResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(201);
|
||||
const evaluationsAtSignIn = evaluations;
|
||||
|
|
@ -1446,31 +1361,11 @@ 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"],
|
||||
|
|
@ -1482,7 +1377,7 @@ describe("Heimdall service", () => {
|
|||
expect(start.statusCode).toBe(400);
|
||||
expect(start.json().error).toBe(error);
|
||||
expect(start.json().authorizationUrl).toBeUndefined();
|
||||
expect(start.json()).not.toHaveProperty("stateToken");
|
||||
expect(start.json().stateToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -1498,15 +1393,14 @@ describe("Heimdall service", () => {
|
|||
apps.push(app);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const verified = verifyJwt(flow.state, keys.publicKey);
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const verified = verifyJwt(stateToken, keys.publicKey);
|
||||
expect(verified.valid).toBe(true);
|
||||
const forged = signJwt({ ...(verified as { payload: Record<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([]);
|
||||
|
|
@ -1538,11 +1432,9 @@ describe("Heimdall service", () => {
|
|||
handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl },
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
});
|
||||
expect(callback.statusCode).toBeLessThan(300);
|
||||
|
||||
|
|
@ -1561,7 +1453,7 @@ describe("Heimdall service", () => {
|
|||
apps.push(app);
|
||||
const start = await connect(app);
|
||||
expect(start.statusCode).toBe(201);
|
||||
const url = await providerRedirect(app, start.json());
|
||||
const url = new URL(start.json().authorizationUrl as string);
|
||||
expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize");
|
||||
expect(url.searchParams.get("client_id")).toBe("joystick-client");
|
||||
expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read");
|
||||
|
|
@ -1575,11 +1467,10 @@ describe("Heimdall service", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
stubJoystick({ channel_id: "deru-chan", username: "deru" });
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const connected = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
const accountId = connected.json().account.id as string;
|
||||
const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
|
||||
|
|
@ -1627,15 +1518,14 @@ describe("Heimdall service", () => {
|
|||
apps.push(app);
|
||||
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
|
||||
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const verified = verifyJwt(flow.state, keys.publicKey);
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const verified = verifyJwt(stateToken, keys.publicKey);
|
||||
expect(verified.valid).toBe(true);
|
||||
const forged = signJwt({ ...(verified as { payload: Record<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");
|
||||
|
|
@ -1652,12 +1542,11 @@ describe("Heimdall service", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" });
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
expect(callback.statusCode).toBeLessThan(300);
|
||||
|
||||
|
|
@ -1686,11 +1575,10 @@ describe("Heimdall service", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
stubJoystick({ channel_id: "chan-9", username: "deru" });
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
|
||||
expect(row?.displayName).toBe("deru");
|
||||
|
|
@ -1701,11 +1589,10 @@ describe("Heimdall service", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), store });
|
||||
apps.push(app);
|
||||
stubJoystick({ username: "deru" });
|
||||
const flow = await launchFlow(app, (await connect(app)).json());
|
||||
const stateToken = (await connect(app)).json().stateToken as string;
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
|
||||
|
|
@ -1900,11 +1787,11 @@ describe("error answers never echo an input", () => {
|
|||
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } });
|
||||
apps.push(app);
|
||||
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie, accept: "text/html" },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(response.headers["content-type"]).toContain("text/html");
|
||||
|
|
@ -1936,11 +1823,9 @@ describe("error answers never echo an input", () => {
|
|||
},
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const flow = await launchFlow(app, start.json());
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
|
|
@ -1958,16 +1843,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 providerErrorRequest(flow: LaunchedFlow) {
|
||||
const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected });
|
||||
return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } };
|
||||
function providerErrorUrl(stateToken: string): string {
|
||||
const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected });
|
||||
return `/v1/oauth/discord/callback?${query.toString()}`;
|
||||
}
|
||||
|
||||
it("is printable and bounded in the JSON answer", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app)));
|
||||
const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const { providerError, providerErrorDescription } = response.json() as Record<string, string>;
|
||||
|
|
@ -1981,8 +1866,11 @@ describe("the relayed provider error", () => {
|
|||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const request = providerErrorRequest(await startDiscordSignIn(app));
|
||||
const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: providerErrorUrl(await startDiscordSignIn(app)),
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(response.headers["content-type"]).toContain("text/html");
|
||||
expect(response.body.match(/<script/gi)).toHaveLength(1);
|
||||
|
|
@ -2014,7 +1902,7 @@ describe("the relayed provider error", () => {
|
|||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
|
||||
await app.inject(providerErrorRequest(await launchFlow(app, start.json())));
|
||||
await app.inject({ method: "GET", url: providerErrorUrl(start.json().stateToken as string) });
|
||||
|
||||
expect(deliveries).toHaveLength(1);
|
||||
const description = String(deliveries[0]!.errorDescription);
|
||||
|
|
@ -2040,11 +1928,10 @@ describe("the failed-callback audit event", () => {
|
|||
});
|
||||
apps.push(app);
|
||||
|
||||
const flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(502);
|
||||
|
||||
|
|
@ -2068,11 +1955,10 @@ 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 flow = await startDiscordSignIn(app);
|
||||
const stateToken = await startDiscordSignIn(app);
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
||||
});
|
||||
expect(callback.statusCode).toBe(502);
|
||||
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
|
||||
|
|
@ -2126,12 +2012,8 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
|
|||
},
|
||||
});
|
||||
apps.push(app);
|
||||
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 stateToken = await startDiscordSignIn(app);
|
||||
await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}` });
|
||||
|
||||
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,7 +24,6 @@ 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> }> = [];
|
||||
|
|
@ -67,7 +66,6 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -104,11 +102,10 @@ 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", cookie },
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +213,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(stateOf(response.json()));
|
||||
const state = jwtPayload(response.json().stateToken as string);
|
||||
expect(state.handoff).toEqual({ kind: "browser_completion" });
|
||||
expect(JSON.stringify(state)).not.toContain(attempt.handle);
|
||||
});
|
||||
|
|
@ -240,7 +237,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(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
|
||||
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
|
||||
} else {
|
||||
expect(response.statusCode, response.body).toBe(400);
|
||||
}
|
||||
|
|
@ -252,7 +249,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(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
|
||||
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -267,8 +264,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 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*\}\);/);
|
||||
// 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\);/);
|
||||
}
|
||||
}
|
||||
expect(callers.sort()).toEqual(["app.ts:1", "private-command-plane.ts:1"]);
|
||||
|
|
@ -309,7 +306,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, stateOf(attackerStart.json()), "attacker");
|
||||
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker");
|
||||
expect(attackerCallback.statusCode, attackerCallback.body).toBe(201);
|
||||
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("pending");
|
||||
|
||||
|
|
@ -343,7 +340,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, stateOf(response.json()), "attacker");
|
||||
await completeAs(app, response.json().stateToken as string, "attacker");
|
||||
expect((await store.findAuthAttempt("ghostlight", attempt.handle))?.status).toBe("pending");
|
||||
expect(await store.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, new Date().toISOString())).toBeNull();
|
||||
});
|
||||
|
|
@ -366,7 +363,7 @@ describe("R21.2: the WeakSet brand at every consumer", () => {
|
|||
if (line.includes("caller.appSlug")) expect(line, `${file}: ${line.trim()}`).toContain("isAppCaller(caller)");
|
||||
}
|
||||
}
|
||||
expect(consumers).toEqual(["app.ts: reads=3 guards=3"]);
|
||||
expect(consumers).toEqual(["app.ts: reads=2 guards=2"]);
|
||||
});
|
||||
|
||||
it("a caller minted for app A is refused where app B is expected, over HTTP and by direct call", async () => {
|
||||
|
|
@ -496,27 +493,26 @@ partialUniqueIndexContract("PostgresStore over pg-mem", async () => {
|
|||
});
|
||||
|
||||
describe("duplicate-handle error surfacing through the callback route", () => {
|
||||
it("a replayed state is refused as already used before any exchange, and leaves the real completion intact", async () => {
|
||||
it("state tokens are replayable, and a replay while the handle's completion is unconsumed echoes the store error in a 502", 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 { 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();
|
||||
const first = await completeAs(app, state, "victim");
|
||||
expect(first.statusCode).toBe(201);
|
||||
// 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" });
|
||||
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.
|
||||
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("completed");
|
||||
expect(await store.consumeAuthCompletionByAttempt("ghostlight", handle, new Date().toISOString())).toBeTruthy();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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> }> = [];
|
||||
|
||||
|
|
@ -53,7 +52,6 @@ function testConfig(): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -88,11 +86,10 @@ 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", cookie },
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +110,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
|
|||
});
|
||||
expect(started.statusCode).toBe(201);
|
||||
|
||||
const callback = await completeAs(app, stateOf(started.json()), "victim");
|
||||
const callback = await completeAs(app, started.json().stateToken as string, "victim");
|
||||
expect(callback.statusCode).toBe(201);
|
||||
const minted = callback.json().completion.code as string;
|
||||
expect(minted).not.toBe("attacker-fixed");
|
||||
|
|
@ -128,7 +125,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, stateOf(started.json()), "victim");
|
||||
const callback = await completeAs(app, started.json().stateToken as string, "victim");
|
||||
expect(callback.statusCode).toBe(201);
|
||||
const code = callback.json().completion.code as string;
|
||||
expect(code).toMatch(uuidV4);
|
||||
|
|
@ -162,7 +159,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, stateOf(victimStart.json()), "victim");
|
||||
const victimCallback = await completeAs(app, victimStart.json().stateToken as string, "victim");
|
||||
const victimCode = victimCallback.json().completion.code as string;
|
||||
|
||||
const attackerStart = await start(app, {
|
||||
|
|
@ -171,7 +168,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, stateOf(attackerStart.json()), "attacker");
|
||||
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker");
|
||||
expect(attackerCallback.statusCode).toBe(201);
|
||||
expect(attackerCallback.json().completion.code).not.toBe(victimCode);
|
||||
|
||||
|
|
@ -207,7 +204,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, stateOf(started.json()), "victim");
|
||||
const callback = await completeAs(app, started.json().stateToken as string, "victim");
|
||||
const code = callback.json().completion.code as string;
|
||||
|
||||
const crossApp = await redeem(app, "bifrost", code);
|
||||
|
|
@ -268,7 +265,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, stateOf(attackerStart.json()), "attacker");
|
||||
await completeAs(app, attackerStart.json().stateToken as string, "attacker");
|
||||
|
||||
const after = await store.findAuthAttempt("ghostlight", attempt.handle);
|
||||
expect(after?.status).toBe("pending");
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ 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> }> = [];
|
||||
|
|
@ -61,7 +60,6 @@ function testConfig(): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -106,11 +104,10 @@ 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(flow.state)}`,
|
||||
headers: { accept: "text/html", cookie: flow.cookie },
|
||||
url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
expect(callback.statusCode).toBe(200);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// GHSA-p68q-wchp-6fh7 (fastify < 5.12.2): a malformed request target under one
|
||||
// plugin prefix, with a method that has no route in the main router, was
|
||||
// dispatched to the not-found handler of a sibling plugin, skipping that
|
||||
// handler's preHandler. This pins the framework fix; it is not about a Heimdall
|
||||
// route.
|
||||
describe("fastify malformed URL under sibling not-found handlers", () => {
|
||||
const apps: Array<ReturnType<typeof Fastify>> = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function build() {
|
||||
const app = Fastify();
|
||||
apps.push(app);
|
||||
await app.register(async (pub) => {
|
||||
pub.get("/ping", async () => "pong");
|
||||
}, { prefix: "/public" });
|
||||
await app.register(async (priv) => {
|
||||
priv.setNotFoundHandler(
|
||||
{
|
||||
preHandler: async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!request.headers.authorization) return reply.code(401).send({ error: "auth" });
|
||||
},
|
||||
},
|
||||
(_request: FastifyRequest, reply: FastifyReply) => reply.code(404).send({ secret: "PRIVATE-TENANT-DATA" }),
|
||||
);
|
||||
priv.get("/x", async () => "x");
|
||||
}, { prefix: "/private" });
|
||||
return app;
|
||||
}
|
||||
|
||||
it("fails closed with 400 and never reaches another prefix's not-found handler", async () => {
|
||||
const app = await build();
|
||||
const response = await app.inject({ method: "DELETE", url: "/public/%zz" });
|
||||
expect(response.body).not.toContain("PRIVATE-TENANT-DATA");
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("still runs the authenticated not-found preHandler for a well-formed URL", async () => {
|
||||
const app = await build();
|
||||
const response = await app.inject({ method: "DELETE", url: "/private/nothing" });
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
64
tests/fixtures/fake-idunn-rudp.ts
vendored
64
tests/fixtures/fake-idunn-rudp.ts
vendored
|
|
@ -1,64 +0,0 @@
|
|||
/**
|
||||
* A stand-in for Idunn's health endpoint: it accepts each RUDP connect, acks
|
||||
* the frame that follows and keeps every delivered frame. Heimdall opens a
|
||||
* fresh socket and session per publication, so sessions are kept per remote.
|
||||
*/
|
||||
import dgram from "node:dgram";
|
||||
import {
|
||||
CultNetRudpSession,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
type CultNetTransportFrame,
|
||||
} from "cultnet-ts";
|
||||
|
||||
export interface FakeIdunnRudp {
|
||||
port: number;
|
||||
frames: CultNetTransportFrame[];
|
||||
/** Resolves with the nth delivered frame (0-based) once it has arrived. */
|
||||
frame(index: number): Promise<CultNetTransportFrame>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function startFakeIdunnRudp(): Promise<FakeIdunnRudp> {
|
||||
const server = dgram.createSocket("udp4");
|
||||
const sessions = new Map<string, CultNetRudpSession>();
|
||||
const frames: CultNetTransportFrame[] = [];
|
||||
const waiting: Array<{ index: number; resolve: (frame: CultNetTransportFrame) => void }> = [];
|
||||
|
||||
server.on("message", (wire, remote) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
const key = `${remote.address}:${remote.port}`;
|
||||
if (packet.packetType === "connect") {
|
||||
const session = new CultNetRudpSession({
|
||||
connectionId: 0x1d0d0001,
|
||||
initialSequence: 1,
|
||||
resendDelayMs: 100,
|
||||
});
|
||||
sessions.set(key, session);
|
||||
server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address);
|
||||
return;
|
||||
}
|
||||
const session = sessions.get(key);
|
||||
if (!session) return;
|
||||
const result = session.receive(packet, Date.now());
|
||||
if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address);
|
||||
for (const frame of result.delivered) {
|
||||
frames.push(frame);
|
||||
for (const waiter of waiting.filter((entry) => entry.index === frames.length - 1)) waiter.resolve(frame);
|
||||
}
|
||||
if (result.delivered.length > 0) {
|
||||
server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
||||
|
||||
return {
|
||||
port: server.address().port,
|
||||
frames,
|
||||
frame: (index) =>
|
||||
frames[index]
|
||||
? Promise.resolve(frames[index]!)
|
||||
: new Promise((resolve) => waiting.push({ index, resolve })),
|
||||
close: () => server.close(),
|
||||
};
|
||||
}
|
||||
1
tests/fixtures/idunn-runtime/.gitattributes
vendored
1
tests/fixtures/idunn-runtime/.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
|||
* -text
|
||||
11
tests/fixtures/idunn-runtime/README.md
vendored
11
tests/fixtures/idunn-runtime/README.md
vendored
|
|
@ -1,11 +0,0 @@
|
|||
# Idunn runtime fixtures
|
||||
|
||||
Written by the Rust owner, not by Heimdall's tests. Copied byte for byte from CultLib
|
||||
`217a9480`, `packages/cultnet-ts/test/fixtures/idunn-runtime/web` (`expected.cc`,
|
||||
`activation.cc`, `activation-key.seed`, `provider-identity.credential`) and that directory's
|
||||
`machine-id`. CultLib regenerates them with `packages/cultnet-rs/examples/idunn_runtime_fixture.rs`
|
||||
(its own README has the Yggdrasil command); the provider identity is bound to `machine-id`, so a
|
||||
test that decodes it supplies that machine id instead of `/etc/machine-id`.
|
||||
|
||||
Every key here is a test key. `provider-identity.credential` is the shape Idunn passes on the
|
||||
presence-identity descriptor: key fields are 32-integer arrays, which only the Rust owner writes.
|
||||
|
|
@ -1 +0,0 @@
|
|||
¯ÂOd×f¯l*oµ‘ÀFªv‰n<E280B0>hLûÑpšS´p
|
||||
BIN
tests/fixtures/idunn-runtime/activation.cc
vendored
BIN
tests/fixtures/idunn-runtime/activation.cc
vendored
Binary file not shown.
BIN
tests/fixtures/idunn-runtime/expected.cc
vendored
BIN
tests/fixtures/idunn-runtime/expected.cc
vendored
Binary file not shown.
1
tests/fixtures/idunn-runtime/machine-id
vendored
1
tests/fixtures/idunn-runtime/machine-id
vendored
|
|
@ -1 +0,0 @@
|
|||
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1
|
||||
Binary file not shown.
61
tests/fixtures/presence-descriptors.ts
vendored
61
tests/fixtures/presence-descriptors.ts
vendored
|
|
@ -1,61 +0,0 @@
|
|||
/**
|
||||
* What Idunn hands a generation for runtime presence: the bundle directory, a
|
||||
* parent-only identity store and an activation seed, the last two passed as
|
||||
* descriptors named in LISTEN_FDNAMES. The bytes are the Rust owner's, copied
|
||||
* from CultLib (see idunn-runtime/README.md); every key in them is a test key.
|
||||
*/
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts";
|
||||
|
||||
export const idunnRuntimeFixtures = path.join(import.meta.dirname, "idunn-runtime");
|
||||
|
||||
const FIXTURE_FILES = [
|
||||
"expected.cc",
|
||||
"activation.cc",
|
||||
"activation-key.seed",
|
||||
"provider-identity.credential",
|
||||
"machine-id",
|
||||
] as const;
|
||||
|
||||
/** The machine id the fixture identity is bound to, in place of /etc/machine-id. */
|
||||
export const fixtureMachineId = async (dir: string = idunnRuntimeFixtures): Promise<string> =>
|
||||
(await readFile(path.join(dir, "machine-id"), "utf8")).trim();
|
||||
|
||||
/** Copy the fixtures into `dir`, readable by any uid (the children run as others). */
|
||||
export async function writePresenceFixtures(dir: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await chmod(dir, 0o755);
|
||||
for (const name of FIXTURE_FILES) {
|
||||
const target = path.join(dir, name);
|
||||
await copyFile(path.join(idunnRuntimeFixtures, name), target);
|
||||
await chmod(target, 0o644);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open both descriptors and name them the way systemd's LISTEN_FDNAMES does. */
|
||||
export function openPresenceDescriptors(dir: string): { env: NodeJS.ProcessEnv; close: () => void } {
|
||||
const fds = [
|
||||
[openSync(path.join(dir, "provider-identity.credential"), "r"), "gamecult-runtime-presence-identity"],
|
||||
[openSync(path.join(dir, "activation-key.seed"), "r"), IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME],
|
||||
] as const;
|
||||
const names: string[] = [];
|
||||
for (const [fd, name] of fds) {
|
||||
while (names.length < fd - 3) names.push(`pad-${names.length}`);
|
||||
names[fd - 3] = name;
|
||||
}
|
||||
return {
|
||||
env: { LISTEN_FDNAMES: names.join(":") },
|
||||
// The authority closes each descriptor it reads; this releases only what it did not.
|
||||
close: () =>
|
||||
fds.forEach(([fd]) => {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// already closed by the reader
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
57
tests/fixtures/state-root-generation-child.ts
vendored
57
tests/fixtures/state-root-generation-child.ts
vendored
|
|
@ -1,57 +0,0 @@
|
|||
/**
|
||||
* One Idunn generation, as a process: it runs as the uid and gid its parent
|
||||
* gave it, under UMask=0007, and does what Heimdall does to a state root on a
|
||||
* pulse. The parent test spawns two of these under different uids that share
|
||||
* a group.
|
||||
*
|
||||
* argv: <role: first|next> <stateRoot> <fixtureDir>
|
||||
*/
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { loadConfig } from "../../src/config.js";
|
||||
import { openPresenceAuthority } from "../../src/runtime-presence.js";
|
||||
import { fixtureMachineId, openPresenceDescriptors } from "./presence-descriptors.js";
|
||||
import { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../../src/verse-state.js";
|
||||
|
||||
const [role, stateRoot, fixtureDir] = process.argv.slice(2) as [string, string, string];
|
||||
process.umask(0o007);
|
||||
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
|
||||
GC_ACCESS_DATA_ROOT: stateRoot,
|
||||
});
|
||||
|
||||
async function publishPresence(): Promise<void> {
|
||||
const descriptors = openPresenceDescriptors(fixtureDir);
|
||||
try {
|
||||
const authority = await openPresenceAuthority(
|
||||
{
|
||||
idunnRuntimeBundlePath: fixtureDir,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"),
|
||||
},
|
||||
descriptors.env,
|
||||
() => fixtureMachineId(fixtureDir),
|
||||
);
|
||||
authority.sign("warming", "generation probe", Date.now());
|
||||
} finally {
|
||||
descriptors.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (role === "next") {
|
||||
// Generation N+1 reads what N left before it replaces it.
|
||||
const previous = await readFile(config.cultCachePath);
|
||||
if (previous.length === 0) throw new Error("previous generation's snapshot is empty");
|
||||
}
|
||||
|
||||
await publishHeimdallVerseState(config, createHeimdallRuntimePulse(config));
|
||||
await publishPresence();
|
||||
|
||||
if (role === "first") {
|
||||
// The 0600 counter the 2026-09-07 generation left behind. Nothing reads it
|
||||
// now; it stays as the stale fixture the next generation must live beside.
|
||||
await writeFile(path.join(stateRoot, "runtime-presence-sequence"), "41\n", { mode: 0o600 });
|
||||
}
|
||||
|
|
@ -1,525 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import { createPublicKey, createPrivateKey, verify } from "node:crypto";
|
||||
import dgram from "node:dgram";
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import {
|
||||
RUNTIME_PRESENCE_SLOT,
|
||||
parseCultNetMessage,
|
||||
runtimePresenceActivationSigningMessage,
|
||||
runtimePresenceProviderSigningMessage,
|
||||
} from "cultnet-ts";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { startFakeIdunnRudp, type FakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
|
||||
import { idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const GENERATION_UID = 61184;
|
||||
const STATE_GID = 4242;
|
||||
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
||||
// The launch needs root (to pick the uid and bind a machine-id) and a mount
|
||||
// namespace (so the fixture machine-id does not replace the host's). The
|
||||
// stopgap's node container has both; anywhere else the test steps aside.
|
||||
const canLaunch =
|
||||
process.getuid?.() === 0 &&
|
||||
(await run("unshare", ["--mount", "--", "true"]).then(
|
||||
() => true,
|
||||
() => false
|
||||
));
|
||||
|
||||
/**
|
||||
* The artifact under test. By default the esbuild bundle `pnpm bundle` makes,
|
||||
* run by this process's node; a runbook proves a sealed release by naming its
|
||||
* bundle and node here instead.
|
||||
*/
|
||||
const bundleOverride = process.env.HEIMDALL_BOOT_BUNDLE;
|
||||
const nodeOverride = process.env.HEIMDALL_BOOT_NODE;
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => {
|
||||
const { port } = probe.address() as net.AddressInfo;
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface Launch {
|
||||
child: ChildProcess;
|
||||
stderr: () => string;
|
||||
exited: Promise<number | null>;
|
||||
candidatePort: number;
|
||||
}
|
||||
|
||||
describe.skipIf(!canLaunch)("the bundle booted as Idunn launches it", () => {
|
||||
let base: string;
|
||||
let bundle: string;
|
||||
let node: string;
|
||||
let idunn: FakeIdunnRudp;
|
||||
let odin: dgram.Socket;
|
||||
let odinPort: number;
|
||||
const launched: ChildProcess[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
base = await mkdtemp(path.join(os.tmpdir(), "heimdall-boot-"));
|
||||
await chmod(base, 0o755);
|
||||
if (bundleOverride) {
|
||||
bundle = bundleOverride;
|
||||
} else {
|
||||
await run("corepack", ["pnpm", "bundle"], { cwd: path.join(import.meta.dirname, "..") });
|
||||
bundle = path.join(base, "heimdall.mjs");
|
||||
await copyFile(path.join(import.meta.dirname, "..", "dist", "heimdall"), bundle);
|
||||
await chmod(bundle, 0o644);
|
||||
}
|
||||
node = nodeOverride ?? process.execPath;
|
||||
idunn = await startFakeIdunnRudp();
|
||||
odin = dgram.createSocket("udp4");
|
||||
await new Promise<void>((resolve) => odin.bind(0, "127.0.0.1", resolve));
|
||||
odinPort = odin.address().port;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
for (const child of launched) child.kill("SIGKILL");
|
||||
idunn?.close();
|
||||
odin?.close();
|
||||
});
|
||||
|
||||
/** Credential files as Idunn holds them: readable only by the parent. */
|
||||
async function credentials(dir: string, identity: Buffer | undefined) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
const seed = path.join(dir, "activation-key.seed");
|
||||
await copyFile(path.join(idunnRuntimeFixtures, "activation-key.seed"), seed);
|
||||
await chmod(seed, 0o400);
|
||||
let identityFile: string | undefined;
|
||||
if (identity) {
|
||||
identityFile = path.join(dir, "provider-identity.credential");
|
||||
await writeFile(identityFile, identity, { mode: 0o400 });
|
||||
}
|
||||
return { seed, identityFile };
|
||||
}
|
||||
|
||||
/** Start the bundle as the generation unit does, with the fixture machine-id bound over the host's. */
|
||||
async function launch(label: string, identity: Buffer | undefined): Promise<Launch> {
|
||||
const dir = path.join(base, label);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await chmod(dir, 0o755);
|
||||
|
||||
// Idunn's runtime bundle: the two records, read-only.
|
||||
const bundleDir = path.join(dir, "bundle");
|
||||
await mkdir(bundleDir);
|
||||
for (const name of ["expected.cc", "activation.cc"]) {
|
||||
await copyFile(path.join(idunnRuntimeFixtures, name), path.join(bundleDir, name));
|
||||
await chmod(path.join(bundleDir, name), 0o444);
|
||||
}
|
||||
await chmod(bundleDir, 0o555);
|
||||
|
||||
const machineId = path.join(dir, "machine-id");
|
||||
await copyFile(path.join(idunnRuntimeFixtures, "machine-id"), machineId);
|
||||
await chmod(machineId, 0o444);
|
||||
|
||||
const stateRoot = path.join(dir, "state");
|
||||
await mkdir(stateRoot);
|
||||
await run("chown", [`0:${STATE_GID}`, stateRoot]);
|
||||
await chmod(stateRoot, 0o2770);
|
||||
|
||||
const heimdall = path.join(dir, "heimdall.mjs");
|
||||
await copyFile(bundle, heimdall);
|
||||
await chmod(heimdall, 0o444);
|
||||
|
||||
const files = await credentials(path.join(dir, "credentials"), identity);
|
||||
const candidatePort = await freePort();
|
||||
|
||||
const fds = [openSync(files.seed, "r")];
|
||||
const names = ["gamecult-idunn-runtime-activation-key"];
|
||||
if (files.identityFile) {
|
||||
fds.push(openSync(files.identityFile, "r"));
|
||||
names.push("gamecult-runtime-presence-identity");
|
||||
}
|
||||
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
LISTEN_FDS: String(fds.length),
|
||||
LISTEN_FDNAMES: names.join(":"),
|
||||
GAMECULT_IDUNN_CANDIDATE_BIND: `127.0.0.1:${candidatePort}`,
|
||||
GAMECULT_IDUNN_RUNTIME_BUNDLE: bundleDir,
|
||||
GAMECULT_IDUNN_PROCESS_WRITE_LEASE: path.join(dir, "process-write-lease.cc"),
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.test",
|
||||
GC_ACCESS_ISSUER: "https://heimdall.test",
|
||||
GC_ACCESS_STORAGE_BACKEND: "memory",
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${idunn.port}`,
|
||||
GC_ACCESS_IDUNN_HEALTH_CONTRACT: "heimdall.cultnet-rudp-provider-health",
|
||||
GC_ACCESS_ODIN_CULTMESH_URI: `rudp://127.0.0.1:${odinPort}`,
|
||||
CULTMESH_URI_ODIN_RUDP: `rudp://127.0.0.1:${odinPort}`,
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP: "1",
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: path.join(stateRoot, "signing.pem"),
|
||||
GC_ACCESS_SIGNING_KEY_ID: "boot-test",
|
||||
};
|
||||
|
||||
// uid, gid and umask as Idunn's DynamicUser unit gives them. The fixture
|
||||
// machine-id is bound only inside this mount namespace.
|
||||
const script =
|
||||
`mount --bind ${machineId} /etc/machine-id && ` +
|
||||
`exec setpriv --reuid ${GENERATION_UID} --regid ${STATE_GID} --clear-groups ` +
|
||||
`sh -c 'umask 0007; cd ${dir}; exec ${node} heimdall.mjs --state-root ${stateRoot}'`;
|
||||
const child = spawn("unshare", ["--mount", "--", "sh", "-c", script], {
|
||||
env,
|
||||
stdio: ["ignore", "ignore", "pipe", ...fds],
|
||||
});
|
||||
launched.push(child);
|
||||
fds.forEach((fd) => closeSync(fd));
|
||||
|
||||
let stderr = "";
|
||||
child.stderr!.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const exited = new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
|
||||
return { child, stderr: () => stderr, exited, candidatePort };
|
||||
}
|
||||
|
||||
async function answers(port: number): Promise<number | undefined> {
|
||||
return fetch(`http://127.0.0.1:${port}/.well-known/jwks.json`).then(
|
||||
(response) => response.status,
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
it("signs warming presence on every pulse and answers HTTP", async () => {
|
||||
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
|
||||
const generation = await launch("signs", credential);
|
||||
|
||||
// Startup publication, then one per 10 s pulse. A publication fault is
|
||||
// reported as it is logged rather than after the frames never arrive.
|
||||
const publicationFault = new Promise<never>((_resolve, reject) => {
|
||||
const watch = setInterval(() => {
|
||||
if (generation.stderr().includes(" failed on ")) {
|
||||
clearInterval(watch);
|
||||
reject(new Error(`publication failed; stderr: ${generation.stderr()}`));
|
||||
}
|
||||
}, 200);
|
||||
void idunn.frame(2).then(() => clearInterval(watch));
|
||||
});
|
||||
const exitedEarly = generation.exited.then((code) => {
|
||||
throw new Error(`bundle exited ${code}; stderr: ${generation.stderr()}`);
|
||||
});
|
||||
// The first statement is the startup publication, which follows the listen
|
||||
// directly; the 10 s pulse loop has not yet fired.
|
||||
await Promise.race([
|
||||
idunn.frame(0),
|
||||
publicationFault,
|
||||
exitedEarly,
|
||||
new Promise<never>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error(`no presence at startup; stderr: ${generation.stderr()}`)), 8_000)
|
||||
),
|
||||
]);
|
||||
await Promise.race([idunn.frame(2), publicationFault, exitedEarly]);
|
||||
|
||||
expect(await answers(generation.candidatePort)).toBe(200);
|
||||
|
||||
// Public keys of the two proofs, from the fixtures' test keys.
|
||||
const store = decode(credential) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
const providerKey = createPublicKey({
|
||||
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(record[2] as number[])]),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
const activationKey = createPublicKey(
|
||||
createPrivateKey({
|
||||
key: Buffer.concat([
|
||||
PKCS8_ED25519_SEED_PREFIX,
|
||||
await readFile(path.join(idunnRuntimeFixtures, "activation-key.seed")),
|
||||
]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
})
|
||||
);
|
||||
|
||||
let previousSequence = 0;
|
||||
for (const frame of idunn.frames.slice(0, 3)) {
|
||||
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") throw new Error("not a raw document put");
|
||||
const fields = decode(message.document.payload) as unknown[];
|
||||
expect(fields[RUNTIME_PRESENCE_SLOT.state]).toBe("warming");
|
||||
|
||||
const unsigned = [...fields];
|
||||
unsigned[RUNTIME_PRESENCE_SLOT.signature] = new Uint8Array();
|
||||
unsigned[RUNTIME_PRESENCE_SLOT.activationSignature] = new Uint8Array();
|
||||
const proof = encode(unsigned);
|
||||
expect(
|
||||
verify(
|
||||
null,
|
||||
runtimePresenceProviderSigningMessage(proof),
|
||||
providerKey,
|
||||
fields[RUNTIME_PRESENCE_SLOT.signature] as Uint8Array
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
verify(
|
||||
null,
|
||||
runtimePresenceActivationSigningMessage(proof),
|
||||
activationKey,
|
||||
fields[RUNTIME_PRESENCE_SLOT.activationSignature] as Uint8Array
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const sequence = fields[RUNTIME_PRESENCE_SLOT.publisherSequence] as number;
|
||||
expect(sequence).toBeGreaterThan(previousSequence);
|
||||
previousSequence = sequence;
|
||||
}
|
||||
|
||||
expect(generation.stderr()).not.toContain("failed on startup");
|
||||
expect(generation.stderr()).not.toContain("failed on interval");
|
||||
generation.child.kill("SIGKILL");
|
||||
}, 90_000);
|
||||
|
||||
it.each([
|
||||
["the identity descriptor is not passed", undefined, "gamecult-runtime-presence-identity"],
|
||||
["the identity is damaged", "damaged", "identity id check"],
|
||||
])("exits before listening when %s", async (_name, damage, expected) => {
|
||||
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
|
||||
const store = decode(credential) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
const publicKey = [...(record[2] as number[])];
|
||||
const original = { publicKeyHex: Buffer.from(publicKey).toString("hex"), id: record[1] as string, binding: record[5] as string };
|
||||
let identity: Buffer | undefined;
|
||||
if (damage) {
|
||||
publicKey[0] = publicKey[0]! ^ 1;
|
||||
record[2] = publicKey;
|
||||
identity = Buffer.from(encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
|
||||
}
|
||||
const started = Date.now();
|
||||
const generation = await launch(damage ? "damaged" : "no-identity", identity);
|
||||
|
||||
const code = await Promise.race([
|
||||
generation.exited,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
|
||||
]);
|
||||
|
||||
expect(code, generation.stderr()).not.toBe("timeout");
|
||||
expect(code).not.toBe(0);
|
||||
expect(Date.now() - started).toBeLessThan(6_000);
|
||||
expect(await answers(generation.candidatePort)).toBeUndefined();
|
||||
expect(generation.stderr()).toContain(expected);
|
||||
// No byte of the credential: not the key bytes, not the id, not the binding.
|
||||
expect(generation.stderr()).not.toContain(original.publicKeyHex);
|
||||
expect(generation.stderr()).not.toContain(original.id.slice(0, 16));
|
||||
expect(generation.stderr()).not.toContain(original.binding.slice(-16));
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -1,12 +1,18 @@
|
|||
import dgram from "node:dgram";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import { parseCultNetMessage } from "cultnet-ts";
|
||||
import {
|
||||
CultNetRudpSession,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
parseCultNetMessage,
|
||||
type CultNetTransportFrame,
|
||||
} from "cultnet-ts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { publishIdunnRudpHealth } from "../src/idunn-rudp-health.js";
|
||||
import { startFakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs/promises")>();
|
||||
|
|
@ -21,12 +27,37 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
|||
|
||||
describe("Heimdall Idunn health publication", () => {
|
||||
it("completes the canonical RUDP handshake before publishing its signed frame", async () => {
|
||||
const server = await startFakeIdunnRudp();
|
||||
const server = dgram.createSocket("udp4");
|
||||
const session = new CultNetRudpSession({
|
||||
connectionId: 0x1d0d0001,
|
||||
initialSequence: 1,
|
||||
resendDelayMs: 100,
|
||||
});
|
||||
let resolveFrame!: (frame: CultNetTransportFrame) => void;
|
||||
const frameReceived = new Promise<CultNetTransportFrame>((resolve) => {
|
||||
resolveFrame = resolve;
|
||||
});
|
||||
|
||||
server.on("message", (wire, remote) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
if (packet.packetType === "connect") {
|
||||
server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address);
|
||||
return;
|
||||
}
|
||||
const result = session.receive(packet, Date.now());
|
||||
if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address);
|
||||
for (const frame of result.delivered) resolveFrame(frame);
|
||||
if (result.delivered.length > 0) {
|
||||
server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "heimdall-idunn-rudp-"));
|
||||
const address = server.address();
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${server.port}`,
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${address.port}`,
|
||||
GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH: path.join(tmp, "provider-health-identity.cc"),
|
||||
});
|
||||
const observedAt = "2026-08-22T00:45:00.000Z";
|
||||
|
|
@ -47,7 +78,7 @@ describe("Heimdall Idunn health publication", () => {
|
|||
observedAt,
|
||||
presence,
|
||||
});
|
||||
const frame = await server.frame(0);
|
||||
const frame = await frameReceived;
|
||||
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
||||
|
||||
expect(frame.channelId).toBe("schema");
|
||||
|
|
|
|||
|
|
@ -1,265 +0,0 @@
|
|||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
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 {
|
||||
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" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Link mode binds a provider identity to the account an authenticated app
|
||||
// named at start. These tests pin the three places that binding is decided:
|
||||
// the callback (initiator), the completion row (target), and redeem (caller,
|
||||
// target, replay).
|
||||
|
||||
const returnTo = "https://repixelizer.gamecult.org/app/";
|
||||
const appAuth = { "x-heimdall-app-secret": "repixelizer-secret" };
|
||||
|
||||
function discordRuntime(providerUserId: string): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return { accessToken: "at", refreshToken: "rt", tokenType: "Bearer", scope: ["identify"], raw: {} };
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return { provider: "discord", providerUserId, username: providerUserId, profile: { id: providerUserId } };
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function rig(providerUserId: string, config: HeimdallConfig = createTestConfig()) {
|
||||
const store = new InMemoryStore();
|
||||
const app = await buildApp({ config, store, oauthRuntimes: { discord: discordRuntime(providerUserId) } });
|
||||
apps.push(app);
|
||||
const now = new Date().toISOString();
|
||||
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
|
||||
return { app, store, initiator };
|
||||
}
|
||||
|
||||
type Rig = Awaited<ReturnType<typeof rig>>;
|
||||
|
||||
function start(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = appAuth) {
|
||||
return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } });
|
||||
}
|
||||
|
||||
/** 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> = {}) {
|
||||
return r.app.inject({ method: "POST", url: "/v1/apps/repixelizer/auth-completions/redeem", headers, payload });
|
||||
}
|
||||
|
||||
describe("link start names its initiator", () => {
|
||||
it("refuses an unauthenticated link start, a missing or unknown initiator, and an initiator outside link mode", async () => {
|
||||
const r = await rig("d-1");
|
||||
const mode = { mode: "link", initiatingAccountId: r.initiator.id };
|
||||
expect((await start(r, mode, {})).statusCode).toBe(401);
|
||||
expect((await start(r, mode, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
|
||||
const missing = await start(r, { mode: "link" });
|
||||
expect([missing.statusCode, missing.json().error]).toEqual([400, "link_initiator_required"]);
|
||||
const unknown = await start(r, { mode: "link", initiatingAccountId: "no-such-account" });
|
||||
expect([unknown.statusCode, unknown.json().error]).toEqual([400, "link_initiator_unknown"]);
|
||||
const stray = await start(r, { mode: "sign_in", initiatingAccountId: r.initiator.id }, {});
|
||||
expect([stray.statusCode, stray.json().error]).toEqual([400, "initiator_only_in_link_mode"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign_in start carries no connection target", () => {
|
||||
it("refuses connection.targetId on sign_in with connection_not_allowed_in_sign_in, with or without app auth", async () => {
|
||||
const r = await rig("d-si");
|
||||
const connection = { kind: "creator", targetId: "victim-target" };
|
||||
for (const headers of [{}, appAuth]) {
|
||||
const res = await start(r, { mode: "sign_in", connection }, headers);
|
||||
expect([res.statusCode, res.json().error]).toEqual([400, "connection_not_allowed_in_sign_in"]);
|
||||
}
|
||||
expect((await start(r, { mode: "sign_in" }, {})).statusCode).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("link callback holds the provider identity to the initiator", () => {
|
||||
it("attaches an unlinked identity to the initiating account, not a new one", async () => {
|
||||
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());
|
||||
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);
|
||||
});
|
||||
|
||||
it("refuses with link_initiator_mismatch when the identity already belongs to another account, and mints nothing", async () => {
|
||||
const r = await rig("d-held");
|
||||
const now = new Date().toISOString();
|
||||
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());
|
||||
expect(done.statusCode).toBe(403);
|
||||
expect(done.json().error).toBe("link_initiator_mismatch");
|
||||
expect(done.json().completion).toBeUndefined();
|
||||
expect(done.body).not.toContain("accessToken");
|
||||
expect((await r.store.findAccountByLinkedIdentity("discord", "d-held"))?.id).toBe(other.id);
|
||||
});
|
||||
|
||||
it("refuses a link state that carries no initiator (forged or stripped), before attaching anything", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "heimdall-link-"));
|
||||
try {
|
||||
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
|
||||
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 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 {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("redeem binds caller, target and replay", () => {
|
||||
async function linked(r: Rig, targetId: string | undefined) {
|
||||
const started = await start(r, {
|
||||
mode: "link",
|
||||
initiatingAccountId: r.initiator.id,
|
||||
...(targetId ? { connection: { kind: "account", targetId } } : {}),
|
||||
});
|
||||
const done = await callback(r, started.json());
|
||||
expect(done.statusCode).toBe(201);
|
||||
return done.json().completion.code as string;
|
||||
}
|
||||
|
||||
it("redeems a link completion only for the authenticated app, and a refused caller does not burn the code", async () => {
|
||||
const r = await rig("d-r1");
|
||||
const completionCode = await linked(r, undefined);
|
||||
const anonymous = await redeem(r, { completionCode });
|
||||
expect([anonymous.statusCode, anonymous.json().error]).toEqual([401, "app_auth_required"]);
|
||||
expect((await redeem(r, { completionCode }, { "x-heimdall-app-secret": "wrong" })).statusCode).toBe(401);
|
||||
expect((await redeem(r, { completionCode }, appAuth)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("refuses a wrong or missing target with completion_target_mismatch without consuming the code", async () => {
|
||||
const r = await rig("d-r2");
|
||||
const completionCode = await linked(r, "target-a");
|
||||
for (const payload of [{ completionCode, targetId: "target-b" }, { completionCode }]) {
|
||||
const refused = await redeem(r, payload, appAuth);
|
||||
expect([refused.statusCode, refused.json().error]).toEqual([403, "completion_target_mismatch"]);
|
||||
expect(refused.json().accessToken).toBeUndefined();
|
||||
}
|
||||
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("refuses a replayed completion", async () => {
|
||||
const r = await rig("d-r3");
|
||||
const completionCode = await linked(r, "target-a");
|
||||
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
|
||||
expect((await redeem(r, { completionCode, targetId: "target-a" }, appAuth)).statusCode).toBe(410);
|
||||
});
|
||||
|
||||
it("refuses a completion at and after its expiry with 410, whatever the caller and target, and honours it just before", async () => {
|
||||
// completionTtlSeconds is 300. Only Date is faked: the app and store run on real timers.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
const t0 = Date.parse("2026-11-03T00:00:00.000Z");
|
||||
const ttlMs = createTestConfig().completionTtlSeconds * 1000;
|
||||
const expired = { statusCode: 410, error: "invalid_or_expired_completion_code" };
|
||||
const outcome = async (r: Rig, payload: Record<string, unknown>) => {
|
||||
const res = await redeem(r, payload, appAuth);
|
||||
return { statusCode: res.statusCode, error: res.json().error };
|
||||
};
|
||||
|
||||
vi.setSystemTime(t0);
|
||||
const r = await rig("d-exp");
|
||||
const code = await linked(r, "target-a");
|
||||
const row = await r.store.findAuthCompletion("repixelizer", code);
|
||||
expect(row?.expiresAt).toBe(new Date(t0 + ttlMs).toISOString());
|
||||
|
||||
// Just before expiry the right caller and target redeem it.
|
||||
const early = await rig("d-exp-early");
|
||||
const earlyCode = await linked(early, "target-a");
|
||||
vi.setSystemTime(t0 + ttlMs - 1);
|
||||
expect((await redeem(early, { completionCode: earlyCode, targetId: "target-a" }, appAuth)).statusCode).toBe(201);
|
||||
|
||||
// At the expiry instant and after it: 410, for the right target and for a wrong one (not 403), unconsumed.
|
||||
for (const at of [t0 + ttlMs, t0 + ttlMs + 1]) {
|
||||
vi.setSystemTime(at);
|
||||
expect(await outcome(r, { completionCode: code, targetId: "target-a" })).toEqual(expired);
|
||||
expect(await outcome(r, { completionCode: code, targetId: "target-b" })).toEqual(expired);
|
||||
}
|
||||
expect((await r.store.findAuthCompletion("repixelizer", code))?.consumedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores the target on the completion row", async () => {
|
||||
const r = await rig("d-r4");
|
||||
const completionCode = await linked(r, "target-a");
|
||||
expect((await r.store.findAuthCompletion("repixelizer", completionCode))?.targetId).toBe("target-a");
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,6 @@ 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));
|
||||
|
|
@ -580,19 +579,17 @@ 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`, headers: mode === "connect" ? { "x-heimdall-app-secret": "streampixels-secret" } : {}, 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`, 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(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
});
|
||||
expect(callback.statusCode).toBe(201);
|
||||
const redeem = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/streampixels/auth-completions/redeem",
|
||||
payload: { completionCode: callback.json().completion.code as string, ...(mode === "connect" ? { targetId: "creator-1" } : {}) },
|
||||
payload: { completionCode: callback.json().completion.code as string },
|
||||
});
|
||||
expect(redeem.statusCode).toBe(201);
|
||||
return { callback, redeem };
|
||||
|
|
@ -633,15 +630,12 @@ 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(flow.state)}`,
|
||||
headers: { cookie: flow.cookie },
|
||||
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
|
||||
});
|
||||
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(callback.body).not.toContain("tok-access");
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
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;
|
||||
const response = await app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` });
|
||||
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> {
|
||||
const url = new URL(start.authorizationUrl);
|
||||
const response = await app.inject({ method: "GET", url: `${url.pathname}${url.search}` });
|
||||
expect(response.statusCode, "launch").toBe(302);
|
||||
return new URL(response.headers.location as string);
|
||||
}
|
||||
|
|
@ -1,516 +0,0 @@
|
|||
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 error 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("provider_error");
|
||||
const replay = await callback(app, victim.state, victim.cookie, "error=access_denied");
|
||||
expect(replay.json()).toEqual({ error: "state_already_used" });
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { openSync } from "node:fs";
|
||||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { openSync, closeSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
openOrEnrollProviderHealthIdentity,
|
||||
openProviderHealthIdentity,
|
||||
passedDescriptor,
|
||||
providerHealthPublicIdentity,
|
||||
} from "../src/provider-health-identity.js";
|
||||
import { fixtureMachineId, idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
||||
() => true,
|
||||
|
|
@ -22,79 +23,59 @@ const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
|||
* The identity is machine-id bound, so enrolment only works where a machine-id
|
||||
* exists. These run on Linux and are skipped elsewhere.
|
||||
*/
|
||||
/** Name the presence descriptor so it lands on whatever fd open() returned. */
|
||||
function presenceEnv(fd: number): NodeJS.ProcessEnv {
|
||||
// The lookup maps a name index onto 3 + index, so the name list is padded
|
||||
// until the presence entry lands on the descriptor open() actually returned.
|
||||
// Assuming it is 3 is how this passes on one machine and reads the wrong
|
||||
// descriptor on another.
|
||||
const names = [
|
||||
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
|
||||
"gamecult-runtime-presence-identity",
|
||||
].join(":");
|
||||
return { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) };
|
||||
}
|
||||
|
||||
describe("presence identity from a passed descriptor", () => {
|
||||
it("decodes the Rust owner's record, whose key fields are integer arrays", async () => {
|
||||
describe.skipIf(!machineIdAvailable)("presence identity from a passed descriptor", () => {
|
||||
it("reads an Idunn-written store, not a cultcache-ts one", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-"));
|
||||
const fd = openSync(path.join(idunnRuntimeFixtures, "provider-identity.credential"), "r");
|
||||
|
||||
const opened = await openProviderHealthIdentity(
|
||||
path.join(dir, "would-be-self-enrolled.cc"),
|
||||
presenceEnv(fd),
|
||||
fixtureMachineId
|
||||
// Enrol with the TypeScript client to get a genuine machine-bound payload,
|
||||
// then re-wrap that payload the way cultcache-rs writes a store: a bare
|
||||
// array of positional [key, type, payload, stored_at, schema_id] envelopes.
|
||||
// Idunn writes with the Rust client, and cultcache-ts cannot read that
|
||||
// layout, so wrapping it here is what makes this test resemble reality.
|
||||
const tsStore = path.join(dir, "ts-enrolled.cc");
|
||||
const enrolled = await openOrEnrollProviderHealthIdentity(tsStore);
|
||||
const records = await new SingleFileMessagePackBackingStore(tsStore).pullAll();
|
||||
expect(records).toHaveLength(1);
|
||||
|
||||
const idunnStore = path.join(dir, "runtime-presence-identity.cc");
|
||||
await writeFile(
|
||||
idunnStore,
|
||||
encode([
|
||||
[
|
||||
"gamecult-provider-health-identity",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
records[0]!.payload,
|
||||
"2026-09-06T00:00:00.000Z",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
// The fixture's own public key means it came from the descriptor. A fresh
|
||||
// self-enrolment would produce a different key, which is exactly the failure
|
||||
// that makes Idunn refuse this daemon's health and stall promotion.
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
|
||||
Buffer.from(record[2] as number[]).toString("hex")
|
||||
);
|
||||
await expect(access(path.join(dir, "would-be-self-enrolled.cc"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
const fd = openSync(idunnStore, "r");
|
||||
try {
|
||||
// The lookup maps a name index onto 3 + index, so the name list is padded
|
||||
// until the presence entry lands on whatever descriptor open() actually
|
||||
// returned. Assuming it is 3 is how this passes on one machine and reads
|
||||
// the wrong descriptor on another.
|
||||
const names = [
|
||||
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
|
||||
"gamecult-runtime-presence-identity",
|
||||
].join(":");
|
||||
|
||||
it("refuses a bin-shaped record on the descriptor path, naming the key-shape check and no value", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-bin-"));
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
// What this process's own enroller writes: the same record with bin key fields.
|
||||
const binRecord = record.map((field, index) =>
|
||||
index === 2 || index === 3 ? Uint8Array.from(field as number[]) : field
|
||||
);
|
||||
const binStore = path.join(dir, "bin-shaped.cc");
|
||||
await writeFile(binStore, encode([[store[0]![0], store[0]![1], encode(binRecord), store[0]![3], store[0]![4]]]));
|
||||
const fd = openSync(binStore, "r");
|
||||
const opened = await openProviderHealthIdentity(
|
||||
path.join(dir, "would-be-self-enrolled.cc"),
|
||||
{ LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) }
|
||||
);
|
||||
|
||||
const failure = await openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId).then(
|
||||
() => undefined,
|
||||
(error: Error) => error
|
||||
);
|
||||
|
||||
expect(failure?.message).toContain("key shape check");
|
||||
expect(failure?.message).not.toContain((record[1] as string).slice(0, 16));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["schema", 0, "bogus", "schema check"],
|
||||
["protector", 4, "bogus", "protector check"],
|
||||
["binding", 5, "bogus", "binding check"],
|
||||
["version", 6, "v9", "version check"],
|
||||
])("a record failing only the %s check is refused by that check", async (_name, index, replacement, check) => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-check-"));
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
record[index] = replacement;
|
||||
const damaged = path.join(dir, "damaged.cc");
|
||||
await writeFile(damaged, encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
|
||||
const fd = openSync(damaged, "r");
|
||||
|
||||
await expect(
|
||||
openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId)
|
||||
).rejects.toThrow(check);
|
||||
// Same key means it came from the descriptor. A fresh self-enrolment
|
||||
// would produce a different key -- which is exactly the failure that
|
||||
// makes Idunn refuse this daemon's health and stall promotion.
|
||||
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
|
||||
providerHealthPublicIdentity(enrolled).publicKeyHex
|
||||
);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -131,29 +112,3 @@ describe("presence identity descriptor selection", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("an Idunn generation without the presence identity descriptor", () => {
|
||||
it("refuses, naming the descriptor, and enrolls nothing", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodescriptor-"));
|
||||
const identityPath = path.join(dir, "provider-health-identity.cc");
|
||||
|
||||
await expect(
|
||||
openProviderHealthIdentity(identityPath, {
|
||||
GAMECULT_IDUNN_RUNTIME_BUNDLE: "/run/idunn/bundle",
|
||||
LISTEN_FDNAMES: "some-other-descriptor",
|
||||
})
|
||||
).rejects.toThrow("gamecult-runtime-presence-identity");
|
||||
|
||||
await expect(access(identityPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("still self-enrolls outside Idunn", async () => {
|
||||
if (!machineIdAvailable) return;
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-outside-idunn-"));
|
||||
const identityPath = path.join(dir, "provider-health-identity.cc");
|
||||
|
||||
await openProviderHealthIdentity(identityPath, {});
|
||||
|
||||
await expect(access(identityPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ 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> }> = [];
|
||||
|
|
@ -31,7 +30,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: {}, google: {} },
|
||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} },
|
||||
} as HeimdallConfig;
|
||||
}
|
||||
|
||||
|
|
@ -74,11 +73,10 @@ 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", cookie },
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
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 }));
|
||||
|
|
@ -135,11 +133,10 @@ 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", cookie },
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
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 }));
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { PublisherSequence, openPresenceAuthority } from "../src/runtime-presence.js";
|
||||
import { fixtureMachineId, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
describe("PublisherSequence", () => {
|
||||
it("starts at the observed millis", () => {
|
||||
expect(new PublisherSequence().next(1_700_000_000_000)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("strictly increases for equal, earlier and later observations", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
const first = sequence.next(1_700_000_000_000);
|
||||
const equal = sequence.next(1_700_000_000_000);
|
||||
const earlier = sequence.next(1_699_999_990_000);
|
||||
const later = sequence.next(1_700_000_050_000);
|
||||
expect([first, equal, earlier, later]).toEqual([
|
||||
1_700_000_000_000,
|
||||
1_700_000_000_001,
|
||||
1_700_000_000_002,
|
||||
1_700_000_050_000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("never falls below the observed millis once the clock moves ahead", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
sequence.next(1_700_000_000_000);
|
||||
sequence.next(1_700_000_000_000);
|
||||
sequence.next(1_700_000_000_000);
|
||||
expect(sequence.next(1_700_000_000_002)).toBe(1_700_000_000_003);
|
||||
expect(sequence.next(1_700_000_000_010)).toBe(1_700_000_000_010);
|
||||
});
|
||||
|
||||
it("refuses a value that is not a safe integer and keeps its place", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
expect(sequence.next(10)).toBe(10);
|
||||
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, 2 ** 60]) {
|
||||
expect(() => sequence.next(bad)).toThrow("observedAtUnixMillis");
|
||||
}
|
||||
expect(sequence.next(5)).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the presence authority", () => {
|
||||
it("opens once, touches no path under dataRoot, and signs every pulse after its descriptors are gone", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-"));
|
||||
const fixtures = path.join(dir, "fixtures");
|
||||
await writePresenceFixtures(fixtures);
|
||||
// A regular file as dataRoot: opening anything beneath it fails with
|
||||
// ENOTDIR for every uid, root included.
|
||||
const dataRoot = path.join(dir, "not-a-directory");
|
||||
await writeFile(dataRoot, "");
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
|
||||
GC_ACCESS_DATA_ROOT: dataRoot,
|
||||
});
|
||||
|
||||
const descriptors = openPresenceDescriptors(fixtures);
|
||||
try {
|
||||
const authority = await openPresenceAuthority(
|
||||
{ ...config, idunnRuntimeBundlePath: fixtures },
|
||||
descriptors.env,
|
||||
fixtureMachineId,
|
||||
);
|
||||
// The descriptors were read whole and closed: a second read would find
|
||||
// nothing, so signing again proves the authority holds what it decoded.
|
||||
const observedAtUnixMillis = Date.parse("2026-10-23T12:00:00.000Z");
|
||||
const first = authority.sign("warming", "probe", observedAtUnixMillis);
|
||||
const second = authority.sign("warming", "probe", observedAtUnixMillis);
|
||||
expect(first.target).toBe("streampixels-web");
|
||||
expect(second.target).toBe("streampixels-web");
|
||||
expect(Buffer.from(second.payload).equals(Buffer.from(first.payload))).toBe(false);
|
||||
} finally {
|
||||
descriptors.close();
|
||||
}
|
||||
expect((await readdir(dir)).sort()).toEqual(["fixtures", "not-a-directory"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -39,7 +39,6 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { chmod, chown, mkdir, mkdtemp, readdir, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const STATE_GID = 4242;
|
||||
const GENERATION_N = 61184;
|
||||
const GENERATION_N_PLUS_1 = 61185;
|
||||
|
||||
// Creating uids and groups needs root; the stopgap's node container runs as root.
|
||||
const isRoot = process.getuid?.() === 0;
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
found.push(full);
|
||||
if (entry.isDirectory()) found.push(...(await walk(full)));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idunn gives every generation a fresh DynamicUser uid and one shared group,
|
||||
* UMask=0007, and a 2770 setgid state root. Generation N+1 runs beside or after
|
||||
* N and must be able to read and replace everything N left there.
|
||||
*/
|
||||
describe.skipIf(!isRoot)("Idunn state root across generations", () => {
|
||||
it("generation N+1, a different uid in the same group, reads and replaces generation N's state", async () => {
|
||||
const base = await mkdtemp(path.join(os.tmpdir(), "heimdall-generations-"));
|
||||
await chmod(base, 0o755);
|
||||
|
||||
// The fixtures stand in for what Idunn passes as descriptors.
|
||||
const fixtures = path.join(base, "fixtures");
|
||||
await writePresenceFixtures(fixtures);
|
||||
|
||||
const stateRoot = path.join(base, "state");
|
||||
await mkdir(stateRoot);
|
||||
await chown(stateRoot, 0, STATE_GID);
|
||||
await chmod(stateRoot, 0o2770);
|
||||
|
||||
// The generations run as uids that cannot read the checkout, so each runs
|
||||
// one self-contained bundle built into the world-readable scratch root.
|
||||
const child = path.join(base, "generation.mjs");
|
||||
await build({
|
||||
entryPoints: [path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node24",
|
||||
outfile: child,
|
||||
external: ["pg-native"],
|
||||
banner: {
|
||||
js: "import{createRequire}from'node:module';const require=createRequire(import.meta.url);",
|
||||
},
|
||||
logLevel: "silent",
|
||||
});
|
||||
await chmod(child, 0o644);
|
||||
const generation = (role: "first" | "next", uid: number) =>
|
||||
run(process.execPath, [child, role, stateRoot, fixtures], {
|
||||
uid,
|
||||
gid: STATE_GID,
|
||||
cwd: base,
|
||||
env: { PATH: process.env.PATH ?? "", HOME: fixtures },
|
||||
});
|
||||
|
||||
await generation("first", GENERATION_N);
|
||||
|
||||
const left = await walk(stateRoot);
|
||||
expect(left.map((entry) => path.relative(stateRoot, entry)).sort()).toEqual([
|
||||
"cultcache",
|
||||
path.join("cultcache", "heimdall.service.cc"),
|
||||
"runtime-presence-sequence",
|
||||
]);
|
||||
|
||||
await generation("next", GENERATION_N_PLUS_1);
|
||||
|
||||
// Generation N+1 replaced the snapshot, and no owner-only file is left
|
||||
// behind for generation N+2: everything but the stale 0600 fixture is
|
||||
// readable and writable by the state group.
|
||||
const snapshot = await stat(path.join(stateRoot, "cultcache", "heimdall.service.cc"));
|
||||
expect(snapshot.uid).toBe(GENERATION_N_PLUS_1);
|
||||
expect(snapshot.gid).toBe(STATE_GID);
|
||||
for (const entry of await walk(stateRoot)) {
|
||||
if (path.basename(entry) === "runtime-presence-sequence") continue;
|
||||
expect((await stat(entry)).mode & 0o060, path.relative(stateRoot, entry)).toBe(0o060);
|
||||
}
|
||||
expect((await readdir(stateRoot)).sort()).toEqual(["cultcache", "runtime-presence-sequence"]);
|
||||
}, 60_000);
|
||||
});
|
||||
|
|
@ -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, InMemoryStore } from "../src/store/index.js";
|
||||
import { createStore } from "../src/store/index.js";
|
||||
import {
|
||||
createPostgresStore,
|
||||
PostgresStore,
|
||||
|
|
@ -198,39 +198,6 @@ describe("PostgresStore", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
|
@ -265,14 +232,12 @@ describe("PostgresStore.checkSchema", () => {
|
|||
"sessions",
|
||||
"auth_attempts",
|
||||
"private_command_receipts",
|
||||
"pending_links",
|
||||
"auth_completions",
|
||||
"oauth_state_uses",
|
||||
"capability_grants",
|
||||
"entitlement_snapshots",
|
||||
"audit_events",
|
||||
]);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(101);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(85);
|
||||
expect(REQUIRED_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
|
||||
|
|
@ -283,12 +248,11 @@ describe("PostgresStore.checkSchema", () => {
|
|||
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
|
||||
])
|
||||
);
|
||||
expect(REQUIRED_KEYS).toHaveLength(14);
|
||||
expect(REQUIRED_KEYS).toHaveLength(12);
|
||||
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: "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" },
|
||||
])
|
||||
|
|
@ -398,26 +362,9 @@ 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);
|
||||
await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
|
||||
return called;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/8; apps=4; healthTransport=CultNet/RUDP",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP",
|
||||
"2026-06-16T17:10:00.000Z",
|
||||
"heimdall.cultnet-rudp-provider-health",
|
||||
"daemon-published",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ describe("Heimdall Verse witness advertisement", () => {
|
|||
"youtube",
|
||||
"spotify",
|
||||
"joystick",
|
||||
"google",
|
||||
]);
|
||||
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
||||
"repixelizer",
|
||||
|
|
|
|||
Loading…
Reference in a new issue