r2: bind OAuth completion to the launching browser, one use per state; gate connect starts #3

Merged
heimdall merged 11 commits from heimdall/r2-state-binding into main 2026-11-05 17:16:40 +00:00
25 changed files with 1333 additions and 268 deletions

View file

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

View file

@ -150,12 +150,29 @@ Response:
"appSlug": "repixelizer",
"mode": "sign_in",
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
"stateToken": "<signed-jwt>",
"authorizationUrl": "https://heimdall.gamecult.org/v1/oauth/discord/launch?state=<signed-jwt>",
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
}
```
Send the user's browser to `authorizationUrl` unchanged. It is Heimdall's launch
hop, not the provider's URL.
### `GET /v1/oauth/{provider}/launch?state=<signed-jwt>`
The first thing the user's browser does with a state. Heimdall verifies the
state (signature, issuer, provider, `nbf`/`exp` with 30 s of skew), claims the
state's single launch, sets the binding cookie
`__Host-heimdall-oauth-<jti>` (`Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`,
`Max-Age` until the state expires; the value is a Heimdall-signed binding for
that state) and answers `302` to the provider's authorize URL, with
`Cache-Control: no-store` and `Referrer-Policy: no-referrer`. A second launch of
the same state is `400 state_already_launched` and sets no cookie. The state is
refused with `400 invalid_state`, `state_provider_mismatch` or `state_expired`.
The binding cookie needs an `https` public base URL (or `localhost`). A browser
must finish the flow in the browser that launched it.
Failure modes:
- `503 provider_not_configured` when the provider client id is missing
@ -169,7 +186,17 @@ Failure modes:
- `401 app_auth_required` when the request body carries `entitlementPolicy` and
the caller did not authenticate as that app via `x-heimdall-app-secret`
(`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of
`handoff.kind` or app slug; a caller with no policy to attach needs no secret
`handoff.kind` or app slug; a caller with no policy to attach needs no secret,
unless the app's profile sets `startRequiresAppCaller` (Bifrost only): then
every start for that app, with or without a policy, needs that app's secret.
Bifrost binds browser completion to a nonce that rides readable in the signed
state's `return_to`, so only Bifrost may mint a Bifrost state; apps whose
browser starts are public by design leave the flag unset
- `401 app_auth_required` also when `mode` is `connect` and the body carries a
`connection` (a creator or account target): a connection target is app-backend
authority, so only the authenticated app named by `appSlug` may start it. No
secret, or another app's, is refused before any state is signed. `sign_in`
starts carrying a `connection` are `400 connection_not_allowed_in_sign_in`
Launch note:
@ -189,7 +216,8 @@ door into a person who already has one.
client): `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`, which is
`https://heimdall.gamecult.org/v1/oauth/google/callback` in production.
- Scopes `openid email profile`. The authorization request carries `state`
(signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge. The verifier
(signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge, all built at the
launch hop, which is where the browser first meets the provider URL. The verifier
and the nonce are derived from the state's `jti` and Heimdall's signing key,
so nothing is stored between start and callback.
- The callback verifies the `id_token` (RS256 against Google's published
@ -228,7 +256,11 @@ Purpose:
Current status:
- validates signed `state`
- validates signed `state` (signature, issuer, provider, expiry), requires the
binding cookie the launch hop set, and consumes the state: a second callback
is `400 state_already_used`, a missing or foreign cookie `400 state_not_bound`,
an expired state `400 state_expired`; the binding is checked before the claim,
so a browser without the cookie cannot burn the launching browser's state
- returns provider errors cleanly
- for Discord, exchanges the authorization code, resolves the provider
identity, persists the local account/link/session/audit records, evaluates

View file

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

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);\""
},
"dependencies": {
"@fastify/cookie": "^11.1.3",
"@fastify/cors": "^11.2.0",
"@msgpack/msgpack": "^3.1.3",
"@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts",

22
pnpm-lock.yaml generated
View file

@ -12,6 +12,9 @@ importers:
.:
dependencies:
'@fastify/cookie':
specifier: ^11.1.3
version: 11.1.5
'@fastify/cors':
specifier: ^11.2.0
version: 11.2.0
@ -229,6 +232,9 @@ packages:
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/cookie@11.1.5':
resolution: {integrity: sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==}
'@fastify/cors@11.2.0':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
@ -469,6 +475,10 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cookie@2.0.1:
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
engines: {node: '>=22'}
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
resolution: {directory: vendor/CultLib/packages/cultmesh-ts, type: directory}
engines: {node: '>=18'}
@ -547,6 +557,9 @@ packages:
fastify-plugin@5.1.0:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
fastify-plugin@6.0.1:
resolution: {integrity: sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==}
fastify@5.12.7:
resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==}
@ -1186,6 +1199,11 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.0
'@fastify/cookie@11.1.5':
dependencies:
cookie: 2.0.1
fastify-plugin: 6.0.1
'@fastify/cors@11.2.0':
dependencies:
fastify-plugin: 5.1.0
@ -1393,6 +1411,8 @@ snapshots:
cookie@1.1.1: {}
cookie@2.0.1: {}
cultmesh-ts@file:vendor/CultLib/packages/cultmesh-ts:
dependencies:
'@gamecult/cultcache-ts': file:vendor/CultLib/packages/cultcache-ts
@ -1500,6 +1520,8 @@ snapshots:
fastify-plugin@5.1.0: {}
fastify-plugin@6.0.1: {}
fastify@5.12.7:
dependencies:
'@fastify/ajv-compiler': 4.0.5

View file

@ -45,6 +45,17 @@ export interface AppProfile {
* plane; absent for apps with no such requirement.
*/
requiredEntitlementPolicyKind?: OAuthEntitlementPolicy["kind"];
/**
* The app binds browser completion to its own pending attempt, and the
* binding is the secrecy of the attempt nonce, which rides readable in the
* signed (not encrypted) state's return_to. If anyone could start a flow for
* this app, whoever learned a victim's nonce could mint a completion bound
* to it and sign the victim's browser in as themselves (login CSRF, finding
* known-nonce-login-csrf). So a start for this app is refused unless the
* caller is this app's authenticated caller: only the app can mint its own
* state. Absent for apps whose browser starts are public by design.
*/
startRequiresAppCaller?: boolean;
}
/** The membership signal shared by every app that gates on GameCult membership. */
@ -142,6 +153,7 @@ const bifrostProfile: AppProfile = {
entitlementSources: ["discord", "patreon"],
managedConnectionProviders: [],
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
startRequiresAppCaller: true,
capabilities: [
{
key: "member_access",

View file

@ -1,6 +1,7 @@
import { createHmac, randomUUID } from "node:crypto";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import Fastify, { type FastifyError, type FastifyInstance } from "fastify";
import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply } from "fastify";
import { type AppCaller, isAppCaller, resolveAppCaller } from "./app-caller.js";
import { deliverBackendHandoff, type BackendHandoffPayload } from "./backend-handoff.js";
import {
@ -95,6 +96,7 @@ function buildOAuthStateToken(options: {
initiatorAccountId: string | undefined;
handoff: OAuthHandoff;
entitlementPolicy: OAuthEntitlementPolicy | undefined;
requestedScopes: string[] | undefined;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
}): { token: string; jti: string; expiresAt: string } {
const issuedAt = nowEpochSeconds();
@ -116,6 +118,7 @@ function buildOAuthStateToken(options: {
initiator_account_id: options.initiatorAccountId ?? null,
handoff: options.handoff,
entitlement_policy: options.entitlementPolicy ?? null,
requested_scopes: options.requestedScopes ?? null,
};
const token = signJwt(payload as unknown as Record<string, unknown>, options.keys);
@ -155,6 +158,11 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
entitlementPolicy !== null &&
(entitlementPolicy as Record<string, unknown>).kind === "patreon_membership_access" &&
typeof (entitlementPolicy as Record<string, unknown>).requiredTierTitle === "string"));
const requestedScopes = payload.requested_scopes;
const requestedScopesAreValid =
requestedScopes === null ||
requestedScopes === undefined ||
(Array.isArray(requestedScopes) && requestedScopes.every((scope) => typeof scope === "string"));
if (
payload.typ !== "heimdall_oauth_state" ||
typeof payload.provider !== "string" ||
@ -173,7 +181,8 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
typeof payload.exp !== "number" ||
!handoffIsValid ||
!entitlementPolicyIsValid ||
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string")
!(payload.initiator_account_id === undefined || payload.initiator_account_id === null || typeof payload.initiator_account_id === "string") ||
!requestedScopesAreValid
) {
return null;
}
@ -188,10 +197,99 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
if (!normalizedPayload.entitlement_policy) {
normalizedPayload.entitlement_policy = null;
}
if (!normalizedPayload.requested_scopes) {
normalizedPayload.requested_scopes = null;
}
return normalizedPayload;
}
type OAuthStateVerdict =
| { ok: true; payload: OAuthStatePayload }
| { ok: false; error: "invalid_state" | "state_provider_mismatch" | "state_expired" };
/**
* The one place a signed OAuth state becomes trusted: signature, shape, issuer,
* audience and provider, and the time window with the same 30 s skew refresh
* tokens use. Every route that turns a state into navigation or a completion
* runs this first; the refusals are fixed codes and never echo the token.
*/
function verifyOAuthState(
config: HeimdallConfig,
keys: ReturnType<typeof createRuntimeKeyMaterial>,
token: string,
provider: Provider
): OAuthStateVerdict {
const verification = verifyJwt(token, keys.publicKey);
if (!verification.valid) {
return { ok: false, error: "invalid_state" };
}
const payload = parseOAuthStatePayload(verification.payload);
if (!payload || payload.iss !== config.issuer) {
return { ok: false, error: "invalid_state" };
}
if (payload.aud !== payload.provider || payload.provider !== provider) {
return { ok: false, error: "state_provider_mismatch" };
}
const now = nowEpochSeconds();
if (payload.nbf > now + 30 || payload.exp <= now - 30) {
return { ok: false, error: "state_expired" };
}
return { ok: true, payload };
}
const oauthBindingType = "heimdall_oauth_binding";
function oauthBindingCookieName(jti: string): string {
return `__Host-heimdall-oauth-${jti}`;
}
/** The signed claim that this browser launched this state: it is the cookie's value, and needs no store read to check. */
function signOAuthBinding(
config: HeimdallConfig,
keys: ReturnType<typeof createRuntimeKeyMaterial>,
state: OAuthStatePayload
): string {
return signJwt(
{ typ: oauthBindingType, iss: config.issuer, jti: state.jti, iat: nowEpochSeconds(), exp: state.exp },
keys
);
}
function oauthBindingHolds(
config: HeimdallConfig,
keys: ReturnType<typeof createRuntimeKeyMaterial>,
token: string | undefined,
state: OAuthStatePayload
): boolean {
if (!token) {
return false;
}
const verification = verifyJwt(token, keys.publicKey);
if (!verification.valid) {
return false;
}
const { typ, iss, jti, exp } = verification.payload;
return typ === oauthBindingType && iss === config.issuer && jti === state.jti && typeof exp === "number" && exp > nowEpochSeconds();
}
function setOAuthBindingCookie(reply: FastifyReply, state: OAuthStatePayload, value: string, maxAgeSeconds: number): void {
reply.setCookie(oauthBindingCookieName(state.jti), value, {
path: "/",
secure: true,
httpOnly: true,
sameSite: "lax",
maxAge: Math.max(0, maxAgeSeconds),
});
}
function oauthCallbackUrl(config: HeimdallConfig, provider: Provider): string {
return `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
}
function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] {
return grants.map((grant) => grantFact(grant.capability));
}
@ -525,8 +623,8 @@ export async function startOAuthFlow(
}
// A connection target is app-backend authority: it rides only on connect
// and link starts, which are the flows gated by x-heimdall-app-secret.
// A sign_in start is open to any browser and must not carry one.
// starts that carry the app's own secret (gated below), and never on a
// sign_in start, which is open to any browser.
if (input.mode === "sign_in" && input.connection !== undefined) {
return {
statusCode: 400,
@ -629,7 +727,7 @@ export async function startOAuthFlow(
if (options?.trustedBrowserAttemptId && handoff.kind === "browser_completion") {
handoff = { kind: "browser_completion", attemptId: options.trustedBrowserAttemptId };
}
if (entitlementPolicy && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
if ((entitlementPolicy || profile.startRequiresAppCaller || (input.mode === "connect" && input.connection !== undefined)) && (!isAppCaller(caller) || caller.appSlug !== profile.slug)) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
@ -641,7 +739,6 @@ export async function startOAuthFlow(
};
}
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
const state = buildOAuthStateToken({
config,
provider,
@ -652,27 +749,18 @@ export async function startOAuthFlow(
initiatorAccountId: options?.linkAccountId ?? input.initiatingAccountId,
handoff,
entitlementPolicy,
requestedScopes: input.requestedScopes,
keys,
});
const oidc = descriptor.oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
return {
statusCode: 201,
body: {
provider,
appSlug: profile.slug,
mode: input.mode,
callbackUrl,
authorizationUrl: buildAuthorizationUrl({
provider,
clientId: providerConfig.clientId,
redirectUri: callbackUrl,
state: state.token,
requestedScopes: input.requestedScopes,
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
}),
callbackUrl: oauthCallbackUrl(config, provider),
authorizationUrl: `${config.publicBaseUrl}/v1/oauth/${provider}/launch?state=${encodeURIComponent(state.token)}`,
handoff,
stateToken: state.token,
stateExpiresAt: state.expiresAt,
},
};
@ -859,6 +947,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
origin: true,
credentials: true,
});
await app.register(cookie);
app.get("/healthz", async () => ({
ok: true,
@ -1005,6 +1094,74 @@ 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" };
}
const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state), state.exp - nowEpochSeconds());
reply.header("Cache-Control", "no-store");
reply.header("Referrer-Policy", "no-referrer");
return reply.redirect(
buildAuthorizationUrl({
provider: state.provider,
clientId,
redirectUri: oauthCallbackUrl(config, state.provider),
state: request.query.state,
requestedScopes: state.requested_scopes ?? undefined,
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
}),
302
);
}
);
app.get<{
Params: { provider: Provider };
Querystring: { code?: string; state?: string; error?: string; error_description?: string };
@ -1038,16 +1195,29 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return { error: "missing_state" };
}
const verification = verifyJwt(request.query.state, keys.publicKey);
if (!verification.valid) {
const verified = verifyOAuthState(config, keys, request.query.state, request.params.provider);
if (!verified.ok) {
reply.code(400);
return { error: "invalid_state", detail: verification.error };
return { error: verified.error };
}
const statePayload = parseOAuthStatePayload(verification.payload);
if (!statePayload || statePayload.provider !== request.params.provider) {
const statePayload = verified.payload;
// The cookie is checked before the claim: a stranger without it must not be
// able to burn the state that the browser that launched it still needs.
if (!oauthBindingHolds(config, keys, request.cookies[oauthBindingCookieName(statePayload.jti)], statePayload)) {
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 denyBrowserAttempt = async (denialCode: string): Promise<void> => {

View file

@ -97,6 +97,8 @@ export interface OAuthStatePayload {
initiator_account_id: string | null;
handoff: OAuthHandoff | null;
entitlement_policy: OAuthEntitlementPolicy | null;
/** The start's requestedScopes, carried so the launch hop can build the provider URL from the signed state alone. */
requested_scopes: string[] | null;
}
export interface LinkedIdentityInput {

View file

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

View file

@ -3,6 +3,7 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput,
type CreatePendingLinkInput,
type StoredPendingLink,
@ -51,6 +52,8 @@ export class InMemoryStore implements HeimdallStore {
private readonly authCompletions = new Map<string, StoredAuthCompletion>();
private readonly pendingLinks = new Map<string, StoredPendingLink>();
private readonly authCompletionsByAttempt = new Map<string, string>();
/** `${jti}:${phase}` -> the state's expiresAt. */
private readonly oauthStateUses = new Map<string, string>();
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
@ -448,6 +451,20 @@ export class InMemoryStore implements HeimdallStore {
return clone(link);
}
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> {
const key = `${input.accountId}:${input.provider}:${input.scope}`;
this.entitlementSnapshots.set(key, clone(input));

View file

@ -5,6 +5,7 @@ import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } f
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type ClaimOAuthStateUseInput,
type CreateAuthCompletionInput,
type CreatePendingLinkInput,
type StoredPendingLink,
@ -417,6 +418,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
private_command_receipts: ["SELECT", "INSERT"],
auth_completions: ["SELECT", "INSERT", "UPDATE"],
pending_links: ["SELECT", "INSERT", "UPDATE"],
oauth_state_uses: ["INSERT"],
capability_grants: ["SELECT", "INSERT"],
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
audit_events: ["INSERT"],
@ -1151,6 +1153,19 @@ export class PostgresStore implements HeimdallStore {
return result.rowCount ? mapPendingLinkRow(expectRow(result.rows[0], "consumePendingLink")) : null;
}
async claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean> {
// No conflict target and no RETURNING: both need SELECT, and the role has INSERT only.
const result = await this.pool.query(
`
INSERT INTO oauth_state_uses (jti, phase, app_slug, used_at, expires_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT DO NOTHING
`,
[input.jti, input.phase, input.appSlug, input.usedAt, input.expiresAt]
);
return result.rowCount === 1;
}
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
await this.pool.query(
`

View file

@ -136,6 +136,20 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
ON auth_completions(app_slug, attempt_id)
WHERE attempt_id IS NOT NULL AND consumed_at IS NULL;
-- One use per (state jti, phase): a signed OAuth state launches once and
-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING
-- reports every later claim as no row. The 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 (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,

View file

@ -167,6 +167,14 @@ export interface StoredAuthCompletion {
payloadJson: Record<string, unknown>;
}
export interface ClaimOAuthStateUseInput {
jti: string;
phase: "launch" | "callback";
appSlug: AppSlug;
usedAt: string;
expiresAt: string;
}
export interface CreateAuthCompletionInput {
/**
* Correlation handle only (see StoredAuthCompletion.attemptId). The store
@ -306,5 +314,15 @@ export interface HeimdallStore {
*/
consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise<StoredPendingLink | null>;
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>;
/**
* Claim one use of a signed OAuth state: true only for the first claim of
* (jti, phase), false for every later one. Without it a state is a bearer
* token any number of browsers can complete, so an attacker who learns a
* victim's state completes it as themselves and hands the victim a code
* for the attacker's account. `expiresAt` is the state's own expiry; the
* state is refused at that time before any claim, so the record is dead
* after it and a store may drop it.
*/
claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean>;
createAuditEvent(input: CreateAuditEventInput): Promise<void>;
}

View file

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

View file

@ -4,7 +4,8 @@ import { join } from "node:path";
import { inspect } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { type FastifyInstance } from "fastify";
import { buildApp, errorIdentity } from "../src/app.js";
import { buildApp, errorIdentity, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
import { resolveAppCaller } from "../src/app-caller.js";
import { builtInAppProfiles, callbackMint } from "../src/app-profiles.js";
import { deliverBackendHandoff, type BackendHandoffPayload } from "../src/backend-handoff.js";
import { type HeimdallConfig, loadConfig } from "../src/config.js";
@ -12,97 +13,7 @@ import { entitlementFacts, identityFacts } from "../src/facts.js";
import { createOAuthRuntimeRegistry, ProviderResponseError, type OAuthProviderRuntime } from "../src/oauth.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
import { InMemoryStore } from "../src/store/index.js";
function createTestConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {
streampixels: "streampixels-secret",
repixelizer: "repixelizer-secret",
bifrost: "bifrost-secret",
},
appBackendCallbacks: {
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
},
storage: {
backend: "memory",
applySchemaOnStartup: true,
},
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
google: { clientId: "google-client", clientSecret: "google-secret" },
},
};
}
function createMockDiscordRuntime(): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return {
accessToken: "discord-access-token",
refreshToken: "discord-refresh-token",
tokenType: "Bearer",
scope: ["identify", "guilds.members.read"],
expiresAt: "2026-04-26T13:00:00.000Z",
raw: { source: "test" },
};
},
async resolveIdentity() {
return {
provider: "discord",
providerUserId: "discord-user-123",
username: "meta",
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
profile: { id: "discord-user-123", username: "meta" },
};
},
async evaluateEntitlements({ callback }) {
return {
facts: [entitlementFacts.appAccess],
snapshots: [
{
accountId: callback.accountId,
provider: "discord",
scope: "repixelizer:discord_role_access:gamecult-guild",
evaluatedAt: "2026-04-26T12:00:00.000Z",
isAllowed: true,
reasonCode: "matched_role",
reasonDetail: "Matched Repixelizer access role.",
rawSummaryJson: {
guildId: "gamecult-guild",
matchedRoles: ["role-repixelizer"],
},
},
],
};
},
};
}
import { createMockDiscordRuntime, createTestConfig, launchFlow, providerRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
const originalFetch = globalThis.fetch;
@ -135,7 +46,7 @@ function getHeimdallContext(app: FastifyInstance) {
}).heimdallContext;
}
async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
async function startDiscordSignIn(app: FastifyInstance): Promise<LaunchedFlow> {
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
@ -147,7 +58,7 @@ async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
});
expect(response.statusCode).toBe(201);
return response.json().stateToken as string;
return launchFlow(app, response.json());
}
afterEach(async () => {
@ -208,9 +119,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -250,6 +161,170 @@ describe("Heimdall service", () => {
expect(response.json()).toEqual(expect.objectContaining({ error: "app_auth_required" }));
});
describe("apps that bind browser completion to their own attempt", () => {
// Bifrost's browser binding is the secrecy of its attempt nonce, which
// travels readable in the signed state. Only Bifrost may mint a Bifrost
// state, so a known nonce cannot carry a stranger's completion
// (finding known-nonce-login-csrf).
const victimAttempt = "VICTIM-ATTEMPT-NONCE";
const bifrostReturn = `https://bifrost.gamecult.org/auth/heimdall/complete?attempt=${victimAttempt}`;
const bifrostStart = (handoff?: { kind: "browser_completion" }) => ({
appSlug: "bifrost" as const,
mode: "sign_in" as const,
returnTo: bifrostReturn,
...(handoff ? { handoff } : {}),
});
it("refuses an unauthenticated Bifrost start for browser_completion and for an omitted handoff", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
for (const payload of [bifrostStart({ kind: "browser_completion" }), bifrostStart()]) {
const response = await app.inject({ method: "POST", url: "/v1/oauth/discord/start", payload });
expect(response.statusCode).toBe(401);
expect(response.json()).toEqual({ error: "app_auth_required" });
}
});
it("the attack fails: a stranger cannot obtain a Bifrost state carrying a chosen return_to nonce", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
payload: bifrostStart({ kind: "browser_completion" }),
});
expect(response.statusCode).toBe(401);
expect(response.body).not.toContain(victimAttempt);
expect(response.body).not.toContain("stateToken");
expect(response.body).not.toContain("authorizationUrl");
});
it("still starts for Bifrost's own app secret, with the requested return_to in the state", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
headers: { "x-heimdall-app-secret": "bifrost-secret" },
payload: bifrostStart({ kind: "browser_completion" }),
});
expect(response.statusCode).toBe(201);
const verified = verifyJwt(stateOf(response.json()), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(expect.objectContaining({ app_slug: "bifrost", return_to: bifrostReturn }));
}
});
it("refuses another app's credentials on a Bifrost start", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
for (const secret of ["streampixels-secret", "repixelizer-secret"]) {
const response = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
headers: { "x-heimdall-app-secret": secret },
payload: bifrostStart({ kind: "browser_completion" }),
});
expect(response.statusCode, secret).toBe(401);
expect(response.json()).toEqual({ error: "app_auth_required" });
}
});
it("refuses another app's authenticated caller on a direct Bifrost start, with no policy attached", async () => {
// Over HTTP the caller is resolved against the body's slug, so a foreign
// secret never becomes a caller at all. The command plane and direct
// callers hand startOAuthFlow a caller of their own, so the slug match is
// decided here too.
const config = createTestConfig();
const app = await buildApp({ config });
apps.push(app);
const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
const own = resolveAppCaller(config, "bifrost", { "x-heimdall-app-secret": "bifrost-secret" });
const refused = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", foreign, bifrostStart({ kind: "browser_completion" }));
expect(refused.statusCode).toBe(401);
const allowed = await startOAuthFlow(getHeimdallRuntimeContext(app), "discord", own, bifrostStart({ kind: "browser_completion" }));
expect(allowed.statusCode).toBe(201);
});
it("gates every mode, provider and handoff kind Bifrost admits, not one spelling of them", async () => {
// Hand mutants narrowing the gate to browser_completion or to sign_in
// survived the single-case tests (finding gate-tests-narrow).
const store = new InMemoryStore();
const now = new Date().toISOString();
const initiator = await store.createAccount({ createdAt: now, lastSeenAt: now });
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
const handoffs = [
undefined,
{ kind: "browser_completion" },
{
kind: "backend_callback",
attemptId: "bifrost-attempt-1",
callbackUrl: "https://bifrost.gamecult.org/auth/heimdall/callback",
},
];
let cases = 0;
for (const mode of ["sign_in", "link", "connect"] as const) {
for (const provider of ["discord", "patreon"] as const) {
for (const handoff of handoffs) {
const label = `${mode} ${provider} ${handoff?.kind ?? "omitted"}`;
const payload = {
appSlug: "bifrost",
mode,
returnTo: bifrostReturn,
...(mode === "link" ? { initiatingAccountId: initiator.id } : {}),
...(handoff ? { handoff } : {}),
};
const refused = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/start`, payload });
expect(refused.statusCode, label).toBe(401);
expect(refused.json(), label).toEqual({ error: "app_auth_required" });
const allowed = await app.inject({
method: "POST",
url: `/v1/oauth/${provider}/start`,
headers: { "x-heimdall-app-secret": "bifrost-secret" },
payload,
});
expect(allowed.statusCode, label).toBe(201);
expect(allowed.json().authorizationUrl, label).toContain(`/v1/oauth/${provider}/launch?state=`);
cases += 1;
}
}
}
expect(cases).toBe(18);
});
it("leaves public-by-design apps unchanged: unauthenticated browser_completion starts still succeed", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
for (const [appSlug, provider, returnTo] of [
["repixelizer", "discord", "https://repixelizer.gamecult.org/app/"],
["streampixels", "twitch", "https://streampixels.gamecult.org/app/"],
] as const) {
const response = await app.inject({
method: "POST",
url: `/v1/oauth/${provider}/start`,
payload: { appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } },
});
expect(response.statusCode, appSlug).toBe(201);
}
});
it("flags only Bifrost's profile", () => {
expect(
Object.values(builtInAppProfiles).filter((profile) => profile.startRequiresAppCaller).map((profile) => profile.slug)
).toEqual(["bifrost"]);
});
});
it("accepts caller-owned entitlement policy for a browser completion handoff when the caller is trusted", async () => {
// The other half of the same pin: a caller holding the secret may attach
// policy even without a backend_callback handoff, because identity no
@ -303,9 +378,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -349,11 +424,11 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
const authorizationUrl = new URL(payload.authorizationUrl as string);
const authorizationUrl = await providerRedirect(app, payload);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
expect(authorizationUrl.searchParams.get("scope")).toBe("identity identity[email]");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -397,9 +472,9 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
expect((await providerRedirect(app, payload)).href).toContain("discord.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -443,10 +518,10 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
const authorizationUrl = new URL(payload.authorizationUrl as string);
const authorizationUrl = await providerRedirect(app, payload);
expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://www.patreon.com/oauth2/authorize");
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
const verified = verifyJwt(stateOf(payload), getPublicKey(app));
expect(verified.valid).toBe(true);
if (verified.valid) {
expect(verified.payload).toEqual(
@ -474,10 +549,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(response.statusCode).toBe(201);
@ -690,10 +766,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const completionCode = callbackResponse.json().completion.code as string;
@ -732,10 +809,11 @@ describe("Heimdall service", () => {
const auditEvents = () =>
[...(store as unknown as { auditEvents: Map<string, { eventType: string; sessionId?: string; eventPayloadJson: unknown }> }).auditEvents.values()];
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const { code, expiresAt } = callback.json().completion as { code: string; expiresAt: string };
const created = auditEvents().filter((event) => event.eventType === "auth_completion_created");
@ -816,10 +894,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callbackResponse.statusCode).toBe(201);
const refreshToken = callbackResponse.json().refreshToken as string;
@ -863,11 +942,12 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: {
cookie: flow.cookie,
accept: "text/html",
},
});
@ -913,12 +993,13 @@ describe("Heimdall service", () => {
},
});
expect(startResponse.statusCode).toBe(201);
const stateToken = startResponse.json().stateToken as string;
const flow = await launchFlow(app, startResponse.json());
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: {
cookie: flow.cookie,
accept: "text/html",
},
});
@ -1124,11 +1205,12 @@ describe("Heimdall service", () => {
returnTo: "https://streampixels.gamecult.org/auth/connect",
},
});
const stateToken = startResponse.json().stateToken as string;
const flow = await launchFlow(app, startResponse.json());
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const accountId = callbackResponse.json().account.id as string;
@ -1211,11 +1293,12 @@ describe("Heimdall service", () => {
returnTo: "https://streampixels.gamecult.org/auth/connect",
},
});
const stateToken = startResponse.json().stateToken as string;
const flow = await launchFlow(app, startResponse.json());
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/twitch/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const accountId = callbackResponse.json().account.id as string;
@ -1277,10 +1360,11 @@ describe("Heimdall service", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callbackResponse.statusCode).toBe(201);
const evaluationsAtSignIn = evaluations;
@ -1362,11 +1446,31 @@ describe("Heimdall service", () => {
const start = await app.inject({
method: "POST",
url: "/v1/oauth/joystick/start",
headers: { "x-heimdall-app-secret": "streampixels-secret" },
payload: { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", ...extra },
});
return start;
}
it("gates a connect start that names a creator on the app's own secret: none and a foreign app's are 401, its own is 201", async () => {
const config = createTestConfig();
const app = await buildApp({ config });
apps.push(app);
const body = { appSlug: "streampixels", mode: "connect", returnTo: "https://streampixels.gamecult.org/auth/connect", connection: creator };
const start = (headers: Record<string, string>) => app.inject({ method: "POST", url: "/v1/oauth/joystick/start", headers, payload: body });
for (const headers of [{}, { "x-heimdall-app-secret": "repixelizer-secret" }, { "x-heimdall-app-secret": "wrong" }]) {
const refused = await start(headers);
expect([refused.statusCode, refused.json()]).toEqual([401, { error: "app_auth_required" }]);
}
expect((await start({ "x-heimdall-app-secret": "streampixels-secret" })).statusCode).toBe(201);
// A direct caller (the command plane hands startOAuthFlow its own) is held to the slug too.
const input = { appSlug: "streampixels" as const, mode: "connect" as const, returnTo: body.returnTo, connection: { kind: "creator" as const, targetId: "creator-1" } };
const foreign = resolveAppCaller(config, "repixelizer", { "x-heimdall-app-secret": "repixelizer-secret" });
expect((await startOAuthFlow(getHeimdallRuntimeContext(app), "joystick", foreign, input)).statusCode).toBe(401);
});
it.each([
["no connection", {}, "creator_connection_required"],
["an account connection", { connection: { kind: "account", targetId: "victim-1" } }, "creator_connection_required"],
@ -1378,7 +1482,7 @@ describe("Heimdall service", () => {
expect(start.statusCode).toBe(400);
expect(start.json().error).toBe(error);
expect(start.json().authorizationUrl).toBeUndefined();
expect(start.json().stateToken).toBeUndefined();
expect(start.json()).not.toHaveProperty("stateToken");
});
it.each([
@ -1394,14 +1498,15 @@ describe("Heimdall service", () => {
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const verified = verifyJwt(stateToken, keys.publicKey);
const flow = await launchFlow(app, (await connect(app)).json());
const verified = verifyJwt(flow.state, keys.publicKey);
expect(verified.valid).toBe(true);
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, connection }, keys);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(calls).toEqual([]);
@ -1433,9 +1538,11 @@ describe("Heimdall service", () => {
handoff: { kind: "backend_callback", attemptId: "attempt-js-1", callbackUrl },
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(start.json().stateToken as string)}`,
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeLessThan(300);
@ -1454,7 +1561,7 @@ describe("Heimdall service", () => {
apps.push(app);
const start = await connect(app);
expect(start.statusCode).toBe(201);
const url = new URL(start.json().authorizationUrl as string);
const url = await providerRedirect(app, start.json());
expect(`${url.origin}${url.pathname}`).toBe("https://joystick.tv/api/oauth/authorize");
expect(url.searchParams.get("client_id")).toBe("joystick-client");
expect(url.searchParams.get("scope")).toBe("identity:read stream:read chat:read followers:read subscribers:read");
@ -1468,10 +1575,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ channel_id: "deru-chan", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const flow = await launchFlow(app, (await connect(app)).json());
const connected = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/joystick/callback?code=a&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const accountId = connected.json().account.id as string;
const before = await store.findStoredLinkedIdentityForAccount(accountId, "joystick");
@ -1519,14 +1627,15 @@ describe("Heimdall service", () => {
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const calls = stubJoystick({ channel_id: "chan-forged", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const verified = verifyJwt(stateToken, keys.publicKey);
const flow = await launchFlow(app, (await connect(app)).json());
const verified = verifyJwt(flow.state, keys.publicKey);
expect(verified.valid).toBe(true);
const forged = signJwt({ ...(verified as { payload: Record<string, unknown> }).payload, mode }, keys);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(forged)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(callback.body).not.toContain("viewer_access");
@ -1543,11 +1652,12 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
const calls = stubJoystick({ channel_id: "chan-9", username: "deru", nickname: "Deru!", photo_url: "https://img/x.png" });
const stateToken = (await connect(app)).json().stateToken as string;
const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/joystick/callback?code=the-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeLessThan(300);
@ -1576,10 +1686,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ channel_id: "chan-9", username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const row = await store.findStoredLinkedIdentityForAccount(callback.json().account.id as string, "joystick");
expect(row?.displayName).toBe("deru");
@ -1590,10 +1701,11 @@ describe("Heimdall service", () => {
const app = await buildApp({ config: createTestConfig(), store });
apps.push(app);
stubJoystick({ username: "deru" });
const stateToken = (await connect(app)).json().stateToken as string;
const flow = await launchFlow(app, (await connect(app)).json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/joystick/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBeGreaterThanOrEqual(400);
expect(await store.findStoredLinkedIdentity("joystick", "undefined")).toBeNull();
@ -1788,11 +1900,11 @@ describe("error answers never echo an input", () => {
const app = await buildApp({ config: createTestConfig(), oauthRuntimes: { discord: failingDiscordRuntime() } });
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
headers: { accept: "text/html" },
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie, accept: "text/html" },
});
expect(response.headers["content-type"]).toContain("text/html");
@ -1824,9 +1936,11 @@ describe("error answers never echo an input", () => {
},
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(start.json().stateToken as string)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(response.statusCode).toBe(502);
@ -1844,16 +1958,16 @@ describe("the relayed provider error", () => {
const injected = `<script>alert(1)</script>"\\\u0007é${"x".repeat(300)}`;
const allowed = /^[\x20-\x21\x23-\x5b\x5d-\x7e]*$/;
function providerErrorUrl(stateToken: string): string {
const query = new URLSearchParams({ state: stateToken, error: `${"e".repeat(100)}\u0000`, error_description: injected });
return `/v1/oauth/discord/callback?${query.toString()}`;
function providerErrorRequest(flow: LaunchedFlow) {
const query = new URLSearchParams({ state: flow.state, error: `${"e".repeat(100)}\u0000`, error_description: injected });
return { method: "GET" as const, url: `/v1/oauth/discord/callback?${query.toString()}`, headers: { cookie: flow.cookie } };
}
it("is printable and bounded in the JSON answer", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({ method: "GET", url: providerErrorUrl(await startDiscordSignIn(app)) });
const response = await app.inject(providerErrorRequest(await startDiscordSignIn(app)));
expect(response.statusCode).toBe(400);
const { providerError, providerErrorDescription } = response.json() as Record<string, string>;
@ -1867,11 +1981,8 @@ describe("the relayed provider error", () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "GET",
url: providerErrorUrl(await startDiscordSignIn(app)),
headers: { accept: "text/html" },
});
const request = providerErrorRequest(await startDiscordSignIn(app));
const response = await app.inject({ ...request, headers: { ...request.headers, accept: "text/html" } });
expect(response.headers["content-type"]).toContain("text/html");
expect(response.body.match(/<script/gi)).toHaveLength(1);
@ -1903,7 +2014,7 @@ describe("the relayed provider error", () => {
});
expect(start.statusCode).toBe(201);
await app.inject({ method: "GET", url: providerErrorUrl(start.json().stateToken as string) });
await app.inject(providerErrorRequest(await launchFlow(app, start.json())));
expect(deliveries).toHaveLength(1);
const description = String(deliveries[0]!.errorDescription);
@ -1929,10 +2040,11 @@ describe("the failed-callback audit event", () => {
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(response.statusCode).toBe(502);
@ -1956,10 +2068,11 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
const store = new InMemoryStore();
const app = await buildApp({ config: createTestConfig(), store, oauthRuntimes: createOAuthRuntimeRegistry() });
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const flow = await startDiscordSignIn(app);
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBe(502);
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: unknown }> }).auditEvents.values()];
@ -2013,8 +2126,12 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () =>
},
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}` });
const flow = await startDiscordSignIn(app);
await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string; eventPayloadJson: Record<string, unknown> }> }).auditEvents.values()];
const failed = events.find((event) => event.eventType === "oauth_callback_failed");

View file

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

View file

@ -11,6 +11,7 @@ import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts } from "../src/facts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { InMemoryStore } from "../src/store/index.js";
import { launchState, stateOf } from "./oauth-flow.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
@ -87,10 +88,11 @@ async function start(app: App, payload: Record<string, unknown>, headers: Record
}
async function completeAs(app: App, stateToken: string, who: string) {
const { cookie } = await launchState(app, stateToken);
return app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=${who}&state=${encodeURIComponent(stateToken)}`,
headers: { accept: "application/json" },
headers: { accept: "application/json", cookie },
});
}
@ -111,7 +113,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
});
expect(started.statusCode).toBe(201);
const callback = await completeAs(app, started.json().stateToken as string, "victim");
const callback = await completeAs(app, stateOf(started.json()), "victim");
expect(callback.statusCode).toBe(201);
const minted = callback.json().completion.code as string;
expect(minted).not.toBe("attacker-fixed");
@ -126,7 +128,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
const { app, store } = await harness();
const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
expect(started.statusCode).toBe(201);
const callback = await completeAs(app, started.json().stateToken as string, "victim");
const callback = await completeAs(app, stateOf(started.json()), "victim");
expect(callback.statusCode).toBe(201);
const code = callback.json().completion.code as string;
expect(code).toMatch(uuidV4);
@ -160,7 +162,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
it("attemptId set to a real victim completion code does not let the attacker's flow touch the victim's completion", async () => {
const { app } = await harness();
const victimStart = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
const victimCallback = await completeAs(app, victimStart.json().stateToken as string, "victim");
const victimCallback = await completeAs(app, stateOf(victimStart.json()), "victim");
const victimCode = victimCallback.json().completion.code as string;
const attackerStart = await start(app, {
@ -169,7 +171,7 @@ describe("takeover chain: caller-chosen attemptId is never the redemption code",
returnTo: "https://repixelizer.gamecult.org/app/",
handoff: { kind: "browser_completion", attemptId: victimCode },
});
const attackerCallback = await completeAs(app, attackerStart.json().stateToken as string, "attacker");
const attackerCallback = await completeAs(app, stateOf(attackerStart.json()), "attacker");
expect(attackerCallback.statusCode).toBe(201);
expect(attackerCallback.json().completion.code).not.toBe(victimCode);
@ -205,7 +207,7 @@ describe("consumeAuthCompletion: single-use, scoped, expiring", () => {
it("a code redeems exactly once under concurrent redemption, and never for another app", async () => {
const { app } = await harness();
const started = await start(app, { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" });
const callback = await completeAs(app, started.json().stateToken as string, "victim");
const callback = await completeAs(app, stateOf(started.json()), "victim");
const code = callback.json().completion.code as string;
const crossApp = await redeem(app, "bifrost", code);
@ -266,7 +268,7 @@ describe("attempt-handle binding: the public start route may attach a completion
handoff: { kind: "browser_completion", attemptId: attempt.handle },
});
expect(attackerStart.statusCode).toBe(201);
await completeAs(app, attackerStart.json().stateToken as string, "attacker");
await completeAs(app, stateOf(attackerStart.json()), "attacker");
const after = await store.findAuthAttempt("ghostlight", attempt.handle);
expect(after?.status).toBe("pending");

View file

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

View file

@ -6,6 +6,7 @@ import { loadConfig, type HeimdallConfig } from "../src/config.js";
import { resetGoogleJwksCache } from "../src/google-oidc.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { InMemoryStore } from "../src/store/index.js";
import { launchFlow, launchWithRedirect, stateOf, type LaunchedFlow } from "./oauth-flow.js";
const CLIENT_ID = "google-client";
const REDIRECT = "https://heimdall.gamecult.org/v1/oauth/google/callback";
@ -48,6 +49,9 @@ function discordRuntime(providerUserId: string, name: string): OAuthProviderRunt
};
}
// Bifrost refuses an unauthenticated start: its backend holds the app secret.
const BIFROST_APP = { "x-heimdall-app-secret": "bifrost-secret" };
function b64(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
@ -110,12 +114,15 @@ async function signInWithDiscord(app: FastifyInstance, providerUserId = "discord
const start = await app.inject({
method: "POST",
url: "/v1/oauth/discord/start",
headers: BIFROST_APP,
payload: { appSlug: "bifrost", mode: "sign_in", returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" },
});
expect(start.statusCode).toBe(201);
const flow = await launchFlow(app, start.json());
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(start.json().stateToken)}`,
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
expect(callback.statusCode).toBe(201);
const body = callback.json();
@ -132,13 +139,17 @@ function startGoogle(app: FastifyInstance, accessToken?: string, mode: "link" |
return app.inject({
method: "POST",
url: "/v1/oauth/google/start",
headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {},
headers: { ...BIFROST_APP, ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}) },
payload: { appSlug: "bifrost", mode, returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" },
});
}
function callbackGoogle(app: FastifyInstance, stateToken: string) {
return app.inject({ method: "GET", url: `/v1/oauth/google/callback?code=g-code&state=${encodeURIComponent(stateToken)}` });
function callbackGoogle(app: FastifyInstance, flow: LaunchedFlow) {
return app.inject({
method: "GET",
url: `/v1/oauth/google/callback?code=g-code&state=${encodeURIComponent(flow.state)}`,
headers: { cookie: flow.cookie },
});
}
const DISCORD_POLICY = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["member"] } as const;
@ -147,9 +158,10 @@ const DISCORD_POLICY = { kind: "discord_role_access", guildId: "g", allowedRoleI
async function linkGoogleCallback(app: FastifyInstance, token: string, claims?: Record<string, unknown>) {
const start = await startGoogle(app, token);
expect(start.statusCode).toBe(201);
const url = new URL(start.json().authorizationUrl);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, ...(claims ? { claims } : {}) });
return callbackGoogle(app, start.json().stateToken);
return callbackGoogle(app, startFlow);
}
function confirmLink(app: FastifyInstance, token: string | undefined, pendingLinkId: string) {
@ -184,13 +196,14 @@ describe("Google as a Heimdall OIDC provider", () => {
expect(start.statusCode).toBe(201);
expect(start.json().callbackUrl).toBe(REDIRECT);
const url = new URL(start.json().authorizationUrl);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
expect(`${url.origin}${url.pathname}`).toBe("https://accounts.google.com/o/oauth2/v2/auth");
expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
expect(url.searchParams.get("redirect_uri")).toBe(REDIRECT);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("scope")).toBe("openid email profile");
expect(url.searchParams.get("state")).toBe(start.json().stateToken);
expect(url.searchParams.get("state")).toBe(stateOf(start.json()));
expect(url.searchParams.get("nonce")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
@ -200,10 +213,11 @@ describe("Google as a Heimdall OIDC provider", () => {
const { app, store } = await build();
const { accessToken, accountId } = await signInWithDiscord(app);
const start = await startGoogle(app, accessToken);
const url = new URL(start.json().authorizationUrl);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
const leg = fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(app, start.json().stateToken);
const done = await callbackGoogle(app, startFlow);
// Phase one verified the identity and recorded it; it linked nothing and issued no session.
expect(done.statusCode).toBe(201);
@ -233,9 +247,10 @@ describe("Google as a Heimdall OIDC provider", () => {
const signIn = await startGoogle(app, undefined, "sign_in");
expect(signIn.statusCode).toBe(201);
const signInUrl = new URL(signIn.json().authorizationUrl);
const signInFlow = await launchWithRedirect(app, signIn.json());
const signInUrl = signInFlow.url;
fakeGoogle({ nonceFrom: () => signInUrl.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(app, signIn.json().stateToken);
const done = await callbackGoogle(app, signInFlow);
expect(done.statusCode).toBe(201);
expect(done.json().account.id).toBe(accountId);
@ -248,7 +263,7 @@ describe("Google as a Heimdall OIDC provider", () => {
const start = await app.inject({
method: "POST",
url: "/v1/oauth/google/start",
headers: { authorization: `Bearer ${a.accessToken}` },
headers: { ...BIFROST_APP, authorization: `Bearer ${a.accessToken}` },
payload: {
appSlug: "bifrost",
mode: "link",
@ -276,7 +291,8 @@ describe("Google as a Heimdall OIDC provider", () => {
url: "/v1/oauth/discord/start",
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
});
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(start.json().stateToken)}` });
const flow = await launchFlow(app, start.json());
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie } });
return cb.json().accessToken as string;
}],
])("refuses a link start carrying %s", async (_name, token) => {
@ -295,11 +311,12 @@ describe("Google as a Heimdall OIDC provider", () => {
await signInWithDiscord(app);
const start = await startGoogle(app, undefined, "sign_in");
expect(start.statusCode).toBe(201);
const url = new URL(start.json().authorizationUrl);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
const before = accountCount(store);
const done = await callbackGoogle(app, start.json().stateToken);
const done = await callbackGoogle(app, startFlow);
expect(done.statusCode).toBe(403);
expect(done.json().error).toBe("identity_not_linked");
@ -318,9 +335,10 @@ describe("Google as a Heimdall OIDC provider", () => {
const b = await signInWithDiscord(second.app, "discord-2");
expect(b.accountId).not.toBe(a.accountId);
const startB = await startGoogle(second.app, b.accessToken);
const urlB = new URL(startB.json().authorizationUrl);
const startBFlow = await launchWithRedirect(second.app, startB.json());
const urlB = startBFlow.url;
fakeGoogle({ nonceFrom: () => urlB.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(second.app, startB.json().stateToken);
const done = await callbackGoogle(second.app, startBFlow);
expect(done.statusCode).toBe(403);
expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId);
@ -335,10 +353,11 @@ describe("Google as a Heimdall OIDC provider", () => {
const { app, store } = await build();
const { accessToken } = await signInWithDiscord(app);
const start = await startGoogle(app, accessToken);
const url = new URL(start.json().authorizationUrl);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims });
const done = await callbackGoogle(app, start.json().stateToken);
const done = await callbackGoogle(app, startFlow);
expect(done.statusCode).toBe(502);
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
@ -386,9 +405,10 @@ describe("Google as a Heimdall OIDC provider", () => {
await linkGoogleCallback(app, a.accessToken, { sub: "victim-google-sub" });
const signIn = await startGoogle(app, undefined, "sign_in");
const url = new URL(signIn.json().authorizationUrl);
const signInFlow = await launchWithRedirect(app, signIn.json());
const url = signInFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims: { sub: "victim-google-sub" } });
expect((await callbackGoogle(app, signIn.json().stateToken)).statusCode).toBe(403);
expect((await callbackGoogle(app, signInFlow)).statusCode).toBe(403);
});
it("a pending link is single-use", async () => {
@ -425,7 +445,8 @@ describe("Google as a Heimdall OIDC provider", () => {
url: "/v1/oauth/discord/start",
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
});
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(other.json().stateToken)}` });
const flow = await launchFlow(app, other.json());
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(flow.state)}`, headers: { cookie: flow.cookie } });
const res = await app.inject({
method: "POST",
url: "/v1/apps/repixelizer/links/confirm",
@ -471,9 +492,10 @@ describe("Google as a Heimdall OIDC provider", () => {
},
});
expect(start.statusCode).toBe(201);
const url = new URL(start.json().authorizationUrl);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(app, start.json().stateToken);
const done = await callbackGoogle(app, startFlow);
expect(done.statusCode).toBe(201);
expect(done.json().claimSet.facts).toContain("entitlement.app_access");
@ -485,9 +507,10 @@ describe("Google as a Heimdall OIDC provider", () => {
await linkGoogle(app, member.accessToken);
const start = await startGoogle(app, undefined, "sign_in");
const url = new URL(start.json().authorizationUrl);
const startFlow = await launchWithRedirect(app, start.json());
const url = startFlow.url;
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
const done = await callbackGoogle(app, start.json().stateToken);
const done = await callbackGoogle(app, startFlow);
expect(done.statusCode).toBe(201);
expect(done.json().claimSet.facts).not.toContain("entitlement.app_access");

View file

@ -7,6 +7,7 @@ import { type HeimdallConfig } from "../src/config.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
import { InMemoryStore } from "../src/store/index.js";
import { launchFlow } from "./oauth-flow.js";
function createTestConfig(): HeimdallConfig {
return {
@ -98,8 +99,15 @@ function start(r: Rig, payload: Record<string, unknown>, headers: Record<string,
return r.app.inject({ method: "POST", url: "/v1/oauth/discord/start", headers, payload: { appSlug: "repixelizer", returnTo, ...payload } });
}
function callback(r: Rig, stateToken: string) {
return r.app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(stateToken)}` });
/** The browser follows authorizationUrl through the launch hop, then returns with the state, or with `forged` in its place, and the cookie it was handed. */
async function callback(r: Rig, started: { authorizationUrl: string }, forged?: (state: string) => string) {
const flow = await launchFlow(r.app, started);
const state = forged ? forged(flow.state) : flow.state;
return r.app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(state)}`,
headers: { cookie: flow.cookie },
});
}
function redeem(r: Rig, payload: Record<string, unknown>, headers: Record<string, string> = {}) {
@ -138,7 +146,7 @@ describe("link callback holds the provider identity to the initiator", () => {
const r = await rig("d-new");
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
expect(started.statusCode).toBe(201);
const done = await callback(r, started.json().stateToken);
const done = await callback(r, started.json());
expect(done.statusCode).toBe(201);
expect(done.json().account.id).toBe(r.initiator.id);
expect((await r.store.findAccountByLinkedIdentity("discord", "d-new"))?.id).toBe(r.initiator.id);
@ -150,7 +158,7 @@ describe("link callback holds the provider identity to the initiator", () => {
const other = await r.store.createAccount({ createdAt: now, lastSeenAt: now });
await r.store.upsertLinkedIdentity({ accountId: other.id, provider: "discord", providerUserId: "d-held", scopes: [], profileJson: {}, createdAt: now, updatedAt: now });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const done = await callback(r, started.json().stateToken);
const done = await callback(r, started.json());
expect(done.statusCode).toBe(403);
expect(done.json().error).toBe("link_initiator_mismatch");
expect(done.json().completion).toBeUndefined();
@ -165,10 +173,11 @@ describe("link callback holds the provider identity to the initiator", () => {
const r = await rig("d-forged", config);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
const started = await start(r, { mode: "link", initiatingAccountId: r.initiator.id });
const verified = verifyJwt(started.json().stateToken, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
expect(verified.valid).toBe(true);
const stripped = signJwt({ ...verified.payload, initiator_account_id: null }, keys);
const done = await callback(r, stripped);
const done = await callback(r, started.json(), (state) => {
const verified = verifyJwt(state, keys.publicKey) as { valid: boolean; payload: Record<string, unknown> };
expect(verified.valid).toBe(true);
return signJwt({ ...verified.payload, initiator_account_id: null }, keys);
});
expect([done.statusCode, done.json().error]).toEqual([403, "link_initiator_missing"]);
expect(await r.store.findAccountByLinkedIdentity("discord", "d-forged")).toBeNull();
} finally {
@ -184,7 +193,7 @@ describe("redeem binds caller, target and replay", () => {
initiatingAccountId: r.initiator.id,
...(targetId ? { connection: { kind: "account", targetId } } : {}),
});
const done = await callback(r, started.json().stateToken);
const done = await callback(r, started.json());
expect(done.statusCode).toBe(201);
return done.json().completion.code as string;
}

View file

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

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

@ -0,0 +1,150 @@
import { expect } from "vitest";
import { type FastifyInstance } from "fastify";
import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts } from "../src/facts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { decodeJwt } from "../src/signing.js";
export function createTestConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: {
streampixels: "streampixels-secret",
repixelizer: "repixelizer-secret",
bifrost: "bifrost-secret",
},
appBackendCallbacks: {
bifrost: ["https://bifrost.gamecult.org/auth/heimdall/callback"],
repixelizer: ["https://repixelizer.gamecult.org/api/auth/heimdall/callback"],
},
storage: {
backend: "memory",
applySchemaOnStartup: true,
},
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
github: { clientId: "github-client", clientSecret: "github-secret" },
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
google: { clientId: "google-client", clientSecret: "google-secret" },
},
};
}
export function createMockDiscordRuntime(): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return {
accessToken: "discord-access-token",
refreshToken: "discord-refresh-token",
tokenType: "Bearer",
scope: ["identify", "guilds.members.read"],
expiresAt: "2026-04-26T13:00:00.000Z",
raw: { source: "test" },
};
},
async resolveIdentity() {
return {
provider: "discord",
providerUserId: "discord-user-123",
username: "meta",
displayName: "Meta",
primaryEmail: "meta@gamecult.org",
profile: { id: "discord-user-123", username: "meta" },
};
},
async evaluateEntitlements({ callback }) {
return {
facts: [entitlementFacts.appAccess],
snapshots: [
{
accountId: callback.accountId,
provider: "discord",
scope: "repixelizer:discord_role_access:gamecult-guild",
evaluatedAt: "2026-04-26T12:00:00.000Z",
isAllowed: true,
reasonCode: "matched_role",
reasonDetail: "Matched Repixelizer access role.",
rawSummaryJson: {
guildId: "gamecult-guild",
matchedRoles: ["role-repixelizer"],
},
},
],
};
},
};
}
/** What a start returns that the browser needs: the launch URL, whose query carries the signed state. */
export interface StartBody {
authorizationUrl: string;
}
export function stateOf(start: StartBody): string {
return new URL(start.authorizationUrl).searchParams.get("state") ?? "";
}
export interface LaunchedFlow {
state: string;
/** The `name=value` pair a browser sends back to the callback. */
cookie: string;
}
export function setCookieOf(response: { headers: Record<string, unknown> }): string[] {
return [response.headers["set-cookie"]].flat().filter((value): value is string => typeof value === "string");
}
async function followLaunch(app: FastifyInstance, state: string): Promise<LaunchedFlow & { providerUrl: URL }> {
const provider = decodeJwt(state).payload.provider as string;
const response = await app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` });
expect(response.statusCode, "launch").toBe(302);
const set = setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-oauth-"));
expect(set, "launch sets the binding cookie").toBeDefined();
return { state, cookie: (set as string).split(";")[0] as string, providerUrl: new URL(response.headers.location as string) };
}
/** What a browser does with a state it was handed: open the launch hop for the state's provider and keep the cookie it sets. */
export async function launchState(app: FastifyInstance, state: string): Promise<LaunchedFlow> {
const { providerUrl: _providerUrl, ...flow } = await followLaunch(app, state);
return flow;
}
/** Drives what a user's browser does after a start: follow authorizationUrl to the launch hop and keep its cookie. */
export function launchFlow(app: FastifyInstance, start: StartBody): Promise<LaunchedFlow> {
return launchState(app, stateOf(start));
}
/** The launch hop's answer in full: the cookie the browser keeps and the provider URL it is redirected to. */
export async function launchWithRedirect(app: FastifyInstance, start: StartBody): Promise<LaunchedFlow & { url: URL }> {
const { providerUrl, ...flow } = await followLaunch(app, stateOf(start));
return { ...flow, url: providerUrl };
}
/** The provider URL a browser is redirected to once it has followed authorizationUrl through the launch hop. */
export async function providerRedirect(app: FastifyInstance, start: StartBody): Promise<URL> {
const url = new URL(start.authorizationUrl);
const response = await app.inject({ method: "GET", url: `${url.pathname}${url.search}` });
expect(response.statusCode, "launch").toBe(302);
return new URL(response.headers.location as string);
}

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

View file

@ -3,7 +3,7 @@ import { Pool } from "pg";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { newDb } from "pg-mem";
import { type HeimdallConfig } from "../src/config.js";
import { createStore } from "../src/store/index.js";
import { createStore, InMemoryStore } from "../src/store/index.js";
import {
createPostgresStore,
PostgresStore,
@ -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", () => {
// 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
@ -234,11 +264,12 @@ describe("PostgresStore.checkSchema", () => {
"private_command_receipts",
"pending_links",
"auth_completions",
"oauth_state_uses",
"capability_grants",
"entitlement_snapshots",
"audit_events",
]);
expect(REQUIRED_COLUMNS).toHaveLength(96);
expect(REQUIRED_COLUMNS).toHaveLength(101);
expect(REQUIRED_COLUMNS).toEqual(
expect.arrayContaining([
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
@ -249,11 +280,12 @@ describe("PostgresStore.checkSchema", () => {
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
])
);
expect(REQUIRED_KEYS).toHaveLength(13);
expect(REQUIRED_KEYS).toHaveLength(14);
expect(REQUIRED_KEYS).toEqual(
expect.arrayContaining([
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
{ table: "private_command_receipts", columns: "app_slug,idempotency_key", predicate: "" },
{ table: "oauth_state_uses", columns: "jti,phase", predicate: "" },
{ table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" },
{ table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" },
])
@ -366,6 +398,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: {} };
await s.upsertEntitlementSnapshot(snapshot);
await s.upsertEntitlementSnapshot({ ...snapshot, isAllowed: false });
const stateUse = { jti: randomUUID(), phase: "launch" as const, appSlug: "ghostlight" as const, usedAt: now, expiresAt: later };
expect(await s.claimOAuthStateUse(stateUse)).toBe(true);
expect(await s.claimOAuthStateUse(stateUse)).toBe(false);
expect(await s.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true);
await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now });
return called;
}