fix(auth): a new email address on an account with a second factor needs that factor

The address is what single sign-on links an existing account by, and
changing it took the password or a session younger than five minutes on
every account. A remembered browser skips the second factor at sign-in and
still makes a fresh session, so the password alone could move the address
of an account the second factor was meant to protect.

On an account with a second factor only a second factor or passkey
completed in the last five minutes counts now; the password is not
checked. The refusal names it (second_factor_required in rejectedFields,
or 401 profile.update.emailSecondFactorRequired when the address was all
that changed), and the profile form opens the re-proof dialog with the
factors the account holds, then saves again, no longer offering the
password field. Accounts without a second factor keep the password or a
recent sign-in. A token cannot change the address of such an account;
the app does not change it.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 00:59:06 +02:00
1 parent bd270d635a
commit 9642c749c9
16 files changed
+280 -61

No files matched your search

@@ -34,7 +34,11 @@ import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import {
describeRejectedProfileField,
describeRejectedProfileFields,
@@ -75,6 +79,12 @@ export function AccountSection() {
// in within the last few minutes; the server decides and names the field if
// it wanted one. Never stored beyond this form.
const [emailPassword, setEmailPassword] = useState("");
// On an account with a second factor the password does not move the
// address: the server asks for the factor instead (`second_factor_required`)
// and the save runs once more after the re-proof dialog. Once that is
// known the password field is not offered, since it could only be refused.
const [emailNeedsFactor, setEmailNeedsFactor] = useState(false);
const recentProof = useRecentProof();
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
@@ -193,8 +203,46 @@ export function AccountSection() {
email.trim() !== "" &&
email.trim().toLowerCase() !== profileSeed.email.trim().toLowerCase();
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
/**
* Did the server keep the new address back until a second factor is
* proved? Either as the one rejected field of a partial save, or as the
* whole answer when the address was all that changed.
*/
function needsSecondFactor(
rejected: RejectedProfileField[] | undefined,
errorCode: string | undefined,
): boolean {
return (
errorCode === "profile.update.emailSecondFactorRequired" ||
(rejected ?? []).some(
(r) => r.path === "email" && r.code === "second_factor_required",
)
);
}
/**
* Ask for the second factor (or a passkey) through the shared re-proof
* dialog, then save once more. Returns false when the person closed the
* dialog or could not confirm, leaving the message to show.
*/
async function proveFactorThenSave(): Promise<boolean> {
setEmailNeedsFactor(true);
try {
await recentProof.run(() => apiGet("/api/auth/reproof"));
} catch (err) {
const message = recentProofErrorMessage(err, "");
if (message) {
setSaveMsg({ text: message });
setSaveMsgType("error");
}
return false;
}
await handleSaveProfile(undefined, true);
return true;
}
async function handleSaveProfile(e?: React.FormEvent, afterFactor = false) {
e?.preventDefault();
setSaving(true);
setSaveMsg(null);
setSaveMsgType(null);
@@ -255,6 +303,10 @@ export function AccountSection() {
} | null;
const rejected = profileJson?.data?.rejectedFields;
if (!afterFactor && needsSecondFactor(rejected, undefined)) {
if (await proveFactorThenSave()) return;
}
if (rejected && rejected.length > 0) {
// The write is field-independent: everything but the rejected
// field(s) landed. Name the field instead of implying full
@@ -304,6 +356,9 @@ export function AccountSection() {
details?: { issues?: RejectedProfileField[] };
} | null;
const errorCode = json?.meta?.errorCode;
if (!afterFactor && needsSecondFactor(undefined, errorCode)) {
if (await proveFactorThenSave()) return;
}
const field = describeRejectedProfileField(json?.details?.issues, t);
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
@@ -350,7 +405,10 @@ export function AccountSection() {
<SettingsCard>
<SettingsCardHeader icon={User} title={t("settings.profile")} />
<form onSubmit={handleSaveProfile} className="space-y-4">
<form
onSubmit={(event) => void handleSaveProfile(event)}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
@@ -386,7 +444,7 @@ export function AccountSection() {
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && (
{emailChanged && !emailNeedsFactor && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
@@ -636,6 +694,7 @@ export function AccountSection() {
</Button>
</SettingsCardActions>
</form>
{recentProof.dialog}
</SettingsCard>
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated