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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "تعذر إكمال تسجيل الدخول الموحّد (SSO). يرجى المحاولة مرة أخرى.",
|
||||
"login_error_generic": "تم رفض تسجيل الدخول الموحّد (SSO). يرجى المحاولة مرة أخرى.",
|
||||
"login_error_email_ambiguous": "تتطابق الحسابات المحلية المتعددة مع عنوان البريد الإلكتروني هذا. اتصل بالمسؤول لحل المشكلة.",
|
||||
"logged_out": "تم تسجيل الخروج بنجاح."
|
||||
"logged_out": "تم تسجيل الخروج بنجاح.",
|
||||
"login_error_email_not_verified_at_idp": "يفيد مزود الهوية الخاص بك بأن عنوان بريدك الإلكتروني غير مؤكد. قم بتأكيد بريدك الإلكتروني لدى مزود الهوية ثم حاول تسجيل الدخول مرة أخرى.",
|
||||
"login_error_email_verification_required": "يتطلب هذا الخادم بريدًا إلكترونيًا مؤكدًا. لم يقم مزود الهوية بتضمين ادعاء التحقق من البريد الإلكتروني. اتصل بالمسؤول."
|
||||
},
|
||||
"storage": {
|
||||
"title": "التخزين",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "Die SSO-Anmeldung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
"login_error_generic": "Die SSO-Anmeldung wurde abgelehnt. Bitte versuchen Sie es erneut.",
|
||||
"login_error_email_ambiguous": "Mehrere lokale Konten stimmen mit dieser E-Mail-Adresse überein. Wenden Sie sich zur Lösung an Ihren Administrator.",
|
||||
"logged_out": "Erfolgreich abgemeldet."
|
||||
"logged_out": "Erfolgreich abgemeldet.",
|
||||
"login_error_email_not_verified_at_idp": "Ihr Identitätsanbieter meldet, dass Ihre E-Mail-Adresse nicht bestätigt ist. Bestätigen Sie Ihre E-Mail bei Ihrem Identitätsanbieter und versuchen Sie es erneut.",
|
||||
"login_error_email_verification_required": "Dieser Server erfordert eine bestätigte E-Mail-Adresse. Ihr Identitätsanbieter hat keinen E-Mail-Bestätigungsanspruch übermittelt. Wenden Sie sich an Ihren Administrator."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicher",
|
||||
|
||||
@@ -737,6 +737,8 @@
|
||||
"login_error_email_ambiguous": "Multiple local accounts match this email address. Contact your administrator to resolve.",
|
||||
"login_error_callback_denied": "Your sign-in link expired or was already used. Please try signing in again.",
|
||||
"login_error_callback_failed": "SSO sign-in couldn't complete. Please try again.",
|
||||
"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.",
|
||||
"login_error_email_verification_required": "This server requires a verified email. Your identity provider did not include an email-verification claim. Contact your administrator.",
|
||||
"login_error_generic": "SSO sign-in was refused. Please try again."
|
||||
},
|
||||
"storage": {
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "No se pudo completar el inicio de sesión SSO. Por favor inténtalo de nuevo.",
|
||||
"login_error_generic": "Se rechazó el inicio de sesión SSO. Por favor inténtalo de nuevo.",
|
||||
"login_error_email_ambiguous": "Varias cuentas locales coinciden con esta dirección de correo electrónico. Póngase en contacto con su administrador para resolverlo.",
|
||||
"logged_out": "Sesión cerrada correctamente."
|
||||
"logged_out": "Sesión cerrada correctamente.",
|
||||
"login_error_email_not_verified_at_idp": "Su proveedor de identidad indica que su dirección de correo electrónico no está verificada. Confirme su correo electrónico en su proveedor de identidad y vuelva a iniciar sesión.",
|
||||
"login_error_email_verification_required": "Este servidor requiere un correo electrónico verificado. Su proveedor de identidad no incluyó una declaración de verificación de correo electrónico. Contacte a su administrador."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Almacenamiento",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "ورود به سیستم SSO کامل نشد. لطفا دوباره امتحان کنید.",
|
||||
"login_error_generic": "ورود به سیستم SSO رد شد. لطفا دوباره امتحان کنید.",
|
||||
"login_error_email_ambiguous": "چندین حساب محلی با این آدرس ایمیل مطابقت دارند. برای حل این مشکل با سرپرست خود تماس بگیرید.",
|
||||
"logged_out": "با موفقیت خارج شدید."
|
||||
"logged_out": "با موفقیت خارج شدید.",
|
||||
"login_error_email_not_verified_at_idp": "ارائهدهنده هویت شما گزارش میدهد که آدرس ایمیل شما تأیید نشده است. ایمیل خود را نزد ارائهدهنده هویت تأیید کنید و دوباره وارد شوید.",
|
||||
"login_error_email_verification_required": "این سرور به ایمیل تأییدشده نیاز دارد. ارائهدهنده هویت شما ادعای تأیید ایمیل را ارائه نداده است. با مدیر خود تماس بگیرید."
|
||||
},
|
||||
"storage": {
|
||||
"title": "فضای ذخیرهسازی",
|
||||
|
||||
@@ -689,6 +689,8 @@
|
||||
"login_error_email_ambiguous": "Plusieurs comptes locaux correspondent à cette adresse e-mail. Contactez votre administrateur pour résoudre.",
|
||||
"login_error_callback_denied": "Votre lien de connexion a expiré ou a déjà été utilisé. Veuillez réessayer.",
|
||||
"login_error_callback_failed": "La connexion SSO n'a pas pu se terminer. Veuillez réessayer.",
|
||||
"login_error_email_not_verified_at_idp": "Votre fournisseur d'identité signale que votre adresse e-mail n'est pas vérifiée. Confirmez votre e-mail chez votre fournisseur d'identité, puis réessayez.",
|
||||
"login_error_email_verification_required": "Ce serveur exige une adresse e-mail vérifiée. Votre fournisseur d'identité n'a pas inclus de revendication de vérification d'e-mail. Contactez votre administrateur.",
|
||||
"login_error_generic": "La connexion SSO a été refusée. Veuillez réessayer.",
|
||||
"password_or_link_hint": "Mot de passe (laisser vide pour un lien de connexion)",
|
||||
"cookie_rejected": "La connexion a réussi mais le navigateur a rejeté le cookie de session. Si vous êtes sur HTTP, définissez OXICLOUD_COOKIE_SECURE=false ou utilisez HTTPS.",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "SSO साइन-इन पूरा नहीं हो सका. कृपया पुन: प्रयास करें।",
|
||||
"login_error_generic": "SSO साइन-इन अस्वीकृत कर दिया गया. कृपया पुन: प्रयास करें।",
|
||||
"login_error_email_ambiguous": "अनेक स्थानीय खाते इस ईमेल पते से मेल खाते हैं। समाधान के लिए अपने व्यवस्थापक से संपर्क करें.",
|
||||
"logged_out": "सफलतापूर्वक साइन आउट हो गए।"
|
||||
"logged_out": "सफलतापूर्वक साइन आउट हो गए।",
|
||||
"login_error_email_not_verified_at_idp": "आपके पहचान प्रदाता ने बताया है कि आपका ईमेल पता सत्यापित नहीं है। कृपया अपने पहचान प्रदाता पर अपना ईमेल पुष्ट करें और फिर से साइन इन करने का प्रयास करें।",
|
||||
"login_error_email_verification_required": "यह सर्वर सत्यापित ईमेल की आवश्यकता है। आपके पहचान प्रदाता ने ईमेल-सत्यापन दावा शामिल नहीं किया है। कृपया अपने व्यवस्थापक से संपर्क करें।"
|
||||
},
|
||||
"storage": {
|
||||
"title": "स्टोरेज",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "Impossibile completare l'accesso SSO. Per favore riprova.",
|
||||
"login_error_generic": "L'accesso SSO è stato rifiutato. Per favore riprova.",
|
||||
"login_error_email_ambiguous": "Più account locali corrispondono a questo indirizzo email. Contatta l'amministratore per risolvere.",
|
||||
"logged_out": "Disconnessione effettuata."
|
||||
"logged_out": "Disconnessione effettuata.",
|
||||
"login_error_email_not_verified_at_idp": "Il tuo provider di identità segnala che il tuo indirizzo e-mail non è verificato. Conferma la tua e-mail presso il tuo provider di identità e riprova ad accedere.",
|
||||
"login_error_email_verification_required": "Questo server richiede un indirizzo e-mail verificato. Il tuo provider di identità non ha incluso una dichiarazione di verifica e-mail. Contatta il tuo amministratore."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Archiviazione",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "SSO サインインを完了できませんでした。もう一度試してください。",
|
||||
"login_error_generic": "SSO サインインが拒否されました。もう一度試してください。",
|
||||
"login_error_email_ambiguous": "複数のローカル アカウントがこの電子メール アドレスに一致します。解決するには管理者に問い合わせてください。",
|
||||
"logged_out": "サインアウトしました。"
|
||||
"logged_out": "サインアウトしました。",
|
||||
"login_error_email_not_verified_at_idp": "IDプロバイダーから、メールアドレスが確認されていないと報告されています。IDプロバイダーでメールを確認してから、もう一度サインインしてください。",
|
||||
"login_error_email_verification_required": "このサーバーは検証済みのメールアドレスを必要とします。IDプロバイダーがメール検証クレームを含めませんでした。管理者に連絡してください。"
|
||||
},
|
||||
"storage": {
|
||||
"title": "ストレージ",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_denied": "로그인 링크가 만료되었거나 이미 사용되었습니다. 다시 로그인해 보세요.",
|
||||
"login_error_callback_failed": "SSO 로그인을 완료할 수 없습니다. 다시 시도해 주세요.",
|
||||
"login_error_generic": "SSO 로그인이 거부되었습니다. 다시 시도해 주세요.",
|
||||
"login_error_email_ambiguous": "여러 로컬 계정이 이 이메일 주소와 일치합니다. 해결하려면 관리자에게 문의하세요."
|
||||
"login_error_email_ambiguous": "여러 로컬 계정이 이 이메일 주소와 일치합니다. 해결하려면 관리자에게 문의하세요.",
|
||||
"login_error_email_not_verified_at_idp": "ID 공급자가 이메일 주소가 확인되지 않았다고 보고했습니다. ID 공급자에서 이메일을 확인한 후 다시 로그인하세요.",
|
||||
"login_error_email_verification_required": "이 서버는 인증된 이메일이 필요합니다. ID 공급자가 이메일 인증 클레임을 포함하지 않았습니다. 관리자에게 문의하세요."
|
||||
},
|
||||
"storage": {
|
||||
"title": "저장소",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "SSO-aanmelding kan niet worden voltooid. Probeer het opnieuw.",
|
||||
"login_error_generic": "SSO-aanmelding is geweigerd. Probeer het opnieuw.",
|
||||
"login_error_email_ambiguous": "Meerdere lokale accounts komen overeen met dit e-mailadres. Neem contact op met uw beheerder om dit op te lossen.",
|
||||
"logged_out": "Succesvol afgemeld."
|
||||
"logged_out": "Succesvol afgemeld.",
|
||||
"login_error_email_not_verified_at_idp": "Uw identiteitsprovider meldt dat uw e-mailadres niet is geverifieerd. Bevestig uw e-mail bij uw identiteitsprovider en probeer opnieuw in te loggen.",
|
||||
"login_error_email_verification_required": "Deze server vereist een geverifieerd e-mailadres. Uw identiteitsprovider heeft geen e-mailverificatie-claim opgenomen. Neem contact op met uw beheerder."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Opslag",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "Nie udało się ukończyć logowania jednokrotnego. Spróbuj ponownie.",
|
||||
"login_error_generic": "Odmówiono logowania jednokrotnego. Spróbuj ponownie.",
|
||||
"login_error_email_ambiguous": "Do tego adresu e-mail pasuje wiele kont lokalnych. Skontaktuj się z administratorem, aby rozwiązać problem.",
|
||||
"logged_out": "Wylogowano pomyślnie."
|
||||
"logged_out": "Wylogowano pomyślnie.",
|
||||
"login_error_email_not_verified_at_idp": "Twój dostawca tożsamości zgłasza, że Twój adres e-mail nie jest zweryfikowany. Potwierdź swój adres e-mail u dostawcy tożsamości i spróbuj zalogować się ponownie.",
|
||||
"login_error_email_verification_required": "Ten serwer wymaga zweryfikowanego adresu e-mail. Twój dostawca tożsamości nie dołączył oświadczenia o weryfikacji e-maila. Skontaktuj się z administratorem."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Pamięć masowa",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "Não foi possível concluir o login do SSO. Por favor, tente novamente.",
|
||||
"login_error_generic": "O login do SSO foi recusado. Por favor, tente novamente.",
|
||||
"login_error_email_ambiguous": "Várias contas locais correspondem a este endereço de e-mail. Entre em contato com seu administrador para resolver.",
|
||||
"logged_out": "Sessão terminada com sucesso."
|
||||
"logged_out": "Sessão terminada com sucesso.",
|
||||
"login_error_email_not_verified_at_idp": "Seu provedor de identidade informa que seu endereço de e-mail não está verificado. Confirme seu e-mail em seu provedor de identidade e tente entrar novamente.",
|
||||
"login_error_email_verification_required": "Este servidor requer um endereço de e-mail verificado. Seu provedor de identidade não incluiu uma declaração de verificação de e-mail. Contate seu administrador."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Armazenamento",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "Не удалось выполнить вход в систему единого входа. Пожалуйста, попробуйте еще раз.",
|
||||
"login_error_generic": "Во входе в систему единого входа было отказано. Пожалуйста, попробуйте еще раз.",
|
||||
"login_error_email_ambiguous": "Несколько локальных учетных записей соответствуют этому адресу электронной почты. Обратитесь к администратору для решения.",
|
||||
"logged_out": "Вы успешно вышли."
|
||||
"logged_out": "Вы успешно вышли.",
|
||||
"login_error_email_not_verified_at_idp": "Ваш поставщик удостоверений сообщает, что ваш адрес электронной почты не подтверждён. Подтвердите почту у поставщика удостоверений и попробуйте войти снова.",
|
||||
"login_error_email_verification_required": "Этот сервер требует подтверждённый адрес электронной почты. Ваш поставщик удостоверений не предоставил утверждение о подтверждении почты. Обратитесь к администратору."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Хранилище",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "SSO 登入無法完成。請再試一次。",
|
||||
"login_error_generic": "SSO 登入被拒絕。請再試一次。",
|
||||
"login_error_email_ambiguous": "多個本機帳戶與此電子郵件地址相符。請聯絡您的管理員來解決。",
|
||||
"logged_out": "已成功登出。"
|
||||
"logged_out": "已成功登出。",
|
||||
"login_error_email_not_verified_at_idp": "您的身份提供者回報您的電子郵件地址未經驗證。請在您的身份提供者處確認電子郵件,然後重新登入。",
|
||||
"login_error_email_verification_required": "此伺服器要求提供已驗證的電子郵件。您的身份提供者未包含電子郵件驗證聲明。請聯絡您的管理員。"
|
||||
},
|
||||
"storage": {
|
||||
"title": "儲存空間",
|
||||
|
||||
@@ -702,7 +702,9 @@
|
||||
"login_error_callback_failed": "SSO 登录无法完成。请再试一次。",
|
||||
"login_error_generic": "SSO 登录被拒绝。请再试一次。",
|
||||
"login_error_email_ambiguous": "多个本地帐户与此电子邮件地址匹配。请联系您的管理员来解决。",
|
||||
"logged_out": "已成功退出。"
|
||||
"logged_out": "已成功退出。",
|
||||
"login_error_email_not_verified_at_idp": "您的身份提供商报告您的电子邮件地址未经验证。请在您的身份提供商处确认电子邮件,然后重新登录。",
|
||||
"login_error_email_verification_required": "此服务器要求提供已验证的电子邮件。您的身份提供商未包含电子邮件验证声明。请联系您的管理员。"
|
||||
},
|
||||
"storage": {
|
||||
"title": "存储空间",
|
||||
|
||||
@@ -28,6 +28,64 @@ use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Decision returned by `classify_email_verification` — encodes the full
|
||||
/// (email_verified × require_verified_email) matrix the OIDC callback's
|
||||
/// email-verification gate needs. Pure function of two inputs; extracted
|
||||
/// so a unit test can walk the matrix without any OIDC/DB machinery.
|
||||
///
|
||||
/// - `reject = true` → the callback returns `OidcCallbackResult::Rejected`
|
||||
/// with `reason` as the wire-visible key (mapped by the handler to a
|
||||
/// distinct `login_error=<key>` on the /login redirect).
|
||||
/// - `reject = false` + `reason = Some(...)` → accept path, but audit-log
|
||||
/// the underlying risky signal (`oidc.email_unverified_accepted`) so
|
||||
/// operators running with the flag off can still spot loose IdPs.
|
||||
/// - `reject = false` + `reason = None` → clean accept, no audit noise.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct EmailVerificationDecision {
|
||||
reject: bool,
|
||||
reason: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Classify what the OIDC callback should do for a given
|
||||
/// `email_verified` claim value under the deployment's
|
||||
/// `OXICLOUD_REQUIRE_VERIFIED_EMAIL` posture.
|
||||
///
|
||||
/// | claim state | require=true | require=false |
|
||||
/// |-----------------------|----------------------------------|--------------------------------------------------------|
|
||||
/// | `Some(true)` → verified | accept, silent | accept, silent |
|
||||
/// | `Some(false)` → asserted-unverified | REJECT `idp_asserts_unverified` | accept, audit `idp_asserts_unverified_flag_off` |
|
||||
/// | `None` → claim absent | REJECT `claim_absent_and_required` | accept, silent (absence isn't itself a signal) |
|
||||
///
|
||||
/// See `project_oidc_callback_error_specific_reasons` memory for the
|
||||
/// audit-line / wire-key correlation rationale.
|
||||
fn classify_email_verification(
|
||||
email_verified: Option<bool>,
|
||||
must_verify: bool,
|
||||
) -> EmailVerificationDecision {
|
||||
match (email_verified, must_verify) {
|
||||
(Some(true), _) => EmailVerificationDecision {
|
||||
reject: false,
|
||||
reason: None,
|
||||
},
|
||||
(Some(false), true) => EmailVerificationDecision {
|
||||
reject: true,
|
||||
reason: Some("idp_asserts_unverified"),
|
||||
},
|
||||
(Some(false), false) => EmailVerificationDecision {
|
||||
reject: false,
|
||||
reason: Some("idp_asserts_unverified_flag_off"),
|
||||
},
|
||||
(None, true) => EmailVerificationDecision {
|
||||
reject: true,
|
||||
reason: Some("claim_absent_and_required"),
|
||||
},
|
||||
(None, false) => EmailVerificationDecision {
|
||||
reject: false,
|
||||
reason: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a client-supplied DPoP JWK thumbprint. RFC 7638 §3 produces
|
||||
/// a base64url-encoded SHA-256 (32 bytes → 43 base64url chars, no
|
||||
/// padding). We accept exactly that shape; anything else is a client
|
||||
@@ -87,6 +145,111 @@ mod dpop_jkt_tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Pure-function coverage for the OIDC email-verification decision
|
||||
// matrix. Ships every leg of the 3×2 truth table so a future refactor
|
||||
// that flips one arm trips its own dedicated assertion — no need for
|
||||
// DB fixtures, mock OIDC service, or a full callback pipeline.
|
||||
//
|
||||
// The reject-branch `reason` values are the exact strings the app
|
||||
// service hoists into `OidcCallbackResult::Rejected` and the handler
|
||||
// then maps to `/login?login_error=<key>` — see the corresponding
|
||||
// handler match arm in `interfaces/api/handlers/auth_handler.rs`. Keep
|
||||
// these string literals in sync across all three sites (audit line +
|
||||
// wire envelope + handler translation).
|
||||
//
|
||||
// Companion FE coverage: `frontend/src/lib/auth/loginError.test.ts`
|
||||
// asserts the FE renders the right copy for each key.
|
||||
#[cfg(test)]
|
||||
mod classify_email_verification_tests {
|
||||
use super::{EmailVerificationDecision, classify_email_verification};
|
||||
|
||||
fn assert_decision(
|
||||
got: EmailVerificationDecision,
|
||||
expected_reject: bool,
|
||||
expected_reason: Option<&'static str>,
|
||||
) {
|
||||
assert_eq!(
|
||||
got,
|
||||
EmailVerificationDecision {
|
||||
reject: expected_reject,
|
||||
reason: expected_reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── ACCEPT paths ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn verified_and_flag_on_accepts_silently() {
|
||||
assert_decision(classify_email_verification(Some(true), true), false, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_and_flag_off_accepts_silently() {
|
||||
assert_decision(classify_email_verification(Some(true), false), false, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unverified_and_flag_off_accepts_but_audits() {
|
||||
// Operator-override branch — accept, but leave a "loose IdP"
|
||||
// audit line so log tail can spot the risky signal after the fact.
|
||||
assert_decision(
|
||||
classify_email_verification(Some(false), false),
|
||||
false,
|
||||
Some("idp_asserts_unverified_flag_off"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_absent_and_flag_off_accepts_silently() {
|
||||
// Absence is a weaker signal than an explicit false — no audit
|
||||
// line, otherwise every log fills with noise for IdPs that
|
||||
// simply don't publish the claim.
|
||||
assert_decision(classify_email_verification(None, false), false, None);
|
||||
}
|
||||
|
||||
// ── REJECT paths (the load-bearing new behaviour) ────────────
|
||||
|
||||
#[test]
|
||||
fn unverified_and_flag_on_rejects_with_idp_asserts_unverified() {
|
||||
assert_decision(
|
||||
classify_email_verification(Some(false), true),
|
||||
true,
|
||||
Some("idp_asserts_unverified"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_absent_and_flag_on_rejects_with_claim_absent_and_required() {
|
||||
assert_decision(
|
||||
classify_email_verification(None, true),
|
||||
true,
|
||||
Some("claim_absent_and_required"),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wire-key drift guard ────────────────────────────────────
|
||||
// These are the exact strings the handler switches on to pick the
|
||||
// `login_error=<key>` for the /login redirect. If either literal
|
||||
// changes here without updating the handler, the redirect silently
|
||||
// falls to `callback_denied` and the SPA shows the misleading
|
||||
// "sign-in link expired" copy — exactly the regression this whole
|
||||
// refactor was meant to prevent.
|
||||
#[test]
|
||||
fn reject_reasons_are_the_wire_keys_the_handler_switches_on() {
|
||||
assert_eq!(
|
||||
classify_email_verification(Some(false), true).reason,
|
||||
Some("idp_asserts_unverified"),
|
||||
"handler maps this reason → login_error=email_not_verified_at_idp",
|
||||
);
|
||||
assert_eq!(
|
||||
classify_email_verification(None, true).reason,
|
||||
Some("claim_absent_and_required"),
|
||||
"handler maps this reason → login_error=email_verification_required",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a successful OIDC callback. The handler layer inspects this to
|
||||
/// decide whether to redirect to the regular frontend or complete a Nextcloud
|
||||
/// Login Flow v2 session.
|
||||
@@ -123,6 +286,24 @@ pub enum OidcCallbackResult {
|
||||
/// on the 409 response so the login page can switch on it.
|
||||
/// See docs/plan/oidc-account-linking.md § Auto-link.
|
||||
AutoLinkRefused { reason: &'static str },
|
||||
/// OIDC login refused before any user lookup by a policy gate that
|
||||
/// carries a specific reason worth surfacing to the user. The
|
||||
/// handler maps each `reason` to a distinct `login_error=<key>`
|
||||
/// redirect, and the SPA renders targeted copy so the user can act
|
||||
/// on it (e.g. "verify your email at the IdP") instead of a
|
||||
/// misleading generic "sign-in link expired" toast.
|
||||
///
|
||||
/// Current reasons — emit the same `reason=` as the
|
||||
/// `oidc.callback_rejected` audit line so log-tail correlation
|
||||
/// stays trivial:
|
||||
///
|
||||
/// - `idp_asserts_unverified` — IdP explicitly claims
|
||||
/// `email_verified=false` AND `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true`.
|
||||
/// - `claim_absent_and_required` — IdP omitted the `email_verified`
|
||||
/// claim AND `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true`.
|
||||
///
|
||||
/// See `project_oidc_callback_error_specific_reasons` memory.
|
||||
Rejected { reason: &'static str },
|
||||
}
|
||||
|
||||
/// Outcome of a successful magic-link redemption. The auth tokens are
|
||||
@@ -4047,29 +4228,33 @@ impl AuthApplicationService {
|
||||
// placeholder later).
|
||||
if let Some(email) = &claims.email {
|
||||
let must_verify = self.require_verified_email();
|
||||
let (reject, reason) = match (claims.email_verified, must_verify) {
|
||||
(Some(true), _) => (false, None),
|
||||
(Some(false), true) => (true, Some("idp_asserts_unverified")),
|
||||
(Some(false), false) => (false, Some("idp_asserts_unverified_flag_off")),
|
||||
(None, true) => (true, Some("claim_absent_and_required")),
|
||||
(None, false) => (false, None),
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
let decision = classify_email_verification(claims.email_verified, must_verify);
|
||||
if let Some(reason) = decision.reason {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = if reject { "oidc.callback_rejected" } else { "oidc.email_unverified_accepted" },
|
||||
event = if decision.reject { "oidc.callback_rejected" } else { "oidc.email_unverified_accepted" },
|
||||
reason = reason,
|
||||
provider = %provider_name,
|
||||
email = %email,
|
||||
"👮🏻♂️ OIDC callback: email-verification signal"
|
||||
);
|
||||
}
|
||||
if reject {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"OIDC",
|
||||
"Email verification required. Please verify your email at the identity provider.",
|
||||
));
|
||||
if decision.reject {
|
||||
// Return `Ok(Rejected)` rather than `Err(AccessDenied)` so
|
||||
// the handler can map this specific `reason` to a distinct
|
||||
// `login_error=<key>` on the /login redirect — instead of
|
||||
// being lumped into the generic `callback_denied` bucket
|
||||
// (which shows a misleading "sign-in link expired" toast).
|
||||
// See `project_oidc_callback_error_specific_reasons` memory.
|
||||
//
|
||||
// `decision.reason` on a reject-branch is guaranteed Some
|
||||
// by `classify_email_verification`'s match arms; the fallback
|
||||
// to "callback_denied" defends against a future refactor
|
||||
// adding a reject branch without a reason (currently
|
||||
// unreachable).
|
||||
return Ok(OidcCallbackResult::Rejected {
|
||||
reason: decision.reason.unwrap_or("callback_denied"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1758,6 +1758,31 @@ pub async fn oidc_callback(
|
||||
);
|
||||
Ok(Redirect::temporary(&redirect_url).into_response())
|
||||
}
|
||||
// Rejected-by-policy (e.g. `email_verified=false` while
|
||||
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true`). Map each stable reason
|
||||
// to its own `login_error` key so the SPA can render targeted
|
||||
// copy — "verify your email at the IdP" instead of a misleading
|
||||
// generic "sign-in link expired" toast. See app service's OIDC
|
||||
// callback email-verification block for the reason enumeration.
|
||||
OidcCallbackResult::Rejected { reason } => {
|
||||
let key = match reason {
|
||||
"idp_asserts_unverified" => "email_not_verified_at_idp",
|
||||
"claim_absent_and_required" => "email_verification_required",
|
||||
// Future-proof fallback — a new backend reason without an
|
||||
// explicit map here lands on the generic bucket rather
|
||||
// than silently 200'ing the user through.
|
||||
_ => "callback_denied",
|
||||
};
|
||||
let config = auth_app.oidc_config().unwrap();
|
||||
let frontend_url = config.frontend_url.trim_end_matches('/');
|
||||
let redirect_url = format!("{}/login?login_error={}", frontend_url, key);
|
||||
tracing::info!(
|
||||
reason = reason,
|
||||
login_error = key,
|
||||
"OIDC callback rejected by policy, redirecting to /login?login_error"
|
||||
);
|
||||
Ok(Redirect::temporary(&redirect_url).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user