App-asserted step-up route: Bifrost asserts a native strong proof, Heimdall mints step_up_at + step_up_method #15

Merged
heimdall merged 4 commits from heimdall/app-asserted-step-up into main 2026-11-13 20:19:55 +00:00
13 changed files with 847 additions and 9 deletions

View file

@ -77,3 +77,22 @@ patron points locally.
This route is a bridge from auth-owned Patreon evidence to Bifrost-owned
governance meaning. Heimdall still does not own points, votes, tiers, ledger
history, or payout policy.
## App-asserted step-up
Bifrost's profile carries `stepUpAssertion: { methods: ["bifrost-passkey",
"bifrost-email-code"] }`, the only such grant. With its secret and a member's
live Heimdall bearer, Bifrost calls `POST /v1/apps/bifrost/step-up` with
`{method, nonce}` after it has checked the factor itself, and receives an access
token for the same session whose `step_up_at` and `step_up_method` satisfy
Heimdall's unlink check. Bifrost must send a fresh nonce per assertion, keep
the returned token only for the action it is for, and never send a method it did
not verify: Heimdall cannot tell. Contract, codes, limits and what it does not
prove are in `docs/service-contract.md`.
Precondition and limit: Bifrost can call this only while it holds the member's
Heimdall bearer, which it deliberately does not today. Holding it in a cookie
ticket would turn cookie theft into bearer theft and, with a passkey or email
code, into unlink power. And unlink accepts any method: a `bifrost-email-code`
proof can unlink an identity a Google re-authentication would have guarded.
Requiring a method per route is the later hardening (identity moves).

View file

@ -348,6 +348,19 @@ for the account that holds it. The store role therefore holds DELETE on
`linked_identities`, checked at startup. The HTTP side (`/v1/me/identities`) is
in `service-contract.md`; an unlink needs a step-up proof inside 600 seconds.
### Step-up proof has two sources
An access token's `step_up_at` is set in exactly two places: the `sign_in`
step-up callback (Heimdall asked the provider to re-authenticate; label
`provider:<name>`) and `assertStepUp`, the app-asserted route (the app says it
checked a factor; label from the profile's `stepUpAssertion.methods`). Both
feed the one reader, the unlink check, and a refresh sets neither. The
asserted route trusts the calling app and is bounded by the app secret, a live
bearer for that app, the method allowlist, a nonce claimed once per session
through `claimOAuthStateUse` (phase `step_up_assert`, no new table or grant;
retained to the session expiry as of the claim, which a refresh may slide past),
and in-process fixed-window limits (`src/fixed-window.ts`).
### Three nested guards
1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then

View file

@ -599,7 +599,8 @@ body is empty or `null`.
with the remaining list in the shape above.
`step_up_at` (epoch seconds) is set on the access claim only when the callback's
signed state has `step_up` true and mode `sign_in`. A refresh never carries it:
signed state has `step_up` true and mode `sign_in`, or by an app's `POST
/v1/apps/{appSlug}/step-up` assertion (below). A refresh never carries it:
tokens a refresh issues cannot unlink, so proof is renewed only by a new step-up
sign-in. The unlink writes the audit event `identity_unlinked` (provider and
identity id only). When the account then holds no identity of that provider, the
@ -626,6 +627,111 @@ another account holds (`links/confirm` answers `409 identity_linked_elsewhere`,
the callback `link_initiator_mismatch`). An unlinked identity belongs to no
account and can be linked by any account through the normal link flow.
### `POST /v1/apps/{appSlug}/step-up`
An app asserts that a member just passed a factor the app itself checked (for
Bifrost: a passkey assertion or an emailed code), and receives an access token
that carries that proof. The unlink route's step-up check then passes unchanged.
Authentication: both, always.
- the app's secret in `x-heimdall-app-secret`, as for every app route; and
- the member's live Heimdall access token for the same app as
`Authorization: Bearer`.
Body (`additionalProperties` are dropped as on the sibling routes):
```json
{ "method": "bifrost-passkey", "nonce": "16 to 128 characters, fresh per assertion" }
```
Nonce: 16 to 128 characters of the base64url alphabet (`A-Za-z0-9_-`), so a
random token fits and spaces, controls and lone surrogates are a 400 schema
refusal. `method` is 1 to 64 characters and then must be in the profile's set.
Schema validation (body shape, `method` length, the nonce pattern and length,
the app slug) runs before the secret is checked and answers `400` with
Fastify's fixed schema error, the same for every caller whatever the secret and
never audited. Then the order of refusals, each a closed `error` code:
- `401 app_auth_required`: missing, wrong or another app's secret. The first
check of the handler; nothing is written.
- `403 step_up_assertion_not_permitted`: the app's profile has no
`stepUpAssertion` grant. Only `bifrost` has one; every other app is refused
with its own secret. This branch has its own small budget (5 per 60 s per
app); past it the answer is 429.
- `429 step_up_rate_limited` (`retryAfterSeconds`): the per-app budget (120 per
60 s, spent by every attempt that passed the secret and the grant, including
ones whose bearer then fails) is spent. In-process fixed windows: they reset
when the process restarts and are not shared between instances.
- `401 sign_in_required`: the bearer is missing, malformed, expired, signed by
another key, a refresh token, or rests on a session that is not live (no row,
wrong account, revoked or expired, revision moved). One answer for all of
them; it does not say whether an account exists.
- `403 bearer_app_mismatch`: a live bearer issued to another app. An app cannot
assert for a token issued to another app. It tells only a holder of the
secret and a valid bearer that the bearer is live and for another app, which
that holder already knows.
- `429 step_up_rate_limited`: the per-account budget (10 per 600 s) is spent.
It is checked after the bearer and its audience, before method and nonce.
- `400 unknown_step_up_method`: `method` is not in the profile's closed set
(`methods` in the answer lists it). Bifrost: `bifrost-passkey`,
`bifrost-email-code`. Provider labels (`provider:*`) cannot be asserted.
- `409 step_up_nonce_replayed`: that nonce was already spent for this session.
The claim is the store's `oauth_state_uses` single-use primitive with phase
`step_up_assert` and the key a SHA-256 of session id and nonce, so it holds
across restarts and instances. **Retention is the session's expiry as of the
claim plus 10.5 minutes (the 10 minute prune margin and 30 s clock skew), and
pruning is lazy: each successful claim deletes at most 50 expired rows.** A refresh slides the session's expiry
without bound, so a nonce can be pruned while its session is still live and
then be accepted again. Replaying it still needs the secret and a live
bearer, which is the power to assert with a fresh nonce; the nonce guards a
captured request, not a compromised app. A refused method or a rate-limited
attempt does not spend the nonce.
- `201`: `{"accessToken", "claimSet", "stepUp": {"at", "method", "validForSeconds"}}`,
`Cache-Control: no-store`.
What the minted token is: a copy of the bearer's claims with a new `jti`,
`iat` and `nbf`, `step_up_at` = now and `step_up_method` = the asserted method.
It keeps the bearer's `exp`, same `sid` and account. Facts, capabilities and
identities are the bearer's and are not recomputed, so the route adds proof and
no capability. It mints no refresh token and writes no session row: it cannot
extend a lifetime or a session. A later refresh carries neither claim. The proof
is good for the usual 600 seconds (`STEP_UP_PROOF_TTL_SECONDS`) and only until
the token's own `exp`.
Receipts: each assertion writes the audit event `step_up_asserted`, and each
refusal after the secret verified writes `step_up_assertion_refused`, except
that two kinds are not recorded row by row: a rate-limited refusal is recorded
once per window per budget (its first; the row's `budget` names `app`,
`account` or `notPermitted`), and an app without the grant once per window.
Every other refusal under budget still writes a row each, up to about 121 a
minute for a holder of the secret, and `audit_events` has no retention: that is
an open follow-up. Rows carry the
app, the account and session when the bearer resolved, `method` (null when it
was not in the allowlist), `outcome`, `code`, `budget` (rate-limit rows) and `nonceDigest`. Never the
secret, the bearer, the minted token or the nonce. A request without the secret,
or failing schema validation, writes nothing.
Precondition: the route is usable only once the app holds the member's Heimdall
bearer. Bifrost deliberately does not hold it today. Putting the bearer in a
cookie ticket would turn cookie theft into bearer theft and, with
`bifrost-passkey` or `bifrost-email-code`, into unlink power; whether and how
Bifrost keeps the bearer is its own decision and is not made here.
Unlink accepts any method today. A `bifrost-email-code` proof unlinks an
identity a Google re-authentication would have guarded. Requiring a method per
route (identity moves first) is the later hardening and is not in this change.
What this does not prove: Heimdall cannot see the passkey or the code. It trusts
the calling app's word, exactly as it trusts `stepUpRequested` (asked, not
verified). The word is bounded by the app secret, a live member bearer for that
app, the profile's method allowlist, a single-use nonce, the proof window and
the rate limits. A compromised Bifrost can assert proofs only for members whose
live bearer it holds. `identity_unlinked` records the proof's `stepUpMethod`
(`provider:<name>`, an app label, or null), and unlink does not yet require any
particular method.
### `POST /v1/apps/{appSlug}/managed-credentials/resolve`
Purpose:
@ -761,6 +867,8 @@ Current claim payload fields:
- `typ`
- `account_id`
- `access_revision`
- `step_up_at` optional: epoch seconds of the proof; set only by a `sign_in` step-up callback or `POST /v1/apps/{appSlug}/step-up`, never by a refresh
- `step_up_method` optional: present exactly when `step_up_at` is; `provider:<name>` for a provider step-up sign-in, otherwise the method label the app asserted
- `display_name` optional
- `app.slug`
- `app.profile_version`

View file

@ -56,6 +56,15 @@ export interface AppProfile {
* state. Absent for apps whose browser starts are public by design.
*/
startRequiresAppCaller?: boolean;
/**
* The app may assert, with its secret and a member's live bearer, that the
* member just passed a factor the app itself checked (POST
* /v1/apps/{app}/step-up). Heimdall cannot see that factor and trusts the
* app, so the grant is explicit per app and closed per method: only these
* labels may be asserted, and they end up in the claim's step_up_method.
* Absent for every app that may not assert.
*/
stepUpAssertion?: { methods: readonly string[] };
}
/** The membership signal shared by every app that gates on GameCult membership. */
@ -154,6 +163,7 @@ const bifrostProfile: AppProfile = {
managedConnectionProviders: [],
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
startRequiresAppCaller: true,
stepUpAssertion: { methods: ["bifrost-passkey", "bifrost-email-code"] },
capabilities: [
{
key: "member_access",

View file

@ -1,4 +1,4 @@
import { createHmac, randomUUID } from "node:crypto";
import { createHash, createHmac, randomUUID } from "node:crypto";
import { deflateRawSync, inflateRawSync } from "node:zlib";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
@ -24,7 +24,8 @@ import {
type OAuthStartRequest,
type Provider,
} from "./contracts.js";
import { issueAccessClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js";
import { issueAccessClaim, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js";
import { FixedWindowBudget, type FixedWindowLimit } from "./fixed-window.js";
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint, isUsableConnectionTarget } from "./app-profiles.js";
import { renderBrowserHandoffPage } from "./browser-handoff.js";
import { consentText, digestOfText, grantViews, GRANTS_PATH, renderConsentPage, renderGrantsPage, type ConsentAsk } from "./consent.js";
@ -883,6 +884,8 @@ export async function startOAuthFlow(
interface ResolvedBearer {
accountId: string;
claims: Record<string, unknown>;
/** When the session row the bearer rests on expires (the refresh horizon, not the access token's `exp`). */
sessionExpiresAt: string;
}
/**
@ -927,7 +930,7 @@ async function resolveBearer(
) {
return null;
}
return { accountId: claims.account_id, claims };
return { accountId: claims.account_id, claims, sessionExpiresAt: session.expiresAt };
}
/** The account a link start binds to: the one a live Heimdall access token for this app names. A link is explicit or it does not happen. */
@ -947,6 +950,120 @@ async function resolveAccessAccount(
return (await resolveBearer(ctx, authorization))?.accountId ?? null;
}
/** What an app may spend asserting step-up proofs: per member account, and per calling app. */
export const STEP_UP_ASSERT_LIMITS: { account: FixedWindowLimit; app: FixedWindowLimit; notPermitted: FixedWindowLimit } = {
account: { max: 10, windowMs: 600_000 },
app: { max: 120, windowMs: 60_000 },
// An app whose profile has no grant, holding its own secret: a handful of refusals per window, no more.
notPermitted: { max: 5, windowMs: 60_000 },
};
export interface StepUpAssertBudgets {
account: FixedWindowBudget;
app: FixedWindowBudget;
notPermitted: FixedWindowBudget;
}
export function createStepUpAssertBudgets(now: () => number = () => Date.now()): StepUpAssertBudgets {
return {
account: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.account, now),
app: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.app, now),
notPermitted: new FixedWindowBudget(STEP_UP_ASSERT_LIMITS.notPermitted, now),
};
}
/**
* POST /v1/apps/{app}/step-up. The app says its member just passed a factor
* the app checked; Heimdall cannot see the factor, so the word is bounded by
* what the route demands: the app's secret AND the member's live bearer for
* that same app, the profile's explicit grant and method allowlist, a nonce
* spent once per session, and a rate per account and per app. Every
* refusal after the secret verified is recorded, except that a flood is not:
* a rate-limited refusal is recorded once per window per budget (its first),
* and an app without the grant is recorded once per window; a request without
* the secret writes nothing, since anyone can send one. The audit rows carry a digest of
* the nonce, never the nonce, the bearer or the secret.
*/
export async function assertStepUp(
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
budgets: StepUpAssertBudgets,
appSlug: AppSlug,
headers: Record<string, string | string[] | undefined>,
input: { method: string; nonce: string }
): Promise<HandlerResult> {
const { config, keys, store } = ctx;
if (!resolveAppCaller(config, appSlug, headers)) {
return { statusCode: 401, body: { error: "app_auth_required" } };
}
const permitted = getAppProfile(appSlug)?.stepUpAssertion;
let known: { accountId: string; sessionId: string } | undefined;
let nonceDigest: string | undefined;
let tripped: string | undefined;
const refuse = async (statusCode: number, error: string, extra: Record<string, unknown> = {}, record = true): Promise<HandlerResult> => {
if (!record) return { statusCode, body: { error, ...extra } };
await store.createAuditEvent({
...(known ? { accountId: known.accountId, sessionId: known.sessionId } : {}),
appSlug,
eventType: "step_up_assertion_refused",
eventPayloadJson: { outcome: "refused", code: error, budget: tripped ?? null, method: permitted?.methods.includes(input.method) ? input.method : null, nonceDigest: nonceDigest ?? null },
createdAt: new Date().toISOString(),
});
return { statusCode, body: { error, ...extra } };
};
// Audit only the first denial of a window: the rows record that a budget tripped, not every request behind it.
const limited = (budget: string, spent: { n: number; retryAfterSeconds: number }, max: number) => {
tripped = budget;
return refuse(429, "step_up_rate_limited", { retryAfterSeconds: spent.retryAfterSeconds }, spent.n === max + 1);
};
if (!permitted) {
const spent = budgets.notPermitted.spend(appSlug);
if (spent.retryAfterSeconds) return limited("notPermitted", spent, STEP_UP_ASSERT_LIMITS.notPermitted.max);
return refuse(403, "step_up_assertion_not_permitted", { detail: `${appSlug} may not assert step-up proofs.` }, spent.n === 1);
}
const appSpent = budgets.app.spend(appSlug);
if (appSpent.retryAfterSeconds) return limited("app", appSpent, STEP_UP_ASSERT_LIMITS.app.max);
const bearer = await resolveBearer(ctx, headers.authorization as string | undefined);
if (!bearer) {
return refuse(401, "sign_in_required", { detail: "Send the member's live Heimdall access token as a Bearer token." });
}
const sessionId = bearer.claims.sid as string;
known = { accountId: bearer.accountId, sessionId };
if (bearer.claims.aud !== appSlug) {
return refuse(403, "bearer_app_mismatch", { detail: `The access token was not issued to ${appSlug}.` });
}
const accountSpent = budgets.account.spend(bearer.accountId);
if (accountSpent.retryAfterSeconds) return limited("account", accountSpent, STEP_UP_ASSERT_LIMITS.account.max);
nonceDigest = createHash("sha256").update(`heimdall-step-up-nonce\0${sessionId}\0${input.nonce}`).digest("hex");
if (!permitted.methods.includes(input.method)) {
return refuse(400, "unknown_step_up_method", { methods: permitted.methods });
}
const at = new Date().toISOString();
if (!(await store.claimOAuthStateUse({ jti: nonceDigest, phase: "step_up_assert", appSlug, usedAt: at, expiresAt: bearer.sessionExpiresAt }))) {
return refuse(409, "step_up_nonce_replayed", { detail: "A nonce is good once per session; send a fresh one." });
}
const minted = signAssertedStepUpClaim({ keys, bearerClaims: bearer.claims, method: input.method });
await store.createAuditEvent({
accountId: bearer.accountId,
sessionId,
appSlug,
eventType: "step_up_asserted",
eventPayloadJson: { outcome: "asserted", code: null, method: input.method, nonceDigest, stepUpAt: minted.stepUpAt },
createdAt: at,
});
return {
statusCode: 201,
body: {
accessToken: minted.accessToken,
claimSet: minted.claimSet,
stepUp: { at: minted.stepUpAt, method: input.method, validForSeconds: STEP_UP_PROOF_TTL_SECONDS },
},
};
}
const grantsPageTokenType = "heimdall_grants_page";
const grantsPageTokenTtlSeconds = 600;
@ -1925,7 +2042,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
facts: [...new Set([...entitlementEvaluation.facts, ...linkedFacts]), ...buildGrantFacts(grants)],
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
// Proof of a fresh re-authentication, from the signed state alone: only a sign_in the app started with stepUp.
...(statePayload.step_up && statePayload.mode === "sign_in" ? { stepUpAt: nowEpochSeconds() } : {}),
...(statePayload.step_up && statePayload.mode === "sign_in" ? { stepUpAt: nowEpochSeconds(), stepUpMethod: `provider:${statePayload.provider}` } : {}),
};
if (account.displayName !== undefined) {
issueInput.displayName = account.displayName;
@ -2435,6 +2552,37 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
);
// An app asserting that a member just passed a factor it checked: see assertStepUp.
const stepUpBudgets = createStepUpAssertBudgets();
app.post<{ Params: { appSlug: AppSlug }; Body: { method: string; nonce: string } }>(
"/v1/apps/:appSlug/step-up",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: { appSlug: { type: "string", enum: [...appSlugs] } },
},
body: {
type: "object",
required: ["method", "nonce"],
additionalProperties: false,
properties: {
method: { type: "string", minLength: 1, maxLength: 64 },
// base64url alphabet: what a random token looks like, and no spaces, controls or lone surrogates.
nonce: { type: "string", pattern: "^[A-Za-z0-9_-]{16,128}$" },
},
},
},
},
async (request, reply) => {
const result = await assertStepUp({ config, keys, store }, stepUpBudgets, request.params.appSlug, request.headers, request.body);
reply.code(result.statusCode).header("cache-control", "no-store");
return result.body;
}
);
app.post<{
Params: { appSlug: AppSlug };
Body: {
@ -2785,7 +2933,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
await store.createAuditEvent({
accountId,
eventType: "identity_unlinked",
eventPayloadJson: { provider: identity.provider, identityId: identity.id },
eventPayloadJson: { provider: identity.provider, identityId: identity.id, stepUpMethod: typeof claims.step_up_method === "string" ? claims.step_up_method : null },
createdAt: at,
});
// The credential went with the row. When no identity of that provider

View file

@ -53,6 +53,8 @@ export interface IssueAccessClaimInput {
ttlSeconds?: number;
/** Epoch seconds of a step-up sign-in completing; only that callback sets it, a refresh never does. */
stepUpAt?: number;
/** Required with stepUpAt (`provider:<name>`); the claim never carries one without the other. */
stepUpMethod?: string;
}
export interface IssuedAccessClaimResult {
@ -137,6 +139,9 @@ export async function issueAccessClaim(options: {
if (options.input.stepUpAt !== undefined) {
claimSet.step_up_at = options.input.stepUpAt;
if (options.input.stepUpMethod !== undefined) {
claimSet.step_up_method = options.input.stepUpMethod;
}
}
if (options.input.displayName) {
@ -200,3 +205,27 @@ export async function issueAccessClaim(options: {
: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
};
}
/**
* A new access token for the session the bearer already holds, carrying proof
* the calling app asserted. Everything the bearer claimed is copied and
* nothing is recomputed, so the route can add proof and no capability; `exp`
* stays the bearer's, so it cannot extend a lifetime; no refresh token is
* minted and no session row is written, so it cannot extend the session.
*/
export function signAssertedStepUpClaim(options: {
keys: RuntimeKeyMaterial;
bearerClaims: Record<string, unknown>;
method: string;
}): { accessToken: string; claimSet: Record<string, unknown>; stepUpAt: number } {
const stepUpAt = nowEpochSeconds();
const claimSet = {
...options.bearerClaims,
jti: randomUUID(),
iat: stepUpAt,
nbf: stepUpAt,
step_up_at: stepUpAt,
step_up_method: options.method,
};
return { accessToken: signJwt(claimSet, options.keys), claimSet, stepUpAt };
}

View file

@ -155,6 +155,13 @@ export interface AccessClaimPayload {
access_revision: number;
/** Epoch seconds of the step-up sign-in this access token was minted by; absent on every other token, and never carried by a refresh. */
step_up_at?: number;
/**
* How the proof in step_up_at was made: `provider:<name>` for a step-up
* sign-in Heimdall ran, or one of the calling app's allowlisted method
* labels when the app asserted it (POST /v1/apps/{app}/step-up). Present
* exactly when step_up_at is; never carried by a refresh.
*/
step_up_method?: string;
display_name?: string;
app: {
slug: AppSlug;

49
src/fixed-window.ts Normal file
View file

@ -0,0 +1,49 @@
// A fixed-window attempt budget per key, held in memory by the process that
// serves the route. It bounds a credentialed caller's rate; it is not a
// security boundary on its own and resets on restart. The clock is injected
// so tests can move it.
export interface FixedWindowLimit {
max: number;
windowMs: number;
}
export class FixedWindowBudget {
private readonly windows = new Map<string, { startedAt: number; used: number }>();
constructor(
private readonly limit: FixedWindowLimit,
private readonly now: () => number = Date.now,
private readonly maxKeys = 4096,
) {}
/**
* Spend one attempt for `key`. `n` counts attempts in the window, denied ones
* included, so a caller can act on the first denial (`n === max + 1`) and not
* on the flood behind it. `retryAfterSeconds` is 0 when allowed.
*/
spend(key: string): { n: number; retryAfterSeconds: number } {
const at = this.now();
let current = this.windows.get(key);
if (!current || at - current.startedAt >= this.limit.windowMs) {
this.sweep(at);
current = { startedAt: at, used: 0 };
this.windows.set(key, current);
}
current.used += 1;
if (current.used <= this.limit.max) return { n: current.used, retryAfterSeconds: 0 };
return { n: current.used, retryAfterSeconds: Math.max(1, Math.ceil((current.startedAt + this.limit.windowMs - at) / 1000)) };
}
/** Drop turned-over windows once the table is full; if it is still full, the oldest key goes. */
private sweep(at: number): void {
if (this.windows.size < this.maxKeys) return;
for (const [key, window] of this.windows) {
if (at - window.startedAt >= this.limit.windowMs) this.windows.delete(key);
}
if (this.windows.size >= this.maxKeys) {
const oldest = this.windows.keys().next().value;
if (oldest !== undefined) this.windows.delete(oldest);
}
}
}

View file

@ -209,7 +209,8 @@ export function oauthStatePruneCutoffMs(nowMs: number): number {
export interface ClaimOAuthStateUseInput {
jti: string;
phase: "launch" | "callback";
/** `step_up_assert` is the app-asserted step-up route's one-use nonce claim: jti is a digest of session and nonce. */
phase: "launch" | "callback" | "step_up_assert";
appSlug: AppSlug;
usedAt: string;
expiresAt: string;

View file

@ -39,3 +39,4 @@
{"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."}
{"ts": "2026-11-12T23:10:15+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-12: sign_in start accepts stepUp (app-authenticated, sign_in only, off by default); signed state carries step_up; launch adds Twitch force_verify=true, YouTube prompt='login consent', Google prompt=login; any other provider is 400 step_up_not_supported; completion reports stepUpRequested. Not deployed. For joystick:follow_up:chat-s7055-7056 (StreamPixels link hardening, second factor via fresh sign-in)."}
{"ts": "2026-11-13T19:10:00+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: GET /v1/me/identities lists an account's identities (no credentials, profile or email); POST /v1/me/identities/:id/unlink deletes one with a step-up proof (access claim step_up_at, 600 s, never carried by refresh), refuses the last identity (409), writes revocation receipts when the provider's last identity goes. upsertLinkedIdentity no longer moves an identity between accounts (IdentityHeldElsewhereError; links/confirm 409 identity_linked_elsewhere, callback link_initiator_mismatch). The store role needs DELETE on linked_identities (startup refuses without it): owner-role GRANT at deploy."}
{"ts": "2026-11-13T20:01:42+00:00", "type": "design", "status": "accepted", "note": "Changelog 2026-11-13: POST /v1/apps/{app}/step-up (profile grant stepUpAssertion, bifrost only; methods bifrost-passkey, bifrost-email-code). App secret plus the member's live bearer for that app; mints a same-session access token with step_up_at now and new claim step_up_method (exp kept, no refresh, no session write); nonce single-use via oauth_state_uses phase step_up_assert (no schema change), retained to the session expiry as of the claim (a refresh may slide the session past it); in-process rate limits 10 per 600 s per account, 120 per 60 s per app; audit step_up_asserted and step_up_assertion_refused with a nonce digest (429s and not-permitted refusals audited once per window; not-permitted has a 5 per 60 s per-app budget); nonce is base64url 16-128. Provider step-up sign-ins now carry step_up_method provider:<name>; identity_unlinked records stepUpMethod. Heimdall cannot verify the factor: it trusts the calling app. Not deployed; no env, secret or migration. For heimdall-body:follow_up:chat-s7419-7419.", "branch": "heimdall/app-asserted-step-up"}

View file

@ -118,11 +118,15 @@ describe("step_up_at on the access claim", () => {
const stepped = await signIn(r, true);
expect(stepped.claimSet.step_up_at).toBeGreaterThanOrEqual(before);
expect(stepped.claimSet.step_up_at).toBeLessThanOrEqual(Math.floor(Date.now() / 1000));
expect((await signIn(r, false)).claimSet.step_up_at).toBeUndefined();
expect(stepped.claimSet.step_up_method).toBe("provider:twitch");
const plain = (await signIn(r, false)).claimSet;
expect(plain.step_up_at).toBeUndefined();
expect(plain.step_up_method).toBeUndefined();
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/streampixels/sessions/refresh", payload: { refreshToken: stepped.refreshToken } });
expect(refreshed.statusCode).toBe(201);
expect(refreshed.json().claimSet.step_up_at, "a refresh never renews proof").toBeUndefined();
expect(refreshed.json().claimSet.step_up_method).toBeUndefined();
});
});
@ -211,7 +215,7 @@ describe("POST /v1/me/identities/:identityId/unlink", () => {
expect(await r.store.listStoredLinkedIdentitiesForAccount(account.id)).toHaveLength(1);
const event = audit.mock.calls.map(([e]) => e).find((e) => e.eventType === "identity_unlinked");
expect(event?.accountId).toBe(account.id);
expect(event?.eventPayloadJson).toEqual({ provider: "discord", identityId: rows[1]!.id });
expect(event?.eventPayloadJson).toEqual({ provider: "discord", identityId: rows[1]!.id, stepUpMethod: "provider:twitch" });
});
it("the account's only identity is 409 last_identity and the row stays", async () => {

View file

@ -0,0 +1,435 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { buildApp, STEP_UP_ASSERT_LIMITS } from "../src/app.js";
import { builtInAppProfiles } from "../src/app-profiles.js";
import { issueAccessClaim } from "../src/claims.js";
import { type Provider } from "../src/contracts.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "../src/signing.js";
import { InMemoryStore } from "../src/store/index.js";
import { type CreateAuditEventInput } from "../src/store/types.js";
import { createTestConfig } from "./oauth-flow.js";
// An app asserting a step-up proof for its member: POST /v1/apps/{app}/step-up.
// Driven over HTTP through the real Fastify app, the way Bifrost would call it,
// with the unlink route as the consumer of the proof it mints.
const now = "2026-11-13T12:00:00.000Z";
const secret = "bifrost-secret";
const appAuth = { "x-heimdall-app-secret": secret };
const nonce = (tag: string) => `nonce-${tag.replace(/[^A-Za-z0-9_-]/g, "_")}-`.padEnd(32, "x");
const dir = mkdtempSync(join(tmpdir(), "heimdall-stepup-assert-"));
const config = { ...createTestConfig(), signingPrivateKeyPath: join(dir, "signing.pem"), bootstrapSigningPrivateKeyOnMissing: true };
afterAll(() => rmSync(dir, { recursive: true, force: true }));
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
await Promise.all(apps.splice(0).map((app) => app.close()));
});
async function rig() {
const store = new InMemoryStore();
const audit: CreateAuditEventInput[] = [];
const realAudit = store.createAuditEvent.bind(store);
store.createAuditEvent = async (input) => {
audit.push(structuredClone(input));
return realAudit(input);
};
const app = await buildApp({ config, store });
apps.push(app);
const keys = createRuntimeKeyMaterial({ ...config, bootstrapSigningPrivateKeyOnMissing: false });
return { app, store, keys, audit };
}
type Rig = Awaited<ReturnType<typeof rig>>;
/** A member with the given identities and a live Heimdall session for `appSlug`, as a sign-in would have left them. */
let seq = 0;
async function member(r: Rig, appSlug = "bifrost", held?: Array<[Provider, string]>, accountId?: string) {
seq += 1;
held ??= [["discord", `d-${seq}`], ["google", `g-${seq}`]];
const account = accountId ? { id: accountId } : await r.store.createAccount({ createdAt: now, lastSeenAt: now });
const rows = [];
if (!accountId) {
for (const [provider, providerUserId] of held) {
rows.push(await r.store.upsertLinkedIdentity({ accountId: account.id, provider, providerUserId, scopes: [], profileJson: {}, createdAt: now, updatedAt: now }));
}
}
const issued = await issueAccessClaim({
config,
keys: r.keys,
store: r.store,
input: { appSlug, accountId: account.id, linkedIdentities: held.map(([provider, providerUserId]) => ({ provider, providerUserId })), facts: [] },
});
return { account, rows, issued, token: issued.accessToken, sid: issued.session.sessionId };
}
const bearer = (token?: string) => (token ? { authorization: `Bearer ${token}` } : {});
const assertStepUp = (r: Rig, o: { app?: string; headers?: Record<string, string>; token?: string | undefined; method?: string; nonce?: string; payload?: object } = {}) =>
r.app.inject({
method: "POST",
url: `/v1/apps/${o.app ?? "bifrost"}/step-up`,
headers: { ...(o.headers ?? appAuth), ...bearer(o.token) },
payload: o.payload ?? { method: o.method ?? "bifrost-passkey", nonce: o.nonce ?? nonce("a") },
});
const unlink = (r: Rig, id: string, token: string) =>
r.app.inject({ method: "POST", url: `/v1/me/identities/${encodeURIComponent(id)}/unlink`, headers: bearer(token) });
const refused = (res: { statusCode: number; json(): { error?: string } }) => [res.statusCode, res.json().error];
const claimsOf = (token: string, r: Rig) => {
const v = verifyJwt(token, r.keys.publicKey);
expect(v.valid).toBe(true);
return v.valid ? v.payload : {};
};
const nowS = () => Math.floor(Date.now() / 1000);
const auditOf = (r: Rig, type: string) => r.audit.filter((e) => e.eventType === type);
describe("the proof it mints", () => {
it("returns a new access token for the same session and account, with step_up_at now and the method, and no refresh token", async () => {
const r = await rig();
const m = await member(r);
const before = nowS();
const res = await assertStepUp(r, { token: m.token });
expect(res.statusCode).toBe(201);
expect(res.headers["cache-control"]).toBe("no-store");
const body = res.json();
expect(Object.keys(body).sort()).toEqual(["accessToken", "claimSet", "stepUp"]);
expect(res.body).not.toContain(m.issued.refreshToken);
const original = claimsOf(m.token, r);
const minted = claimsOf(body.accessToken, r);
expect(minted.step_up_method).toBe("bifrost-passkey");
expect(minted.step_up_at).toBeGreaterThanOrEqual(before);
expect(minted.step_up_at).toBeLessThanOrEqual(nowS());
expect(body.claimSet).toEqual(minted);
expect(body.stepUp).toEqual({ at: minted.step_up_at, method: "bifrost-passkey", validForSeconds: 600 });
expect(minted.jti).not.toBe(original.jti);
// Everything else is the bearer's own: it adds proof, not capability, and does not move the session.
const { jti, iat, nbf, step_up_at, step_up_method, ...rest } = minted;
const { jti: _j, iat: _i, nbf: _n, ...originalRest } = original;
expect(rest).toEqual(originalRest);
expect([jti, iat, nbf, step_up_at, step_up_method].every((v) => v !== undefined)).toBe(true);
expect(minted.sid).toBe(m.sid);
expect(minted.account_id).toBe(m.account.id);
expect(minted.aud).toBe("bifrost");
});
it("the unlink check passes with the returned bearer and fails 403 step_up_required with the original", async () => {
const r = await rig();
const m = await member(r);
expect(refused(await unlink(r, m.rows[1]!.id, m.token))).toEqual([403, "step_up_required"]);
const minted = (await assertStepUp(r, { token: m.token })).json().accessToken;
expect((await unlink(r, m.rows[1]!.id, m.token)).statusCode, "the original bearer gains nothing").toBe(403);
const done = await unlink(r, m.rows[1]!.id, minted);
expect(done.statusCode).toBe(200);
expect(done.json().unlinked.id).toBe(m.rows[1]!.id);
const unlinkEvent = auditOf(r, "identity_unlinked")[0]!;
expect(unlinkEvent.eventPayloadJson).toMatchObject({ provider: "google", stepUpMethod: "bifrost-passkey" });
});
it("does not extend the lifetime: exp stays the bearer's, the session row is untouched, no refresh is minted", async () => {
const r = await rig();
const m = await member(r);
const sessionBefore = await r.store.findSession("bifrost", m.sid);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(Date.now() + 1_500_000);
const res = await assertStepUp(r, { token: m.token });
expect(res.statusCode).toBe(201);
const original = claimsOf(m.token, r);
const minted = claimsOf(res.json().accessToken, r);
expect(minted.exp).toBe(original.exp);
expect(minted.iat).toBeGreaterThan(original.iat as number);
expect(await r.store.findSession("bifrost", m.sid)).toEqual(sessionBefore);
});
it("a refresh after an assertion carries neither step_up_at nor step_up_method", async () => {
const r = await rig();
const m = await member(r);
expect((await assertStepUp(r, { token: m.token })).statusCode).toBe(201);
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/bifrost/sessions/refresh", payload: { refreshToken: m.issued.refreshToken } });
expect(refreshed.statusCode).toBe(201);
const claims = claimsOf(refreshed.json().accessToken, r);
expect(claims).not.toHaveProperty("step_up_at");
expect(claims).not.toHaveProperty("step_up_method");
expect(refreshed.json().claimSet).not.toHaveProperty("step_up_at");
expect(refreshed.json().claimSet).not.toHaveProperty("step_up_method");
});
it("the proof is good for 599 s and not for 601 s, as for any step-up", async () => {
const r = await rig();
const m = await member(r, "bifrost", [["discord", "d-w"], ["google", "g-w"], ["patreon", "p-w"]]);
vi.useFakeTimers({ toFake: ["Date"] });
const minted = (await assertStepUp(r, { token: m.token })).json().accessToken;
vi.setSystemTime(Date.now() + 601_000);
expect(refused(await unlink(r, m.rows[1]!.id, minted))).toEqual([403, "step_up_required"]);
vi.setSystemTime(Date.now() - 2_000);
expect((await unlink(r, m.rows[1]!.id, minted)).statusCode).toBe(200);
});
it("the provider step-up callback labels its proof provider:<name>", async () => {
// The sign_in path itself is proved in identity-unlink.test.ts; here the claim input is the contract.
const r = await rig();
const m = await member(r);
const issued = await issueAccessClaim({
config, keys: r.keys, store: r.store,
input: { appSlug: "bifrost", accountId: m.account.id, linkedIdentities: [], facts: [], stepUpAt: nowS(), stepUpMethod: "provider:google" },
});
expect(issued.claimSet.step_up_method).toBe("provider:google");
});
});
describe("it is never issuable without both credentials", () => {
it("401 app_auth_required for a missing, wrong, or another app's secret, even with a good bearer; nothing is written", async () => {
const r = await rig();
const m = await member(r);
for (const headers of [{}, { "x-heimdall-app-secret": "wrong" }, { "x-heimdall-app-secret": "streampixels-secret" }, { "x-heimdall-app-secret": "" }]) {
const res = await assertStepUp(r, { headers, token: m.token });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: "app_auth_required" });
}
expect(r.audit.filter((e) => e.eventType.startsWith("step_up"))).toEqual([]);
});
it("401 sign_in_required for a missing, garbage, expired, foreign-signed, revoked-session or unknown-session bearer; the same answer for each", async () => {
const r = await rig();
const m = await member(r);
const claims = claimsOf(m.token, r);
const past = nowS() - 10;
const other = createRuntimeKeyMaterial({ ...config, signingPrivateKeyPath: join(dir, "other.pem"), bootstrapSigningPrivateKeyOnMissing: true });
const bad: Array<[string, string | undefined]> = [
["missing", undefined],
["garbage", "not-a-token"],
["expired", signJwt({ ...claims, iat: past - 100, nbf: past - 100, exp: past }, r.keys)],
["foreign key", signJwt(claims, other)],
["unknown session", signJwt({ ...claims, sid: "no-such-session" }, r.keys)],
["wrong account for the session", signJwt({ ...claims, account_id: "someone-else" }, r.keys)],
["refresh token as bearer", m.issued.refreshToken],
];
for (const [label, token] of bad) {
const res = await assertStepUp(r, { token, nonce: nonce(label) });
expect([label, ...refused(res)]).toEqual([label, 401, "sign_in_required"]);
expect(res.body).not.toContain("account");
}
// A revoked session: the row is closed and its revision moved on.
expect(await r.store.revokeSession("bifrost", m.sid, m.account.id, 1, now)).not.toBeNull();
const revoked = await assertStepUp(r, { token: m.token, nonce: nonce("revoked") });
expect(refused(revoked)).toEqual([401, "sign_in_required"]);
expect(r.audit.some((e) => e.eventType === "step_up_asserted")).toBe(false);
});
it("a bearer issued to another app is 403 bearer_app_mismatch, never a proof, and says nothing about the account", async () => {
const r = await rig();
const sp = await member(r, "streampixels");
const res = await assertStepUp(r, { token: sp.token });
expect(refused(res)).toEqual([403, "bearer_app_mismatch"]);
expect(res.body).not.toContain(sp.account.id);
// A token for a session that does not exist is indistinguishable from garbage, whatever app it names.
const ghost = signJwt({ ...claimsOf(sp.token, r), sid: "ghost" }, r.keys);
expect(refused(await assertStepUp(r, { token: ghost }))).toEqual([401, "sign_in_required"]);
});
it("only a profile that grants it may assert: every other app is 403 with its own secret and a live bearer", async () => {
expect(Object.values(builtInAppProfiles).filter((p) => p.stepUpAssertion).map((p) => p.slug)).toEqual(["bifrost"]);
const r = await rig();
for (const [slug, appSecret] of [["streampixels", "streampixels-secret"], ["repixelizer", "repixelizer-secret"]] as const) {
const m = await member(r, slug);
const res = await assertStepUp(r, { app: slug, headers: { "x-heimdall-app-secret": appSecret }, token: m.token });
expect([slug, ...refused(res)]).toEqual([slug, 403, "step_up_assertion_not_permitted"]);
expect(res.json()).not.toHaveProperty("accessToken");
}
// Another app's secret on bifrost's route is still app_auth_required, not the flag.
const b = await member(r);
expect(refused(await assertStepUp(r, { headers: { "x-heimdall-app-secret": "streampixels-secret" }, token: b.token }))).toEqual([401, "app_auth_required"]);
});
});
describe("the method and the nonce", () => {
it("rejects a method outside the profile's closed set with 400, names the set, and does not spend the nonce", async () => {
const r = await rig();
const m = await member(r);
for (const method of ["passkey", "provider:google", "streampixels-passkey", "BIFROST-PASSKEY", "bifrost-passkey "]) {
const res = await assertStepUp(r, { token: m.token, method, nonce: nonce("m") });
expect([method, ...refused(res)]).toEqual([method, 400, "unknown_step_up_method"]);
expect(res.json().methods).toEqual(["bifrost-passkey", "bifrost-email-code"]);
}
expect((await assertStepUp(r, { token: m.token, method: "bifrost-email-code", nonce: nonce("m") })).statusCode).toBe(201);
});
it("malformed bodies are 400 before anything is minted", async () => {
const r = await rig();
const m = await member(r);
for (const payload of [{}, { method: "bifrost-passkey" }, { nonce: nonce("a") }, { method: "bifrost-passkey", nonce: "short" }, { method: "bifrost-passkey", nonce: "has a space in it, 16+ chars" }, { method: "bifrost-passkey", nonce: `${"a".repeat(20)}\ud800` }, { method: "bifrost-passkey", nonce: "a".repeat(129) }, { method: "bifrost-passkey", nonce: "ok-looking-nonce-é-ünï" }, { method: 7, nonce: nonce("a") }]) {
expect((await assertStepUp(r, { token: m.token, payload })).statusCode, JSON.stringify(payload)).toBe(400);
}
expect(auditOf(r, "step_up_asserted")).toEqual([]);
});
it("a nonce is single-use per session: the second try is 409, another session may use the same value", async () => {
const r = await rig();
const m = await member(r);
const second = await member(r, "bifrost", [], m.account.id);
expect((await assertStepUp(r, { token: m.token })).statusCode).toBe(201);
const replay = await assertStepUp(r, { token: m.token });
expect(refused(replay)).toEqual([409, "step_up_nonce_replayed"]);
expect(replay.json()).not.toHaveProperty("accessToken");
// The replay does not depend on which method rides with it.
expect(refused(await assertStepUp(r, { token: m.token, method: "bifrost-email-code" }))).toEqual([409, "step_up_nonce_replayed"]);
expect((await assertStepUp(r, { token: second.token })).statusCode).toBe(201);
});
it("a nonce is retained to the session's expiry as of the claim plus the 10.5 min prune horizon; a refresh slides the session past that and the nonce can be reused (documented limit)", async () => {
const r = await rig();
const m = await member(r);
vi.useFakeTimers({ toFake: ["Date"] });
const t0 = Date.now();
const R = config.refreshTtlSeconds * 1000;
expect((await assertStepUp(r, { token: m.token, nonce: nonce("N") })).statusCode).toBe(201);
vi.setSystemTime(t0 + R - 60_000);
const refreshed = await r.app.inject({ method: "POST", url: "/v1/apps/bifrost/sessions/refresh", payload: { refreshToken: m.issued.refreshToken } });
expect(refreshed.statusCode).toBe(201);
const token = refreshed.json().accessToken as string;
expect(refused(await assertStepUp(r, { token, nonce: nonce("N") })).join()).toBe("409,step_up_nonce_replayed");
// 10 min past the old expiry: a claim runs the prune, and N is still inside the 10.5 min horizon (10 min margin + 30 s skew).
vi.setSystemTime(t0 + R + 10 * 60_000);
expect((await assertStepUp(r, { token, nonce: nonce("M") })).statusCode).toBe(201);
expect(refused(await assertStepUp(r, { token, nonce: nonce("N") }))).toEqual([409, "step_up_nonce_replayed"]);
// Past the horizon the next claim prunes N (lazily, with the claim) while the session lives on.
vi.setSystemTime(t0 + R + 10 * 60_000 + 31_000 + 2_000);
expect((await assertStepUp(r, { token, nonce: nonce("K") })).statusCode).toBe(201);
expect((await assertStepUp(r, { token, nonce: nonce("N") })).statusCode).toBe(201);
});
it("a nonce stays spent for the session's life, past the access token's own expiry, and across a restart of the process", async () => {
const r = await rig();
const m = await member(r);
expect((await assertStepUp(r, { token: m.token })).statusCode).toBe(201);
// A second app instance over the same store has no memory of the first: the store carries the claim.
const again = await buildApp({ config, store: r.store });
apps.push(again);
const res = await again.inject({ method: "POST", url: "/v1/apps/bifrost/step-up", headers: { ...appAuth, ...bearer(m.token) }, payload: { method: "bifrost-passkey", nonce: nonce("a") } });
expect(refused(res)).toEqual([409, "step_up_nonce_replayed"]);
});
});
describe("rate limits", () => {
it("trips per account, leaves other accounts alone, and turns over with the window", async () => {
const r = await rig();
const m = await member(r);
const other = await member(r);
vi.useFakeTimers({ toFake: ["Date"] });
for (let i = 0; i < STEP_UP_ASSERT_LIMITS.account.max; i += 1) {
expect((await assertStepUp(r, { token: m.token, nonce: nonce(`r${i}`) })).statusCode, `attempt ${i}`).toBe(201);
}
const tripped = await assertStepUp(r, { token: m.token, nonce: nonce("over") });
expect(refused(tripped)).toEqual([429, "step_up_rate_limited"]);
expect(tripped.json().retryAfterSeconds).toBeGreaterThan(0);
expect(tripped.json()).not.toHaveProperty("accessToken");
expect((await assertStepUp(r, { token: other.token })).statusCode).toBe(201);
vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.account.windowMs + 1000);
expect((await assertStepUp(r, { token: m.token, nonce: nonce("after") })).statusCode).toBe(201);
// The tripped attempt did not spend its nonce.
expect((await assertStepUp(r, { token: m.token, nonce: nonce("over") })).statusCode).toBe(201);
});
it("trips per app across accounts, counting attempts that failed their bearer", async () => {
const r = await rig();
const m = await member(r);
vi.useFakeTimers({ toFake: ["Date"] });
for (let i = 0; i < STEP_UP_ASSERT_LIMITS.app.max; i += 1) {
expect((await assertStepUp(r, { token: "garbage", nonce: nonce(`g${i}`) })).statusCode).toBe(401);
}
expect(refused(await assertStepUp(r, { token: m.token }))).toEqual([429, "step_up_rate_limited"]);
vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.app.windowMs + 1000);
expect((await assertStepUp(r, { token: m.token })).statusCode).toBe(201);
});
});
describe("flood handling", () => {
it("the not-permitted budget is per app: one app's flood neither blocks bifrost's own call nor another ungranted app's first refusal", async () => {
const r = await rig();
const sp = await member(r, "streampixels");
const rp = await member(r, "repixelizer");
const b = await member(r);
const as = (app: string, appSecret: string, token: string, i: number) => assertStepUp(r, { app, headers: { "x-heimdall-app-secret": appSecret }, token, nonce: nonce(`i${i}`) });
for (let i = 0; i < 50; i += 1) await as("streampixels", "streampixels-secret", sp.token, i);
expect((await as("streampixels", "streampixels-secret", sp.token, 99)).statusCode).toBe(429);
expect(refused(await as("repixelizer", "repixelizer-secret", rp.token, 1))).toEqual([403, "step_up_assertion_not_permitted"]);
expect((await assertStepUp(r, { token: b.token })).statusCode).toBe(201);
});
it("a flood is not an audit flood: an app without the grant is audited once per window, a tripped budget once per window", async () => {
const r = await rig();
const sp = await member(r, "streampixels");
vi.useFakeTimers({ toFake: ["Date"] });
const codes: Record<number, number> = {};
for (let i = 0; i < 500; i += 1) {
const res = await assertStepUp(r, { app: "streampixels", headers: { "x-heimdall-app-secret": "streampixels-secret" }, token: sp.token, nonce: nonce(`f${i}`) });
codes[res.statusCode] = (codes[res.statusCode] ?? 0) + 1;
}
expect(codes).toEqual({ 403: STEP_UP_ASSERT_LIMITS.notPermitted.max, 429: 500 - STEP_UP_ASSERT_LIMITS.notPermitted.max });
const rows = (code: string) => auditOf(r, "step_up_assertion_refused").filter((e) => e.eventPayloadJson.code === code);
expect(rows("step_up_assertion_not_permitted")).toHaveLength(1);
expect(rows("step_up_rate_limited")).toHaveLength(1);
expect(rows("step_up_rate_limited")[0]!.eventPayloadJson.budget).toBe("notPermitted");
vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.notPermitted.windowMs + 1000);
await assertStepUp(r, { app: "streampixels", headers: { "x-heimdall-app-secret": "streampixels-secret" }, token: sp.token });
expect(rows("step_up_assertion_not_permitted")).toHaveLength(2);
// The same for the granted app's per-app and per-account budgets.
const m = await member(r);
const before = r.audit.length;
for (let i = 0; i < 300; i += 1) await assertStepUp(r, { token: "garbage", nonce: nonce(`g${i}`) });
expect(r.audit.length - before, "garbage-bearer refusals up to the budget, then one row for the trip").toBe(STEP_UP_ASSERT_LIMITS.app.max + 1);
vi.setSystemTime(Date.now() + STEP_UP_ASSERT_LIMITS.app.windowMs + 1000);
const mark = r.audit.length;
for (let i = 0; i < 30; i += 1) await assertStepUp(r, { token: m.token, nonce: nonce(`a${i}`) });
const accountRows = r.audit.slice(mark);
expect(accountRows.filter((e) => e.eventType === "step_up_asserted")).toHaveLength(STEP_UP_ASSERT_LIMITS.account.max);
const trips = accountRows.filter((e) => e.eventPayloadJson.code === "step_up_rate_limited");
expect(trips.map((e) => e.eventPayloadJson.budget)).toEqual(["account"]);
});
});
describe("receipts", () => {
it("records every assertion and every post-secret refusal with account, app, session, method, outcome and time, and nothing secret", async () => {
const r = await rig();
const m = await member(r);
const sp = await member(r, "streampixels");
const printed: string[] = [];
for (const channel of ["log", "info", "warn", "error", "debug"] as const) vi.spyOn(console, channel).mockImplementation((...a) => void printed.push(a.join(" ")));
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => (printed.push(String(chunk)), true));
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => (printed.push(String(chunk)), true));
const nonceOk = nonce("ok-CANARY");
const nonceBadMethod = nonce("bad-CANARY");
const responses = [
await assertStepUp(r, { token: m.token, nonce: nonceOk }),
await assertStepUp(r, { token: m.token, nonce: nonceOk }),
await assertStepUp(r, { token: m.token, method: "evil-CANARY-method", nonce: nonceBadMethod }),
await assertStepUp(r, { token: sp.token, nonce: nonce("mismatch-CANARY") }),
await assertStepUp(r, { token: "garbage-CANARY", nonce: nonce("anon-CANARY") }),
];
expect(responses.map((x) => x.statusCode)).toEqual([201, 409, 400, 403, 401]);
const ok = auditOf(r, "step_up_asserted");
expect(ok).toHaveLength(1);
expect(ok[0]).toMatchObject({ accountId: m.account.id, sessionId: m.sid, appSlug: "bifrost", eventPayloadJson: { outcome: "asserted", method: "bifrost-passkey" } });
expect(Date.parse(ok[0]!.createdAt)).not.toBeNaN();
const refusals = auditOf(r, "step_up_assertion_refused");
expect(refusals.map((e) => e.eventPayloadJson.code)).toEqual(["step_up_nonce_replayed", "unknown_step_up_method", "bearer_app_mismatch", "sign_in_required"]);
expect(refusals.map((e) => e.accountId)).toEqual([m.account.id, m.account.id, sp.account.id, undefined]);
expect(refusals[1]!.eventPayloadJson.method).toBeNull();
expect(refusals.every((e) => e.appSlug === "bifrost" && e.eventPayloadJson.outcome === "refused")).toBe(true);
for (const e of [...ok, ...refusals.slice(0, 2)]) expect(e.eventPayloadJson.nonceDigest).toMatch(/^[0-9a-f]{64}$/);
const everything = JSON.stringify([r.audit, printed, responses.map((x) => x.body.replace(/"accessToken":"[^"]+"/, ""))]);
const minted = responses[0]!.json().accessToken as string;
const secrets = [secret, m.token, sp.token, m.issued.refreshToken, "CANARY"];
for (const needle of secrets) expect(everything, needle).not.toContain(needle);
expect(JSON.stringify(r.audit)).not.toContain(minted);
expect(printed.join("")).not.toContain(minted);
// A stored session row holds nothing the minted token adds.
expect(JSON.stringify(await r.store.findSession("bifrost", m.sid))).not.toContain("step_up");
});
});

View file

@ -879,6 +879,20 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD
expect(found.rowCount).toBe(1);
});
it("the step_up_assert nonce claim is single-use under the store's own role, and independent of the other phases", async () => {
const { name, owner } = await appliedDatabase();
await grant(owner, STORE_TABLE_PRIVILEGES);
const pool = fixturePool(urlFor(name, role, rolePassword));
cleanup.push(async () => {
await pool.end();
});
const store = new PostgresStore(pool);
const claim = { ...use("digest", boundary, boundary + 600_000), phase: "step_up_assert" as const, appSlug: "bifrost" };
expect(await store.claimOAuthStateUse(claim)).toBe(true);
expect(await store.claimOAuthStateUse(claim)).toBe(false);
expect(await store.claimOAuthStateUse({ ...claim, phase: "callback" })).toBe(true);
});
it("a role without DELETE still claims, and leaves the old records", async () => {
const { name, owner } = await appliedDatabase();
await grant(owner, { ...STORE_TABLE_PRIVILEGES, oauth_state_uses: ["SELECT", "INSERT"] });