Files
HealthLog/src/lib/auth/second-factor.ts
T
Marc-André Bombeck bbef4eea99 feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
2026-10-29 08:56:51 +01:00

100 lines
4.2 KiB
TypeScript

import { prisma } from "@/lib/db";
import { isOidcConfigured } from "@/lib/auth/oidc";
/**
* 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 `auth/guards/fresh-factor.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`, `oidc` 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.
*
* `oidc` is a fresh sign-in at the account's linked identity provider
* (`GET /api/auth/oidc/reproof`), not a body proof: it stamps
* `Session.reproofAt` exactly as a password does, and like a password it never
* stands in for a second factor.
*/
export type RecentProofMethod =
"password" | "oidc" | "totp" | "webauthn" | "passkey";
/**
* The proofs that stamp only `Session.reproofAt` and so never clear a gate that
* asks for a second factor (`requireFreshMfa`).
*/
export function isFirstFactorOnlyProof(method: RecentProofMethod): boolean {
return method === "password" || method === "oidc";
}
/**
* 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, oidcIssuer: true, oidcSub: 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");
// A linked identity is only a way to confirm while the instance still
// talks to a provider: with the provider group unset the round trip has
// nowhere to go.
if (credential?.oidcIssuer && credential.oidcSub && isOidcConfigured()) {
methods.push("oidc");
}
}
if (passkeys > 0) methods.push("passkey");
return methods;
}