feat(oidc): improve error handling
This commit is contained in:
@@ -97,6 +97,13 @@
|
|||||||
// immediately after so revisits / manual logouts don't re-show
|
// immediately after so revisits / manual logouts don't re-show
|
||||||
// the stale message.
|
// the stale message.
|
||||||
let sessionExpiredNotice = $state(false);
|
let sessionExpiredNotice = $state(false);
|
||||||
|
// One-shot notice populated from ?login_error=<key> on mount.
|
||||||
|
// Set by the OIDC callback's AutoLinkRefused redirect when the
|
||||||
|
// IdP-returned email matches an existing local account but the
|
||||||
|
// auto-link decision tree refused (verified=false, disabled by
|
||||||
|
// config, or the local account is already linked to a different
|
||||||
|
// identity). See docs/plan/oidc-account-linking.md § Auto-link.
|
||||||
|
let loginErrorNotice = $state<string | null>(null);
|
||||||
// Refs used by the mode-driven auto-focus effect. Bound with
|
// Refs used by the mode-driven auto-focus effect. Bound with
|
||||||
// `bind:this` on the first input of each mode's form so the effect
|
// `bind:this` on the first input of each mode's form so the effect
|
||||||
// can focus the "primary" field each time the mode changes without
|
// can focus the "primary" field each time the mode changes without
|
||||||
@@ -290,6 +297,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 '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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// 0) Consume the one-shot `?source=session_expired` flag, if any.
|
// 0) Consume the one-shot `?source=session_expired` flag, if any.
|
||||||
// Strip it from the URL so the banner never re-appears on
|
// Strip it from the URL so the banner never re-appears on
|
||||||
@@ -306,6 +350,22 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot auto-link refusal notice. Reason key is a stable
|
||||||
|
// snake_case identifier the OIDC callback emitted; map each to
|
||||||
|
// localized copy and strip the param so a reload doesn't
|
||||||
|
// re-surface the same notice.
|
||||||
|
const loginErrorKey = page.url.searchParams.get('login_error');
|
||||||
|
if (loginErrorKey) {
|
||||||
|
loginErrorNotice = loginErrorMessage(loginErrorKey);
|
||||||
|
const stripped = new URL(page.url);
|
||||||
|
stripped.searchParams.delete('login_error');
|
||||||
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
'',
|
||||||
|
stripped.pathname + stripped.search + stripped.hash
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 1) OIDC code-exchange fallback: the IdP round-trip may land back here
|
// 1) OIDC code-exchange fallback: the IdP round-trip may land back here
|
||||||
// with ?oidc_code=. Exchange it for a session and redirect into the app.
|
// with ?oidc_code=. Exchange it for a session and redirect into the app.
|
||||||
const oidcCode = page.url.searchParams.get('oidc_code');
|
const oidcCode = page.url.searchParams.get('oidc_code');
|
||||||
@@ -386,6 +446,27 @@
|
|||||||
magic-link toggle) in place. Guarding the whole form
|
magic-link toggle) in place. Guarding the whole form
|
||||||
behind `booting` caused a "logo only, then form" flash
|
behind `booting` caused a "logo only, then form" flash
|
||||||
on first paint. -->
|
on first paint. -->
|
||||||
|
{#if loginErrorNotice}
|
||||||
|
<!-- Dedicated error view — hides the login form entirely
|
||||||
|
until the user dismisses. Lands the user on a focused
|
||||||
|
"this went wrong" screen instead of a form buried
|
||||||
|
under a red banner. Sibling of the callback redirect
|
||||||
|
that surfaced this notice in the first place.
|
||||||
|
Reuses `.auth-title` and `.auth-button` for theme
|
||||||
|
consistency with the normal login/register/setup views. -->
|
||||||
|
<div class="auth-error-view" role="alert" data-testid="login-error-notice">
|
||||||
|
<h1 class="auth-title">{t('auth.login_error_title', 'Sign-in failed')}</h1>
|
||||||
|
<p class="auth-error-view__message">{loginErrorNotice}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-button"
|
||||||
|
data-testid="login-error-back-btn"
|
||||||
|
onclick={() => (loginErrorNotice = null)}
|
||||||
|
>
|
||||||
|
{t('auth.login_error_back_to_login', 'Back to login')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<h1 class="auth-title">
|
<h1 class="auth-title">
|
||||||
{#if mode === 'login'}
|
{#if mode === 'login'}
|
||||||
{t('auth.sign_in', 'Sign in')}
|
{t('auth.sign_in', 'Sign in')}
|
||||||
@@ -473,7 +554,10 @@
|
|||||||
bind:value={username}
|
bind:value={username}
|
||||||
bind:this={loginIdentifierInput}
|
bind:this={loginIdentifierInput}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
placeholder={t('auth.login_identifier_placeholder', 'Enter your username or email')}
|
placeholder={t(
|
||||||
|
'auth.login_identifier_placeholder',
|
||||||
|
'Enter your username or email'
|
||||||
|
)}
|
||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
@@ -622,7 +706,10 @@
|
|||||||
{#if passwordLoginEnabled}
|
{#if passwordLoginEnabled}
|
||||||
<div class="auth-input-group">
|
<div class="auth-input-group">
|
||||||
<label class="auth-label" for="reg-password">
|
<label class="auth-label" for="reg-password">
|
||||||
{t('auth.password_optional', 'Password (optional — leave blank for a sign-in link)')}
|
{t(
|
||||||
|
'auth.password_optional',
|
||||||
|
'Password (optional — leave blank for a sign-in link)'
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
||||||
<input
|
<input
|
||||||
@@ -862,6 +949,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="auth-lang">
|
<div class="auth-lang">
|
||||||
<select
|
<select
|
||||||
@@ -911,4 +999,22 @@
|
|||||||
.auth-notice-dismiss:hover {
|
.auth-notice-dismiss:hover {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dedicated error view — centered in the auth-panel with the
|
||||||
|
message padded off the title/button. `.auth-button` (from
|
||||||
|
ported/auth.css) already carries the full-width primary
|
||||||
|
styling used by the login submit, so the back button lands
|
||||||
|
right on the existing theme. */
|
||||||
|
.auth-error-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-view__message {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1509,14 +1509,35 @@ pub async fn oidc_callback(
|
|||||||
|
|
||||||
tracing::info!("OIDC callback received with code");
|
tracing::info!("OIDC callback received with code");
|
||||||
|
|
||||||
// Exchange code, validate state/nonce/PKCE, authenticate user
|
// Exchange code, validate state/nonce/PKCE, authenticate user.
|
||||||
let result = auth_app
|
// Any Err path (expired state on refresh, consumed code on replay,
|
||||||
|
// anti-takeover email refusal, etc.) is caught below and turned
|
||||||
|
// into a redirect to /login?login_error=<key> — a JSON 4xx here
|
||||||
|
// would render as raw JSON in the browser since the caller is
|
||||||
|
// mid-navigation from the IdP, not the SPA. The SPA login page
|
||||||
|
// renders localized copy per key.
|
||||||
|
let result = match auth_app
|
||||||
.oidc_callback(&query.code, &query.state, &state.locale_registry)
|
.oidc_callback(&query.code, &query.state, &state.locale_registry)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
tracing::error!("OIDC callback failed: {}", e);
|
tracing::error!("OIDC callback failed: {}", e);
|
||||||
AppError::from(e)
|
let config = auth_app.oidc_config().unwrap();
|
||||||
})?;
|
let frontend_url = config.frontend_url.trim_end_matches('/');
|
||||||
|
// AccessDenied covers the CSRF/state/code/nonce validation
|
||||||
|
// failures (the common "refresh replayed a consumed state"
|
||||||
|
// case). Everything else is bucketed as a generic callback
|
||||||
|
// failure — operators dig into the log line above for the
|
||||||
|
// specifics; end-users only need "try again" guidance.
|
||||||
|
let reason = match e.kind {
|
||||||
|
crate::domain::errors::ErrorKind::AccessDenied => "callback_denied",
|
||||||
|
_ => "callback_failed",
|
||||||
|
};
|
||||||
|
let redirect_url = format!("{}/login?login_error={}", frontend_url, reason);
|
||||||
|
return Ok(Redirect::temporary(&redirect_url).into_response());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
OidcCallbackResult::WebLogin { exchange_code } => {
|
OidcCallbackResult::WebLogin { exchange_code } => {
|
||||||
@@ -1582,26 +1603,22 @@ pub async fn oidc_callback(
|
|||||||
);
|
);
|
||||||
Ok(Redirect::temporary(&redirect_url).into_response())
|
Ok(Redirect::temporary(&redirect_url).into_response())
|
||||||
}
|
}
|
||||||
// Map each auto-link refusal reason to a distinct stable
|
// Redirect the browser back to the login page with a
|
||||||
// CamelCase `error_type`. The SPA switches on this to render
|
// machine-readable reason on the query string, mirroring the
|
||||||
// targeted copy (contact-admin vs. verify-email-at-IdP vs.
|
// LinkRefused → `/profile?link_error=<reason>` pattern above.
|
||||||
// already-linked-elsewhere) rather than a generic error toast.
|
// The browser is mid-redirect from the IdP; returning a 409
|
||||||
// Status stays 409 (CONFLICT) — semantically an existing user
|
// JSON body would leave the user staring at raw JSON. The SPA
|
||||||
// blocks the auto-provision path.
|
// login page reads `?login_error=<reason>` on mount, renders a
|
||||||
|
// localized notice, and strips the param via history.replaceState.
|
||||||
OidcCallbackResult::AutoLinkRefused { reason } => {
|
OidcCallbackResult::AutoLinkRefused { reason } => {
|
||||||
let error_type = match reason {
|
let config = auth_app.oidc_config().unwrap();
|
||||||
"auto_link_disabled" => "AutoLinkDisabled",
|
let frontend_url = config.frontend_url.trim_end_matches('/');
|
||||||
"auto_link_email_not_verified" => "AutoLinkEmailNotVerified",
|
let redirect_url = format!("{}/login?login_error={}", frontend_url, reason);
|
||||||
"already_linked_elsewhere" => "AutoLinkAlreadyLinkedElsewhere",
|
tracing::info!(
|
||||||
_ => "AutoLinkRefused",
|
reason = reason,
|
||||||
};
|
"OIDC auto-link refused, redirecting to /login?login_error"
|
||||||
Err(AppError::new(
|
);
|
||||||
StatusCode::CONFLICT,
|
Ok(Redirect::temporary(&redirect_url).into_response())
|
||||||
"OIDC login blocked — a local account with this email already exists. \
|
|
||||||
Contact your administrator, or sign in with your existing credentials \
|
|
||||||
and connect SSO from your profile.",
|
|
||||||
error_type,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -40,9 +40,12 @@
|
|||||||
# (iss, sub) miss but email matches admin, so it auto-
|
# (iss, sub) miss but email matches admin, so it auto-
|
||||||
# links + logs admin in.
|
# links + logs admin in.
|
||||||
# 2. Auto-link refused — email_verified=false. Callback
|
# 2. Auto-link refused — email_verified=false. Callback
|
||||||
# returns HTTP 409 with error_type "AutoLinkEmailNotVerified"
|
# redirects the browser to
|
||||||
# (one of three distinct auto-link refusal error_types —
|
# /login?login_error=auto_link_email_not_verified so the
|
||||||
# see auth_handler.rs AutoLinkRefused arm).
|
# SPA login page can render a localized notice. Sibling
|
||||||
|
# of the /profile?link_error=<reason> redirect used by
|
||||||
|
# the self-service link flow. See auth_handler.rs
|
||||||
|
# AutoLinkRefused arm.
|
||||||
#
|
#
|
||||||
# [OIDC-only user]
|
# [OIDC-only user]
|
||||||
# 10. `oidc_user` unlink refused (would lock them out) with
|
# 10. `oidc_user` unlink refused (would lock them out) with
|
||||||
@@ -512,19 +515,18 @@ HTTP 200
|
|||||||
|
|
||||||
|
|
||||||
# Follow the whole OIDC dance. Hurl's location: true follows 3xx
|
# Follow the whole OIDC dance. Hurl's location: true follows 3xx
|
||||||
# up to the callback; the callback returns 409 (non-3xx) and
|
# up to the callback; the callback redirects to /login with a
|
||||||
# location follow stops. The final response is what we assert on.
|
# machine-readable reason on the query string, and the SPA login
|
||||||
|
# page lands at 200 (index.html fallback). Assert on URL, since
|
||||||
|
# that's the load-bearing wire contract the SPA reads on mount.
|
||||||
GET {{base_url}}/api/auth/oidc/authorize
|
GET {{base_url}}/api/auth/oidc/authorize
|
||||||
[Options]
|
[Options]
|
||||||
location: true
|
location: true
|
||||||
location-trusted: true
|
location-trusted: true
|
||||||
|
|
||||||
HTTP 409
|
HTTP 200
|
||||||
[Asserts]
|
[Asserts]
|
||||||
# Distinct CamelCase key per auto-link refusal reason — the SPA
|
url matches "^http://localhost:8087/login\\?login_error=auto_link_email_not_verified$"
|
||||||
# switches on this to render "verify your email at the IdP" copy
|
|
||||||
# rather than the generic contact-admin fallback.
|
|
||||||
jsonpath "$.error_type" == "AutoLinkEmailNotVerified"
|
|
||||||
|
|
||||||
|
|
||||||
# Belt-and-braces invariant: admin's row is still un-linked
|
# Belt-and-braces invariant: admin's row is still un-linked
|
||||||
|
|||||||
Reference in New Issue
Block a user