mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user