Files
HealthLog/src/components/settings/security-section/index.tsx
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

101 lines
3.5 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { PasswordCard } from "@/components/settings/password-card";
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
import { TotpCard } from "./totp-card";
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
import { PasskeyListSection } from "./passkey-list-section";
import { PasskeyUpgradeNudge } from "./passkey-upgrade-nudge";
interface MfaStatus {
totp: { enabled: boolean };
recoveryCodesRemaining: number;
webauthn: WebauthnKeyInfo[];
passkeyNudgeDismissed: boolean;
proofMethods: string[];
}
interface PasskeyInfo {
id: string;
}
export function SecuritySection() {
const { isAuthenticated } = useAuth();
const { data: status, isLoading } = useQuery({
queryKey: queryKeys.mfaStatus(),
queryFn: async () => apiGet<MfaStatus>("/api/auth/me/mfa"),
enabled: isAuthenticated,
});
// Passkey count drives whether the upgrade nudge shows. Shares the cached
// `passkeys()` key with the list below, so this is not an extra round-trip.
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
enabled: isAuthenticated,
});
const showNudge =
status != null &&
!status.passkeyNudgeDismissed &&
passkeys != null &&
passkeys.length === 0;
return (
<div className="space-y-6">
{showNudge && <PasskeyUpgradeNudge />}
{isLoading || !status ? (
<>
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</>
) : (
<>
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
<SecurityKeysCard
keys={status.webauthn}
totpEnabled={status.totp.enabled}
/>
<PasskeyListSection
isAuthenticated={isAuthenticated}
proofMethods={status.proofMethods}
/>
</>
)}
{/* The password comes last: authenticator, then security keys, then
passkeys, then the password. It sits OUTSIDE the status conditional
on purpose — it depends on nothing this page fetches and is the only
way to change a password now that the Account card is gone, so a
`/api/auth/me/mfa` that never answers must not take it down with the
cards that do need the status. */}
<PasswordCard />
{/* v1.44 — who is signed in and what happened, beside the controls
that decide how one signs in. These lived on the privacy page;
`/settings/privacy#sessions` (and the other two anchors) forward
here. Outside the status conditional for the same reason as the
password: they read their own routes. */}
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
<SecurityActivityCard isAuthenticated={isAuthenticated} />
</div>
);
}