mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
docs(api): publish the factor-dependent reach of a step-up elevation
The contract now names which factors satisfy the fresh-factor routes and which do not, carries `satisfiesFreshFactor` on the mint response so a client can pick the ceremony before spending a proof, and describes the options endpoint's two ceremony kinds. The second-factor status read is documented as plain authentication — it carries no credential material, so it needs no elevation.
This commit is contained in:
1 parent
0991d754e4
commit
4cd0adc46d
3 files changed
+198
-32
No files matched your search
+122
-22
@@ -1605,11 +1605,18 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Begin a passkey re-proof for a step-up elevation (Bearer only)
|
||||
description: "Returns SimpleWebAuthn assertion options scoped to the calling account's primary passkeys, plus a
|
||||
challenge id to present at POST /api/auth/step-up. Bearer-only: a cookie session is refused, because a browser
|
||||
re-proves its factor at login and carries the result on its session row. Returns 409 when the account has no
|
||||
passkey registered — use the password arm of the mint instead."
|
||||
summary: Begin a WebAuthn re-proof for a step-up elevation (Bearer only)
|
||||
description: "Returns SimpleWebAuthn assertion options plus a challenge id to present at POST /api/auth/step-up.
|
||||
`method: \"passkey\"` scopes the assertion to the account's primary passkeys; `method: \"webauthn\"` to its
|
||||
registered second-factor security keys. Bearer-only: a cookie session is refused, because a browser re-proves
|
||||
its factor at login and carries the result on its session row. Returns 409 when the account has no credential of
|
||||
the requested kind — fall back to another arm of the mint."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StepUpOptionsRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Assertion options issued.
|
||||
@@ -1632,26 +1639,34 @@ paths:
|
||||
- Auth
|
||||
summary: Mint a single-use step-up elevation (Bearer only)
|
||||
description: >-
|
||||
Re-prove the account password or a primary passkey and receive an opaque elevation that authorises exactly ONE
|
||||
second-factor-management call.
|
||||
Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.
|
||||
|
||||
|
||||
The elevation is the Bearer transport's equivalent of the web's fresh-second-factor session stamp. It is bound
|
||||
to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use,
|
||||
and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside
|
||||
the normal `Authorization: Bearer` header.
|
||||
WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie
|
||||
session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable,
|
||||
recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web
|
||||
marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the
|
||||
right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT
|
||||
accepted here; an account that has lost its authenticator manages its second factor on the web.
|
||||
|
||||
|
||||
The elevation is bound to the exact token that minted it (another token, including the same account's, cannot
|
||||
redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as
|
||||
`X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route
|
||||
is about to act, so a 429, a 422, or a wrong code does not burn it.
|
||||
|
||||
|
||||
Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong
|
||||
password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge —
|
||||
returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and
|
||||
per source address.
|
||||
password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a
|
||||
replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per
|
||||
account (5 / 15 min) and per source address.
|
||||
|
||||
|
||||
Accepting routes (the complete set): GET /api/auth/me/mfa; POST /api/auth/me/mfa/totp/setup; POST
|
||||
/api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate;
|
||||
POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and
|
||||
DELETE /api/auth/me/mfa/webauthn/{id}. Nothing else accepts one — admin endpoints stay cookie-only.
|
||||
Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST
|
||||
/api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST
|
||||
/api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE
|
||||
/api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET
|
||||
/api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1774,10 +1789,11 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Second-factor status (cookie session or step-up elevation)
|
||||
summary: Second-factor status (cookie session or Bearer token)
|
||||
description: Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata
|
||||
only — no secret, code, or public key. Accepts a cookie session, or a Bearer token presenting a single-use
|
||||
elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.
|
||||
only — no secret, no code, no public key, no credential id. Plain authentication (cookie session or
|
||||
cookie-equivalent token); unlike every mutation on this surface it needs no step-up elevation, because the
|
||||
payload carries no credential material.
|
||||
responses:
|
||||
"200":
|
||||
description: Second-factor status.
|
||||
@@ -11472,6 +11488,18 @@ components:
|
||||
- code
|
||||
- codeVerifier
|
||||
description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.
|
||||
StepUpOptionsRequest:
|
||||
type: object
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
enum:
|
||||
- passkey
|
||||
- webauthn
|
||||
description: "`passkey` begins an assertion against the account's primary passkeys; `webauthn` against its registered
|
||||
second-factor security keys."
|
||||
required:
|
||||
- method
|
||||
StepUpMintRequest:
|
||||
oneOf:
|
||||
- type: object
|
||||
@@ -11486,6 +11514,60 @@ components:
|
||||
required:
|
||||
- method
|
||||
- password
|
||||
- type: object
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
const: totp
|
||||
code:
|
||||
type: string
|
||||
pattern: ^\d{6}$
|
||||
required:
|
||||
- method
|
||||
- code
|
||||
- type: object
|
||||
properties:
|
||||
method:
|
||||
type: string
|
||||
const: webauthn
|
||||
challengeId:
|
||||
type: string
|
||||
minLength: 1
|
||||
credential:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
rawId:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
const: public-key
|
||||
response:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
authenticatorAttachment:
|
||||
type: string
|
||||
enum:
|
||||
- platform
|
||||
- cross-platform
|
||||
clientExtensionResults:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- id
|
||||
- rawId
|
||||
- type
|
||||
- response
|
||||
description: SimpleWebAuthn ceremony response payload.
|
||||
required:
|
||||
- method
|
||||
- challengeId
|
||||
- credential
|
||||
- type: object
|
||||
properties:
|
||||
method:
|
||||
@@ -11529,7 +11611,10 @@ components:
|
||||
- method
|
||||
- challengeId
|
||||
- credential
|
||||
description: Re-prove the account password or a primary passkey to mint a single-use, token-bound step-up elevation.
|
||||
description: Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a
|
||||
plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes
|
||||
(disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web
|
||||
stamps a session second-factor-verified.
|
||||
type: object
|
||||
MfaDisableRequest:
|
||||
type: object
|
||||
@@ -15473,10 +15558,25 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
method:
|
||||
type: string
|
||||
enum:
|
||||
- password
|
||||
- totp
|
||||
- webauthn
|
||||
- passkey
|
||||
description: The factor that was re-proved.
|
||||
satisfiesFreshFactor:
|
||||
type: boolean
|
||||
description: Whether this elevation reaches the fresh-factor routes (MFA disable, recovery-code regeneration,
|
||||
security-key removal). False for a password proof — mirroring the web, where a password login never marks a
|
||||
session second-factor-verified.
|
||||
required:
|
||||
- elevation
|
||||
- expiresAt
|
||||
- expiresInSeconds
|
||||
- method
|
||||
- satisfiesFreshFactor
|
||||
additionalProperties: false
|
||||
TotpSetupEnvelope:
|
||||
type: object
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
mfaWebauthnLoginVerifySchema,
|
||||
} from "@/lib/validations/mfa";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
import {
|
||||
stepUpMintSchema,
|
||||
stepUpOptionsSchema,
|
||||
} from "@/lib/validations/step-up";
|
||||
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
|
||||
|
||||
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
|
||||
@@ -312,6 +315,14 @@ const stepUpMintResponse = z
|
||||
),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
expiresInSeconds: z.number().int(),
|
||||
method: z
|
||||
.enum(["password", "totp", "webauthn", "passkey"])
|
||||
.describe("The factor that was re-proved."),
|
||||
satisfiesFreshFactor: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether this elevation reaches the fresh-factor routes (MFA disable, recovery-code regeneration, security-key removal). False for a password proof — mirroring the web, where a password login never marks a session second-factor-verified.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "StepUpMintResponse" });
|
||||
|
||||
@@ -426,9 +437,14 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/step-up/options": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin a passkey re-proof for a step-up elevation (Bearer only)",
|
||||
summary:
|
||||
"Begin a WebAuthn re-proof for a step-up elevation (Bearer only)",
|
||||
description:
|
||||
"Returns SimpleWebAuthn assertion options scoped to the calling account's primary passkeys, plus a challenge id to present at POST /api/auth/step-up. Bearer-only: a cookie session is refused, because a browser re-proves its factor at login and carries the result on its session row. Returns 409 when the account has no passkey registered — use the password arm of the mint instead.",
|
||||
'Returns SimpleWebAuthn assertion options plus a challenge id to present at POST /api/auth/step-up. `method: "passkey"` scopes the assertion to the account\'s primary passkeys; `method: "webauthn"` to its registered second-factor security keys. Bearer-only: a cookie session is refused, because a browser re-proves its factor at login and carries the result on its session row. Returns 409 when the account has no credential of the requested kind — fall back to another arm of the mint.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: stepUpOptionsSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assertion options issued.",
|
||||
@@ -454,10 +470,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Mint a single-use step-up elevation (Bearer only)",
|
||||
description:
|
||||
"Re-prove the account password or a primary passkey and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" +
|
||||
"The elevation is the Bearer transport's equivalent of the web's fresh-second-factor session stamp. It is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header.\n\n" +
|
||||
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
|
||||
"Accepting routes (the complete set): GET /api/auth/me/mfa; POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}. Nothing else accepts one — admin endpoints stay cookie-only.",
|
||||
"Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" +
|
||||
"WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" +
|
||||
"The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" +
|
||||
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
|
||||
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: stepUpMintSchema } },
|
||||
@@ -581,10 +598,9 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/me/mfa": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Second-factor status (cookie session or step-up elevation)",
|
||||
summary: "Second-factor status (cookie session or Bearer token)",
|
||||
description:
|
||||
"Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, code, or public key." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
"Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, no code, no public key, no credential id. Plain authentication (cookie session or cookie-equivalent token); unlike every mutation on this surface it needs no step-up elevation, because the payload carries no credential material.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Second-factor status.",
|
||||
|
||||
@@ -817,6 +817,25 @@ describe("E10 — a password proof cannot reach the destructive routes", () => {
|
||||
expect(await getPrismaClient().webauthnMfaCredential.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses at the CLAIM too, not only at the validation", async () => {
|
||||
// Defence in depth, and it needs its own test: the gate validates before it
|
||||
// claims, so removing the check inside `claimStepUpElevation` leaves every
|
||||
// route-level case above green. Driven straight at the primitive.
|
||||
const { raw, id } = await mintToken("e10i");
|
||||
useToken(raw);
|
||||
const elevation = elevationOf(await mintElevation(PASSWORD));
|
||||
|
||||
const { claimStepUpElevation } = await import("@/lib/auth/step-up");
|
||||
const claimed = await claimStepUpElevation({
|
||||
rawToken: elevation,
|
||||
userId: USER_ID,
|
||||
apiTokenId: id,
|
||||
requireFreshFactor: true,
|
||||
});
|
||||
|
||||
expect(claimed).toEqual({ ok: false, reason: "insufficient_factor" });
|
||||
});
|
||||
|
||||
it("does NOT burn the elevation when the factor is too weak", async () => {
|
||||
// The refusal is about reach, not validity. The same elevation must still
|
||||
// work on the routes it was always entitled to.
|
||||
@@ -1035,6 +1054,37 @@ describe("E12 — a rejected request keeps the elevation spendable", () => {
|
||||
expect(good.status).toBe(200);
|
||||
});
|
||||
|
||||
it("IS spent once the route actually acts", async () => {
|
||||
// The other half of the two-phase split. Deferring the claim buys nothing if
|
||||
// the route then forgets to make it — the elevation would stay redeemable
|
||||
// for the rest of its five minutes.
|
||||
//
|
||||
// Driven through recovery-code regeneration rather than disable: disable
|
||||
// calls `destroyOtherSessions`, which deletes the account's elevations
|
||||
// outright, so the row is gone afterwards either way and the assertion
|
||||
// would pass whether or not the route committed. Regeneration leaves the
|
||||
// row in place, so `consumedAt` is a real observation.
|
||||
const secret = await enrolTotp();
|
||||
const { raw } = await mintToken("e12c");
|
||||
useToken(raw);
|
||||
const elevation = elevationOf(await mintTotpElevation(secret));
|
||||
|
||||
useElevation(elevation);
|
||||
const { POST } =
|
||||
await import("@/app/api/auth/me/mfa/recovery-codes/regenerate/route");
|
||||
const ok = await POST(
|
||||
req("/api/auth/me/mfa/recovery-codes/regenerate", "POST"),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
const row = await getPrismaClient().stepUpElevation.findFirst();
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.consumedAt).not.toBeNull();
|
||||
|
||||
// And the spent value opens nothing else.
|
||||
expect((await callMfaSetup()).status).toBe(401);
|
||||
});
|
||||
|
||||
it("survives a malformed body", async () => {
|
||||
const { raw } = await mintToken("e12b");
|
||||
useToken(raw);
|
||||
|
||||
Reference in new issue
Block a user