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:
Marc-André Bombeck committed 2026-10-29 08:56:51 +01:00
1 parent 35a0ceb12c
commit bbef4eea99
35 files changed
+1654 -187

No files matched your search

@@ -0,0 +1,40 @@
"use client";
import { useEffect } from "react";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useTranslations } from "@/lib/i18n/context";
import { dropParams, writeUrlState } from "@/lib/url-state";
/**
* The outcome a confirmation at the identity provider came back with
* (`?reproof=done|failed`, set by the OIDC callback). Pure, so the parsing is
* testable without a browser.
*/
export function readReproofReturn(search: string): "done" | "failed" | null {
const raw = new URLSearchParams(search).get("reproof");
return raw === "done" || raw === "failed" ? raw : null;
}
/**
* Says once how the confirmation at the identity provider went, then takes the
* marker off the address so a reload does not say it again. The browser lands
* on the page it left, which is where the person repeats what they were
* about to do.
*/
export function OidcReproofReturnBridge() {
const { t } = useTranslations();
useEffect(() => {
const outcome = readReproofReturn(window.location.search);
if (!outcome) return;
writeUrlState(dropParams("reproof"), { mode: "replace", state: "keep" });
toastWrittenOutcome(
outcome === "done" ? "success" : "failed",
outcome === "done"
? t("settings.reproof.oidcDone")
: t("settings.reproof.oidcFailed"),
);
// A later run (a locale switch) finds no marker and does nothing.
}, [t]);
return null;
}
+2
View File
@@ -21,6 +21,7 @@ import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
import { OidcReproofReturnBridge } from "@/components/auth/oidc-reproof-return";
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
@@ -432,6 +433,7 @@ export function Providers({
in its own account, rather than asking every call site to. */}
<SharedRecordGrantLossBridge />
<RecordSessionFenceBridge />
<OidcReproofReturnBridge />
{children}
<AppToaster />
<KeyboardInsetBridge />
@@ -0,0 +1,78 @@
/**
* The web side of confirming at the identity provider (#1296): the dialogs
* offer it when the server names it, the round trip starts from and returns
* to the page the person is on, and the outcome marker is read once.
*/
import { describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import { oidcReproofStartUrl } from "../existing-factor-reauth-dialog";
import { passkeyProofChoices } from "../passkey-list-section";
import { createRecentProofFlow } from "../recent-proof-flow";
import { readReproofReturn } from "@/components/auth/oidc-reproof-return";
describe("passkeyProofChoices", () => {
it("offers only what the account can give once its list is known", () => {
expect(passkeyProofChoices(["oidc"])).toEqual(["oidc"]);
expect(passkeyProofChoices(["password", "oidc", "passkey"])).toEqual([
"password",
"oidc",
"passkey",
]);
expect(passkeyProofChoices(["totp", "something-new"])).toEqual(["totp"]);
});
it("falls back to the four body proofs while the list is loading", () => {
expect(passkeyProofChoices(undefined)).toEqual([
"password",
"totp",
"passkey",
"webauthn",
]);
});
});
describe("oidcReproofStartUrl", () => {
it("names the current page as the way back, without an old outcome", () => {
const href = oidcReproofStartUrl({
pathname: "/settings/api",
search: "?tab=mcp&reproof=failed",
});
const url = new URL(href, "https://healthlog.example.test");
expect(url.pathname).toBe("/api/auth/oidc/reproof");
expect(url.searchParams.get("next")).toBe("/settings/api?tab=mcp");
});
});
describe("readReproofReturn", () => {
it("reads a known outcome off the query string", () => {
expect(readReproofReturn("?tab=mcp&reproof=done")).toBe("done");
expect(readReproofReturn("?reproof=failed")).toBe("failed");
});
it("ignores an address without a known outcome", () => {
expect(readReproofReturn("")).toBeNull();
expect(readReproofReturn("?reproof=maybe")).toBeNull();
});
});
describe("the re-proof dialog flow", () => {
it("opens with the identity provider when that is the account's only way", async () => {
const setMethods = vi.fn();
const flow = createRecentProofFlow({
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
submit: vi.fn(),
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
});
void flow
.run(async () => {
throw new ApiError("Confirm it is you", 401, {
errorCode: "auth.reproof.required",
methods: ["oidc"],
});
})
.catch(() => {});
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
flow.cancel();
});
});
@@ -19,7 +19,30 @@ import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
export type ReauthMethod =
"password" | "oidc" | "totp" | "passkey" | "webauthn";
/**
* Where the browser goes to confirm at the identity provider: the re-proof
* start, told to come back to the page it is on now. A `reproof` outcome left
* on the current address from an earlier round trip is dropped, so the page
* does not come back announcing the old one.
*/
export function oidcReproofStartUrl(current: {
pathname: string;
search: string;
}): string {
const params = new URLSearchParams(current.search);
params.delete("reproof");
const query = params.toString();
const next = `${current.pathname}${query ? `?${query}` : ""}`;
return `/api/auth/oidc/reproof?${new URLSearchParams({ next }).toString()}`;
}
/** Leave for the identity provider. The page unloads; nothing resolves. */
export function startOidcReproof(): void {
window.location.assign(oidcReproofStartUrl(window.location));
}
/** The body the enrollment routes accept as a fresh existing-factor proof. */
export type ExistingFactorProof =
@@ -46,23 +69,19 @@ export function isReproofRequired(err: unknown): boolean {
const REAUTH_METHODS: readonly ReauthMethod[] = [
"password",
"oidc",
"totp",
"passkey",
"webauthn",
];
/**
* The proofs the refusal says this account can give, or null when it names
* none. On an account with a second factor the server leaves the password out,
* so the dialog does not offer a choice that can only be refused.
*/
/**
* The refusal says the account holds nothing it could re-prove here: an
* account that signs in through an identity provider only, with no password,
* passkey or second factor. A dialog would only offer it a password it does
* not have, so the callers say "sign in again" instead — a fresh sign-in is
* the proof such an account can give (`Session.createdAt` within five
* minutes).
* The refusal says the account holds nothing it could re-prove here: no
* password, passkey or second factor, and no identity provider to confirm at
* (an account whose provider the operator has since switched off). A dialog
* would only offer it a password it does not have, so the callers say "sign
* in again" instead — a fresh sign-in is the proof such an account can give
* (`Session.createdAt` within five minutes).
*/
export function nothingToReprove(err: unknown): boolean {
return (
@@ -72,6 +91,11 @@ export function nothingToReprove(err: unknown): boolean {
);
}
/**
* The proofs the refusal says this account can give, or null when it names
* none. On an account with a second factor the server leaves the password out,
* so the dialog does not offer a choice that can only be refused.
*/
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
if (!(err instanceof ApiError)) return null;
const raw = err.meta?.methods;
@@ -87,7 +111,9 @@ export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
* new factor is added. A passkey or security-key assertion begins at the
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
* an assertion challenge and nothing else; the resulting proof is handed to
* `onProof`, which retries the enrollment call with it.
* `onProof`, which retries the enrollment call with it. The identity provider
* is not a proof in the body: choosing it leaves the page for the provider and
* comes back to it, and the action is then repeated by hand.
*/
export function ExistingFactorReauthDialog({
open,
@@ -117,6 +143,11 @@ export function ExistingFactorReauthDialog({
async function submit() {
setAssertionError(null);
if (method === "oidc") {
setAsserting(true);
startOidcReproof();
return;
}
if (method === "password") {
onProof({ method, password: secret });
return;
@@ -152,6 +183,7 @@ export function ExistingFactorReauthDialog({
const labels: Record<ReauthMethod, string> = {
password: t("settings.passkeyReauth.methods.password"),
oidc: t("settings.passkeyReauth.methods.oidc"),
totp: t("settings.passkeyReauth.methods.totp"),
passkey: t("settings.passkeyReauth.methods.passkey"),
webauthn: t("settings.passkeyReauth.methods.webauthn"),
@@ -223,6 +255,10 @@ export function ExistingFactorReauthDialog({
/>
)}
{method === "oidc" && (
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
)}
{shown && (
<div
role="alert"
@@ -253,7 +289,9 @@ export function ExistingFactorReauthDialog({
{busy && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.passkeyReauth.verifyAndContinue")}
{method === "oidc"
? t("settings.passkeyReauth.continueToProvider")
: t("settings.passkeyReauth.verifyAndContinue")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -20,6 +20,7 @@ interface MfaStatus {
recoveryCodesRemaining: number;
webauthn: WebauthnKeyInfo[];
passkeyNudgeDismissed: boolean;
proofMethods: string[];
}
interface PasskeyInfo {
@@ -71,7 +72,10 @@ export function SecuritySection() {
totpEnabled={status.totp.enabled}
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<PasskeyListSection
isAuthenticated={isAuthenticated}
proofMethods={status.proofMethods}
/>
</>
)}
@@ -45,6 +45,11 @@ import {
} from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
import { describeStepUp } from "./security-keys-card";
import {
isReproofRequired,
startOidcReproof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
interface PasskeyInfo {
@@ -56,17 +61,102 @@ interface PasskeyInfo {
lastUsedAt: string | null;
}
type ReauthMethod = "password" | "passkey" | "totp" | "webauthn";
type ExistingFactorProof =
| { method: "password"; password: string }
| { method: "totp"; code: string }
| { method: "oidc" }
| {
method: "passkey" | "webauthn";
challengeId: string;
credential: unknown;
};
/** What the card offers while the account's own list has not arrived. */
const FALLBACK_METHODS: readonly ReauthMethod[] = [
"password",
"totp",
"passkey",
"webauthn",
];
/**
* The proofs to offer before adding a passkey: the account's own list when the
* status read has it, so an account without a password is not handed a
* password field, and the four body proofs otherwise.
*/
export function passkeyProofChoices(
proofMethods: readonly string[] | undefined,
): ReauthMethod[] {
if (!proofMethods) return [...FALLBACK_METHODS];
const known: readonly ReauthMethod[] = [
"password",
"oidc",
"totp",
"passkey",
"webauthn",
];
return proofMethods.filter((m): m is ReauthMethod =>
known.includes(m as ReauthMethod),
);
}
/**
* Prove an existing factor, then run the registration ceremony. The proof
* finishes before the first registration challenge is requested, so a
* cancelled or failed proof leaves no enrollable challenge behind.
*/
async function addPasskey(method: ReauthMethod, secret: string): Promise<void> {
const webauthn = await import("@simplewebauthn/browser");
let freshFactor: ExistingFactorProof;
if (method === "oidc") {
// No secret: the proof is a provider re-proof already on the session.
freshFactor = { method };
} else if (method === "password") {
freshFactor = { method, password: secret };
} else if (method === "totp") {
freshFactor = { method, code: secret };
} else {
const assertionChallenge = await apiPost<{
options: Parameters<
typeof webauthn.startAuthentication
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", { method });
const credential = await webauthn.startAuthentication({
optionsJSON: assertionChallenge.options,
});
freshFactor = {
method,
challengeId: assertionChallenge.challengeId,
credential,
};
}
let begun: {
options: Parameters<typeof webauthn.startRegistration>[0]["optionsJSON"];
challengeId: string;
};
try {
begun = await apiPost("/api/auth/passkey/register-options", freshFactor);
} catch (err) {
// No provider re-proof on the session yet: go and get one. The page
// unloads and comes back here, so the mutation stays pending rather
// than flashing an error on the way out.
if (method !== "oidc" || !isReproofRequired(err)) throw err;
startOidcReproof();
return new Promise<never>(() => {});
}
const { options, challengeId } = begun;
const credential = await webauthn.startRegistration({
optionsJSON: options,
});
await apiPost("/api/auth/passkey/register-verify", {
challengeId,
credential,
});
}
/**
* Primary-passkey management: list (name, device, backup, created, last used),
* add (registration ceremony), rename, and delete. The passwordless-primary
@@ -74,8 +164,11 @@ type ExistingFactorProof =
*/
export function PasskeyListSection({
isAuthenticated,
proofMethods,
}: {
isAuthenticated: boolean;
/** `proofMethods` from `GET /api/auth/me/mfa`. */
proofMethods?: readonly string[];
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -83,7 +176,10 @@ export function PasskeyListSection({
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>("password");
const choices = passkeyProofChoices(proofMethods);
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>(
choices[0] ?? "password",
);
const [reauthSecret, setReauthSecret] = useState("");
const { data: passkeys } = useQuery({
@@ -93,56 +189,18 @@ export function PasskeyListSection({
});
const add = useMutation({
mutationFn: async ({
mutationFn: ({
method,
secret,
}: {
method: ReauthMethod;
secret: string;
}) => {
const webauthn = await import("@simplewebauthn/browser");
let freshFactor: ExistingFactorProof;
if (method === "password") {
freshFactor = { method, password: secret };
} else if (method === "totp") {
freshFactor = { method, code: secret };
} else {
const assertionChallenge = await apiPost<{
options: Parameters<
typeof webauthn.startAuthentication
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", { method });
const credential = await webauthn.startAuthentication({
optionsJSON: assertionChallenge.options,
});
freshFactor = {
method,
challengeId: assertionChallenge.challengeId,
credential,
};
}
const { options, challengeId } = await apiPost<{
options: Parameters<
typeof webauthn.startRegistration
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", freshFactor);
const credential = await webauthn.startRegistration({
optionsJSON: options,
});
await apiPost("/api/auth/passkey/register-verify", {
challengeId,
credential,
});
},
}) => addPasskey(method, secret),
onSuccess: () => {
setMsg(null);
setReauthOpen(false);
setReauthSecret("");
setReauthMethod("password");
setReauthMethod(choices[0] ?? "password");
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
},
@@ -195,6 +253,14 @@ export function PasskeyListSection({
},
});
const methodLabels: Record<ReauthMethod, string> = {
password: t("settings.passkeyReauth.methods.password"),
oidc: t("settings.passkeyReauth.methods.oidc"),
totp: t("settings.passkeyReauth.methods.totp"),
passkey: t("settings.passkeyReauth.methods.passkey"),
webauthn: t("settings.passkeyReauth.methods.webauthn"),
};
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
@@ -350,7 +416,14 @@ export function PasskeyListSection({
onOpenChange={(open) => {
if (add.isPending) return;
setReauthOpen(open);
if (open) setMsg(null);
if (open) {
setMsg(null);
// The choice the account's list leads with, in case the list
// arrived after the card first rendered.
if (!choices.includes(reauthMethod)) {
setReauthMethod(choices[0] ?? "password");
}
}
}}
>
<SettingsCardActions>
@@ -384,14 +457,7 @@ export function PasskeyListSection({
role="group"
aria-label={t("settings.passkeyReauth.methodLabel")}
>
{(
[
["password", t("settings.passkeyReauth.methods.password")],
["totp", t("settings.passkeyReauth.methods.totp")],
["passkey", t("settings.passkeyReauth.methods.passkey")],
["webauthn", t("settings.passkeyReauth.methods.webauthn")],
] satisfies Array<[ReauthMethod, string]>
).map(([method, label]) => (
{choices.map((method) => (
<Button
key={method}
type="button"
@@ -407,11 +473,15 @@ export function PasskeyListSection({
setMsg(null);
}}
>
{label}
{methodLabels[method]}
</Button>
))}
</div>
{reauthMethod === "oidc" && (
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
)}
{reauthMethod === "password" && (
<Input
type="password"
@@ -14,6 +14,7 @@ import {
const KNOWN_METHODS: readonly ReauthMethod[] = [
"password",
"oidc",
"totp",
"webauthn",
"passkey",
@@ -93,7 +94,9 @@ export function createRecentProofFlow(deps: {
if (!isProofRefusal(err)) throw err;
const offered = methodsFrom(err as ApiError);
// Nothing to re-prove in place: an account that signs in through an
// identity provider only. A fresh sign-in is the proof it can give.
// identity provider the instance no longer offers. A fresh sign-in is
// the proof it can give. One that can still reach its provider is
// offered `oidc` instead, which leaves the page and comes back to it.
if (offered.length === 0) {
throw new ApiError(deps.messages().signInAgain, 401, {
errorCode: "auth.reproof.sign_in_again",