From 33a83c34c424a08d37fd5921313c901cfbd8b057 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 19:59:26 +0000 Subject: [PATCH 1/4] App-asserted step-up: POST /v1/apps/{app}/step-up mints a same-session access token with step_up_at and step_up_method App secret plus the member's live bearer for that app, a per-profile grant and method allowlist (bifrost only), a nonce spent once per session through the existing oauth_state_uses claim (no schema change), an in-process fixed-window rate per account and per app, and an audit row per assertion or post-secret refusal. Provider step-up sign-ins now label their proof provider:. Co-Authored-By: Claude Sonnet 5.5 --- src/app-profiles.ts | 10 + src/app.ts | 140 +++++++++++- src/claims.ts | 29 +++ src/contracts.ts | 7 + src/fixed-window.ts | 47 ++++ src/store/types.ts | 3 +- tests/identity-unlink.test.ts | 8 +- tests/step-up-assertion.test.ts | 367 ++++++++++++++++++++++++++++++++ tests/store.test.ts | 14 ++ 9 files changed, 617 insertions(+), 8 deletions(-) create mode 100644 src/fixed-window.ts create mode 100644 tests/step-up-assertion.test.ts diff --git a/src/app-profiles.ts b/src/app-profiles.ts index c25dbb2..c1d5661 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -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", diff --git a/src/app.ts b/src/app.ts index 2eceba0..43ead5f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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; + /** 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,103 @@ 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 } = { + account: { max: 10, windowMs: 600_000 }, + app: { max: 120, windowMs: 60_000 }, +}; + +export interface StepUpAssertBudgets { + account: FixedWindowBudget; + app: 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) }; +} + +/** + * 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; 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, + budgets: StepUpAssertBudgets, + appSlug: AppSlug, + headers: Record, + input: { method: string; nonce: string } +): Promise { + 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; + const refuse = async (statusCode: number, error: string, extra: Record = {}): Promise => { + await store.createAuditEvent({ + ...(known ? { accountId: known.accountId, sessionId: known.sessionId } : {}), + appSlug, + eventType: "step_up_assertion_refused", + eventPayloadJson: { outcome: "refused", code: error, method: permitted?.methods.includes(input.method) ? input.method : null, nonceDigest: nonceDigest ?? null }, + createdAt: new Date().toISOString(), + }); + return { statusCode, body: { error, ...extra } }; + }; + const limited = (seconds: number) => refuse(429, "step_up_rate_limited", { retryAfterSeconds: seconds }); + + if (!permitted) { + return refuse(403, "step_up_assertion_not_permitted", { detail: `${appSlug} may not assert step-up proofs.` }); + } + const appWait = budgets.app.spend(appSlug); + if (appWait) return limited(appWait); + + 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 accountWait = budgets.account.spend(bearer.accountId); + if (accountWait) return limited(accountWait); + + 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 +2025,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + "/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 }, + nonce: { type: "string", minLength: 16, maxLength: 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 +2915,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise`); 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; + method: string; +}): { accessToken: string; claimSet: Record; 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 }; +} diff --git a/src/contracts.ts b/src/contracts.ts index 2d582f5..d7a521c 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -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:` 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; diff --git a/src/fixed-window.ts b/src/fixed-window.ts new file mode 100644 index 0000000..7110bf3 --- /dev/null +++ b/src/fixed-window.ts @@ -0,0 +1,47 @@ +// 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(); + + constructor( + private readonly limit: FixedWindowLimit, + private readonly now: () => number = Date.now, + private readonly maxKeys = 4096, + ) {} + + /** Spend one attempt for `key`. Returns 0 when allowed, else the seconds until the window turns over. */ + spend(key: string): number { + const at = this.now(); + const current = this.windows.get(key); + if (!current || at - current.startedAt >= this.limit.windowMs) { + this.sweep(at); + this.windows.set(key, { startedAt: at, used: 1 }); + return 0; + } + if (current.used >= this.limit.max) { + return Math.max(1, Math.ceil((current.startedAt + this.limit.windowMs - at) / 1000)); + } + current.used += 1; + return 0; + } + + /** 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); + } + } +} diff --git a/src/store/types.ts b/src/store/types.ts index a056f9f..c044161 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -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; diff --git a/tests/identity-unlink.test.ts b/tests/identity-unlink.test.ts index ae70b7d..e12e418 100644 --- a/tests/identity-unlink.test.ts +++ b/tests/identity-unlink.test.ts @@ -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 () => { diff --git a/tests/step-up-assertion.test.ts b/tests/step-up-assertion.test.ts new file mode 100644 index 0000000..e505829 --- /dev/null +++ b/tests/step-up-assertion.test.ts @@ -0,0 +1,367 @@ +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}-`.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>[] = []; +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>; + +/** 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; 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:", 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: 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 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("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"); + }); +}); diff --git a/tests/store.test.ts b/tests/store.test.ts index 76665cc..0e4dcac 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -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"] }); -- 2.47.3 From 2f93809a2970d913322619c73f7dd1818a43c839 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 20:01:46 +0000 Subject: [PATCH 2/4] Docs: app-asserted step-up route, step_up_method claim, Bifrost profile grant, architecture note, evidence line Co-Authored-By: Claude Sonnet 5.5 --- docs/app-profiles/bifrost.md | 12 ++++++ docs/architecture.md | 12 ++++++ docs/service-contract.md | 77 +++++++++++++++++++++++++++++++++++- state/evidence.jsonl | 1 + 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/docs/app-profiles/bifrost.md b/docs/app-profiles/bifrost.md index 976452f..a174d50 100644 --- a/docs/app-profiles/bifrost.md +++ b/docs/app-profiles/bifrost.md @@ -77,3 +77,15 @@ 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`. diff --git a/docs/architecture.md b/docs/architecture.md index f50396f..d78687c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -348,6 +348,18 @@ 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:`) 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), +and in-process fixed-window limits (`src/fixed-window.ts`). + ### Three nested guards 1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then diff --git a/docs/service-contract.md b/docs/service-contract.md index 9c40713..35cb6fc 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -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,78 @@ 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" } +``` + +Order of refusals, each a closed `error` code: + +- `401 app_auth_required`: missing, wrong or another app's secret. Checked + first; 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. +- `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) or the per-account budget (10 per 600 s) 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. +- `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 and is kept until the session's own expiry. 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`, with the +app, the account and session when the bearer resolved, `method` (null when it +was not in the allowlist), `outcome`, `code` and `nonceDigest`. Never the +secret, the bearer, the minted token or the nonce. A request without the secret +writes nothing. + +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:`, an app label, or null), and unlink does not yet require any +particular method. + ### `POST /v1/apps/{appSlug}/managed-credentials/resolve` Purpose: @@ -761,6 +834,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:` for a provider step-up sign-in, otherwise the method label the app asserted - `display_name` optional - `app.slug` - `app.profile_version` diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 5fd99b4..24a56eb 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -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 per session via oauth_state_uses phase step_up_assert (no schema change); 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. Provider step-up sign-ins now carry step_up_method provider:; 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"} -- 2.47.3 From 12599116325bf82c8649f4ff57d3ec2e3e5cc1f2 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 20:13:13 +0000 Subject: [PATCH 3/4] Step-up assertion: audit a flood once per window, budget the not-permitted branch, base64url nonce, honest nonce retention and precondition docs Co-Authored-By: Claude Sonnet 5.5 --- docs/app-profiles/bifrost.md | 7 +++++ docs/architecture.md | 3 +- docs/service-contract.md | 42 ++++++++++++++++++++----- src/app.ts | 39 ++++++++++++++++------- src/fixed-window.ts | 20 ++++++------ state/evidence.jsonl | 2 +- tests/step-up-assertion.test.ts | 55 +++++++++++++++++++++++++++++++-- 7 files changed, 135 insertions(+), 33 deletions(-) diff --git a/docs/app-profiles/bifrost.md b/docs/app-profiles/bifrost.md index a174d50..41508e5 100644 --- a/docs/app-profiles/bifrost.md +++ b/docs/app-profiles/bifrost.md @@ -89,3 +89,10 @@ 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). diff --git a/docs/architecture.md b/docs/architecture.md index d78687c..f1d66c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -357,7 +357,8 @@ 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), +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 diff --git a/docs/service-contract.md b/docs/service-contract.md index 35cb6fc..32b402a 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -645,32 +645,45 @@ Body (`additionalProperties` are dropped as on the sibling routes): { "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. + Order of refusals, each a closed `error` code: - `401 app_auth_required`: missing, wrong or another app's secret. Checked first; 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. + 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) or the per-account budget (10 per 600 s) is - spent. In-process fixed windows: they reset when the process restarts and are - not shared between instances. + 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. + 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 and is kept until the session's own expiry. A - refused method or a rate-limited attempt does not spend the nonce. + across restarts and instances. **Retention is the session's expiry as of the + claim, plus the store's prune margin.** 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`. @@ -684,12 +697,25 @@ 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`, with the +refusal after the secret verified writes `step_up_assertion_refused`, except +that a flood is not recorded row by row: a rate-limited refusal is recorded +once per window per budget (its first), and an app without the grant once per +window, with the app, the account and session when the bearer resolved, `method` (null when it was not in the allowlist), `outcome`, `code` and `nonceDigest`. Never the secret, the bearer, the minted token or the nonce. A request without the secret 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 diff --git a/src/app.ts b/src/app.ts index 43ead5f..ac2f97a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -951,18 +951,25 @@ async function resolveAccessAccount( } /** 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 } = { +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) }; + 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), + }; } /** @@ -971,8 +978,10 @@ export function createStepUpAssertBudgets(now: () => number = () => Date.now()): * 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; a request without the secret - * writes nothing, since anyone can send one. The audit rows carry a digest of + * 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( @@ -989,7 +998,8 @@ export async function assertStepUp( const permitted = getAppProfile(appSlug)?.stepUpAssertion; let known: { accountId: string; sessionId: string } | undefined; let nonceDigest: string | undefined; - const refuse = async (statusCode: number, error: string, extra: Record = {}): Promise => { + const refuse = async (statusCode: number, error: string, extra: Record = {}, record = true): Promise => { + if (!record) return { statusCode, body: { error, ...extra } }; await store.createAuditEvent({ ...(known ? { accountId: known.accountId, sessionId: known.sessionId } : {}), appSlug, @@ -999,13 +1009,17 @@ export async function assertStepUp( }); return { statusCode, body: { error, ...extra } }; }; - const limited = (seconds: number) => refuse(429, "step_up_rate_limited", { retryAfterSeconds: seconds }); + // Audit only the first denial of a window: the rows record that a budget tripped, not every request behind it. + const limited = (spent: { n: number; retryAfterSeconds: number }, max: number) => + refuse(429, "step_up_rate_limited", { retryAfterSeconds: spent.retryAfterSeconds }, spent.n === max + 1); if (!permitted) { - return refuse(403, "step_up_assertion_not_permitted", { detail: `${appSlug} may not assert step-up proofs.` }); + const spent = budgets.notPermitted.spend(appSlug); + if (spent.retryAfterSeconds) return limited(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 appWait = budgets.app.spend(appSlug); - if (appWait) return limited(appWait); + const appSpent = budgets.app.spend(appSlug); + if (appSpent.retryAfterSeconds) return limited(appSpent, STEP_UP_ASSERT_LIMITS.app.max); const bearer = await resolveBearer(ctx, headers.authorization as string | undefined); if (!bearer) { @@ -1016,8 +1030,8 @@ export async function assertStepUp( if (bearer.claims.aud !== appSlug) { return refuse(403, "bearer_app_mismatch", { detail: `The access token was not issued to ${appSlug}.` }); } - const accountWait = budgets.account.spend(bearer.accountId); - if (accountWait) return limited(accountWait); + const accountSpent = budgets.account.spend(bearer.accountId); + if (accountSpent.retryAfterSeconds) return limited(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)) { @@ -2553,7 +2567,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise= this.limit.windowMs) { this.sweep(at); - this.windows.set(key, { startedAt: at, used: 1 }); - return 0; - } - if (current.used >= this.limit.max) { - return Math.max(1, Math.ceil((current.startedAt + this.limit.windowMs - at) / 1000)); + current = { startedAt: at, used: 0 }; + this.windows.set(key, current); } current.used += 1; - return 0; + 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. */ diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 24a56eb..caaa8e6 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -39,4 +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 per session via oauth_state_uses phase step_up_assert (no schema change); 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. Provider step-up sign-ins now carry step_up_method provider:; 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"} +{"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:; 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"} diff --git a/tests/step-up-assertion.test.ts b/tests/step-up-assertion.test.ts index e505829..b3115f1 100644 --- a/tests/step-up-assertion.test.ts +++ b/tests/step-up-assertion.test.ts @@ -18,7 +18,7 @@ import { createTestConfig } from "./oauth-flow.js"; 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}-`.padEnd(32, "x"); +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 }; @@ -259,7 +259,7 @@ describe("the method and the nonce", () => { 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: 7, nonce: nonce("a") }]) { + 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([]); @@ -278,6 +278,25 @@ describe("the method and the nonce", () => { expect((await assertStepUp(r, { token: second.token })).statusCode).toBe(201); }); + it("a nonce is retained to the session's expiry as of the claim: a refresh slides the session past it, and then 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; + // Inside the retained horizon the replay is still refused, refreshed session or not. + expect(refused(await assertStepUp(r, { token, nonce: nonce("N") }))).toEqual([409, "step_up_nonce_replayed"]); + // Past the old expiry plus the store's prune margin, the next claim prunes N while the session lives on. + vi.setSystemTime(t0 + R + 11 * 60_000); + expect((await assertStepUp(r, { token, nonce: nonce("M") })).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); @@ -323,6 +342,38 @@ describe("rate limits", () => { }); }); +describe("flood handling", () => { + 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 = {}; + 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); + 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); + expect(accountRows.filter((e) => e.eventPayloadJson.code === "step_up_rate_limited")).toHaveLength(1); + }); +}); + 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(); -- 2.47.3 From 6ed6d8fa0dbd2ac8d7700cfea4bd7e7bbe1accce Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 13 Nov 2026 20:19:41 +0000 Subject: [PATCH 4/4] Step-up assertion: pin per-app not-permitted budget and the 10.5 min prune horizon, name the tripped budget in the audit row, correct validation-order and audit docs Co-Authored-By: Claude Sonnet 5.5 --- docs/service-contract.md | 27 +++++++++++++++++---------- src/app.ts | 15 +++++++++------ tests/step-up-assertion.test.ts | 29 +++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/docs/service-contract.md b/docs/service-contract.md index 32b402a..2266c0d 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -649,10 +649,13 @@ 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. -Order of refusals, each a closed `error` code: +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. Checked - first; nothing is written. +- `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 @@ -678,7 +681,8 @@ Order of refusals, each a closed `error` code: 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 the store's prune margin.** A refresh slides the session's expiry + 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 @@ -698,13 +702,16 @@ 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 a flood is not recorded row by row: a rate-limited refusal is recorded -once per window per budget (its first), and an app without the grant once per -window, with the +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` and `nonceDigest`. Never the -secret, the bearer, the minted token or the nonce. A request without the secret -writes nothing. +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 diff --git a/src/app.ts b/src/app.ts index ac2f97a..9e8c334 100644 --- a/src/app.ts +++ b/src/app.ts @@ -998,28 +998,31 @@ export async function assertStepUp( 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 = {}, record = true): Promise => { 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, method: permitted?.methods.includes(input.method) ? input.method : null, nonceDigest: nonceDigest ?? null }, + 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 = (spent: { n: number; retryAfterSeconds: number }, max: number) => - refuse(429, "step_up_rate_limited", { retryAfterSeconds: spent.retryAfterSeconds }, spent.n === max + 1); + 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(spent, STEP_UP_ASSERT_LIMITS.notPermitted.max); + 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(appSpent, STEP_UP_ASSERT_LIMITS.app.max); + 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) { @@ -1031,7 +1034,7 @@ export async function assertStepUp( 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(accountSpent, STEP_UP_ASSERT_LIMITS.account.max); + 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)) { diff --git a/tests/step-up-assertion.test.ts b/tests/step-up-assertion.test.ts index b3115f1..f064e00 100644 --- a/tests/step-up-assertion.test.ts +++ b/tests/step-up-assertion.test.ts @@ -278,7 +278,7 @@ describe("the method and the nonce", () => { expect((await assertStepUp(r, { token: second.token })).statusCode).toBe(201); }); - it("a nonce is retained to the session's expiry as of the claim: a refresh slides the session past it, and then the nonce can be reused (documented limit)", async () => { + 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"] }); @@ -289,11 +289,14 @@ describe("the method and the nonce", () => { 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; - // Inside the retained horizon the replay is still refused, refreshed session or not. - expect(refused(await assertStepUp(r, { token, nonce: nonce("N") }))).toEqual([409, "step_up_nonce_replayed"]); - // Past the old expiry plus the store's prune margin, the next claim prunes N while the session lives on. - vi.setSystemTime(t0 + R + 11 * 60_000); + 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); }); @@ -343,6 +346,18 @@ describe("rate limits", () => { }); 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"); @@ -356,6 +371,7 @@ describe("flood handling", () => { 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); @@ -370,7 +386,8 @@ describe("flood handling", () => { 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); - expect(accountRows.filter((e) => e.eventPayloadJson.code === "step_up_rate_limited")).toHaveLength(1); + const trips = accountRows.filter((e) => e.eventPayloadJson.code === "step_up_rate_limited"); + expect(trips.map((e) => e.eventPayloadJson.budget)).toEqual(["account"]); }); }); -- 2.47.3