mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(auth): add token-bound step-up elevations for second-factor management
Second-factor management has been cookie-only since v1.23. The gate is right: a Bearer token is a credential whose provenance the server cannot see, and tearing down a second factor must not be reachable from the weaker of an interactive login and a value pasted into a script. What was missing was never Bearer access to those routes — it was a way to say, over a transport with no session row, that the human holding this device just re-proved a factor. An elevation is that proof. It is minted only against a re-proved account password or primary passkey, bound to the exact token that re-proved, single-use via a conditional UPDATE, five-minute-lived to match the cookie path's step-up window, and stored hashed. A stolen token mints nothing; a second token cannot redeem another's elevation; a password change drops every one the account holds. requireMfaManagementAuth is the one gate the management routes share. On a cookie session it delegates to the original helpers unchanged, so the web flow runs the code it always did. requireAdmin stays cookie-only and out of reach.
This commit is contained in:
1 parent
571e415eaa
commit
e557d9e9e6
19 files changed
+808
-51
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { revokeStepUpElevations } from "@/lib/auth/step-up";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { checkPasswordBreach } from "@/lib/auth/hibp";
|
||||
@@ -83,6 +84,11 @@ export const POST = apiHandler(
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
// v1.30.34 — an operator-forced rotation invalidates the target's step-up
|
||||
// elevations for the same reason a self-service change does: they attest to
|
||||
// a credential that no longer exists.
|
||||
await revokeStepUpElevations(id);
|
||||
|
||||
// Invalidate all sessions of the target user
|
||||
await destroyAllSessions(id);
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* POST /api/auth/me/mfa/disable
|
||||
*
|
||||
* Tear down the second factor. Double-gated:
|
||||
* 1. `requireFreshMfa` — a cookie session that completed a second factor
|
||||
* within the step-up window (Bearer can never satisfy this).
|
||||
* 1. `requireMfaManagementAuth({ freshFactor: true })` — a cookie session that
|
||||
* completed a second factor within the step-up window, OR a Bearer token
|
||||
* presenting a single-use elevation it minted by re-proving a factor.
|
||||
* 2. A current TOTP or recovery code in the body — proves live possession at
|
||||
* the moment of the destructive action, not just a stale fresh-session.
|
||||
*
|
||||
@@ -12,9 +13,8 @@
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfa,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
@@ -38,7 +38,9 @@ const DISABLE_WINDOW_MS = 15 * 60 * 1000;
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
||||
// session is not freshly second-factor-verified.
|
||||
const { user, session } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { user, session } = await requireMfaManagementAuth({
|
||||
freshFactor: true,
|
||||
});
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:disable:${user.id}`,
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
* POST /api/auth/me/mfa/recovery-codes/regenerate
|
||||
*
|
||||
* Issue a fresh recovery-code batch, invalidating the entire prior set in one
|
||||
* transaction. Step-up gated (`requireFreshMfa`) — a recovery-code rotation is
|
||||
* a sensitive action; Bearer can never satisfy the gate. The new codes are
|
||||
* returned **once**.
|
||||
* transaction. Step-up gated (`requireMfaManagementAuth({ freshFactor: true })`)
|
||||
* — a recovery-code rotation is sensitive, so it takes either a freshly
|
||||
* second-factor-verified cookie session or a single-use step-up elevation minted
|
||||
* by the calling token. The new codes are returned **once**.
|
||||
*/
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfa,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -24,7 +24,7 @@ const REGEN_RATE_LIMIT = 5;
|
||||
const REGEN_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { user } = await requireMfaManagementAuth({ freshFactor: true });
|
||||
|
||||
// Defence in depth — regeneration only makes sense for an active factor.
|
||||
if (!user.totpConfirmedAt) {
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
*
|
||||
* Second-factor status for the security settings hub: whether TOTP is active,
|
||||
* how many recovery codes remain, and the registered WebAuthn security keys.
|
||||
* Cookie-only (`requireCookieAuth`) — managing MFA is never a Bearer surface.
|
||||
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
|
||||
* presenting a single-use step-up elevation. A token alone is not enough.
|
||||
*
|
||||
* Returns only metadata: no secret, no recovery-code plaintext, no public key.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -16,7 +17,7 @@ import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
|
||||
const [recoveryCodesRemaining, webauthn] = await Promise.all([
|
||||
countRemainingRecoveryCodes(user.id),
|
||||
|
||||
@@ -6,13 +6,21 @@
|
||||
* the accepted time-step (replay guard), generates + persists the Argon2id-
|
||||
* hashed recovery codes, and returns the codes **once** for the user to save.
|
||||
*
|
||||
* The completing session is also stamped `mfaVerifiedAt = now()` so the user
|
||||
* can immediately manage the freshly-enabled factor (disable / regenerate
|
||||
* codes) without an extra step-up round-trip.
|
||||
* On the cookie path the completing session is stamped `mfaVerifiedAt = now()`
|
||||
* so the user can immediately manage the freshly-enabled factor (disable /
|
||||
* regenerate codes) without an extra step-up round-trip. A Bearer caller has no
|
||||
* session row to stamp — the stamping `updateMany` simply matches nothing — and
|
||||
* mints a fresh elevation per action instead.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA.
|
||||
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
|
||||
* presenting a single-use step-up elevation. A token on its own can still never
|
||||
* enrol MFA.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
} from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -38,7 +46,7 @@ const CONFIRM_RATE_LIMIT = 10;
|
||||
const CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
const { user, session } = await requireMfaManagementAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:confirm:${user.id}`,
|
||||
|
||||
@@ -8,11 +8,17 @@
|
||||
* not active until `/confirm` verifies a code, so `totpConfirmedAt` stays
|
||||
* null here.
|
||||
*
|
||||
* Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. The
|
||||
* recovery-code batch is issued at `/confirm` (after the factor is proven),
|
||||
* not here, so an abandoned setup never persists codes.
|
||||
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
|
||||
* presenting a single-use step-up elevation minted against a re-proved factor.
|
||||
* A token on its own can still never enrol MFA. The recovery-code batch is
|
||||
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
|
||||
* setup never persists codes.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireMfaManagementAuth,
|
||||
HttpError,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -27,7 +33,7 @@ const SETUP_RATE_LIMIT = 5;
|
||||
const SETUP_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:setup:${user.id}`,
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
* PATCH /api/auth/me/mfa/webauthn/[id] — rename a registered security key.
|
||||
* DELETE /api/auth/me/mfa/webauthn/[id] — remove one (step-up gated).
|
||||
*
|
||||
* Rename is cookie-authenticated (non-destructive). Removal is a sensitive
|
||||
* action: it is gated on a fresh second-factor step-up (`requireFreshMfa`,
|
||||
* cookie-only — a Bearer token can never satisfy it).
|
||||
* Both go through `requireMfaManagementAuth`: a cookie session, or a Bearer
|
||||
* token presenting a single-use step-up elevation. Removal additionally demands
|
||||
* the fresh-factor arm (`freshFactor: true`) — on the cookie path a session that
|
||||
* completed a second factor inside the step-up window, on the Bearer path an
|
||||
* elevation minted against a re-proved factor.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
apiHandler,
|
||||
requireCookieAuth,
|
||||
requireFreshMfa,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -31,7 +28,7 @@ export const PATCH = apiHandler(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
@@ -70,7 +67,7 @@ export const DELETE = apiHandler(
|
||||
) => {
|
||||
// Step-up gate first — throws StepUpRequiredError (401 + errorCode) if the
|
||||
// session is not freshly second-factor-verified.
|
||||
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
const { user } = await requireMfaManagementAuth({ freshFactor: true });
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.webauthnMfaCredential.findUnique({
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/webauthn/register/options
|
||||
*
|
||||
* Begin registering a WebAuthn security key as a second factor. Cookie-only —
|
||||
* an API token can never enrol MFA. Returns the SimpleWebAuthn creation
|
||||
* options + the server-issued challenge id to present back at /register/verify.
|
||||
* Begin registering a WebAuthn security key as a second factor. Takes a cookie
|
||||
* session or a Bearer token presenting a single-use step-up elevation; a token
|
||||
* on its own can never enrol MFA. Returns the SimpleWebAuthn creation options +
|
||||
* the server-issued challenge id to present back at /register/verify.
|
||||
*/
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -14,7 +15,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mfa:webauthn:register:${user.id}`,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* POST /api/auth/me/mfa/webauthn/register/verify
|
||||
*
|
||||
* Finish registering a WebAuthn security key as a second factor. Cookie-only.
|
||||
* Verifies the attestation against the user-bound challenge and stores the
|
||||
* credential in `WebauthnMfaCredential` (kept separate from primary passkeys).
|
||||
* Finish registering a WebAuthn security key as a second factor. Takes a cookie
|
||||
* session or a Bearer token presenting a single-use step-up elevation. Verifies
|
||||
* the attestation against the user-bound challenge and stores the credential in
|
||||
* `WebauthnMfaCredential` (kept separate from primary passkeys).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -23,7 +24,7 @@ import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireCookieAuth();
|
||||
const { user } = await requireMfaManagementAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { destroyAllSessions, createSession } from "@/lib/auth/session";
|
||||
import { revokeStepUpElevations } from "@/lib/auth/step-up";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { checkPasswordBreach } from "@/lib/auth/hibp";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
@@ -102,6 +103,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data: { passwordHash: newHash },
|
||||
});
|
||||
|
||||
// v1.30.34 — a step-up elevation is a statement about a credential that no
|
||||
// longer exists. Drop every one the account holds before the sessions go, so
|
||||
// an elevation minted moments before the rotation cannot outlive it.
|
||||
await revokeStepUpElevations(user.id);
|
||||
|
||||
// Invalidate all existing sessions and create a fresh one.
|
||||
// v1.4.22 W5 reconcile (Sr-H1) — `createSession` re-anchors the
|
||||
// `hl_onboarding` cookie itself.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* POST /api/auth/step-up/options
|
||||
*
|
||||
* Begin a primary-passkey assertion for the step-up mint below. Returns
|
||||
* SimpleWebAuthn assertion options scoped to the calling account's registered
|
||||
* passkeys, plus the server-issued challenge id to present back at
|
||||
* `POST /api/auth/step-up`.
|
||||
*
|
||||
* Bearer-only (`requireBearerAuth`) — a browser has no use for this surface; it
|
||||
* re-proves a factor at login and carries the result on its session row. The
|
||||
* allow-list is never empty by construction: an account with no passkey gets a
|
||||
* 409 telling the client to use the password arm instead.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireBearerAuth();
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:step-up",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const passkeyCount = await prisma.passkey.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
if (passkeyCount === 0) {
|
||||
annotate({ action: { name: "auth.stepup.passkey.none" } });
|
||||
return apiError("No passkey registered", 409);
|
||||
}
|
||||
|
||||
const { options, challengeId } = await createAuthenticationOptions(user.id);
|
||||
|
||||
annotate({ action: { name: "auth.stepup.passkey.options" } });
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* POST /api/auth/step-up
|
||||
*
|
||||
* Re-prove a factor and receive a single-use, token-bound step-up elevation for
|
||||
* one second-factor-management call.
|
||||
*
|
||||
* This is the whole point of the mechanism, so it is worth being blunt about
|
||||
* what it is not: it is NOT a way to turn a token into a stronger credential.
|
||||
* Presenting the token gets you as far as this endpoint and no further — the
|
||||
* body must carry a fresh proof of the account password or of a primary passkey
|
||||
* the device holds. A stolen token can call this all day and mint nothing.
|
||||
*
|
||||
* Bearer-only (`requireBearerAuth`). A browser cannot reach it even with a valid
|
||||
* session, which keeps an ambient cookie credential out of the mint path
|
||||
* entirely.
|
||||
*
|
||||
* Failures are deliberately indistinguishable. A wrong password, an account with
|
||||
* no password set, a passkey belonging to someone else, a stale challenge — all
|
||||
* return the same 401 with the same prose. The audit row carries the real reason;
|
||||
* the wire carries none of it.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import {
|
||||
mintStepUpElevation,
|
||||
STEP_UP_ELEVATION_TTL_SECONDS,
|
||||
type StepUpMethod,
|
||||
} from "@/lib/auth/step-up";
|
||||
import { stepUpMintSchema } from "@/lib/validations/step-up";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Per-account ceiling. Tight because the password arm runs an Argon2id verify
|
||||
* against a user-supplied string — the same 5-per-15-minutes the password-change
|
||||
* route uses, for the same reason.
|
||||
*/
|
||||
const MINT_LIMIT = 5;
|
||||
const MINT_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const auth = await requireBearerAuth();
|
||||
const { user } = auth;
|
||||
// `requireAuth`'s contract puts the resolved `ApiToken` row id here. It is the
|
||||
// binding the minted elevation is tied to.
|
||||
const apiTokenId = auth.session.id;
|
||||
const ip = getClientIp(request);
|
||||
|
||||
// Two buckets. The per-IP surface bucket collapses to the tight shared anon
|
||||
// bucket when the proxy trust chain is broken, so a misconfigured deployment
|
||||
// caps a spray rather than opening one.
|
||||
const ipRl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:step-up",
|
||||
20,
|
||||
MINT_WINDOW_MS,
|
||||
);
|
||||
if (!ipRl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(ipRl),
|
||||
});
|
||||
}
|
||||
|
||||
const userRl = await checkRateLimit(
|
||||
`auth:step-up:${user.id}`,
|
||||
MINT_LIMIT,
|
||||
MINT_WINDOW_MS,
|
||||
);
|
||||
if (!userRl.allowed) {
|
||||
await auditLog("auth.stepup.mint.rate_limited", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ action: { name: "auth.stepup.mint.rate_limited" } });
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429, {
|
||||
headers: rateLimitHeaders(userRl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
let proved = false;
|
||||
let method: StepUpMethod = parsed.data.method;
|
||||
// Audit-only detail. Never reaches the response — see the file header.
|
||||
let failure = "invalid";
|
||||
|
||||
if (parsed.data.method === "password") {
|
||||
if (!user.passwordHash) {
|
||||
// An SSO-provisioned account has no password to re-prove. Same refusal as
|
||||
// a wrong one: the account's credential shape is not a token holder's to
|
||||
// enumerate. `/api/auth/step-up/options` is the honest discovery path —
|
||||
// it 409s when there is no passkey either.
|
||||
failure = "no_password";
|
||||
} else {
|
||||
proved = await verifyPassword(user.passwordHash, parsed.data.password);
|
||||
if (!proved) failure = "bad_password";
|
||||
}
|
||||
} else {
|
||||
method = "passkey";
|
||||
// Bind the challenge to the caller BEFORE verifying. `verifyAuthentication`
|
||||
// resolves a challenge by id alone; pinning ownership here means a challenge
|
||||
// minted for another account cannot be carried into this ceremony.
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: parsed.data.challengeId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!challenge || challenge.userId !== user.id) {
|
||||
failure = "foreign_challenge";
|
||||
} else {
|
||||
try {
|
||||
const result = await verifyAuthentication(
|
||||
parsed.data.challengeId,
|
||||
parsed.data.credential,
|
||||
);
|
||||
// Both halves matter: a verified assertion against SOMEONE ELSE'S
|
||||
// passkey proves possession of a factor, just not of this account's.
|
||||
proved =
|
||||
result.verification.verified && result.passkey.userId === user.id;
|
||||
if (!proved) failure = "bad_assertion";
|
||||
} catch {
|
||||
// A malformed / expired challenge is a failed attempt, not a 500.
|
||||
failure = "bad_assertion";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!proved) {
|
||||
await auditLog("auth.stepup.mint.failed", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method: parsed.data.method, reason: failure },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.stepup.mint.failed" },
|
||||
meta: { method: parsed.data.method, reason: failure },
|
||||
});
|
||||
return apiError("Verification failed", 401);
|
||||
}
|
||||
|
||||
const { token, expiresAt } = await mintStepUpElevation({
|
||||
userId: user.id,
|
||||
apiTokenId,
|
||||
method,
|
||||
});
|
||||
|
||||
await auditLog("auth.stepup.mint.succeeded", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.stepup.mint.succeeded" },
|
||||
meta: { method },
|
||||
});
|
||||
|
||||
// `elevation` is the raw secret and is returned exactly once. It is never
|
||||
// persisted in plaintext and never logged — no wide-event field carries a
|
||||
// response body, and `redactSecrets` holds an `hle_` rule regardless.
|
||||
return apiSuccess({
|
||||
elevation: token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
expiresInSeconds: STEP_UP_ELEVATION_TTL_SECONDS,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user