feat(oidc): explicit rejection reason

Show explicitly login rejection (for example when a user does not have a valid
email reported from OIDC but email verification is set)
This commit is contained in:
Edouard Vanbelle
2026-08-14 13:13:43 +02:00
parent 5d44b0ea75
commit 0d5a726ef4
21 changed files with 419 additions and 70 deletions
+81
View File
@@ -0,0 +1,81 @@
/**
* Coverage for the `?login_error=<key>` → user-facing copy mapping. The
* i18n `t()` module is stubbed to echo the fallback string verbatim so
* the assertions read naturally without pulling in the full i18n bag.
*/
import { describe, expect, it, vi } from 'vitest';
// Stub $lib/i18n before importing the module under test — the `t`
// function in the real module needs the i18n bag to be initialized;
// here we just want to see which fallback string each case returns.
vi.mock('$lib/i18n/index.svelte', () => ({
t: (_key: string, ...rest: unknown[]) => {
// Real `t` signatures: t(key, fallback) or t(key, params, fallback).
// Whichever shape is used, the fallback is the last string arg.
for (let i = rest.length - 1; i >= 0; i--) {
if (typeof rest[i] === 'string') return rest[i] as string;
}
return _key;
}
}));
import { loginErrorMessage } from './loginError';
describe('loginErrorMessage', () => {
// ── OIDC-callback rejection reasons the backend emits post-refactor
// (project_oidc_callback_error_specific_reasons memory). These
// were the whole point of the refactor: distinct, targeted copy
// rather than the misleading "sign-in link expired" bucket.
it('email_not_verified_at_idp → verify-at-IdP prompt', () => {
const msg = loginErrorMessage('email_not_verified_at_idp');
expect(msg).toMatch(/verified/i);
expect(msg).toMatch(/identity provider/i);
});
it('email_verification_required → server policy + admin hint', () => {
const msg = loginErrorMessage('email_verification_required');
expect(msg).toMatch(/verified/i);
expect(msg).toMatch(/administrator/i);
});
// ── Auto-link refusals (docs/plan/oidc-account-linking.md § Auto-link)
it('auto_link_disabled → server-policy explanation', () => {
expect(loginErrorMessage('auto_link_disabled')).toMatch(/auto-link/i);
});
it('auto_link_email_not_verified → verify-then-retry', () => {
const msg = loginErrorMessage('auto_link_email_not_verified');
expect(msg).toMatch(/verify|verified/i);
});
it('already_linked_elsewhere → admin escalation', () => {
expect(loginErrorMessage('already_linked_elsewhere')).toMatch(/administrator/i);
});
it('email_ambiguous → admin escalation', () => {
expect(loginErrorMessage('email_ambiguous')).toMatch(/administrator/i);
});
// ── Generic callback failure buckets — kept for backward compat +
// for any AccessDenied path that didn't get its own reason yet.
it('callback_denied → link-expired copy', () => {
expect(loginErrorMessage('callback_denied')).toMatch(/expired|already used|try/i);
});
it('callback_failed → generic retry', () => {
expect(loginErrorMessage('callback_failed')).toMatch(/try again/i);
});
// ── Forward-compat: unknown keys never blank out. A new backend
// reason without an explicit case here still surfaces SOMETHING
// the user can act on (retry).
it('unknown key → generic non-empty fallback', () => {
const msg = loginErrorMessage('never_heard_of_this_reason');
expect(msg).toBeTruthy();
expect(msg.length).toBeGreaterThan(10);
});
it('empty key → generic non-empty fallback', () => {
expect(loginErrorMessage('')).toBeTruthy();
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Stable `?login_error=<key>` translation table.
*
* The OIDC callback handler (`src/interfaces/api/handlers/auth_handler.rs`)
* emits one of these snake_case keys on any rejection redirect. This
* module maps each to localized copy the login page renders on mount.
*
* Adding a new backend reason: pick a matching snake_case key in the
* handler, add a case here + an `auth.login_error_<key>` i18n entry.
* Unknown keys silently fall back to the generic copy — a new backend
* reason without a FE entry surfaces something the user can act on,
* not a blank string.
*
* Extracted from `routes/login/+page.svelte` so a Vitest unit can
* exercise the mapping in isolation without a browser.
*/
import { t } from '$lib/i18n/index.svelte';
export function loginErrorMessage(key: string): string {
switch (key) {
case 'auto_link_disabled':
return t(
'auth.login_error_auto_link_disabled',
'This server does not auto-link SSO accounts. Sign in with your existing credentials, then connect SSO from your profile.'
);
case 'auto_link_email_not_verified':
return t(
'auth.login_error_auto_link_email_not_verified',
'Your SSO provider did not confirm your email address. Verify your email at your identity provider, then try again.'
);
case 'already_linked_elsewhere':
return t(
'auth.login_error_already_linked_elsewhere',
'A local account with this email already exists and is linked to a different SSO identity. Contact your administrator.'
);
case 'email_ambiguous':
return t(
'auth.login_error_email_ambiguous',
'Multiple local accounts match this email address. Contact your administrator to resolve.'
);
case 'callback_denied':
return t(
'auth.login_error_callback_denied',
'Your sign-in link expired or was already used. Please try signing in again.'
);
case 'callback_failed':
return t(
'auth.login_error_callback_failed',
"SSO sign-in couldn't complete. Please try again."
);
case 'email_not_verified_at_idp':
return t(
'auth.login_error_email_not_verified_at_idp',
'Your identity provider reports that your email address is not verified. Confirm your email at your identity provider, then try signing in again.'
);
case 'email_verification_required':
return t(
'auth.login_error_email_verification_required',
'This server requires a verified email. Your identity provider did not include an email-verification claim. Contact your administrator.'
);
default:
return t('auth.login_error_generic', 'SSO sign-in was refused. Please try again.');
}
}
+3 -41
View File
@@ -21,6 +21,7 @@
type OidcProviders
} from '$lib/api/endpoints/auth';
import { i18n, SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { loginErrorMessage } from '$lib/auth/loginError';
import { session } from '$lib/stores/session.svelte';
import { hasSessionHint } from '$lib/api/csrf';
@@ -309,47 +310,8 @@
}
}
// Reason keys mirror the OIDC callback's redirect arms in
// auth_handler.rs — snake_case, matching the URL param shape used
// by the sibling /profile?link_error=<reason> flow. Any unknown
// key falls back to the generic copy so a new backend reason never
// blanks out the notice.
function loginErrorMessage(key: string): string {
switch (key) {
case 'auto_link_disabled':
return t(
'auth.login_error_auto_link_disabled',
'This server does not auto-link SSO accounts. Sign in with your existing credentials, then connect SSO from your profile.'
);
case 'auto_link_email_not_verified':
return t(
'auth.login_error_auto_link_email_not_verified',
'Your SSO provider did not confirm your email address. Verify your email at your identity provider, then try again.'
);
case 'already_linked_elsewhere':
return t(
'auth.login_error_already_linked_elsewhere',
'A local account with this email already exists and is linked to a different SSO identity. Contact your administrator.'
);
case 'email_ambiguous':
return t(
'auth.login_error_email_ambiguous',
'Multiple local accounts match this email address. Contact your administrator to resolve.'
);
case 'callback_denied':
return t(
'auth.login_error_callback_denied',
'Your sign-in link expired or was already used. Please try signing in again.'
);
case 'callback_failed':
return t(
'auth.login_error_callback_failed',
"SSO sign-in couldn't complete. Please try again."
);
default:
return t('auth.login_error_generic', 'SSO sign-in was refused. Please try again.');
}
}
// `?login_error=<key>` → localized copy lives in $lib/auth/loginError
// (extracted so a Vitest can exercise the mapping in isolation).
onMount(async () => {
// 0) Consume the one-shot `?source=session_expired` flag, if any.