fix(oidc): a sign-in links to an account by email only when the address was not set by the account itself

An SSO login that matched no account by its subject took the account with the
same email address and linked the provider identity to it. That address proves
nothing when the account typed it in itself: anyone could sign up with, or
switch their profile to, a colleague's address and receive the colleague's
first SSO login. The provider switch branch (#2110) relinked a row on any email
match without even asking for email_verified, and also for another subject of
the same provider.

users.email_self_asserted remembers an address the account set itself: signup
(not the first account) and an email change in the profile settings set it, an
admin entering a new address clears it, and accounts the provider creates start
without it. Existing accounts keep linking, except those whose address moved
away from the registered one without an admin editing it. A login by email now
needs a verified address and an unmarked account, and is refused for another
subject of the same issuer, with email_link_blocked and a translated message on
the login screen. An email change in the profile settings writes a
user.email_change audit row.

Refs #2110
This commit is contained in:
Maurice
2026-10-12 17:06:52 +02:00
parent 1b58acc78f
commit 3d7e8be9aa
49 changed files with 688 additions and 29 deletions
+19
View File
@@ -392,6 +392,25 @@ describe('useLogin — OIDC callback', () => {
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
});
it('FE-LOGIN-HOOK-020b: says why a sign-in was not linked to an existing account by email', async () => {
setSearch('?oidc_error=email_link_blocked');
const blocked = renderLogin();
await waitFor(() =>
expect(blocked.result.current.error).toBe(
'This sign-in could not be linked to the existing account with this email. Ask your administrator to confirm the address.',
),
);
blocked.unmount();
setSearch('?oidc_error=email_not_verified');
const unverified = renderLogin();
await waitFor(() =>
expect(unverified.result.current.error).toBe(
'Your sign-in provider has not confirmed this email address, so it cannot be linked to an existing account.',
),
);
});
it('FE-LOGIN-HOOK-021: passes an unknown oidc_error through unchanged', async () => {
setSearch('?oidc_error=some_new_provider_code');
+2
View File
@@ -237,6 +237,8 @@ export function useLogin() {
no_email: t('login.oidc.noEmail'),
token_failed: t('login.oidc.tokenFailed'),
invalid_state: t('login.oidc.invalidState'),
email_not_verified: t('login.oidc.emailNotVerified'),
email_link_blocked: t('login.oidc.emailLinkBlocked'),
}
setError(errorMessages[oidcError] || oidcError)
sessionStorage.removeItem('oidc_redirect')