Files
HealthLog/src/app/api/auth/me/mfa/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

42 lines
1.5 KiB
TypeScript

/**
* GET /api/auth/me/mfa
*
* Second-factor status for the security settings hub: whether TOTP is active,
* how many recovery codes remain, and the registered WebAuthn security keys.
* 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, requireMfaManagementAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireMfaManagementAuth();
const [recoveryCodesRemaining, webauthn] = await Promise.all([
countRemainingRecoveryCodes(user.id),
prisma.webauthnMfaCredential.findMany({
where: { userId: user.id },
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
orderBy: { createdAt: "desc" },
}),
]);
annotate({ action: { name: "auth.mfa.status" } });
return apiSuccess({
totp: { enabled: Boolean(user.totpConfirmedAt) },
recoveryCodesRemaining,
webauthn,
// The security hub also drives the passkey-upgrade nudge from this one
// status read, so it knows whether the user has already dismissed it.
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
});
});