From fa7b65189700d200fad23ac8d0e06a351bea4be2 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 8 Aug 2026 20:09:02 +0200 Subject: [PATCH] feat(oidc): provide reason of autolink failure --- .../services/auth_application_service.rs | 25 +++++++++++++------ src/interfaces/api/handlers/auth_handler.rs | 21 ++++++++++++++++ tests/oidc/link_unlink.hurl | 20 ++++++++------- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 53ce5823..4fe8524b 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -56,6 +56,14 @@ pub enum OidcCallbackResult { /// `/profile?link_error=` redirect. See /// docs/plan/oidc-account-linking.md ยง Safety checks. 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 @@ -3877,14 +3885,15 @@ impl AuthApplicationService { reason = reason, "๐Ÿ”— auto-link refused", ); - return Err(DomainError::new( - ErrorKind::AlreadyExists, - "OIDC", - format!( - "A user with email '{}' already exists. Contact admin to link your OIDC identity.", - oidc_email - ), - )); + // Ok(AutoLinkRefused) rather than Err(AlreadyExists) + // so the handler can map each reason to a distinct + // stable CamelCase error_type (AutoLinkDisabled / + // AutoLinkEmailNotVerified / AutoLinkAlreadyLinked- + // Elsewhere). Bubbling as a generic AlreadyExists + // 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 diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 54e93475..db1ad883 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -1582,6 +1582,27 @@ pub async fn oidc_callback( ); 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, + )) + } } } diff --git a/tests/oidc/link_unlink.hurl b/tests/oidc/link_unlink.hurl index 13dc1ab5..d42cba9d 100644 --- a/tests/oidc/link_unlink.hurl +++ b/tests/oidc/link_unlink.hurl @@ -40,8 +40,9 @@ # (iss, sub) miss but email matches admin, so it auto- # links + logs admin in. # 2. Auto-link refused โ€” email_verified=false. Callback -# returns HTTP 409 with error_type "Already Exists" (the -# "contact admin to link your OIDC identity" refusal). +# returns HTTP 409 with error_type "AutoLinkEmailNotVerified" +# (one of three distinct auto-link refusal error_types โ€” +# see auth_handler.rs AutoLinkRefused arm). # # [OIDC-only user] # 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 # in the SPA login form's error toast. # -# NOTE: `error_type` here is "Already Exists" (with a space) -# because it comes from `ErrorKind::as_str()`, not from a -# handler-set stable key. The auto-link refusal branch is -# reusing the generic AlreadyExists mapping โ€” a follow-up -# could give it a dedicated `error_type` like -# `AutoLinkEmailNotVerified` for the SPA to switch on. +# The handler maps each auto-link refusal reason to a distinct +# CamelCase error_type โ€” AutoLinkDisabled / +# AutoLinkEmailNotVerified / AutoLinkAlreadyLinkedElsewhere โ€” +# so the SPA can render targeted copy per refusal reason. # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -522,7 +521,10 @@ location-trusted: true HTTP 409 [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