mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(auth): adding a credential beside a second factor needs that factor
On an account with a second factor, TOTP setup, security-key registration and passkey registration no longer accept the password, a password re-proof or a session younger than five minutes. A remembered browser skips the second factor at sign-in and still creates a fresh session, so the password alone could add a key that then satisfies every gate the factor protected. Only a TOTP code, a security key, a passkey or a completed second factor within five minutes opens enrollment there; the Bearer arm of TOTP setup and security-key registration asks the same of its elevation. Accounts without a second factor keep the previous rules. The refusal names the proofs the account can give, and the enrollment dialogs offer exactly those. A proof that verifies now gives its attempt back to the shared re-proof budget, including the current password on a password change.
This commit is contained in:
1 parent
66b1aef40c
commit
0692017f34
14 files changed
+519
-88
No files matched your search
@@ -44,6 +44,28 @@ export function isReproofRequired(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const REAUTH_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"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.
|
||||
*/
|
||||
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
const raw = err.meta?.methods;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const offered = raw.filter((m): m is ReauthMethod =>
|
||||
REAUTH_METHODS.includes(m as ReauthMethod),
|
||||
);
|
||||
return offered.length > 0 ? offered : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a fresh proof of a credential the account already holds, before a
|
||||
* new factor is added. A passkey or security-key assertion begins at the
|
||||
|
||||
@@ -36,6 +36,7 @@ import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
@@ -98,8 +99,14 @@ export function SecurityKeysCard({
|
||||
// options call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const reauthMethods: ReauthMethod[] = [
|
||||
"password",
|
||||
// An account with a second factor confirms with that factor or a passkey;
|
||||
// the server refuses a password there and names what it will take.
|
||||
const hasSecondFactor = totpEnabled || keys.length > 0;
|
||||
const [offeredMethods, setOfferedMethods] = useState<ReauthMethod[] | null>(
|
||||
null,
|
||||
);
|
||||
const reauthMethods: ReauthMethod[] = offeredMethods ?? [
|
||||
...(hasSecondFactor ? [] : (["password"] as const)),
|
||||
...(totpEnabled ? (["totp"] as const) : []),
|
||||
"passkey",
|
||||
...(keys.length > 0 ? (["webauthn"] as const) : []),
|
||||
@@ -126,6 +133,7 @@ export function SecurityKeysCard({
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setOfferedMethods(offeredReauthMethods(err));
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
|
||||
@@ -36,7 +36,9 @@ import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
interface SetupData {
|
||||
@@ -128,6 +130,11 @@ export function TotpCard({
|
||||
// call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const [reauthMethods, setReauthMethods] = useState<ReauthMethod[]>([
|
||||
"password",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
]);
|
||||
|
||||
const beginSetup = useMutation({
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
@@ -148,6 +155,8 @@ export function TotpCard({
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
const offered = offeredReauthMethods(err);
|
||||
if (offered) setReauthMethods(offered);
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
@@ -278,7 +287,7 @@ export function TotpCard({
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={["password", "passkey", "webauthn"]}
|
||||
methods={reauthMethods}
|
||||
pending={beginSetup.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => beginSetup.mutate(proof)}
|
||||
|
||||
Reference in new issue
Block a user