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
@@ -74,9 +74,11 @@ impl UserRepository for UserPgRepository {
INSERT INTO auth.users (
id, username, email, password_hash, role,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
) VALUES (
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
$12, $13
)
RETURNING *
"#
@@ -92,6 +94,8 @@ impl UserRepository for UserPgRepository {
.bind(user_clone.updated_at())
.bind(user_clone.last_login_at())
.bind(user_clone.is_active())
.bind(user_clone.oidc_provider())
.bind(user_clone.oidc_subject())
.execute(&mut **tx)
.await
.map_err(Self::map_sqlx_error)?;
@@ -114,7 +118,8 @@ impl UserRepository for UserPgRepository {
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
FROM auth.users
WHERE id = $1
"#
@@ -131,7 +136,7 @@ impl UserRepository for UserPgRepository {
_ => UserRole::User,
};
Ok(User::from_data(
Ok(User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
@@ -143,6 +148,8 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
))
}
@@ -153,7 +160,8 @@ impl UserRepository for UserPgRepository {
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
FROM auth.users
WHERE username = $1
"#
@@ -170,7 +178,7 @@ impl UserRepository for UserPgRepository {
_ => UserRole::User,
};
Ok(User::from_data(
Ok(User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
@@ -182,6 +190,8 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
))
}
@@ -192,7 +202,8 @@ impl UserRepository for UserPgRepository {
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
FROM auth.users
WHERE email = $1
"#
@@ -209,7 +220,7 @@ impl UserRepository for UserPgRepository {
_ => UserRole::User,
};
Ok(User::from_data(
Ok(User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
@@ -221,6 +232,8 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
))
}
@@ -322,7 +335,8 @@ impl UserRepository for UserPgRepository {
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
FROM auth.users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
@@ -343,7 +357,7 @@ impl UserRepository for UserPgRepository {
_ => UserRole::User,
};
User::from_data(
User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
@@ -355,6 +369,8 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
)
})
.collect();
@@ -432,7 +448,8 @@ impl UserRepository for UserPgRepository {
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
FROM auth.users
WHERE role::text = $1
ORDER BY created_at DESC
@@ -452,7 +469,7 @@ impl UserRepository for UserPgRepository {
_ => UserRole::User,
};
User::from_data(
User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
@@ -464,6 +481,8 @@ impl UserRepository for UserPgRepository {
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
)
})
.collect();
@@ -486,6 +505,48 @@ impl UserRepository for UserPgRepository {
Ok(())
}
/// Finds a user by OIDC provider + subject pair
async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> UserRepositoryResult<User> {
let row = sqlx::query(
r#"
SELECT
id, username, email, password_hash, role::text as role_text,
storage_quota_bytes, storage_used_bytes,
created_at, updated_at, last_login_at, active,
oidc_provider, oidc_subject
FROM auth.users
WHERE oidc_provider = $1 AND oidc_subject = $2
"#
)
.bind(provider)
.bind(subject)
.fetch_one(&*self.pool)
.await
.map_err(Self::map_sqlx_error)?;
let role_str: Option<String> = row.try_get("role_text").unwrap_or(None);
let role = match role_str.as_deref() {
Some("admin") => UserRole::Admin,
_ => UserRole::User,
};
Ok(User::from_data_full(
row.get("id"),
row.get("username"),
row.get("email"),
row.get("password_hash"),
role,
row.get("storage_quota_bytes"),
row.get("storage_used_bytes"),
row.get("created_at"),
row.get("updated_at"),
row.get("last_login_at"),
row.get("active"),
row.get("oidc_provider"),
row.get("oidc_subject"),
))
}
}
// Implementación del puerto de almacenamiento para la capa de aplicación
@@ -536,4 +597,10 @@ impl UserStoragePort for UserPgRepository {
.await
.map_err(DomainError::from)
}
async fn get_user_by_oidc_subject(&self, provider: &str, subject: &str) -> Result<User, DomainError> {
UserRepository::get_user_by_oidc_subject(self, provider, subject)
.await
.map_err(DomainError::from)
}
}