feat(auth): add OpenID Connect (OIDC) authentication support

Implements OIDC Authorization Code Flow for external identity providers
(Authentik, Keycloak, etc.) with JIT user provisioning.

New features:
- OidcService with OpenID Discovery, JWKS caching, RS256 ID token validation
- Authorization Code Flow: /api/auth/oidc/authorize -> IdP -> /api/auth/oidc/callback
- JIT user provisioning from OIDC claims (sub, email, name, groups)
- OIDC group-to-role mapping (admin_groups config)
- Provider info endpoint: GET /api/auth/oidc/providers
- Option to disable password login entirely (OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN)
- Auto-provision toggle (OXICLOUD_OIDC_AUTO_PROVISION)
- Email collision detection (security: prevents account takeover)

Configuration (env vars):
- OXICLOUD_OIDC_ENABLED, OXICLOUD_OIDC_ISSUER_URL
- OXICLOUD_OIDC_CLIENT_ID, OXICLOUD_OIDC_CLIENT_SECRET
- OXICLOUD_OIDC_REDIRECT_URI, OXICLOUD_OIDC_SCOPES
- OXICLOUD_OIDC_FRONTEND_URL, OXICLOUD_OIDC_PROVIDER_NAME
- OXICLOUD_OIDC_AUTO_PROVISION, OXICLOUD_OIDC_ADMIN_GROUPS
- OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN

DB migration:
- ALTER TABLE auth.users ADD oidc_provider, oidc_subject columns
- UNIQUE index on (oidc_provider, oidc_subject)

Files changed: 14 files, ~1400 lines added
Dependencies: reqwest 0.12 (rustls-tls-webpki-roots), base64 0.22
This commit is contained in:
Dionisio
2026-02-10 20:32:32 +01:00
parent 65f4e10236
commit 8ef62109a3
14 changed files with 1407 additions and 33 deletions
+119 -4
View File
@@ -2,14 +2,15 @@ use std::sync::Arc;
use axum::{
Router,
routing::{post, get, put},
extract::{State, Json},
extract::{State, Json, Query},
http::{StatusCode, HeaderMap, header},
response::IntoResponse,
response::{IntoResponse, Redirect},
};
use crate::common::di::AppState;
use crate::application::dtos::user_dto::{
LoginDto, RegisterDto, ChangePasswordDto, RefreshTokenDto
LoginDto, RegisterDto, ChangePasswordDto, RefreshTokenDto,
OidcCallbackQueryDto, OidcProviderInfoDto,
};
use crate::interfaces::errors::AppError;
@@ -19,7 +20,11 @@ pub fn auth_routes() -> Router<Arc<AppState>> {
.route("/register", post(register))
.route("/login", post(login))
.route("/refresh", post(refresh_token))
.route("/status", get(get_system_status));
.route("/status", get(get_system_status))
// OIDC endpoints (all public)
.route("/oidc/providers", get(oidc_providers))
.route("/oidc/authorize", get(oidc_authorize))
.route("/oidc/callback", get(oidc_callback));
// Rutas que SÍ requieren autenticación - usamos route_layer para aplicar middleware
// El middleware usará el state que se pase con .with_state() desde main.rs
@@ -141,6 +146,13 @@ async fn login(
return Err(AppError::internal_error("Servicio de autenticación no configurado"));
}
};
// Check if password login is disabled (OIDC-only mode)
if auth_service.auth_application_service.password_login_disabled() {
return Err(AppError::unauthorized(
"Password login is disabled. Please use SSO/OIDC to sign in."
));
}
// Try the normal login process
match auth_service.auth_application_service.login(dto.clone()).await {
@@ -308,3 +320,106 @@ async fn get_system_status(
Ok((StatusCode::OK, Json(status)))
}
// ============================================================================
// OIDC Handlers
// ============================================================================
/// GET /api/auth/oidc/providers — Returns OIDC provider info for the UI
async fn oidc_providers(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_app = &auth_service.auth_application_service;
if !auth_app.oidc_enabled() {
return Ok(Json(OidcProviderInfoDto {
enabled: false,
provider_name: String::new(),
authorize_endpoint: String::new(),
password_login_enabled: true,
}));
}
let config = auth_app.oidc_config().unwrap();
Ok(Json(OidcProviderInfoDto {
enabled: true,
provider_name: config.provider_name.clone(),
authorize_endpoint: "/api/auth/oidc/authorize".to_string(),
password_login_enabled: !config.disable_password_login,
}))
}
/// GET /api/auth/oidc/authorize — Redirects user to the OIDC provider
async fn oidc_authorize(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_app = &auth_service.auth_application_service;
if !auth_app.oidc_enabled() {
return Err(AppError::new(
StatusCode::NOT_FOUND,
"OIDC is not enabled",
"OidcDisabled",
));
}
// Generate CSRF state
let state_token = auth_app.generate_oidc_state()?;
// Build authorization URL
let authorize_url = auth_app.oidc_authorize_url(&state_token)?;
tracing::info!("OIDC authorize redirect generated");
Ok(Redirect::temporary(&authorize_url))
}
/// GET /api/auth/oidc/callback?code=...&state=... — Handles OIDC callback
async fn oidc_callback(
State(state): State<Arc<AppState>>,
Query(query): Query<OidcCallbackQueryDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state.auth_service.as_ref()
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
let auth_app = &auth_service.auth_application_service;
if !auth_app.oidc_enabled() {
return Err(AppError::new(
StatusCode::NOT_FOUND,
"OIDC is not enabled",
"OidcDisabled",
));
}
tracing::info!("OIDC callback received with code");
// Exchange code and authenticate
let auth_response = auth_app.oidc_callback(&query.code).await
.map_err(|e| {
tracing::error!("OIDC callback failed: {}", e);
AppError::from(e)
})?;
// Redirect to frontend with tokens as fragment
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!(
"{}/#access_token={}&refresh_token={}&token_type=Bearer&expires_in={}",
frontend_url,
auth_response.access_token,
auth_response.refresh_token,
auth_response.expires_in,
);
tracing::info!("OIDC login successful for user: {}", auth_response.user.username);
Ok(Redirect::temporary(&redirect_url))
}