Files
HealthLog/src/lib/auth/second-factor.ts
T
Marc-André Bombeck 567a564913 perf(auth): resolve the caller to a narrow user projection
The session lookup and the Bearer resolver loaded the whole users row on
every authenticated request, including the profile picture bytes, the
cached briefing text and every stored integration credential. Both now
select SESSION_USER_SELECT, a named allowlist of the small columns the
request path reads, and so does the owner load for a switched record.

The few routes that need a credential or a large column read it
themselves at the moment of use: the factor verifiers load the password
hash and TOTP secret, the profile response loads the insurance number,
and the snapshot read loads the cached briefing text. A structural guard
holds the lookups to the projection and keeps every Bytes and
secret-shaped column out of it.
2026-10-20 20:07:25 +02:00

79 lines
3.3 KiB
TypeScript

import { prisma } from "@/lib/db";
/**
* Does this account have a SECOND FACTOR at all?
*
* Either factor enrols it: a confirmed TOTP secret OR a registered WebAuthn
* security key. A webauthn-only account must clear step-up too, so every
* boundary that asks this question tracks `requireFreshMfa`'s either-factor
* rule rather than reading `totpConfirmedAt` alone. It was written out three
* times before it was a function, which is two more places for the second arm
* to be forgotten.
*
* DELIBERATELY DOES NOT COUNT A PRIMARY PASSKEY, even though a passkey login
* stamps `mfaVerifiedAt` and could therefore satisfy the gate. This predicate
* answers "is there a second factor", and that is the question
* `requireFreshMfaIfEnrolled` asks before deciding whether account deletion, the
* data reset, the password change, the encrypted export and key rotation demand
* a step-up at all. Counting a passkey here would silently pull every
* passkey-holding account into a gate those routes have never applied to them —
* a behaviour change to five destructive actions, made in passing, for the
* benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question
* lives.
*
* Since v1.43.3 a passkey DOES count as a second factor where the gate is
* unconditional: `requireFreshMfa` defaults to `any-possession`, so a
* passkey-only account clears the managed-profile acts with a passkey
* re-proof instead of being told to enrol something. That decision was made
* there and not here, on purpose; the note beside `FreshFactorProofSource`
* lists what it does and does not change.
*/
export async function hasSecondFactorEnrolled(user: {
id: string;
totpConfirmedAt: Date | null;
}): Promise<boolean> {
if (user.totpConfirmedAt) return true;
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
});
return webauthnKeyCount > 0;
}
/**
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
* account without a second factor; `totp`, `webauthn` and `passkey` on one
* with a second factor, where a password alone has never been enough.
*/
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
/**
* The proofs that would satisfy `assertRecentCookieProof` for this
* account. Also what `POST /api/auth/reproof` accepts, so the dialog and the
* endpoint cannot disagree.
*/
export async function recentProofMethods(
user: { id: string; totpConfirmedAt: Date | null },
enrolled?: boolean,
): Promise<RecentProofMethod[]> {
const hasSecond = enrolled ?? (await hasSecondFactorEnrolled(user));
// Whether a password exists is read here rather than off the caller's row:
// the hash is not part of the per-request user (`SESSION_USER_SELECT`).
const [keys, passkeys, credential] = await Promise.all([
prisma.webauthnMfaCredential.count({ where: { userId: user.id } }),
prisma.passkey.count({ where: { userId: user.id } }),
prisma.user.findUnique({
where: { id: user.id },
select: { passwordHash: true },
}),
]);
const methods: RecentProofMethod[] = [];
if (hasSecond) {
if (user.totpConfirmedAt) methods.push("totp");
if (keys > 0) methods.push("webauthn");
} else if (credential?.passwordHash) {
methods.push("password");
}
if (passkeys > 0) methods.push("passkey");
return methods;
}