mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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.
This commit is contained in:
1 parent
35a0ceb12c
commit
bbef4eea99
35 files changed
+1654
-187
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
|
||||
/**
|
||||
* Does this account have a SECOND FACTOR at all?
|
||||
@@ -40,11 +41,25 @@ export async function hasSecondFactorEnrolled(user: {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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" | "totp" | "webauthn" | "passkey";
|
||||
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
|
||||
@@ -63,15 +78,21 @@ export async function recentProofMethods(
|
||||
prisma.passkey.count({ where: { userId: user.id } }),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { passwordHash: true },
|
||||
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");
|
||||
} 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;
|
||||
|
||||
Reference in new issue
Block a user