From 5ebe2d3bae56fde6603bd6e6542bc84864abf404 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 3 Aug 2026 00:22:19 +0200 Subject: [PATCH] feat(oidc): add auto-redirect for OIDC add `auto_redirect_if_standalone_oidc` in `OXICLOUD_AUTH_POLICIES` let admin decide to redirect immediately to IdP if OIDC is the only auth method enabled --- docs/config/authentication.md | 1 + docs/config/env.md | 2 +- example.env | 13 +++++ frontend/src/lib/styles/ported/auth.css | 13 +++-- frontend/src/routes/login/+page.svelte | 28 ++-------- src/application/dtos/user_dto.rs | 8 +++ .../services/auth_application_service.rs | 39 ++++++++++++-- src/common/config.rs | 21 ++++++++ src/infrastructure/auth_factory.rs | 1 + src/interfaces/api/handlers/auth_handler.rs | 3 ++ src/interfaces/web/mod.rs | 51 ++++++++++++++++++- src/main.rs | 2 +- 12 files changed, 144 insertions(+), 38 deletions(-) diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 2aea8f04..0fa625df 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -122,6 +122,7 @@ The verification-piggyback flow above deliberately **bypasses the `has_password` | Token | Effect | | --- | --- | | `permit_magic_link_for_password_users` | Allow magic-link login for accounts that also have a password. OIDC-linked users are still refused. | +| `auto_redirect_if_standalone_oidc` | When OIDC is the ONLY working login method (no password, no magic-link — via allowlist or the OIDC-master rule), the login SPA auto-redirects to the IdP on page load instead of showing a click-to-continue SSO button. Off by default to avoid redirect loops on IdP failure and to preserve logout UX (logging out then visiting `/login` would otherwise bounce the user right back in). Silent no-op when other methods are also live. Frontend reads this via `auto_redirect_to_oidc` on `GET /api/auth/oidc/providers`. | Unknown tokens are logged-and-skipped at startup so a typo doesn't silently zero the vector. diff --git a/docs/config/env.md b/docs/config/env.md index 4dd71e91..8fd5be1c 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -47,7 +47,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts | | `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted on `POST /api/auth/register` (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed. **Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`**: this one gates SELF-registration (public sign-up), the external list gates INVITATIONS (grants + magic-link to third parties). An operator can lock sign-up to their company domain while leaving invitations open. Subdomains must be listed explicitly. Rejected registrations return 403 `RegistrationDomainNotAllowed` and emit an `audit` line. Example: `mycompany.com,mycompany-eu.com`. | | `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of auth methods (`password`, `magic_link`, `oidc`). **Fail-fast**: unknown token → boot panic; empty allowlist → boot panic; `oidc` in list without `OXICLOUD_OIDC_ENABLED=true` → boot panic. Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. Setting `OXICLOUD_AUTH_METHODS=oidc` is the cleanest "SSO-only" posture. **Loose semantic (deprecation warning)**: if this list is explicitly set WITHOUT `oidc` but `OXICLOUD_OIDC_ENABLED=true`, OIDC is served regardless — a boot warning is emitted and this will become a fail-fast panic in the next major release. **Startup gate**: if `magic_link` is the only working method (no `password`, no `oidc`) AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start. **OIDC master rule**: when OIDC is enabled, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list. | -| `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless). | +| `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless); `auto_redirect_if_standalone_oidc` (when OIDC is the only working login method, auto-redirect the login page to the IdP instead of showing a click-to-continue button — off by default to avoid redirect loops on IdP failure and preserve logout UX). | | `OXICLOUD_REQUIRE_VERIFIED_EMAIL` | `false` | When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for any account whose `email_verified_at` is NULL. Users can prove control by requesting a magic-link (whose redemption stamps `email_verified_at`), so this composes with `magic_link` in `OXICLOUD_AUTH_METHODS` to give users a self-service verification path. Admin-created (`POST /api/admin/users`) and setup-admin (`POST /api/setup`) users are auto-verified. OIDC-JIT users are also stamped verified at creation. | ### Rate Limiting & Account Lockout diff --git a/example.env b/example.env index 4223e24e..9577001b 100644 --- a/example.env +++ b/example.env @@ -805,8 +805,21 @@ OXICLOUD_WOPI_ENABLED=false # policy — the IdP is the security boundary and may enforce MFA we # shouldn't bypass. # +# auto_redirect_if_standalone_oidc +# When OIDC is the ONLY working login method (no password, no +# magic-link, whether via the allowlist or the OIDC-master rule), +# the login SPA auto-redirects to the OIDC authorize endpoint on +# page load instead of showing a click-to-continue SSO button. +# Off by default because auto-redirect can loop on IdP failure +# (login → IdP error → back to login → auto-redirect again) and +# makes logout-then-visit-login flows feel broken (bounces the +# user right back into the app). Silent no-op when the login page +# has more than one method available (nothing to auto-choose). +# # Example: #OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users +#OXICLOUD_AUTH_POLICIES=auto_redirect_if_standalone_oidc +#OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users,auto_redirect_if_standalone_oidc # Operator-level kill switch for share-notification emails to internal diff --git a/frontend/src/lib/styles/ported/auth.css b/frontend/src/lib/styles/ported/auth.css index dc7eeb4d..c1ffc91f 100644 --- a/frontend/src/lib/styles/ported/auth.css +++ b/frontend/src/lib/styles/ported/auth.css @@ -212,18 +212,17 @@ margin-top: var(--space-5); } -/* SSO / OIDC button */ +/* SSO / OIDC button — inherits the primary .auth-button visual + (accent gradient + on-accent text), only overriding layout so an + optional provider icon can sit beside the label. The previous + text-color gradient was unreadable in dark mode because the + inherited text color also resolved to a light value. */ .auth-button-oidc { - background: linear-gradient(135deg, var(--color-text) 0%, var(--color-text-secondary) 100%); - box-shadow: 0 4px 12px var(--color-shadow-3xl); display: flex; align-items: center; justify-content: center; gap: var(--space-2-5); -} - -.auth-button-oidc:hover { - box-shadow: 0 6px 20px var(--color-shadow-4xl); + text-decoration: none; } .auth-button-sso { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index d7166627..d938113f 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -249,19 +249,6 @@ } } - // Shared by onMount step 4 and onSetup: true + navigates away iff OIDC is - // the only login method. Centralised so the guard can't drift between the - // two call sites (only the `?error=` loop-guard, checked at onMount time, - // doesn't apply post-setup — a freshly created admin can't have bounced - // off the IdP yet). - function tryAutoRedirectToIdp(): boolean { - if (oidc.enabled && oidc.password_login_enabled === false && oidc.authorize_endpoint) { - window.location.replace(oidc.authorize_endpoint); - return true; - } - return false; - } - async function onSetup(e: SubmitEvent) { e.preventDefault(); setupError = ''; @@ -276,10 +263,6 @@ setupEmail = setupPassword = setupConfirm = ''; // Admin now exists — fold the setup affordance away and return to login. setupAvailable = false; - // OIDC-only: the login page would immediately redirect on the next - // visit anyway — skip the "you can now sign in" detour and forward - // straight to the IdP instead of leaving a dead-end local form. - if (tryAutoRedirectToIdp()) return; setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.'); setTimeout(() => { mode = 'login'; @@ -340,12 +323,11 @@ setupAvailable = !status.initialized; if (setupAvailable) mode = 'setup'; - // 4) Auto-redirect: when OIDC is the only auth method, skip the login page. - // Guard against loops: if the IdP returned ?error=, fall through to the UI. - if (!setupAvailable && !page.url.searchParams.has('error') && tryAutoRedirectToIdp()) { - return; - } - + // Auto-redirect to the IdP in standalone-OIDC posture is enforced + // server-side via the `auto_redirect_if_standalone_oidc` auth policy + // (see interfaces/web/mod.rs::oidc_standalone_login_redirect). Keeping + // a client-side copy would make the policy toggle a no-op — the SPA + // would auto-redirect regardless of what the admin configured. booting = false; }); diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 0bc0aab9..059164af 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -383,6 +383,14 @@ pub struct OidcProviderInfoDto { /// straight after signup. #[serde(default)] pub require_verified_email: bool, + /// True iff the effective allowlist is `[Oidc]` AND the + /// `auto_redirect_if_standalone_oidc` policy is set. Frontend + /// uses this to decide whether to auto-redirect to the authorize + /// endpoint on login-page mount (true) or show a click-to-continue + /// button (false). Default false — the safe posture that avoids + /// redirect loops when the IdP is degraded. + #[serde(default)] + pub auto_redirect_to_oidc: bool, } /// Claims extracted from the validated OIDC ID token diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 1ba1f762..3c1611ef 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -9,7 +9,7 @@ use crate::application::ports::auth_ports::{ use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason}; use crate::application::services::user_lifecycle_service::UserLifecycleService; -use crate::common::config::{AuthMethod, OidcConfig}; +use crate::common::config::{AuthMethod, AuthPolicy, OidcConfig}; use crate::common::errors::{DomainError, ErrorKind}; use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus}; use crate::domain::entities::session::Session; @@ -161,6 +161,11 @@ pub struct AuthApplicationService { /// `is_password_login_allowed()` / `is_magic_link_login_allowed()` /// so callers don't have to reach for the app config. allowed_auth_methods: Vec, + /// Additive auth-policy switches (mirrors `AuthConfig::auth_policies`). + /// Consulted by handlers / providers-info endpoint to compose the + /// login-page UX hints (e.g. `AutoRedirectIfStandaloneOidc`) without + /// reaching into the app config on every call. + auth_policies: Vec, /// Whether `POST /api/auth/login` refuses accounts whose /// `email_verified_at IS NULL`. Mirrors /// `AuthConfig::require_verified_email`. @@ -209,20 +214,24 @@ impl AuthApplicationService { .time_to_live(USER_FLAGS_CACHE_TTL) .build(), allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink], + auth_policies: Vec::new(), require_verified_email: false, } } - /// Populates the auth-method allowlist + `require_verified_email` - /// snapshot from the loaded config. Called by the DI factory. If - /// left uncalled (test builds), defaults are permissive: both - /// methods enabled, verified-email not required. + /// Populates the auth-method allowlist + policy vector + + /// `require_verified_email` snapshot from the loaded config. + /// Called by the DI factory. If left uncalled (test builds), + /// defaults are permissive: both self-service methods enabled, + /// no policies, verified-email not required. pub fn with_auth_policy( mut self, allowed_methods: Vec, + auth_policies: Vec, require_verified_email: bool, ) -> Self { self.allowed_auth_methods = allowed_methods; + self.auth_policies = auth_policies; self.require_verified_email = require_verified_email; self } @@ -264,6 +273,26 @@ impl AuthApplicationService { self.require_verified_email } + /// True iff the login SPA should auto-redirect to the OIDC + /// authorize endpoint on page load (SSO-only, no click needed). + /// + /// Composed to be BOTH policy-set AND effectively-standalone: + /// * `AutoRedirectIfStandaloneOidc` policy is in the vector, AND + /// * OIDC is enabled AND is the only WORKING login method + /// (password + magic-link both refused by the composition of + /// the allowlist + OIDC-master rule). + /// + /// When the policy is set but other methods are also live, this is + /// a silent no-op — the FE renders the multi-method chooser. If + /// the policy is NOT set, this is always false regardless. + pub fn auto_redirect_to_oidc(&self) -> bool { + self.auth_policies + .contains(&AuthPolicy::AutoRedirectIfStandaloneOidc) + && self.oidc_enabled() + && !self.is_password_login_allowed() + && !self.is_magic_link_login_allowed() + } + /// Resolve a login-identifier (username OR email) to the account's /// registered email address. Mirrors the `POST /api/auth/login` /// dispatcher (`@` presence → email lookup, else → username diff --git a/src/common/config.rs b/src/common/config.rs index 723bebd4..dd4b2e6c 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -1493,6 +1493,24 @@ pub enum AuthPolicy { /// Deprecated legacy alias: `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true` /// adds this variant to the vector with a startup warning. PermitMagicLinkForPasswordUsers, + + /// When OIDC is the ONLY auth method available (standalone SSO + /// posture — no password + no magic-link), instruct the login SPA + /// to auto-redirect to the OIDC authorize endpoint on page load + /// instead of showing a click-to-continue button. + /// + /// Opt-in because: + /// + /// - Auto-redirect can create loops on IdP failure (login → IdP + /// error → back to login → auto-redirect again). + /// - Logout followed by "visit login page" would bounce the user + /// right back into the app they just logged out of. + /// + /// Only takes effect when the effective allowlist is `[Oidc]` + /// (or magic-link is off via the OIDC-master rule and password is + /// disabled): if any other method is live the policy is a silent + /// no-op (there's a choice to render, not a single path). + AutoRedirectIfStandaloneOidc, } impl AuthPolicy { @@ -1504,6 +1522,9 @@ impl AuthPolicy { "permit_magic_link_for_password_users" | "permit-magic-link-for-password-users" => { Some(Self::PermitMagicLinkForPasswordUsers) } + "auto_redirect_if_standalone_oidc" | "auto-redirect-if-standalone-oidc" => { + Some(Self::AutoRedirectIfStandaloneOidc) + } _ => None, } } diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index b2ff7f60..d8c64c42 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -57,6 +57,7 @@ pub async fn create_auth_services( // rather than reaching into the app config on every call. auth_app_service = auth_app_service.with_auth_policy( config.auth.allowed_auth_methods.clone(), + config.auth.auth_policies.clone(), config.auth.require_verified_email, ); diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index f47ecdf4..b29a4e9d 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -1062,6 +1062,7 @@ pub async fn oidc_providers( let password_login_enabled = auth_app.is_password_login_allowed(); let magic_link_login_enabled = auth_app.is_magic_link_login_allowed(); let require_verified_email = auth_app.require_verified_email(); + let auto_redirect_to_oidc = auth_app.auto_redirect_to_oidc(); if !auth_app.oidc_enabled() { return Ok(Json(OidcProviderInfoDto { @@ -1071,6 +1072,7 @@ pub async fn oidc_providers( password_login_enabled, magic_link_login_enabled, require_verified_email, + auto_redirect_to_oidc: false, })); } @@ -1083,6 +1085,7 @@ pub async fn oidc_providers( password_login_enabled, magic_link_login_enabled, require_verified_email, + auto_redirect_to_oidc, })) } diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index 2239a1a4..e37a47b5 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -1,7 +1,10 @@ use crate::common::config::AppConfig; use crate::common::di::AppState; use axum::Router; +use axum::extract::{Request, State}; use axum::http::header::{CACHE_CONTROL, HeaderValue}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::get_service; use base64::Engine as _; use sha2::{Digest, Sha256}; @@ -41,7 +44,7 @@ pub fn resolve_static_path(config: &AppConfig) -> PathBuf { /// Caching: content-hashed assets under `/_app/immutable` are cached forever; /// everything else — crucially the `index.html` shell — is `no-cache` so a deploy /// can't leave a stale app pinned in browsers. -pub fn create_web_routes() -> Router> { +pub fn create_web_routes(app_state: Arc) -> Router> { let config = AppConfig::from_env(); let static_path = resolve_static_path(&config); @@ -91,6 +94,52 @@ pub fn create_web_routes() -> Router> { CACHE_CONTROL, HeaderValue::from_static("no-cache"), )) + // Short-circuit `GET /login` to the OIDC authorize endpoint when + // the AutoRedirectIfStandaloneOidc policy resolves. Runs BEFORE + // the SPA shell is served, so there's no form-then-redirect flash. + // The SPA carries the same predicate as belt-and-suspenders for + // deep links / browser-cache hits that skip this hop. + .layer(axum::middleware::from_fn_with_state( + app_state, + oidc_standalone_login_redirect, + )) +} + +/// Intercept `GET /login` and 302 to `/api/auth/oidc/authorize` when OIDC is +/// the only working method (see `AuthApplicationService::auto_redirect_to_oidc`). +/// +/// Loop-guards mirror the SPA: +/// - `?error=…` — the IdP bounced us back; falling through lets the SPA render +/// the error rather than looping straight back to the failing IdP. +/// - `?oidc_code=…` — the callback landing carries the exchange code; the SPA +/// must handle it, not another authorize round-trip. +async fn oidc_standalone_login_redirect( + State(state): State>, + req: Request, + next: Next, +) -> Response { + if req.method() == axum::http::Method::GET && req.uri().path() == "/login" { + let has_loop_guard_param = req + .uri() + .query() + .map(|q| { + q.split('&') + .any(|p| p.starts_with("error=") || p.starts_with("oidc_code=")) + }) + .unwrap_or(false); + + let should_redirect = !has_loop_guard_param + && state + .auth_service + .as_ref() + .map(|svc| svc.auth_application_service.auto_redirect_to_oidc()) + .unwrap_or(false); + + if should_redirect { + return Redirect::temporary("/api/auth/oidc/authorize").into_response(); + } + } + next.run(req).await } /// Build the `content-security-policy` header value served on every response. diff --git a/src/main.rs b/src/main.rs index 33e667b4..11acf93e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -628,7 +628,7 @@ async fn run() -> Result<(), Box> { let api_routes = create_api_routes(&app_state); let public_api_routes = create_public_api_routes(&app_state); let health_routes = create_health_routes(&app_state); - let web_routes = create_web_routes(); + let web_routes = create_web_routes(app_state.clone()); let mut app;