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 <noreply@anthropic.com>
This commit is contained in:
parent
2f93809a29
commit
1259911632
7 changed files with 135 additions and 33 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
39
src/app.ts
39
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<string, unknown> = {}): Promise<HandlerResult> => {
|
||||
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,
|
||||
|
|
@ -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<FastifyIn
|
|||
additionalProperties: false,
|
||||
properties: {
|
||||
method: { type: "string", minLength: 1, maxLength: 64 },
|
||||
nonce: { type: "string", minLength: 16, maxLength: 128 },
|
||||
// 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}$" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -17,20 +17,22 @@ export class FixedWindowBudget {
|
|||
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 {
|
||||
/**
|
||||
* 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();
|
||||
const current = this.windows.get(key);
|
||||
let 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 = { 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. */
|
||||
|
|
|
|||
|
|
@ -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:<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"}
|
||||
{"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"}
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue