fix(auth): link single sign-on to an existing account only after its own sign-in

The SSO callback bound a verified provider email to the local account with
the same address on the spot. The local address is not verified: anyone
could register with it, or type it into their profile, and the SSO user
then landed in an account the other person still held the password to.

The callback now links nothing. It records the link it would make in a
sealed, ten-minute, HttpOnly cookie and sends the browser to the sign-in
page, and the link is made only when that browser then signs in to that
same account with its password (and second factor) or passkey. Under an
SSO-only policy the password form opens for exactly that account. The
native sign-in answers oidc_link_required; the link is confirmed once on
the web.

Moving an account to a new email address now also needs a fresh proof: a
sign-in or second factor in the last five minutes, or the current password
sent alongside the change. Without it the address is left as it was and
reported in rejectedFields; the rest of the save still lands.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 16:10:32 +02:00
1 parent 7b5bc130d8
commit 8da7583ec6
27 files changed
+878 -76

No files matched your search

@@ -116,7 +116,11 @@ describe("change-password card placement", () => {
"settings.passwordReset",
"settings.changePassword",
"/api/auth/password",
"current-password",
// A change-password form is the pair current + new. The Account form
// asks for the CURRENT password alone, beside a new email address,
// which is a proof and not a password change; the new-password half is
// what would mean the old form came back.
"new-password",
]) {
expect(account, `Account still carries ${marker}`).not.toContain(marker);
}
@@ -71,6 +71,10 @@ export function AccountSection() {
);
const [email, setEmail] = useState("");
// A new email address needs the current password unless the session signed
// 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("");
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
@@ -185,6 +189,10 @@ export function AccountSection() {
navigate,
});
const emailChanged =
email.trim() !== "" &&
email.trim().toLowerCase() !== profileSeed.email.trim().toLowerCase();
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
@@ -212,6 +220,9 @@ export function AccountSection() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
@@ -229,6 +240,8 @@ export function AccountSection() {
: Promise.resolve({ ok: true } as Response),
]);
setEmailPassword("");
if (profileRes.ok && tzRes.ok) {
// Instant same-tab flip of every rendered timestamp (issue #490):
// mirror the accepted zone before the `/me` refetch confirms it —
@@ -373,6 +386,24 @@ export function AccountSection() {
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
</Label>
<Input
id="email-current-password"
type="password"
value={emailPassword}
onChange={(e) => {
setEmailPassword(e.target.value);
clearFieldError("email");
}}
autoComplete="current-password"
maxLength={512}
/>
</div>
)}
</div>
</div>