mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user