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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<AuthMethod>,
|
||||
/// 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<AuthPolicy>,
|
||||
/// 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<AuthMethod>,
|
||||
auth_policies: Vec<AuthPolicy>,
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Arc<AppState>> {
|
||||
pub fn create_web_routes(app_state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
let config = AppConfig::from_env();
|
||||
let static_path = resolve_static_path(&config);
|
||||
|
||||
@@ -91,6 +94,52 @@ pub fn create_web_routes() -> Router<Arc<AppState>> {
|
||||
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<Arc<AppState>>,
|
||||
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.
|
||||
|
||||
+1
-1
@@ -628,7 +628,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user