mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
test(auth): pin the step-up elevation boundary and publish its contract
Adds the structural guard that freezes the exact route set an elevation can unlock, plus the behavioural suite that drives each threat case against a real Postgres: a token alone mints nothing, an elevation minted for one token is refused for another (same account included), a second redemption fails, a concurrent redemption lets exactly one winner through, an expired one is refused, an elevation never reaches requireAdmin, a wrong password is throttled and audited without saying what was wrong, and the cookie flow is untouched. The OpenAPI registry carries both new routes and names the accepting set in prose, so the client implements against the contract rather than against a description of it.
This commit is contained in:
1 parent
e557d9e9e6
commit
433d983e60
4 files changed
+1059
-24
No files matched your search
+104
-11
@@ -18,6 +18,7 @@ import {
|
||||
mfaWebauthnLoginVerifySchema,
|
||||
} from "@/lib/validations/mfa";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
|
||||
|
||||
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
|
||||
@@ -284,6 +285,36 @@ const checkUserResponse = z
|
||||
"Account-existence + credential shape. The response is identical whether or not the identifier matched (account-existence is the explicit contract iOS needs); the identifier is never echoed.",
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared tail for every route the step-up elevation can unlock. Kept in one
|
||||
* place so the published contract cannot describe the set inconsistently.
|
||||
*/
|
||||
const MFA_MANAGEMENT_AUTH_NOTE =
|
||||
" 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.";
|
||||
|
||||
// ── v1.30.34 step-up elevation (Bearer transport) ────────────────────
|
||||
|
||||
const stepUpOptionsResponse = z
|
||||
.object({
|
||||
options: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe("SimpleWebAuthn assertion options for the caller's passkeys."),
|
||||
challengeId: z.string(),
|
||||
})
|
||||
.meta({ id: "StepUpOptionsResponse" });
|
||||
|
||||
const stepUpMintResponse = z
|
||||
.object({
|
||||
elevation: z
|
||||
.string()
|
||||
.describe(
|
||||
"Opaque single-use elevation (`hle_<64hex>`). Present it in the `X-Step-Up` header on ONE second-factor-management call. Returned exactly once; store it in memory only.",
|
||||
),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
expiresInSeconds: z.number().int(),
|
||||
})
|
||||
.meta({ id: "StepUpMintResponse" });
|
||||
|
||||
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/check-user": {
|
||||
post: {
|
||||
@@ -392,12 +423,65 @@ 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)",
|
||||
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.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Assertion options issued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
stepUpOptionsResponse,
|
||||
"StepUpOptionsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description: "No passkey registered on this account.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/step-up": {
|
||||
post: {
|
||||
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.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: stepUpMintSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Factor re-proved — elevation issued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(stepUpMintResponse, "StepUpMintEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/mfa/totp/setup": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin TOTP enrollment (cookie session only)",
|
||||
summary: "Begin TOTP enrollment (cookie session or step-up elevation)",
|
||||
description:
|
||||
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm. Cookie-only — a Bearer token cannot enrol MFA.",
|
||||
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Pending secret created.",
|
||||
@@ -418,9 +502,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/me/mfa/totp/confirm": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Confirm TOTP enrollment (cookie session only)",
|
||||
summary: "Confirm TOTP enrollment (cookie session or step-up elevation)",
|
||||
description:
|
||||
"Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes. Cookie-only.",
|
||||
"Verifies a code against the pending secret, activates the factor, and returns the one-time recovery codes." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: totpConfirmSchema } },
|
||||
@@ -451,7 +536,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Disable the second factor (step-up gated)",
|
||||
description:
|
||||
"Requires a fresh second-factor step-up (cookie session) AND a current TOTP or recovery code. Clears the secret and deletes recovery codes. Bearer can never satisfy the step-up gate.",
|
||||
"Requires a fresh second-factor step-up AND a current TOTP or recovery code. Clears the secret and deletes recovery codes." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: mfaDisableSchema } },
|
||||
@@ -474,7 +560,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Regenerate recovery codes (step-up gated)",
|
||||
description:
|
||||
"Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated; Bearer can never satisfy the gate.",
|
||||
"Invalidates the entire prior recovery-code set and returns a fresh batch once. Step-up gated." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Fresh recovery codes issued.",
|
||||
@@ -494,9 +581,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/me/mfa": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Second-factor status (cookie session only)",
|
||||
summary: "Second-factor status (cookie session or step-up elevation)",
|
||||
description:
|
||||
"Whether TOTP is active, how many recovery codes remain, and the registered WebAuthn security keys. Metadata only — no secret, code, or public key. Cookie-only.",
|
||||
"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,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Second-factor status.",
|
||||
@@ -515,7 +603,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Begin registering a security key as a second factor",
|
||||
description:
|
||||
"Returns SimpleWebAuthn creation options + a challenge id. Cookie-only — a Bearer token cannot enrol MFA.",
|
||||
"Returns SimpleWebAuthn creation options + a challenge id." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Registration options issued.",
|
||||
@@ -537,7 +626,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Finish registering a security key as a second factor",
|
||||
description:
|
||||
"Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys). Cookie-only.",
|
||||
"Verifies the attestation against the user-bound challenge and stores the credential in the second-factor store (separate from primary passkeys)." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -568,6 +658,9 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
summary: "Rename a registered security key",
|
||||
description:
|
||||
"Rename a registered second-factor security key." +
|
||||
MFA_MANAGEMENT_AUTH_NOTE,
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -596,7 +689,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Remove a registered security key (step-up gated)",
|
||||
description:
|
||||
"Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.",
|
||||
"Requires a fresh second-factor step-up." + MFA_MANAGEMENT_AUTH_NOTE,
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
|
||||
Reference in new issue
Block a user