Files
HealthLog/src/app/api/auth/step-up/options/route.ts
T
Marc-André Bombeck e557d9e9e6 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.
2026-07-19 12:42:00 +02:00

52 lines
1.8 KiB
TypeScript

/**
* 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 });
});