Files
Oxicloud/src/application/services/auth_application_service.rs
T
zjean 18518bedaf fix: resolve clippy warnings and rustfmt issues for CI compliance
Fix all clippy lints (collapsible if, clone on Copy, needless borrow,
redundant bindings, unused params) and apply rustfmt across the codebase.
Update test mocks to match Uuid-based trait signatures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:34:07 +01:00

1318 lines
46 KiB
Rust
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::application::dtos::user_dto::{
AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, UserDto,
};
use crate::application::ports::auth_ports::{
OidcIdClaims, OidcServicePort, PasswordHasherPort, SessionStoragePort, TokenServicePort,
UserStoragePort,
};
use crate::application::ports::inbound::FolderUseCase;
use crate::application::services::folder_service::FolderService;
use crate::common::config::OidcConfig;
use crate::common::errors::{DomainError, ErrorKind};
use crate::domain::entities::session::Session;
use crate::domain::entities::user::{User, UserRole};
use crate::infrastructure::repositories::pg::SessionPgRepository;
use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::infrastructure::services::jwt_service::JwtTokenService;
use crate::infrastructure::services::oidc_service::OidcService;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
use moka::sync::Cache;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use std::time::Duration;
use uuid::Uuid;
/// Result of a successful OIDC callback. The handler layer inspects this to
/// decide whether to redirect to the regular frontend or complete a Nextcloud
/// Login Flow v2 session.
pub enum OidcCallbackResult {
/// Regular web login — contains a one-time exchange code for the frontend.
WebLogin { exchange_code: String },
/// Nextcloud Login Flow v2 — the user authenticated via OIDC but the flow
/// was initiated from the Nextcloud login page. The handler must create an
/// app password and complete the NC login flow.
NextcloudLogin {
nc_flow_token: String,
user_id: Uuid,
username: String,
},
}
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
#[derive(Clone)]
struct PendingOidcFlow {
pkce_verifier: String,
nonce: String,
/// When set, this OIDC flow was initiated from the Nextcloud Login Flow v2
/// page. On successful callback the flow will mint an app-password and
/// complete the Nextcloud login flow instead of issuing internal JWTs.
nc_flow_token: Option<String>,
}
/// Tracks a pending one-time token exchange after successful OIDC callback
#[derive(Clone)]
struct PendingOidcToken {
auth_response: AuthResponseDto,
}
/// Interior state for OIDC — protected by RwLock for hot-reload.
struct OidcState {
service: Option<Arc<OidcService>>,
config: Option<OidcConfig>,
}
/// Default quota: 100 GB
const DEFAULT_ADMIN_QUOTA: i64 = 107_374_182_400;
const DEFAULT_USER_QUOTA: i64 = 1_073_741_824; // 1 GB
pub struct AuthApplicationService {
user_storage: Arc<UserPgRepository>,
session_storage: Arc<SessionPgRepository>,
password_hasher: Arc<Argon2PasswordHasher>,
token_service: Arc<JwtTokenService>,
folder_service: Option<Arc<FolderService>>,
/// Path to the storage directory, used for disk-space–aware quota calculation
storage_path: PathBuf,
oidc: RwLock<OidcState>,
/// Pending OIDC authorization flows keyed by state token (CSRF + PKCE + nonce).
/// Auto-expires after 10 minutes via moka TTL; max 10 000 entries for DoS protection.
pending_oidc_flows: Cache<String, PendingOidcFlow>,
/// Pending one-time token codes for secure token delivery after OIDC callback.
/// Auto-expires after 60 seconds via moka TTL; max 10 000 entries for DoS protection.
pending_oidc_tokens: Cache<String, PendingOidcToken>,
}
impl AuthApplicationService {
pub fn new(
user_storage: Arc<UserPgRepository>,
session_storage: Arc<SessionPgRepository>,
password_hasher: Arc<Argon2PasswordHasher>,
token_service: Arc<JwtTokenService>,
storage_path: PathBuf,
) -> Self {
Self {
user_storage,
session_storage,
password_hasher,
token_service,
folder_service: None,
storage_path,
oidc: RwLock::new(OidcState {
service: None,
config: None,
}),
pending_oidc_flows: Cache::builder()
.max_capacity(10_000)
.time_to_live(Duration::from_secs(600))
.build(),
pending_oidc_tokens: Cache::builder()
.max_capacity(10_000)
.time_to_live(Duration::from_secs(60))
.build(),
}
}
/// Returns the default quota for the given role, capped to the available
/// disk space on the filesystem that hosts the storage directory.
fn capped_quota(&self, role: &UserRole) -> i64 {
let base_quota = match role {
UserRole::Admin => DEFAULT_ADMIN_QUOTA,
_ => DEFAULT_USER_QUOTA,
};
match Self::available_disk_space(&self.storage_path) {
Some(avail) => {
let avail_i64 = avail as i64;
if avail_i64 < base_quota {
tracing::info!(
"Available disk space ({} bytes) is less than default {} quota ({} bytes) — capping quota",
avail_i64,
if *role == UserRole::Admin {
"admin"
} else {
"user"
},
base_quota,
);
avail_i64
} else {
base_quota
}
}
None => {
tracing::warn!("Could not determine available disk space, using default quota");
base_quota
}
}
}
/// Query the available space on the filesystem that contains `path`.
fn available_disk_space(path: &std::path::Path) -> Option<u64> {
use fs2::available_space;
match available_space(path) {
Ok(space) => Some(space),
Err(e) => {
tracing::warn!("Failed to query disk space for {:?}: {}", path, e);
None
}
}
}
/// Configures the folder service, needed to create personal folders
pub fn with_folder_service(mut self, folder_service: Arc<FolderService>) -> Self {
self.folder_service = Some(folder_service);
self
}
/// Configures the OIDC service
pub fn with_oidc(self, oidc_service: Arc<OidcService>, oidc_config: OidcConfig) -> Self {
{
let mut state = self.oidc.write().unwrap();
state.service = Some(oidc_service);
state.config = Some(oidc_config);
}
self
}
/// Hot-reload OIDC configuration at runtime (called from admin settings service)
pub fn reload_oidc(&self, oidc_service: Arc<OidcService>, oidc_config: OidcConfig) {
let mut state = self.oidc.write().unwrap();
state.service = Some(oidc_service);
state.config = Some(oidc_config);
}
/// Disable OIDC at runtime (called from admin settings service)
pub fn disable_oidc(&self) {
let mut state = self.oidc.write().unwrap();
state.service = None;
state.config = None;
}
/// Returns whether OIDC is configured and enabled
pub fn oidc_enabled(&self) -> bool {
let state = self.oidc.read().unwrap();
state.service.is_some() && state.config.as_ref().is_some_and(|c| c.enabled)
}
/// Returns whether password login is disabled (OIDC-only mode)
pub fn password_login_disabled(&self) -> bool {
let state = self.oidc.read().unwrap();
state
.config
.as_ref()
.is_some_and(|c| c.disable_password_login)
}
/// Returns a clone of the OIDC config if available
pub fn oidc_config(&self) -> Option<OidcConfig> {
let state = self.oidc.read().unwrap();
state.config.clone()
}
/// Returns an Arc clone of the OIDC service if available
pub fn oidc_service(&self) -> Option<Arc<OidcService>> {
let state = self.oidc.read().unwrap();
state.service.clone()
}
pub async fn register(&self, dto: RegisterDto) -> Result<UserDto, DomainError> {
// Check for duplicate user
if self
.user_storage
.get_user_by_username(&dto.username)
.await
.is_ok()
{
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"User",
format!("User '{}' already exists", dto.username),
));
}
if self
.user_storage
.get_user_by_email(&dto.email)
.await
.is_ok()
{
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"User",
format!("Email '{}' is already registered", dto.email),
));
}
// SECURITY: Public registration ALWAYS creates regular users.
// Admin users can only be created via:
// 1. The one-time /api/setup endpoint (first boot)
// 2. The admin panel (admin_create_user)
let role = UserRole::User;
// Quota based on role, capped to available disk space
let quota = self.capped_quota(&role);
// Validate password length before hashing
if dto.password.len() < 8 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Password must be at least 8 characters long",
));
}
// Hash the password using the infrastructure service
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
// Create user with the pre-generated hash
let user = User::new(dto.username.clone(), dto.email, password_hash, role, quota).map_err(
|e| {
DomainError::new(
ErrorKind::InvalidInput,
"User",
format!("Error creating user: {}", e),
)
},
)?;
// Save user
let created_user = self.user_storage.create_user(user).await?;
// Create personal folder for the user
self.create_personal_folder(&dto.username, created_user.id())
.await;
tracing::info!("User registered: {}", created_user.id());
Ok(UserDto::from(created_user))
}
/// Create the first admin user during initial system setup.
///
/// This is called by the `/api/setup` endpoint after verifying the setup
/// token. It unconditionally creates an admin user. The caller (handler)
/// is responsible for:
/// 1. Verifying the setup token
/// 2. Checking that the system is not already initialized
/// 3. Marking the system as initialized after this call succeeds
pub async fn setup_create_admin(
&self,
username: String,
email: String,
password: String,
) -> Result<UserDto, DomainError> {
// Validate username
if username.len() < 3 || username.len() > 32 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Username must be between 3 and 32 characters".to_string(),
));
}
// Check for duplicate username
if self
.user_storage
.get_user_by_username(&username)
.await
.is_ok()
{
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"User",
format!("User '{}' already exists", username),
));
}
// Check email uniqueness
if self.user_storage.get_user_by_email(&email).await.is_ok() {
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"User",
format!("Email '{}' is already registered", email),
));
}
// Validate password
if password.len() < 8 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Password must be at least 8 characters long".to_string(),
));
}
let role = UserRole::Admin;
let quota = self.capped_quota(&role);
let password_hash = self.password_hasher.hash_password(&password).await?;
let user = User::new(username.clone(), email, password_hash, role, quota).map_err(|e| {
DomainError::new(
ErrorKind::InvalidInput,
"User",
format!("Error creating admin user: {}", e),
)
})?;
let created_user = self.user_storage.create_user(user).await?;
// Create personal folder for the admin
self.create_personal_folder(&username, created_user.id())
.await;
tracing::info!(
"Initial admin created via setup: {} ({})",
username,
created_user.id()
);
Ok(UserDto::from(created_user))
}
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
// Find user
let mut user = self
.user_storage
.get_user_by_username(&dto.username)
.await
.map_err(|_| {
DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials")
})?;
// Check if user is active
if !user.is_active() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Account deactivated",
));
}
// Verify password using the injected hasher
let is_valid = self
.password_hasher
.verify_password(&dto.password, user.password_hash())
.await?;
if !is_valid {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Invalid credentials",
));
}
// Update last login
user.register_login();
self.user_storage.update_user(user.clone()).await?;
// Generate tokens using the injected token service
let access_token = self.token_service.generate_access_token(&user)?;
let refresh_token = self.token_service.generate_refresh_token();
// Save session
let session = Session::new(
user.id(),
refresh_token.clone(),
None, // IP (can be added from the HTTP layer)
None, // User-Agent (can be added from the HTTP layer)
self.token_service.refresh_token_expiry_days(),
);
self.session_storage.create_session(session).await?;
// Authentication response
Ok(AuthResponseDto {
user: UserDto::from(user),
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: self.token_service.refresh_token_expiry_secs(),
})
}
/// Verifies username/password credentials without creating a session.
pub async fn verify_credentials(
&self,
username: &str,
password: &str,
) -> Result<crate::application::dtos::user_dto::CurrentUser, DomainError> {
let user = self
.user_storage
.get_user_by_username(username)
.await
.map_err(|_| {
DomainError::new(ErrorKind::AccessDenied, "Auth", "Invalid credentials")
})?;
if !user.is_active() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Account deactivated",
));
}
let is_valid = self
.password_hasher
.verify_password(password, user.password_hash())
.await?;
if !is_valid {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Invalid credentials",
));
}
Ok(crate::application::dtos::user_dto::CurrentUser {
id: user.id(),
username: user.username().to_string(),
email: user.email().to_string(),
role: user.role().to_string(),
})
}
pub async fn refresh_token(
&self,
dto: RefreshTokenDto,
) -> Result<AuthResponseDto, DomainError> {
// Get valid session
let session = self
.session_storage
.get_session_by_refresh_token(&dto.refresh_token)
.await?;
// Check if the session is expired or revoked
if session.is_expired() || session.is_revoked() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Session expired or invalid",
));
}
// Get user
let user = self.user_storage.get_user_by_id(session.user_id()).await?;
// Check if user is active
if !user.is_active() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Account deactivated",
));
}
// Revoke current session
self.session_storage.revoke_session(session.id()).await?;
// Generate new tokens
let access_token = self.token_service.generate_access_token(&user)?;
let new_refresh_token = self.token_service.generate_refresh_token();
// Create new session
let new_session = Session::new(
user.id(),
new_refresh_token.clone(),
None,
None,
self.token_service.refresh_token_expiry_days(),
);
self.session_storage.create_session(new_session).await?;
Ok(AuthResponseDto {
user: UserDto::from(user),
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
expires_in: self.token_service.refresh_token_expiry_secs(),
})
}
pub async fn logout(&self, user_id: Uuid, refresh_token: &str) -> Result<(), DomainError> {
// Get session
let session = match self
.session_storage
.get_session_by_refresh_token(refresh_token)
.await
{
Ok(s) => s,
// If the session doesn't exist, we consider the logout successful
Err(_) => return Ok(()),
};
// Verify that the session belongs to the user
if session.user_id() != user_id {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"The session does not belong to the user",
));
}
// Revoke session
self.session_storage.revoke_session(session.id()).await?;
Ok(())
}
pub async fn logout_all(&self, user_id: Uuid) -> Result<u64, DomainError> {
// Revoke all user sessions
let revoked_count = self
.session_storage
.revoke_all_user_sessions(user_id)
.await?;
Ok(revoked_count)
}
pub async fn change_password(
&self,
user_id: Uuid,
dto: ChangePasswordDto,
) -> Result<(), DomainError> {
// Get user
let mut user = self.user_storage.get_user_by_id(user_id).await?;
// Block password changes for OIDC-provisioned users
if user.is_oidc_user() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Password changes are not available for SSO/OIDC accounts. Your password is managed by your identity provider.",
));
}
// Verify current password using the injected hasher
let is_valid = self
.password_hasher
.verify_password(&dto.current_password, user.password_hash())
.await?;
if !is_valid {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Auth",
"Current password is incorrect",
));
}
// Validate new password
if dto.new_password.len() < 8 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Password must be at least 8 characters long",
));
}
// Hash new password and update user
let new_hash = self
.password_hasher
.hash_password(&dto.new_password)
.await?;
user.update_password_hash(new_hash);
// Save updated user
self.user_storage.update_user(user).await?;
// Optional: revoke all sessions to force re-login with new password
self.session_storage
.revoke_all_user_sessions(user_id)
.await?;
Ok(())
}
pub async fn get_user(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
Ok(UserDto::from(user))
}
// Alias for consistency with handler method
pub async fn get_user_by_id(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
self.get_user(user_id).await
}
// New method to get user by username - needed for admin user handling
pub async fn get_user_by_username(&self, username: &str) -> Result<UserDto, DomainError> {
let user = self.user_storage.get_user_by_username(username).await?;
Ok(UserDto::from(user))
}
// Method to count how many admin users exist in the system
// Used to determine if we have multiple admins or just the default one
pub async fn count_admin_users(&self) -> Result<i64, DomainError> {
// Use the list_users_by_role method or similar from user_storage port
// For now, we'll use a basic implementation that counts all users with role = "admin"
let admin_users = self
.user_storage
.list_users_by_role("admin")
.await
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"User",
format!("Error counting admin users: {}", e),
)
})?;
Ok(admin_users.len() as i64)
}
pub async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<UserDto>, DomainError> {
let users = self.user_storage.list_users(limit, offset).await?;
Ok(users.into_iter().map(UserDto::from).collect())
}
pub async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<UserDto>, DomainError> {
let users = self.user_storage.search_users(query, limit).await?;
Ok(users.into_iter().map(UserDto::from).collect())
}
// ========================================================================
// Admin User Management Methods
// ========================================================================
/// Admin-only: create a user bypassing registration guards.
pub async fn admin_create_user(
&self,
dto: crate::application::dtos::settings_dto::AdminCreateUserDto,
) -> Result<UserDto, DomainError> {
// Validate username length
if dto.username.len() < 3 || dto.username.len() > 32 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Username must be between 3 and 32 characters".to_string(),
));
}
// Check for duplicate username
if self
.user_storage
.get_user_by_username(&dto.username)
.await
.is_ok()
{
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"User",
format!("User '{}' already exists", dto.username),
));
}
// Email: use provided or generate placeholder
let email = dto
.email
.filter(|e| !e.trim().is_empty())
.unwrap_or_else(|| format!("{}@oxicloud.local", dto.username));
// Check email uniqueness
if self.user_storage.get_user_by_email(&email).await.is_ok() {
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"User",
format!("Email '{}' is already registered", email),
));
}
// Validate password
if dto.password.len() < 8 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Password must be at least 8 characters long".to_string(),
));
}
// Determine role
let role = match dto.role.as_deref() {
Some("admin") => UserRole::Admin,
_ => UserRole::User,
};
// Determine quota
let quota = dto.quota_bytes.unwrap_or_else(|| {
if role == UserRole::Admin {
107_374_182_400
} else {
1_073_741_824
}
});
// Hash password
let password_hash = self.password_hasher.hash_password(&dto.password).await?;
// Create domain entity
let user =
User::new(dto.username.clone(), email, password_hash, role, quota).map_err(|e| {
DomainError::new(
ErrorKind::InvalidInput,
"User",
format!("Error creating user: {}", e),
)
})?;
// Persist
let created = self.user_storage.create_user(user).await?;
// Deactivate if requested (User::new always sets active=true)
if let Some(false) = dto.active {
self.user_storage
.set_user_active_status(created.id(), false)
.await?;
}
// Create personal folder
self.create_personal_folder(&dto.username, created.id())
.await;
tracing::info!("Admin created user: {} ({})", dto.username, created.id());
Ok(UserDto::from(created))
}
/// Admin-only: reset a user's password.
pub async fn admin_reset_password(
&self,
user_id: Uuid,
new_password: &str,
) -> Result<(), DomainError> {
// Block password reset for OIDC-provisioned users
let user = self.user_storage.get_user_by_id(user_id).await?;
if user.is_oidc_user() {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"Auth",
"Cannot reset password for SSO/OIDC accounts. The user's password is managed by their identity provider.",
));
}
if new_password.len() < 8 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Password must be at least 8 characters long".to_string(),
));
}
let hash = self.password_hasher.hash_password(new_password).await?;
self.user_storage.change_password(user_id, &hash).await?;
// Invalidate all existing sessions so the user must re-login
// with the new password. Mirrors the behaviour of change_password().
self.session_storage
.revoke_all_user_sessions(user_id)
.await?;
tracing::info!(user_id = %user_id, "Admin reset password — all sessions revoked");
Ok(())
}
/// Get a single user by ID (for admin panel)
pub async fn get_user_admin(&self, user_id: Uuid) -> Result<UserDto, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
Ok(UserDto::from(user))
}
/// Delete a user by ID (admin only)
pub async fn delete_user_admin(&self, user_id: Uuid) -> Result<(), DomainError> {
// Prevent deleting yourself
let user = self.user_storage.get_user_by_id(user_id).await?;
tracing::info!("Admin deleting user: {} ({})", user.username(), user_id);
self.user_storage.delete_user(user_id).await
}
/// Activate or deactivate a user (admin only)
pub async fn set_user_active(&self, user_id: Uuid, active: bool) -> Result<(), DomainError> {
self.user_storage
.set_user_active_status(user_id, active)
.await
}
/// Change user role (admin only)
pub async fn change_user_role(&self, user_id: Uuid, role: &str) -> Result<(), DomainError> {
if role != "admin" && role != "user" {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
format!("Invalid role: {}. Must be 'admin' or 'user'", role),
));
}
self.user_storage.change_role(user_id, role).await
}
/// Update user's storage quota (admin only)
pub async fn update_user_quota(
&self,
user_id: Uuid,
quota_bytes: i64,
) -> Result<(), DomainError> {
if quota_bytes < 0 {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"User",
"Quota must be non-negative".to_string(),
));
}
self.user_storage
.update_storage_quota(user_id, quota_bytes)
.await
}
/// Check if a user has enough quota for an upload of the given size
pub async fn check_quota(
&self,
user_id: Uuid,
additional_bytes: i64,
) -> Result<bool, DomainError> {
let user = self.user_storage.get_user_by_id(user_id).await?;
let quota = user.storage_quota_bytes();
if quota <= 0 {
// 0 or negative means unlimited
return Ok(true);
}
Ok(user.storage_used_bytes() + additional_bytes <= quota)
}
/// Count users efficiently
pub async fn count_users_efficient(&self) -> Result<i64, DomainError> {
self.user_storage.count_users().await
}
// ========================================================================
// OIDC Methods
// ========================================================================
/// Prepare the OIDC authorization flow: generates CSRF state, PKCE pair,
/// nonce, stores them in pending_oidc_flows, and returns the authorize URL.
pub async fn prepare_oidc_authorize(&self) -> Result<String, DomainError> {
let oidc = self.oidc_service().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"OIDC",
"OIDC service not configured",
)
})?;
// Generate CSRF state token
use rand_core::{OsRng, RngCore};
let mut state_bytes = [0u8; 32];
OsRng.fill_bytes(&mut state_bytes);
let state_token = hex::encode(state_bytes);
// Generate nonce for ID token binding
let mut nonce_bytes = [0u8; 32];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = hex::encode(nonce_bytes);
// Generate PKCE pair (RFC 7636, S256)
let mut verifier_bytes = [0u8; 32];
OsRng.fill_bytes(&mut verifier_bytes);
let pkce_verifier = base64_url_encode(&verifier_bytes);
let pkce_challenge = {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(pkce_verifier.as_bytes());
base64_url_encode(&hash)
};
// Store pending flow (auto-expires after 10 min via moka TTL)
self.pending_oidc_flows.insert(
state_token.clone(),
PendingOidcFlow {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: None,
},
);
// Build authorization URL with state, nonce, and PKCE challenge
let authorize_url = oidc
.get_authorize_url(&state_token, &nonce, &pkce_challenge)
.await?;
tracing::info!(
"OIDC authorize flow prepared (state={}...)",
&state_token[..8]
);
Ok(authorize_url)
}
/// Prepare an OIDC authorization flow for a Nextcloud Login Flow v2 session.
///
/// Works like [`prepare_oidc_authorize`] but associates the Nextcloud flow
/// token with the OIDC state so that [`oidc_callback`] can complete the
/// Nextcloud login flow (app-password + poll result) instead of issuing
/// internal JWTs.
pub async fn prepare_oidc_authorize_for_nextcloud(
&self,
nc_flow_token: &str,
) -> Result<String, DomainError> {
let oidc = self.oidc_service().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"OIDC",
"OIDC service not configured",
)
})?;
use rand_core::{OsRng, RngCore};
let mut state_bytes = [0u8; 32];
OsRng.fill_bytes(&mut state_bytes);
let state_token = hex::encode(state_bytes);
let mut nonce_bytes = [0u8; 32];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = hex::encode(nonce_bytes);
let mut verifier_bytes = [0u8; 32];
OsRng.fill_bytes(&mut verifier_bytes);
let pkce_verifier = base64_url_encode(&verifier_bytes);
let pkce_challenge = {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(pkce_verifier.as_bytes());
base64_url_encode(&hash)
};
// Store pending flow (auto-expires after 10 min via moka TTL)
self.pending_oidc_flows.insert(
state_token.clone(),
PendingOidcFlow {
pkce_verifier,
nonce: nonce.clone(),
nc_flow_token: Some(nc_flow_token.to_string()),
},
);
let authorize_url = oidc
.get_authorize_url(&state_token, &nonce, &pkce_challenge)
.await?;
tracing::info!(
"OIDC authorize flow prepared for Nextcloud Login Flow v2 (state={}...)",
&state_token[..8]
);
Ok(authorize_url)
}
/// Handle the OIDC callback: validate CSRF state, exchange code with PKCE,
/// validate ID token nonce, find or create user (JIT provisioning),
/// issue internal tokens, and return a one-time exchange code.
///
/// If the pending flow carries a Nextcloud flow token, this method returns
/// `Err(NcOidcComplete { .. })` with a special error kind so the handler
/// layer can complete the Nextcloud flow instead.
pub async fn oidc_callback(
&self,
code: &str,
state: &str,
) -> Result<OidcCallbackResult, DomainError> {
// 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token
// (entry is auto-expired by moka TTL — remove returns None if expired)
let flow = self.pending_oidc_flows.remove(state).ok_or_else(|| {
tracing::warn!("OIDC callback with invalid/expired state token");
DomainError::new(
ErrorKind::AccessDenied, "OIDC",
"Invalid or expired OIDC state — possible CSRF attack. Please try logging in again.",
)
})?;
let (pkce_verifier, nonce, nc_flow_token) =
(flow.pkce_verifier, flow.nonce, flow.nc_flow_token);
// Clone the Arc and config out of the RwLock so we don't hold the lock across await points
let (oidc, oidc_config) = {
let state = self.oidc.read().unwrap();
let svc = state.service.clone().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"OIDC",
"OIDC service not configured",
)
})?;
let cfg = state.config.clone().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"OIDC",
"OIDC config not available",
)
})?;
(svc, cfg)
};
// 1. Exchange authorization code for tokens (with PKCE verifier)
let token_set = oidc.exchange_code(code, &pkce_verifier).await?;
// 2. Validate ID token and extract claims (with nonce verification)
let claims = oidc
.validate_id_token(&token_set.id_token, Some(&nonce))
.await?;
// 3. Try to enrich claims from UserInfo endpoint if email is missing
let claims = if claims.email.is_none() {
match oidc.fetch_user_info(&token_set.access_token).await {
Ok(user_info) => OidcIdClaims {
email: user_info.email.or(claims.email),
preferred_username: user_info.preferred_username.or(claims.preferred_username),
name: user_info.name.or(claims.name),
email_verified: user_info.email_verified.or(claims.email_verified),
groups: if user_info.groups.is_empty() {
claims.groups
} else {
user_info.groups
},
..claims
},
Err(e) => {
tracing::warn!(
"Failed to fetch UserInfo (continuing with ID token claims): {}",
e
);
claims
}
}
} else {
claims
};
let provider_name = oidc.provider_name().to_string();
// Check email_verified - only if email is present in claims
if let Some(email) = &claims.email {
let verified = claims.email_verified.unwrap_or(false);
if !verified {
tracing::warn!(
"OIDC login rejected: email not verified (provider: {}, email: {})",
provider_name,
email
);
return Err(DomainError::new(
ErrorKind::AccessDenied,
"OIDC",
"Email verification required. Please verify your email at the identity provider.",
));
}
}
// 4. Determine username and email
let oidc_username = claims
.preferred_username
.clone()
.or(claims.name.clone())
.unwrap_or_else(|| format!("oidc_{}", &claims.sub[..8.min(claims.sub.len())]));
let oidc_email = claims
.email
.clone()
.unwrap_or_else(|| format!("{}@oidc.local", oidc_username));
// 5. Look up existing user by OIDC subject
let user = match self
.user_storage
.get_user_by_oidc_subject(&provider_name, &claims.sub)
.await
{
Ok(mut existing_user) => {
// User exists — update last login
existing_user.register_login();
self.user_storage.update_user(existing_user.clone()).await?;
existing_user
}
Err(_) => {
// User doesn't exist — try to match by email
let matched_user = self.user_storage.get_user_by_email(&oidc_email).await.ok();
if let Some(_existing) = matched_user {
// Email match but no OIDC link — for security, don't auto-link
return Err(DomainError::new(
ErrorKind::AlreadyExists,
"OIDC",
format!(
"A user with email '{}' already exists. Contact admin to link your OIDC identity.",
oidc_email
),
));
}
// No match — JIT provision if enabled
if !oidc_config.auto_provision {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"OIDC",
"Auto-provisioning is disabled. Contact admin to create your account.",
));
}
// Determine role from OIDC groups
let role = self.map_oidc_role(&claims.groups, &oidc_config);
let quota = self.capped_quota(&role);
// Sanitize username (max 32 chars, ensure uniqueness)
let mut username = oidc_username.chars().take(32).collect::<String>();
if username.len() < 3 {
username = format!("user_{}", &claims.sub[..8.min(claims.sub.len())]);
}
// Check for username collision
if self
.user_storage
.get_user_by_username(&username)
.await
.is_ok()
{
let suffix = &claims.sub[..4.min(claims.sub.len())];
username = format!("{}_{}", &username[..username.len().min(27)], suffix);
}
let new_user = User::new_oidc(
username.clone(),
oidc_email,
role,
quota,
provider_name.clone(),
claims.sub.clone(),
)
.map_err(|e| {
DomainError::new(
ErrorKind::InvalidInput,
"OIDC",
format!("Failed to create OIDC user: {}", e),
)
})?;
let created_user = self.user_storage.create_user(new_user).await?;
// Create personal folder
self.create_personal_folder(&username, created_user.id())
.await;
tracing::info!(
"OIDC user provisioned: {} (provider: {}, sub: {})",
created_user.id(),
provider_name,
claims.sub
);
created_user
}
};
// ── Branch: Nextcloud Login Flow v2 vs regular web login ──
if let Some(nc_token) = nc_flow_token {
// Nextcloud path: return user info so the handler can mint an
// app-password and complete the NC login flow.
tracing::info!(
user = %user.username(),
"OIDC login successful for Nextcloud Login Flow v2"
);
return Ok(OidcCallbackResult::NextcloudLogin {
nc_flow_token: nc_token,
user_id: user.id(),
username: user.username().to_string(),
});
}
// 6. Issue internal tokens (same as regular login)
let access_token = self.token_service.generate_access_token(&user)?;
let refresh_token = self.token_service.generate_refresh_token();
let session = Session::new(
user.id(),
refresh_token.clone(),
None,
None,
self.token_service.refresh_token_expiry_days(),
);
self.session_storage.create_session(session).await?;
let auth_response = AuthResponseDto {
user: UserDto::from(user),
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: self.token_service.refresh_token_expiry_secs(),
};
// 7. Store auth response behind a one-time exchange code (Fix #4: no tokens in URL)
let mut code_bytes = [0u8; 32];
use rand_core::{OsRng, RngCore};
OsRng.fill_bytes(&mut code_bytes);
let exchange_code = hex::encode(code_bytes);
// Store auth response (auto-expires after 60 s via moka TTL)
self.pending_oidc_tokens
.insert(exchange_code.clone(), PendingOidcToken { auth_response });
tracing::info!("OIDC login successful, one-time exchange code generated");
Ok(OidcCallbackResult::WebLogin { exchange_code })
}
/// Exchange a one-time code for the authentication tokens.
/// The code is single-use and expires after 60 seconds (moka TTL).
pub fn exchange_oidc_token(&self, one_time_code: &str) -> Result<AuthResponseDto, DomainError> {
let pending = self
.pending_oidc_tokens
.remove(one_time_code)
.ok_or_else(|| {
DomainError::new(
ErrorKind::AccessDenied,
"OIDC",
"Invalid or expired exchange code. Please try logging in again.",
)
})?;
Ok(pending.auth_response)
}
/// Map OIDC groups to internal role
fn map_oidc_role(&self, groups: &[String], config: &OidcConfig) -> UserRole {
if config.admin_groups.is_empty() {
return UserRole::User;
}
let admin_groups: Vec<&str> = config.admin_groups.split(',').map(|s| s.trim()).collect();
for group in groups {
if admin_groups.iter().any(|ag| ag.eq_ignore_ascii_case(group)) {
return UserRole::Admin;
}
}
UserRole::User
}
/// Helper to create a personal folder for a new user
async fn create_personal_folder(&self, username: &str, user_id: Uuid) {
if let Some(folder_service) = &self.folder_service {
let folder_name = format!("My Folder - {}", username);
match folder_service
.create_home_folder(user_id, folder_name.clone())
.await
{
Ok(folder) => {
tracing::info!(
"Personal folder created for user {}: {} (ID: {})",
user_id,
folder.name,
folder.id
);
}
Err(e) => {
tracing::error!(
"Failed to create personal folder for user {}: {}",
user_id,
e
);
}
}
}
}
}
/// URL-safe base64 encoding without padding (RFC 4648 §5)
fn base64_url_encode(input: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input)
}