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;
}