App-asserted step-up route: Bifrost asserts a native strong proof, Heimdall mints step_up_at + step_up_method #15
13 changed files with 847 additions and 9 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
158
src/app.ts
158
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<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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
49
src/fixed-window.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
435
tests/step-up-assertion.test.ts
Normal file
435
tests/step-up-assertion.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"] });
|
||||
|
|
|
|||
Loading…
Reference in a new issue