Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
4185e657bb Tests: state time windows to the second, requested_scopes shapes, the launch route's validation, the phases each route claims
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:25:52 +02:00
Meta Vens
5076e04950 Docs: the launch hop, the binding cookie and the callback refusals
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:18 +02:00
Meta Vens
3def0fb426 OAuth states complete once, only in the browser that launched them: a launch hop sets a signed binding cookie, launch and callback each consume the state, and the callback checks expiry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:17 +02:00
Meta Vens
14e0cde26c Store: claimOAuthStateUse claims one use of a signed OAuth state per (jti, phase), on both stores and an INSERT-only table
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:16 +02:00
Meta Vens
607c2b0a20 Depend on @fastify/cookie for the OAuth binding cookie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:16 +02:00
Meta Vens
ece6ba1d62 Tests: a state completes once and only in the browser that launched it; the gate holds for every mode, provider and handoff (red against the callback that accepts any signed state)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:04:10 +02:00
Meta Vens
9fd152feff Tests: a foreign app's caller cannot start a Bifrost flow, with no policy attached
Kills the surviving mutant on the caller.appSlug comparison in startOAuthFlow: over HTTP a foreign secret never resolves to a caller, so only a direct call reaches that arm.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:24:42 +02:00
Meta Vens
ed964201d4 Docs: startRequiresAppCaller, and Heimdall authenticates while Bifrost issues identity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:14:37 +02:00
Meta Vens
7e17a0e952 Start: a profile with startRequiresAppCaller refuses unauthenticated starts; set on Bifrost
Bifrost's browser binding is the secrecy of a nonce carried readable in the signed state, so only Bifrost may mint a Bifrost state. The gate sits in startOAuthFlow beside the entitlementPolicy caller check, so HTTP and the private command plane share it. Follows the red tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:14:37 +02:00
Meta Vens
6d43eacd84 Tests: apps that bind browser completion refuse unauthenticated starts (red; does not pass yet)
The replay of finding known-nonce-login-csrf at the HTTP surface, plus authenticated, cross-app and public-app cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:13:02 +02:00
23 changed files with 1209 additions and 224 deletions

View file

@ -154,7 +154,11 @@ The important split:
- Heimdall owns provider OAuth, identity linking, entitlement refresh, signed - Heimdall owns provider OAuth, identity linking, entitlement refresh, signed
sessions, claims, grants, audit surfaces, and managed provider credential sessions, claims, grants, audit surfaces, and managed provider credential
custody custody. It authenticates; it does not issue GameCult identity. Bifrost is
the identity provider, and the account id Heimdall mints is an authenticator
handle. Remaining scar: Heimdall still creates that account id on first
login (`store.createAccount`, `src/app.ts`), which the chat work does not
need moved
- the host experiment owns its workload, queue, editor, render path, viewer - the host experiment owns its workload, queue, editor, render path, viewer
profile model, creator model, or other product-specific machinery profile model, creator model, or other product-specific machinery
- the host experiment also owns which local entity a connection attaches to, - the host experiment also owns which local entity a connection attaches to,
@ -613,8 +617,9 @@ Only the browser-driven OAuth flow:
| Path | Why it must be public | | Path | Why it must be public |
|---|---| |---|---|
| `/v1/oauth/{provider}/start` | the user's browser goes here | | `/v1/oauth/{provider}/start` | the app's server calls it, with its app secret where the profile requires one |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable | | `/v1/oauth/{provider}/launch` | the user's browser goes here first; it sets the binding cookie and redirects to the provider, once per state |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot: it needs the binding cookie and consumes the state |
| `/.well-known/jwks.json` | *nothing external consumes it today* — see below | | `/.well-known/jwks.json` | *nothing external consumes it today* — see below |
Everything else is internal by the design already written down: Everything else is internal by the design already written down:

View file

@ -149,12 +149,29 @@ Response:
"appSlug": "repixelizer", "appSlug": "repixelizer",
"mode": "sign_in", "mode": "sign_in",
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback", "callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
"authorizationUrl": "https://discord.com/oauth2/authorize?...", "authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=<signed-jwt>",
"stateToken": "<signed-jwt>",
"stateExpiresAt": "2026-04-26T12:10:00.000Z" "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: Failure modes:
- `503 provider_not_configured` when the provider client id is missing - `503 provider_not_configured` when the provider client id is missing
@ -168,7 +185,12 @@ Failure modes:
- `401 app_auth_required` when the request body carries `entitlementPolicy` and - `401 app_auth_required` when the request body carries `entitlementPolicy` and
the caller did not authenticate as that app via `x-heimdall-app-secret` the caller did not authenticate as that app via `x-heimdall-app-secret`
(`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of (`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of
`handoff.kind` or app slug; a caller with no policy to attach needs no secret `handoff.kind` or app slug; a caller with no policy to attach needs no secret,
unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then
every start for that app, with or without a policy, needs that app's secret.
Bifrost binds browser completion to a nonce that rides readable in the signed
state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose
browser starts are public by design leave the flag unset
Launch note: Launch note:
@ -189,7 +211,9 @@ Purpose:
Current status: Current status:
- validates signed `state` - validates signed `state` (signature, issuer, provider, expiry), requires the
binding cookie the launch hop set, and consumes the state: a second callback
is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`
- returns provider errors cleanly - returns provider errors cleanly
- for Discord, exchanges the authorization code, resolves the provider - for Discord, exchanges the authorization code, resolves the provider
identity, persists the local account/link/session/audit records, evaluates identity, persists the local account/link/session/audit records, evaluates

View file

@ -11,7 +11,8 @@ evidence only.
## Owner Map ## Owner Map
- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim - Owner: Heimdall owns provider identity/linking (an authenticator handle, not
GameCult identity, which is Bifrost's), OAuth attempts, signed session and claim
issuance, entitlement refresh, managed provider token custody, grants, issuance, entitlement refresh, managed provider token custody, grants,
auth-completion handoffs, refresh sessions, and audit events. auth-completion handoffs, refresh sessions, and audit events.
- Inputs: provider OAuth callbacks, app profile requests, caller-supplied - Inputs: provider OAuth callbacks, app profile requests, caller-supplied

View file

@ -17,6 +17,7 @@
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node24 --outfile=dist/heimdall --external:pg-native --banner:js=\"#!/usr/bin/env node\nimport{createRequire}from'node:module';const require=createRequire(import.meta.url);\"" "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": { "dependencies": {
"@fastify/cookie": "^11.1.3",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts", "@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts",

22
pnpm-lock.yaml generated
View file

@ -12,6 +12,9 @@ importers:
.: .:
dependencies: dependencies:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.3
'@fastify/cors': '@fastify/cors':
specifier: ^11.2.0 specifier: ^11.2.0
version: 11.2.0 version: 11.2.0
@ -229,6 +232,9 @@ packages:
'@fastify/ajv-compiler@4.0.5': '@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/cookie@11.1.3':
resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==}
'@fastify/cors@11.2.0': '@fastify/cors@11.2.0':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
@ -469,6 +475,10 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
cookie@2.0.1:
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
engines: {node: '>=22'}
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory} resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -541,6 +551,9 @@ packages:
fastify-plugin@5.1.0: fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify-plugin@6.0.1:
resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==}
fastify@5.8.5: fastify@5.8.5:
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
@ -1180,6 +1193,11 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0) ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.0 fast-uri: 3.1.0
'@fastify/cookie@11.1.3':
dependencies:
cookie: 2.0.1
fastify-plugin: 6.0.1
'@fastify/cors@11.2.0': '@fastify/cors@11.2.0':
dependencies: dependencies:
fastify-plugin: 5.1.0 fastify-plugin: 5.1.0
@ -1387,6 +1405,8 @@ snapshots:
cookie@1.1.1: {} cookie@1.1.1: {}
cookie@2.0.1: {}
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts: cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
dependencies: dependencies:
'@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts '@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts
@ -1483,6 +1503,8 @@ snapshots:
fastify-plugin@5.1.0: {} fastify-plugin@5.1.0: {}
fastify-plugin@6.0.1: {}
fastify@5.8.5: fastify@5.8.5:
dependencies: dependencies:
'@fastify/ajv-compiler': 4.0.5 '@fastify/ajv-compiler': 4.0.5

View file

@ -45,6 +45,17 @@ export interface AppProfile {
* plane; absent for apps with no such requirement. * plane; absent for apps with no such requirement.
*/ */
requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"]; 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. */ /** The membership signal shared by every app that gates on GameCult membership. */
@ -142,6 +153,7 @@ const bifrostProfile: AppProfile = {
entitlementSources: ["discord", "patreon"], entitlementSources: ["discord", "patreon"],
managedConnectionProviders: [], managedConnectionProviders: [],
allowedReturnOrigins: ["https://bifrost.gamecult.org"], allowedReturnOrigins: ["https://bifrost.gamecult.org"],
startRequiresAppCaller: true,
capabilities: [ capabilities: [
{ {
key: "member_access", key: "member_access",

View file

@ -1,6 +1,7 @@
import { createHmac, randomUUID } from "node:crypto"; import { createHmac, randomUUID } from "node:crypto";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import Fastify, { type FastifyError, type FastifyInstance } from "fastify"; import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply } from "fastify";
import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js"; import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js";
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
import { import {
@ -93,6 +94,7 @@ function buildOAuthStateToken(options: {
connection: OAuthConnectionBinding | undefined; connection: OAuthConnectionBinding | undefined;
handoff: OAuthHandoff; handoff: OAuthHandoff;
entitlementPolicy: OAuthEntitlementPolicy | undefined; entitlementPolicy: OAuthEntitlementPolicy | undefined;
requestedScopes: string[] | undefined;
keys: ReturnType<typeof createRuntimeKeyMaterial>; keys: ReturnType<typeof createRuntimeKeyMaterial>;
}): { token: string; expiresAt: string } { }): { token: string; expiresAt: string } {
const issuedAt = nowEpochSeconds(); const issuedAt = nowEpochSeconds();
@ -113,6 +115,7 @@ function buildOAuthStateToken(options: {
connection: options.connection ?? null, connection: options.connection ?? null,
handoff: options.handoff, handoff: options.handoff,
entitlement_policy: options.entitlementPolicy ?? null, entitlement_policy: options.entitlementPolicy ?? null,
requested_scopes: options.requestedScopes ?? null,
}; };
const token = signJwt(payload as unknown as Record<string, unknown>, options.keys); const token = signJwt(payload as unknown as Record<string, unknown>, options.keys);
@ -151,6 +154,11 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
entitlementPolicy !== null && entitlementPolicy !== null &&
(entitlementPolicy as Record<string, unknown>).kind === "patreon_membership_access" && (entitlementPolicy as Record<string, unknown>).kind === "patreon_membership_access" &&
typeof (entitlementPolicy as Record<string, unknown>).requiredTierTitle === "string")); 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 ( if (
payload.typ !== "heimdall_oauth_state" || payload.typ !== "heimdall_oauth_state" ||
typeof payload.provider !== "string" || typeof payload.provider !== "string" ||
@ -168,7 +176,8 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
typeof payload.nbf !== "number" || typeof payload.nbf !== "number" ||
typeof payload.exp !== "number" || typeof payload.exp !== "number" ||
!handoffIsValid || !handoffIsValid ||
!entitlementPolicyIsValid !entitlementPolicyIsValid ||
!requestedScopesAreValid
) { ) {
return null; return null;
} }
@ -180,10 +189,99 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
if (!normalizedPayload.entitlement_policy) { if (!normalizedPayload.entitlement_policy) {
normalizedPayload.entitlement_policy = null; normalizedPayload.entitlement_policy = null;
} }
if (!normalizedPayload.requested_scopes) {
normalizedPayload.requested_scopes = null;
}
return normalizedPayload; 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[] { function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] {
return grants.map((grant) => grantFact(grant.capability)); return grants.map((grant) => grantFact(grant.capability));
} }
@ -554,7 +652,7 @@ export async function startOAuthFlow(
if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") { if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") {
handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId }; handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId };
} }
if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) { if ((entitlementPolicy || profile.startRequiresAppCaller) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
return { statusCode: 401, body: { error: "app_auth_required" } }; return { statusCode: 401, body: { error: "app_auth_required" } };
} }
@ -566,7 +664,6 @@ export async function startOAuthFlow(
}; };
} }
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
const state = buildOAuthStateToken({ const state = buildOAuthStateToken({
config, config,
provider, provider,
@ -576,6 +673,7 @@ export async function startOAuthFlow(
connection: input.connection, connection: input.connection,
handoff, handoff,
entitlementPolicy, entitlementPolicy,
requestedScopes: input.requestedScopes,
keys, keys,
}); });
@ -585,16 +683,9 @@ export async function startOAuthFlow(
provider, provider,
appSlug: profile.slug, appSlug: profile.slug,
mode: input.mode, mode: input.mode,
callbackUrl, callbackUrl: oauthCallbackUrl(config, provider),
authorizationUrl: buildAuthorizationUrl({ authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`,
provider,
clientId: providerConfig.clientId,
redirectUri: callbackUrl,
state: state.token,
requestedScopes: input.requestedScopes,
}),
handoff, handoff,
stateToken: state.token,
stateExpiresAt: state.expiresAt, stateExpiresAt: state.expiresAt,
}, },
}; };
@ -738,6 +829,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
origin: true, origin: true,
credentials: true, credentials: true,
}); });
await app.register(cookie);
app.get("/healthz", async () => ({ app.get("/healthz", async () => ({
ok: true, ok: true,
@ -872,6 +964,72 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
} }
); );
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) {
reply.code(400);
return { error: "state_already_launched" };
}
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,
}),
302
);
}
);
app.get<{ app.get<{
Params: { provider: Provider }; Params: { provider: Provider };
Querystring: { code?: string; state?: string; error?: string; error_description?: string }; Querystring: { code?: string; state?: string; error?: string; error_description?: string };
@ -905,16 +1063,29 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return { error: "missing_state" }; return { error: "missing_state" };
} }
const verification = verifyJwt(request.query.state, keys.publicKey); const verified = verifyOAuthState(config, keys, request.query.state, request.params.provider);
if (!verification.valid) { if (!verified.ok) {
reply.code(400); reply.code(400);
return { error: "invalid_state", detail: verification.error }; return { error: verified.error };
} }
const statePayload = verified.payload;
const statePayload = parseOAuthStatePayload(verification.payload); // The cookie is checked before the claim: a stranger without it must not be
if (!statePayload || statePayload.provider !== request.params.provider) { // able to burn the state that the browser that launched it still needs.
if (!oauthBindingHolds(config, keys, request.cookies[oauthBindingCookieName(statePayload.jti)], statePayload)) {
reply.code(400); reply.code(400);
return { error: "state_provider_mismatch" }; return { error: "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) {
reply.code(400);
return { error: "state_already_used" };
} }
const handoff = statePayload.handoff ?? { kind: "browser_completion" }; const handoff = statePayload.handoff ?? { kind: "browser_completion" };
const denyBrowserAttempt = async (denialCode: string): Promise<void> => { const denyBrowserAttempt = async (denialCode: string): Promise<void> => {

View file

@ -94,6 +94,8 @@ export interface OAuthStatePayload {
connection: OAuthConnectionBinding | null; connection: OAuthConnectionBinding | null;
handoff: OAuthHandoff | null; handoff: OAuthHandoff | null;
entitlement_policy: OAuthEntitlementPolicy | 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 { export interface LinkedIdentityInput {

View file

@ -305,7 +305,6 @@ async function beginAuth(
throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied.")); throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied."));
} }
const authorizationUrl = String(startPayload.authorizationUrl ?? ""); const authorizationUrl = String(startPayload.authorizationUrl ?? "");
const authorizationOrigin = new URL(authorizationUrl).origin;
return { return {
status: "accepted", status: "accepted",
payloadSchema: "heimdall.auth_begin_receipt.v1", payloadSchema: "heimdall.auth_begin_receipt.v1",
@ -316,7 +315,7 @@ async function beginAuth(
expiresAt: attempt.expiresAt, expiresAt: attempt.expiresAt,
navigation: { navigation: {
url: authorizationUrl, url: authorizationUrl,
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin], allowedOrigins: [new URL(config.publicBaseUrl).origin],
}, },
}, },
}; };

View file

@ -3,6 +3,7 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra
import { import {
type CreateAccountInput, type CreateAccountInput,
type CreateAuthAttemptInput, type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput, type CreateAuthCompletionInput,
type CreateAuditEventInput, type CreateAuditEventInput,
type CreateCapabilityGrantInput, type CreateCapabilityGrantInput,
@ -48,6 +49,8 @@ export class InMemoryStore implements HeimdallStore {
private readonly privateCommandReceipts = new Map<string, StoredPrivateCommandReceipt>(); private readonly privateCommandReceipts = new Map<string, StoredPrivateCommandReceipt>();
private readonly authCompletions = new Map<string, StoredAuthCompletion>(); private readonly authCompletions = new Map<string, StoredAuthCompletion>();
private readonly authCompletionsByAttempt = new Map<string, string>(); 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 entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
private readonly auditEvents = new Map<string, CreateAuditEventInput>(); private readonly auditEvents = new Map<string, CreateAuditEventInput>();
@ -424,6 +427,20 @@ export class InMemoryStore implements HeimdallStore {
return this.consumeAuthCompletion(appSlug, code, at); return this.consumeAuthCompletion(appSlug, code, at);
} }
async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean> {
for (const [key, expiresAt] of this.oauthStateUses) {
if (expiresAt <= input.usedAt) {
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> { async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
const key = `${input.accountId}:${input.provider}:${input.scope}`; const key = `${input.accountId}:${input.provider}:${input.scope}`;
this.entitlementSnapshots.set(key, clone(input)); this.entitlementSnapshots.set(key, clone(input));

View file

@ -5,6 +5,7 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f
import { import {
type CreateAccountInput, type CreateAccountInput,
type CreateAuthAttemptInput, type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput, type CreateAuthCompletionInput,
type CreateAuditEventInput, type CreateAuditEventInput,
type CreateCapabilityGrantInput, type CreateCapabilityGrantInput,
@ -378,6 +379,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
auth_attempts: ["SELECT", "INSERT", "UPDATE"], auth_attempts: ["SELECT", "INSERT", "UPDATE"],
private_command_receipts: ["SELECT", "INSERT"], private_command_receipts: ["SELECT", "INSERT"],
auth_completions: ["SELECT", "INSERT", "UPDATE"], auth_completions: ["SELECT", "INSERT", "UPDATE"],
oauth_state_uses: ["INSERT"],
capability_grants: ["SELECT", "INSERT"], capability_grants: ["SELECT", "INSERT"],
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"], entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
audit_events: ["INSERT"], audit_events: ["INSERT"],
@ -1061,6 +1063,19 @@ export class PostgresStore implements HeimdallStore {
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : null; return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : 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> { async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
await this.pool.query( await this.pool.query(
` `

View file

@ -119,6 +119,20 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
ON auth_completions(app_slug, attempt_id) ON auth_completions(app_slug, attempt_id)
WHERE attempt_id IS NOT NULL AND consumed_at IS NULL; 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 store deletes nothing, so rows stay
-- like consumed auth_completions; after expires_at a state is refused before
-- any claim, so a row 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 ( CREATE TABLE IF NOT EXISTS capability_grants (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,

View file

@ -165,6 +165,14 @@ export interface StoredAuthCompletion {
payloadJson: Record<string, unknown>; payloadJson: Record<string, unknown>;
} }
export interface ClaimOAuthStateUseInput {
jti: string;
phase: "launch" | "callback";
appSlug: AppSlug;
usedAt: string;
expiresAt: string;
}
export interface CreateAuthCompletionInput { export interface CreateAuthCompletionInput {
/** /**
* Correlation handle only (see StoredAuthCompletion.attemptId). The store * Correlation handle only (see StoredAuthCompletion.attemptId). The store
@ -272,5 +280,15 @@ export interface HeimdallStore {
*/ */
consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise<StoredAuthCompletion | null>; consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise<StoredAuthCompletion | null>;
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>; 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>; createAuditEvent(input: CreateAuditEventInput): Promise<void>;
} }

View file

@ -8,6 +8,7 @@ import { entitlementFacts } from "../src/facts.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js"; import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js"; import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { type OAuthProviderRuntime } from "../src/oauth.js"; import { type OAuthProviderRuntime } from "../src/oauth.js";
import { launchState } from "./oauth-flow.js";
const secret = "ghostlight-private-command-secret"; const secret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = []; const resources: Array<{ close(): Promise<unknown> }> = [];
@ -107,9 +108,12 @@ describe("Heimdall Eve access plugin", () => {
const begin = openPrivateEnvelope(beginWire, secret); const begin = openPrivateEnvelope(beginWire, secret);
expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1"); expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1");
const handle = String(begin.handle); const handle = String(begin.handle);
const navigation = begin.navigation as { url: string }; const navigation = begin.navigation as { url: string; allowedOrigins: string[] };
const state = new URL(navigation.url).searchParams.get("state"); const state = new URL(navigation.url).searchParams.get("state");
expect(state).toBeTruthy(); 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({ const pendingEnvelope = sealPrivateEnvelope({
appSlug: "ghostlight", appSlug: "ghostlight",
@ -124,9 +128,11 @@ describe("Heimdall Eve access plugin", () => {
const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret); const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret);
expect(pending).toMatchObject({ status: "pending", handle }); expect(pending).toMatchObject({ status: "pending", handle });
const { cookie } = await launchState(app, state ?? "");
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`,
headers: { cookie },
}); });
expect(callback.statusCode, callback.body).toBe(201); expect(callback.statusCode, callback.body).toBe(201);
// The completion code is always minted by the store now, never the // The completion code is always minted by the store now, never the

View file

@ -4,7 +4,8 @@ import { join } from "node:path";
import { inspect } from "node:util"; import { inspect } from "node:util";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { type FastifyInstance } from "fastify"; import { type FastifyInstance } from "fastify";
import { buildApp, errorIdentity } from "../src/app.js"; import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
import { resolveAppCaller } from "../src/app-caller.js";
import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js"; import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js";
import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js"; import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
import { type HeimdallConfig, loadConfig } from "../src/config.js"; import { type HeimdallConfig, loadConfig } from "../src/config.js";
@ -12,96 +13,7 @@ import { entitlementFacts, identityFacts } from "../src/facts.js";
import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js"; import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
import { InMemoryStore } from "../src/store/index.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 apps: Awaited<ReturnType<typeof buildApp>>[] = [];
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@ -134,7 +46,7 @@ function getHeimdallContext(app: FastifyInstance) {
}).heimdallContext; }).heimdallContext;
} }
async function startDiscordSignIn(app: FastifyInstance): Promise<string> { async function startDiscordSignIn(app: FastifyInstance): Promise<LaunchedFlow> {
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
url: "/v1/oauth/discord/start", url: "/v1/oauth/discord/start",
@ -146,7 +58,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
}); });
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
return response.json().stateToken as string; return launchFlow(app, response.json());
} }
afterEach(async () => { afterEach(async () => {
@ -207,9 +119,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
const payload = response.json(); const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true); expect(verified.valid).toBe(true);
if (verified.valid) { if (verified.valid) {
expect(verified.payload).toEqual( expect(verified.payload).toEqual(
@ -249,6 +161,161 @@ describe("Heimdall service", () => {
expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" })); 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 app = await buildApp({ config: createTestConfig() });
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, ...(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 () => { 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 // The other half of the same pin: a caller holding the secret may attach
// policy even without a backend_callback handoff, because identity no // policy even without a backend_callback handoff, because identity no
@ -302,9 +369,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
const payload = response.json(); const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true); expect(verified.valid).toBe(true);
if (verified.valid) { if (verified.valid) {
expect(verified.payload).toEqual( expect(verified.payload).toEqual(
@ -348,11 +415,11 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
const payload = response.json(); const payload = response.json();
const authorizationUrl = new URL(payload.authorizationUrl as string); const authorizationUrl = await providerRedirect(app, payload);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]"); expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true); expect(verified.valid).toBe(true);
if (verified.valid) { if (verified.valid) {
expect(verified.payload).toEqual( expect(verified.payload).toEqual(
@ -396,9 +463,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
const payload = response.json(); const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize"); expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true); expect(verified.valid).toBe(true);
if (verified.valid) { if (verified.valid) {
expect(verified.payload).toEqual( expect(verified.payload).toEqual(
@ -442,10 +509,10 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
const payload = response.json(); const payload = response.json();
const authorizationUrl = new URL(payload.authorizationUrl as string); const authorizationUrl = await providerRedirect(app, payload);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize"); expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true); expect(verified.valid).toBe(true);
if (verified.valid) { if (verified.valid) {
expect(verified.payload).toEqual( expect(verified.payload).toEqual(
@ -473,10 +540,11 @@ describe("Heimdall service", () => {
}); });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
@ -689,10 +757,11 @@ describe("Heimdall service", () => {
}); });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({ const callbackResponse = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
const completionCode = callbackResponse.json().completion.code as string; const completionCode = callbackResponse.json().completion.code as string;
@ -731,10 +800,11 @@ describe("Heimdall service", () => {
const auditEvents = () => const auditEvents = () =>
[...(store as unknown as { auditEvents: Map<string, { eventType: string; sessionId?: string; eventPayloadJson: unknown }> }).auditEvents.values()]; [...(store as unknown as { auditEvents: Map<string, { eventType: string; sessionId?: string; eventPayloadJson: unknown }> }).auditEvents.values()];
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string }; const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string };
const created = auditEvents().filter((event) => event.eventType === "auth_completion_created"); const created = auditEvents().filter((event) => event.eventType === "auth_completion_created");
@ -815,10 +885,11 @@ describe("Heimdall service", () => {
}); });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({ const callbackResponse = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(callbackResponse.statusCode).toBe(201); expect(callbackResponse.statusCode).toBe(201);
const refreshToken = callbackResponse.json().refreshToken as string; const refreshToken = callbackResponse.json().refreshToken as string;
@ -862,11 +933,12 @@ describe("Heimdall service", () => {
}); });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { headers: {
cookie: flow.cookie,
accept: "text/html", accept: "text/html",
}, },
}); });
@ -912,12 +984,13 @@ describe("Heimdall service", () => {
}, },
}); });
expect(startResponse.statusCode).toBe(201); expect(startResponse.statusCode).toBe(201);
const stateToken = startResponse.json().stateToken as string; const flow = await launchFlow(app, startResponse.json());
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { headers: {
cookie: flow.cookie,
accept: "text/html", accept: "text/html",
}, },
}); });
@ -1123,11 +1196,12 @@ describe("Heimdall service", () => {
returnTo: "https://streampixels.gamecult.org/auth/connect", returnTo: "https://streampixels.gamecult.org/auth/connect",
}, },
}); });
const stateToken = startResponse.json().stateToken as string; const flow = await launchFlow(app, startResponse.json());
const callbackResponse = await app.inject({ const callbackResponse = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
const accountId = callbackResponse.json().account.id as string; const accountId = callbackResponse.json().account.id as string;
@ -1210,11 +1284,12 @@ describe("Heimdall service", () => {
returnTo: "https://streampixels.gamecult.org/auth/connect", returnTo: "https://streampixels.gamecult.org/auth/connect",
}, },
}); });
const stateToken = startResponse.json().stateToken as string; const flow = await launchFlow(app, startResponse.json());
const callbackResponse = await app.inject({ const callbackResponse = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
const accountId = callbackResponse.json().account.id as string; const accountId = callbackResponse.json().account.id as string;
@ -1276,10 +1351,11 @@ describe("Heimdall service", () => {
}); });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({ const callbackResponse = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(callbackResponse.statusCode).toBe(201); expect(callbackResponse.statusCode).toBe(201);
const evaluationsAtSignIn = evaluations; const evaluationsAtSignIn = evaluations;
@ -1377,7 +1453,7 @@ describe("Heimdall service", () => {
expect(start.statusCode).toBe(400); expect(start.statusCode).toBe(400);
expect(start.json().error).toBe(error); expect(start.json().error).toBe(error);
expect(start.json().authorizationUrl).toBeUndefined(); expect(start.json().authorizationUrl).toBeUndefined();
expect(start.json().stateToken).toBeUndefined(); expect(start.json()).not.toHaveProperty("stateToken");
}); });
it.each([ it.each([
@ -1393,14 +1469,15 @@ describe("Heimdall service", () => {
apps.push(app); apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string; const flow = await launchFlow(app, (await connect(app)).json());
const verified = verifyJwt(stateToken, keys.publicKey); const verified = verifyJwt(flow.state, keys.publicKey);
expect(verified.valid).toBe(true); expect(verified.valid).toBe(true);
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, connection }, keys); const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, connection }, keys);
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
headers: { cookie: flow.cookie },
}); });
expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(calls).toEqual([]); expect(calls).toEqual([]);
@ -1432,9 +1509,11 @@ describe("Heimdall service", () => {
handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl }, handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl },
}); });
expect(start.statusCode).toBe(201); expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(callback.statusCode).toBeLessThan(300); expect(callback.statusCode).toBeLessThan(300);
@ -1453,7 +1532,7 @@ describe("Heimdall service", () => {
apps.push(app); apps.push(app);
const start = await connect(app); const start = await connect(app);
expect(start.statusCode).toBe(201); expect(start.statusCode).toBe(201);
const url = new URL(start.json().authorizationUrl as string); const url = await providerRedirect(app, start.json());
expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize"); expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize");
expect(url.searchParams.get("client_id")).toBe("joystick-client"); 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"); expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read");
@ -1467,10 +1546,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store }); const app = await buildApp({ config: createTestConfig(), store });
apps.push(app); apps.push(app);
stubJoystick({ channel_id: "deru-chan", username: "deru" }); stubJoystick({ channel_id: "deru-chan", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string; const flow = await launchFlow(app, (await connect(app)).json());
const connected = await app.inject({ const connected = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
const accountId = connected.json().account.id as string; const accountId = connected.json().account.id as string;
const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick"); const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
@ -1518,14 +1598,15 @@ describe("Heimdall service", () => {
apps.push(app); apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false }); const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" }); const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string; const flow = await launchFlow(app, (await connect(app)).json());
const verified = verifyJwt(stateToken, keys.publicKey); const verified = verifyJwt(flow.state, keys.publicKey);
expect(verified.valid).toBe(true); expect(verified.valid).toBe(true);
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, mode }, keys); const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, mode }, keys);
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`, url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
headers: { cookie: flow.cookie },
}); });
expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(callback.body).not.toContain("viewer_access"); expect(callback.body).not.toContain("viewer_access");
@ -1542,11 +1623,12 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store }); const app = await buildApp({ config: createTestConfig(), store });
apps.push(app); apps.push(app);
const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" }); const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" });
const stateToken = (await connect(app)).json().stateToken as string; const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(callback.statusCode).toBeLessThan(300); expect(callback.statusCode).toBeLessThan(300);
@ -1575,10 +1657,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store }); const app = await buildApp({ config: createTestConfig(), store });
apps.push(app); apps.push(app);
stubJoystick({ channel_id: "chan-9", username: "deru" }); stubJoystick({ channel_id: "chan-9", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string; const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick"); const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
expect(row?.displayName).toBe("deru"); expect(row?.displayName).toBe("deru");
@ -1589,10 +1672,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store }); const app = await buildApp({ config: createTestConfig(), store });
apps.push(app); apps.push(app);
stubJoystick({ username: "deru" }); stubJoystick({ username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string; const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull(); expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
@ -1787,11 +1871,11 @@ describe("error answers never echo an input", () => {
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } }); const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { accept: "text/html" }, headers: { cookie: flow.cookie, accept: "text/html" },
}); });
expect(response.headers["content-type"]).toContain("text/html"); expect(response.headers["content-type"]).toContain("text/html");
@ -1823,9 +1907,11 @@ describe("error answers never echo an input", () => {
}, },
}); });
expect(start.statusCode).toBe(201); expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(response.statusCode).toBe(502); expect(response.statusCode).toBe(502);
@ -1843,16 +1929,16 @@ describe("the relayed provider error", () => {
const injected = `<script>alert(1)</script>"\\\u0007é${"x".repeat(300)}`; const injected = `<script>alert(1)</script>"\\\u0007é${"x".repeat(300)}`;
const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/; const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/;
function providerErrorUrl(stateToken: string): string { function providerErrorRequest(flow: LaunchedFlow) {
const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected }); const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected });
return `/v1/oauth/discord/callback?${query.toString()}`; return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } };
} }
it("is printable and bounded in the JSON answer", async () => { it("is printable and bounded in the JSON answer", async () => {
const app = await buildApp({ config: createTestConfig() }); const app = await buildApp({ config: createTestConfig() });
apps.push(app); apps.push(app);
const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) }); const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app)));
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
const { providerError, providerErrorDescription } = response.json() as Record<string, string>; const { providerError, providerErrorDescription } = response.json() as Record<string, string>;
@ -1866,11 +1952,8 @@ describe("the relayed provider error", () => {
const app = await buildApp({ config: createTestConfig() }); const app = await buildApp({ config: createTestConfig() });
apps.push(app); apps.push(app);
const response = await app.inject({ const request = providerErrorRequest(await startDiscordSignIn(app));
method: "GET", const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } });
url: providerErrorUrl(await startDiscordSignIn(app)),
headers: { accept: "text/html" },
});
expect(response.headers["content-type"]).toContain("text/html"); expect(response.headers["content-type"]).toContain("text/html");
expect(response.body.match(/<script/gi)).toHaveLength(1); expect(response.body.match(/<script/gi)).toHaveLength(1);
@ -1902,7 +1985,7 @@ describe("the relayed provider error", () => {
}); });
expect(start.statusCode).toBe(201); expect(start.statusCode).toBe(201);
await app.inject({ method: "GET", url: providerErrorUrl(start.json().stateToken as string) }); await app.inject(providerErrorRequest(await launchFlow(app, start.json())));
expect(deliveries).toHaveLength(1); expect(deliveries).toHaveLength(1);
const description = String(deliveries[0]!.errorDescription); const description = String(deliveries[0]!.errorDescription);
@ -1928,10 +2011,11 @@ describe("the failed-callback audit event", () => {
}); });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(response.statusCode).toBe(502); expect(response.statusCode).toBe(502);
@ -1955,10 +2039,11 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
const store = new InMemoryStore(); const store = new InMemoryStore();
const app = await buildApp({ config: createTestConfig(), store, oauthRuntimes: createOAuthRuntimeRegistry() }); const app = await buildApp({ config: createTestConfig(), store, oauthRuntimes: createOAuthRuntimeRegistry() });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(callback.statusCode).toBe(502); expect(callback.statusCode).toBe(502);
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()]; const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
@ -2012,8 +2097,12 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
}, },
}); });
apps.push(app); apps.push(app);
const stateToken = await startDiscordSignIn(app); const flow = await startDiscordSignIn(app);
await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}` }); await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: Record<string, unknown> }> }).auditEvents.values()]; const 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"); const failed = events.find((event) => event.eventType === "oauth_callback_failed");

View file

@ -24,6 +24,7 @@ import { InMemoryStore } from "../src/store/index.js";
import { PostgresStore } from "../src/store/postgres.js"; import { PostgresStore } from "../src/store/postgres.js";
import { CREATE_SCHEMA_SQL } from "../src/store/schema.js"; import { CREATE_SCHEMA_SQL } from "../src/store/schema.js";
import { type CreateAuthCompletionInput, type HeimdallStore } from "../src/store/types.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 ghostlightSecret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = []; const resources: Array<{ close(): Promise<unknown> }> = [];
@ -102,10 +103,11 @@ async function start(app: App, payload: unknown, headers: Record<string, string>
} }
async function completeAs(app: App, stateToken: string, who: string) { async function completeAs(app: App, stateToken: string, who: string) {
const { cookie } = await launchState(app, stateToken);
return app.inject({ return app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`,
headers: { accept: "application/json" }, headers: { accept: "application/json", cookie },
}); });
} }
@ -213,7 +215,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
}); });
expect(response.statusCode, response.body).toBe(201); expect(response.statusCode, response.body).toBe(201);
expect(response.json().handoff).toEqual({ kind: "browser_completion" }); expect(response.json().handoff).toEqual({ kind: "browser_completion" });
const state = jwtPayload(response.json().stateToken as string); const state = jwtPayload(stateOf(response.json()));
expect(state.handoff).toEqual({ kind: "browser_completion" }); expect(state.handoff).toEqual({ kind: "browser_completion" });
expect(JSON.stringify(state)).not.toContain(attempt.handle); expect(JSON.stringify(state)).not.toContain(attempt.handle);
}); });
@ -237,7 +239,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
expect(response.body).not.toContain(planted); expect(response.body).not.toContain(planted);
if (response.statusCode === 201) { if (response.statusCode === 201) {
expect(response.json().handoff).toEqual({ kind: "browser_completion" }); expect(response.json().handoff).toEqual({ kind: "browser_completion" });
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" }); expect(jwtPayload(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
} else { } else {
expect(response.statusCode, response.body).toBe(400); expect(response.statusCode, response.body).toBe(400);
} }
@ -249,7 +251,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 }); const response = await start(app, { appSlug: "ghostlight", mode: "sign_in", returnTo: legitReturn, ...extra });
expect(response.statusCode, response.body).toBe(201); expect(response.statusCode, response.body).toBe(201);
expect(response.body).not.toContain(planted); expect(response.body).not.toContain(planted);
expect(jwtPayload(response.json().stateToken as string).handoff).toEqual({ kind: "browser_completion" }); expect(jwtPayload(stateOf(response.json())).handoff).toEqual({ kind: "browser_completion" });
} }
}); });
@ -306,7 +308,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
handoff: { kind: "browser_completion", attemptId: handle }, handoff: { kind: "browser_completion", attemptId: handle },
}); });
expect(attackerStart.statusCode).toBe(201); expect(attackerStart.statusCode).toBe(201);
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker"); const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
expect(attackerCallback.statusCode, attackerCallback.body).toBe(201); expect(attackerCallback.statusCode, attackerCallback.body).toBe(201);
expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("pending"); expect((await store.findAuthAttempt("ghostlight", handle))?.status).toBe("pending");
@ -340,7 +342,7 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
expect(response.statusCode, response.body).toBe(201); expect(response.statusCode, response.body).toBe(201);
// Delivery to port 1 fails; whatever the callback returns, the attempt // Delivery to port 1 fails; whatever the callback returns, the attempt
// must be untouched and no completion may be bound to it. // must be untouched and no completion may be bound to it.
await completeAs(app, response.json().stateToken as string, "attacker"); await completeAs(app, stateOf(response.json()), "attacker");
expect((await store.findAuthAttempt("ghostlight", attempt.handle))?.status).toBe("pending"); expect((await store.findAuthAttempt("ghostlight", attempt.handle))?.status).toBe("pending");
expect(await store.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, new Date().toISOString())).toBeNull(); expect(await store.consumeAuthCompletionByAttempt("ghostlight", attempt.handle, new Date().toISOString())).toBeNull();
}); });
@ -493,26 +495,27 @@ partialUniqueIndexContract("PostgresStore over pg-mem", async () => {
}); });
describe("duplicate-handle error surfacing through the callback route", () => { describe("duplicate-handle error surfacing through the callback route", () => {
it("state tokens are replayable, and a replay while the handle's completion is unconsumed echoes the store error in a 502", async () => { it("a replayed state is refused as already used before any exchange, and leaves the real completion intact", async () => {
const { app, store, begin } = await planeHarness(); const { app, store, begin } = await planeHarness();
const receipt = await begin("begin-replay"); const receipt = await begin("begin-replay");
const handle = String(receipt.handle); const handle = String(receipt.handle);
const state = new URL((receipt.navigation as { url: string }).url).searchParams.get("state") ?? ""; const state = new URL((receipt.navigation as { url: string }).url).searchParams.get("state") ?? "";
const first = await completeAs(app, state, "victim"); const { cookie } = await launchState(app, state);
const callbackWith = () =>
app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=victim&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json", cookie },
});
const first = await callbackWith();
expect(first.statusCode).toBe(201); expect(first.statusCode).toBe(201);
const replay = await completeAs(app, state, "victim"); // The state's jti was consumed by the first callback, so the second never
// Nothing consumed the state's jti, so the second exchange still runs and // reaches createAuthCompletion, whose unique index used to be the only
// reaches createAuthCompletion, where the unique index refuses it. The // thing refusing a replay (and whose Postgres 23505 text the route had to
// route's catch (app.ts ~1290) now answers with a fixed detail rather than // keep out of the answer).
// the store's own text, so a Postgres 23505 cannot hand the caller a const replay = await callbackWith();
// constraint name. The full text stays in the audit event. expect(replay.statusCode, replay.body).toBe(400);
expect(replay.statusCode, replay.body).toBe(502); expect(replay.json()).toEqual({ error: "state_already_used" });
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.findAuthAttempt("ghostlight", handle))?.status).toBe("completed");
expect(await store.consumeAuthCompletionByAttempt("ghostlight", handle, new Date().toISOString())).toBeTruthy(); expect(await store.consumeAuthCompletionByAttempt("ghostlight", handle, new Date().toISOString())).toBeTruthy();
}); });

View file

@ -11,6 +11,7 @@ import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts } from "../src/facts.js"; import { entitlementFacts } from "../src/facts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js"; import { type OAuthProviderRuntime } from "../src/oauth.js";
import { InMemoryStore } from "../src/store/index.js"; import { InMemoryStore } from "../src/store/index.js";
import { launchState, stateOf } from "./oauth-flow.js";
const resources: Array<{ close(): Promise<unknown> }> = []; const resources: Array<{ close(): Promise<unknown> }> = [];
@ -86,10 +87,11 @@ async function start(app: App, payload: Record<string, unknown>, headers: Record
} }
async function completeAs(app: App, stateToken: string, who: string) { async function completeAs(app: App, stateToken: string, who: string) {
const { cookie } = await launchState(app, stateToken);
return app.inject({ return app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`, url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`,
headers: { accept: "application/json" }, headers: { accept: "application/json", cookie },
}); });
} }
@ -110,7 +112,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
}); });
expect(started.statusCode).toBe(201); expect(started.statusCode).toBe(201);
const callback = await completeAs(app, started.json().stateToken as string, "victim"); const callback = await completeAs(app, stateOf(started.json()), "victim");
expect(callback.statusCode).toBe(201); expect(callback.statusCode).toBe(201);
const minted = callback.json().completion.code as string; const minted = callback.json().completion.code as string;
expect(minted).not.toBe("attacker-fixed"); expect(minted).not.toBe("attacker-fixed");
@ -125,7 +127,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
const { app, store } = await harness(); const { app, store } = await harness();
const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }); const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
expect(started.statusCode).toBe(201); expect(started.statusCode).toBe(201);
const callback = await completeAs(app, started.json().stateToken as string, "victim"); const callback = await completeAs(app, stateOf(started.json()), "victim");
expect(callback.statusCode).toBe(201); expect(callback.statusCode).toBe(201);
const code = callback.json().completion.code as string; const code = callback.json().completion.code as string;
expect(code).toMatch(uuidV4); expect(code).toMatch(uuidV4);
@ -159,7 +161,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 () => { 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 { app } = await harness();
const victimStart = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }); const victimStart = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
const victimCallback = await completeAs(app, victimStart.json().stateToken as string, "victim"); const victimCallback = await completeAs(app, stateOf(victimStart.json()), "victim");
const victimCode = victimCallback.json().completion.code as string; const victimCode = victimCallback.json().completion.code as string;
const attackerStart = await start(app, { const attackerStart = await start(app, {
@ -168,7 +170,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
returnTo: "https://repixelizer.gamecult.org/app/", returnTo: "https://repixelizer.gamecult.org/app/",
handoff: { kind: "browser_completion", attemptId: victimCode }, handoff: { kind: "browser_completion", attemptId: victimCode },
}); });
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker"); const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
expect(attackerCallback.statusCode).toBe(201); expect(attackerCallback.statusCode).toBe(201);
expect(attackerCallback.json().completion.code).not.toBe(victimCode); expect(attackerCallback.json().completion.code).not.toBe(victimCode);
@ -204,7 +206,7 @@ describe("consumeAuthCompletion: single-use, scoped, expiring", () => {
it("a code redeems exactly once under concurrent redemption, and never for another app", async () => { it("a code redeems exactly once under concurrent redemption, and never for another app", async () => {
const { app } = await harness(); const { app } = await harness();
const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" }); const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
const callback = await completeAs(app, started.json().stateToken as string, "victim"); const callback = await completeAs(app, stateOf(started.json()), "victim");
const code = callback.json().completion.code as string; const code = callback.json().completion.code as string;
const crossApp = await redeem(app, "bifrost", code); const crossApp = await redeem(app, "bifrost", code);
@ -265,7 +267,7 @@ describe("attempt-handle binding: the public start route may attach a completion
handoff: { kind: "browser_completion", attemptId: attempt.handle }, handoff: { kind: "browser_completion", attemptId: attempt.handle },
}); });
expect(attackerStart.statusCode).toBe(201); expect(attackerStart.statusCode).toBe(201);
await completeAs(app, attackerStart.json().stateToken as string, "attacker"); await completeAs(app, stateOf(attackerStart.json()), "attacker");
const after = await store.findAuthAttempt("ghostlight", attempt.handle); const after = await store.findAuthAttempt("ghostlight", attempt.handle);
expect(after?.status).toBe("pending"); expect(after?.status).toBe("pending");

View file

@ -18,6 +18,7 @@ import { type OAuthProviderRuntime } from "../src/oauth.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js"; import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js"; import { sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { InMemoryStore } from "../src/store/index.js"; import { InMemoryStore } from "../src/store/index.js";
import { launchFlow } from "./oauth-flow.js";
const ghostlightSecret = "ghostlight-private-command-secret"; const ghostlightSecret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = []; const resources: Array<{ close(): Promise<unknown> }> = [];
@ -104,10 +105,11 @@ describe("caller identity: unauthenticated mint paths", () => {
}); });
expect(start.statusCode).toBe(201); expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(start.json().stateToken as string)}`, url: `/v1/oauth/discord/callback?code=victim-code&state=${encodeURIComponent(flow.state)}`,
headers: { accept: "text/html" }, headers: { accept: "text/html", cookie: flow.cookie },
}); });
expect(callback.statusCode).toBe(200); expect(callback.statusCode).toBe(200);

View file

@ -14,6 +14,7 @@ import { createOAuthRuntimeRegistry } from "../src/oauth.js";
import { providerCatalog } from "../src/providers.js"; import { providerCatalog } from "../src/providers.js";
import { InMemoryStore, PostgresStore, type HeimdallStore } from "../src/store/index.js"; import { InMemoryStore, PostgresStore, type HeimdallStore } from "../src/store/index.js";
import { type StoredLinkedIdentity } from "../src/store/types.js"; import { type StoredLinkedIdentity } from "../src/store/types.js";
import { launchFlow } from "./oauth-flow.js";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@ -581,9 +582,11 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
const app = rig.apps[0]!; const app = rig.apps[0]!;
const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } }); const start = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload: { appSlug: "streampixels", mode, returnTo, ...(mode === "connect" ? { connection: { kind: "creator", targetId: "creator-1" } } : {}) } });
expect(start.statusCode).toBe(201); expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, url: `/v1/oauth/${provider}/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(callback.statusCode).toBe(201); expect(callback.statusCode).toBe(201);
const redeem = await app.inject({ const redeem = await app.inject({
@ -633,9 +636,11 @@ for (const { kind, live } of rigKinds) describe.skipIf(live && !adminUrl)(`manag
payload: { appSlug: "streampixels", mode: "connect", returnTo, connection: { kind: "creator", targetId: "creator-1" } }, payload: { appSlug: "streampixels", mode: "connect", returnTo, connection: { kind: "creator", targetId: "creator-1" } },
}); });
expect(start.statusCode).toBe(201); expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`, url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
}); });
expect(callback.statusCode).toBeGreaterThanOrEqual(400); expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(callback.body).not.toContain("tok-access"); expect(callback.body).not.toContain("tok-access");

138
tests/oauth-flow.ts Normal file
View file

@ -0,0 +1,138 @@
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" },
},
};
}
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");
}
/** 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 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 };
}
/** 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 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);
}

View file

@ -0,0 +1,400 @@
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" });
}
expect((await ctx.store.findAuthAttempt("repixelizer", attempt.handle))?.status).toBe("pending");
// The victim's own callback is unharmed.
const response = await callback(app, victim.state, victim.cookie, "code=victim-code");
expect(response.statusCode).toBe(201);
});
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);
});
});

View file

@ -12,6 +12,7 @@ import { type HeimdallConfig } from "../src/config.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js"; import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js"; import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { InMemoryStore } from "../src/store/index.js"; import { InMemoryStore } from "../src/store/index.js";
import { launchState } from "./oauth-flow.js";
const secret = "ghostlight-private-command-secret"; const secret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = []; const resources: Array<{ close(): Promise<unknown> }> = [];
@ -73,10 +74,11 @@ async function refreshAgainst(refreshAnswer: () => Response) {
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1", 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 })); { 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 state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
const { cookie } = await launchState(app, state);
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`, url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json" }, headers: { accept: "application/json", cookie },
}); });
expect(callback.statusCode, callback.body).toBe(201); 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 })); const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));
@ -133,10 +135,11 @@ it("keeps the completion code out of the audit trail when the plane redeems by a
const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1", 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 })); { 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 state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!;
const { cookie } = await launchState(app, state);
const callback = await app.inject({ const callback = await app.inject({
method: "GET", method: "GET",
url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`, url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`,
headers: { accept: "application/json" }, headers: { accept: "application/json", cookie },
}); });
const code = (callback.json().completion as { code: string }).code; const 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 })); const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle }));

View file

@ -3,7 +3,7 @@ import { Pool } from "pg";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { newDb } from "pg-mem"; import { newDb } from "pg-mem";
import { type HeimdallConfig } from "../src/config.js"; import { type HeimdallConfig } from "../src/config.js";
import { createStore } from "../src/store/index.js"; import { createStore, InMemoryStore } from "../src/store/index.js";
import { import {
createPostgresStore, createPostgresStore,
PostgresStore, PostgresStore,
@ -198,6 +198,36 @@ 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 has expired, 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);
// At and after expiry the record is dead weight: the state is refused before any claim.
await store.claimOAuthStateUse({ ...use, jti: "state-3", usedAt: "2026-04-26T12:10:00.000Z" });
expect(await store.claimOAuthStateUse(use)).toBe(true);
});
});
describe("createPostgresStore", () => { describe("createPostgresStore", () => {
// pg emits an idle client's connection loss on the pool. With no listener // 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 // that event kills the process, and Node prints the pg Client with its
@ -233,11 +263,12 @@ describe("PostgresStore.checkSchema", () => {
"auth_attempts", "auth_attempts",
"private_command_receipts", "private_command_receipts",
"auth_completions", "auth_completions",
"oauth_state_uses",
"capability_grants", "capability_grants",
"entitlement_snapshots", "entitlement_snapshots",
"audit_events", "audit_events",
]); ]);
expect(REQUIRED_COLUMNS).toHaveLength(85); expect(REQUIRED_COLUMNS).toHaveLength(90);
expect(REQUIRED_COLUMNS).toEqual( expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([ expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false }, { table: "auth_completions", column: "code", type: "TEXT", nullable: false },
@ -248,11 +279,12 @@ describe("PostgresStore.checkSchema", () => {
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true }, { table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
]) ])
); );
expect(REQUIRED_KEYS).toHaveLength(12); expect(REQUIRED_KEYS).toHaveLength(13);
expect(REQUIRED_KEYS).toEqual( expect(REQUIRED_KEYS).toEqual(
expect.arrayContaining([ expect.arrayContaining([
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" }, { table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
{ table: "private_command_receipts", columns: "app_slug,idempotency_key", 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: "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" }, { table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" },
]) ])
@ -365,6 +397,10 @@ async function exerciseStore(store: PostgresStore): Promise<Set<string>> {
const snapshot = { accountId: account.id, provider: "discord" as const, scope: "ghostlight:probe", evaluatedAt: now, isAllowed: true, reasonCode: "ok", rawSummaryJson: {} }; 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);
await s.upsertEntitlementSnapshot({ ...snapshot, isAllowed: false }); 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 }); await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
return called; return called;
} }