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
+89
View File
@@ -33,6 +33,8 @@ pub struct User {
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
active: bool,
oidc_provider: Option<String>,
oidc_subject: Option<String>,
}
impl User {
@@ -88,6 +90,45 @@ impl User {
updated_at: now,
last_login_at: None,
active: true,
oidc_provider: None,
oidc_subject: None,
})
}
/// Create a new OIDC-authenticated user (no password required).
pub fn new_oidc(
username: String,
email: String,
role: UserRole,
storage_quota_bytes: i64,
oidc_provider: String,
oidc_subject: String,
) -> UserResult<Self> {
if username.is_empty() || username.len() < 3 || username.len() > 32 {
return Err(UserError::InvalidUsername(
"Username debe tener entre 3 y 32 caracteres".to_string(),
));
}
if !email.contains('@') || email.len() < 5 {
return Err(UserError::ValidationError(
"Email inválido".to_string(),
));
}
let now = Utc::now();
Ok(Self {
id: Uuid::new_v4().to_string(),
username,
email,
password_hash: "__OIDC_NO_PASSWORD__".to_string(),
role,
storage_quota_bytes,
storage_used_bytes: 0,
created_at: now,
updated_at: now,
last_login_at: None,
active: true,
oidc_provider: Some(oidc_provider),
oidc_subject: Some(oidc_subject),
})
}
@@ -117,6 +158,41 @@ impl User {
updated_at,
last_login_at,
active,
oidc_provider: None,
oidc_subject: None,
}
}
/// Reconstruct from DB with OIDC fields
pub fn from_data_full(
id: String,
username: String,
email: String,
password_hash: String,
role: UserRole,
storage_quota_bytes: i64,
storage_used_bytes: i64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_login_at: Option<DateTime<Utc>>,
active: bool,
oidc_provider: Option<String>,
oidc_subject: Option<String>,
) -> Self {
Self {
id,
username,
email,
password_hash,
role,
storage_quota_bytes,
storage_used_bytes,
created_at,
updated_at,
last_login_at,
active,
oidc_provider,
oidc_subject,
}
}
@@ -164,6 +240,19 @@ impl User {
pub fn password_hash(&self) -> &str {
&self.password_hash
}
pub fn oidc_provider(&self) -> Option<&str> {
self.oidc_provider.as_deref()
}
pub fn oidc_subject(&self) -> Option<&str> {
self.oidc_subject.as_deref()
}
/// Returns true if this is an OIDC-only user (no password)
pub fn is_oidc_user(&self) -> bool {
self.oidc_provider.is_some()
}
/// Update the password hash.
///