mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
100 lines
4.2 KiB
TypeScript
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;
|
|
}
|