mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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.
52 lines
1.8 KiB
TypeScript
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 });
|
|
});
|