feat(oidc): provide reason of autolink failure

This commit is contained in:
Edouard Vanbelle
2026-08-08 20:09:02 +02:00
parent 30bf64667c
commit fa7b651897
3 changed files with 49 additions and 17 deletions
@@ -56,6 +56,14 @@ pub enum OidcCallbackResult {
/// `/profile?link_error=<reason>` redirect. See /// `/profile?link_error=<reason>` redirect. See
/// docs/plan/oidc-account-linking.md § Safety checks. /// docs/plan/oidc-account-linking.md § Safety checks.
LinkRefused { reason: &'static str }, LinkRefused { reason: &'static str },
/// Auto-link decision refused during the OIDC LOGIN callback path
/// (existing local user matched by email but the decision tree
/// rejected). `reason` is one of `auto_link_disabled`,
/// `auto_link_email_not_verified`, `already_linked_elsewhere`;
/// the handler maps each to a distinct CamelCase `error_type`
/// 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 },
} }
/// Outcome of a successful magic-link redemption. The auth tokens are /// Outcome of a successful magic-link redemption. The auth tokens are
@@ -3877,14 +3885,15 @@ impl AuthApplicationService {
reason = reason, reason = reason,
"🔗 auto-link refused", "🔗 auto-link refused",
); );
return Err(DomainError::new( // Ok(AutoLinkRefused) rather than Err(AlreadyExists)
ErrorKind::AlreadyExists, // so the handler can map each reason to a distinct
"OIDC", // stable CamelCase error_type (AutoLinkDisabled /
format!( // AutoLinkEmailNotVerified / AutoLinkAlreadyLinked-
"A user with email '{}' already exists. Contact admin to link your OIDC identity.", // Elsewhere). Bubbling as a generic AlreadyExists
oidc_email // would collapse all three reasons into "Already
), // Exists" on the wire and leave the SPA without a
)); // switch arm for user-facing copy.
return Ok(OidcCallbackResult::AutoLinkRefused { reason });
} }
// All checks passed — commit the auto-link, re-fetch // All checks passed — commit the auto-link, re-fetch
@@ -1582,6 +1582,27 @@ 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
// CamelCase `error_type`. The SPA switches on this to render
// targeted copy (contact-admin vs. verify-email-at-IdP vs.
// already-linked-elsewhere) rather than a generic error toast.
// Status stays 409 (CONFLICT) — semantically an existing user
// blocks the auto-provision path.
OidcCallbackResult::AutoLinkRefused { reason } => {
let error_type = match reason {
"auto_link_disabled" => "AutoLinkDisabled",
"auto_link_email_not_verified" => "AutoLinkEmailNotVerified",
"already_linked_elsewhere" => "AutoLinkAlreadyLinkedElsewhere",
_ => "AutoLinkRefused",
};
Err(AppError::new(
StatusCode::CONFLICT,
"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,
))
}
} }
} }
+11 -9
View File
@@ -40,8 +40,9 @@
# (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 "Already Exists" (the # returns HTTP 409 with error_type "AutoLinkEmailNotVerified"
# "contact admin to link your OIDC identity" refusal). # (one of three distinct auto-link refusal error_types —
# 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
@@ -491,12 +492,10 @@ HTTP 200
# "contact admin to link your OIDC identity" text that surfaces # "contact admin to link your OIDC identity" text that surfaces
# in the SPA login form's error toast. # in the SPA login form's error toast.
# #
# NOTE: `error_type` here is "Already Exists" (with a space) # The handler maps each auto-link refusal reason to a distinct
# because it comes from `ErrorKind::as_str()`, not from a # CamelCase error_type — AutoLinkDisabled /
# handler-set stable key. The auto-link refusal branch is # AutoLinkEmailNotVerified / AutoLinkAlreadyLinkedElsewhere —
# reusing the generic AlreadyExists mapping — a follow-up # so the SPA can render targeted copy per refusal reason.
# could give it a dedicated `error_type` like
# `AutoLinkEmailNotVerified` for the SPA to switch on.
# ═════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════
@@ -522,7 +521,10 @@ location-trusted: true
HTTP 409 HTTP 409
[Asserts] [Asserts]
jsonpath "$.error_type" == "Already Exists" # Distinct CamelCase key per auto-link refusal reason — the SPA
# 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