# OIDC Integration for OxiCloud This document outlines the implementation plan for adding OpenID Connect (OIDC) support to OxiCloud, enabling Single Sign-On (SSO) with identity providers like Authentik, Authelia, KeyCloak, and others. ## Overview OpenID Connect (OIDC) is an identity layer built on top of the OAuth 2.0 protocol. It allows clients to verify the identity of end-users based on the authentication performed by an authorization server, as well as to obtain basic profile information about the end-user. Implementing OIDC in OxiCloud will: 1. Allow users to authenticate using their existing identity provider (IdP) credentials 2. Reduce the need for separate username/password management in OxiCloud 3. Enhance security by leveraging modern authentication best practices 4. Provide a seamless experience for users already using SSO in their environment ## Implementation Plan ### 1. Add OIDC Configuration Options Extend the `AuthConfig` struct in `src/common/config.rs`: ```rust pub struct AuthConfig { pub jwt_secret: String, pub access_token_expiry_secs: i64, pub refresh_token_expiry_secs: i64, pub hash_memory_cost: u32, pub hash_time_cost: u32, // New OIDC configuration pub enable_oidc: bool, pub oidc_providers: Vec, } pub struct OidcProviderConfig { pub name: String, // Display name (e.g., "Authentik", "KeyCloak") pub client_id: String, // OIDC client ID pub client_secret: String, // OIDC client secret pub discovery_url: String, // OIDC discovery URL (.well-known/openid-configuration) pub redirect_uri: String, // Redirect URI after authentication pub scopes: Vec, // Scopes to request pub user_id_attribute: String, // Which claim to use as user ID pub default_role: String, // Default role for new users pub auto_create_users: bool, // Create users on first login } impl Default for AuthConfig { fn default() -> Self { Self { // Existing defaults... // OIDC defaults enable_oidc: false, oidc_providers: Vec::new(), } } } ``` Update the environment variable handling in `AppConfig::from_env()` to include OIDC configurations. ### 2. Create OIDC Service Implementation Add a new file `src/domain/services/oidc_service.rs`: ```rust use openid::{Client, Discovered, DiscoveredClient, Options, Token, StandardClaims}; use std::sync::Arc; use reqwest::Client as HttpClient; use async_trait::async_trait; use uuid::Uuid; use crate::common::config::OidcProviderConfig; use crate::domain::entities::user::{User, UserRole}; use crate::domain::repositories::user_repository::UserRepository; use crate::common::errors::{DomainError, ErrorKind}; pub struct OidcService { providers: Vec, user_repository: Arc, } struct OidcProvider { config: OidcProviderConfig, client: DiscoveredClient, } impl OidcService { pub async fn new( configs: Vec, user_repository: Arc, ) -> Result { let http_client = HttpClient::new(); let mut providers = Vec::new(); for config in configs { let client = openid::Client::discover( http_client.clone(), &config.client_id, &config.client_secret, &config.redirect_uri, &config.discovery_url, ) .await .map_err(|e| DomainError::new( ErrorKind::InternalError, "OIDC", format!("Failed to discover OIDC provider {}: {}", config.name, e) ))?; providers.push(OidcProvider { config: config.clone(), client, }); } Ok(Self { providers, user_repository, }) } pub fn get_provider(&self, provider_name: &str) -> Option<&OidcProvider> { self.providers.iter().find(|p| p.config.name == provider_name) } pub fn get_providers_info(&self) -> Vec { self.providers.iter().map(|p| OidcProviderInfo { name: p.config.name.clone(), display_name: p.config.name.clone(), }).collect() } pub fn generate_authorization_url(&self, provider_name: &str, state: &str) -> Result { let provider = self.get_provider(provider_name).ok_or_else(|| DomainError::new( ErrorKind::NotFound, "OIDC", format!("Provider {} not found", provider_name) ))?; let mut options = Options::default(); options.scope = Some(provider.config.scopes.join(" ")); let auth_url = provider.client.auth_url(&options, Some(state)); Ok(auth_url.to_string()) } pub async fn process_callback( &self, provider_name: &str, code: &str, state: &str ) -> Result<(User, Token), DomainError> { let provider = self.get_provider(provider_name).ok_or_else(|| DomainError::new( ErrorKind::NotFound, "OIDC", format!("Provider {} not found", provider_name) ))?; // Exchange code for token let token = provider.client.request_token(code).await.map_err(|e| DomainError::new( ErrorKind::AccessDenied, "OIDC", format!("Failed to exchange code for token: {}", e) ))?; // Extract user information from claims let claims = token.id_token.payload().clone(); // Get user ID from configured attribute let user_id_attr = &provider.config.user_id_attribute; let external_user_id = match user_id_attr.as_str() { "sub" => claims.sub.clone(), "email" => claims.email.clone().unwrap_or_default(), // Add other standard claims as needed _ => claims.additional_claims.get(user_id_attr) .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default(), }; if external_user_id.is_empty() { return Err(DomainError::new( ErrorKind::InvalidInput, "OIDC", format!("Could not extract user ID from claim '{}'", user_id_attr) )); } // Check if user exists with this external ID let mapped_user_id = format!("{}:{}", provider_name, external_user_id); let user = match self.user_repository.get_user_by_external_id(&mapped_user_id).await { Ok(existing_user) => existing_user, Err(_) => { // User doesn't exist, create if allowed if !provider.config.auto_create_users { return Err(DomainError::new( ErrorKind::AccessDenied, "OIDC", "User does not exist and auto-creation is disabled" )); } // Get user information from claims let email = claims.email.clone().unwrap_or_else(|| format!("{}@oidc.oxicloud.local", Uuid::new_v4()) ); let username = claims.preferred_username.clone() .or_else(|| claims.email.clone()) .unwrap_or_else(|| format!("user_{}", Uuid::new_v4())); // Create the user let role = match provider.config.default_role.as_str() { "admin" => UserRole::Admin, _ => UserRole::User, }; // Default quota let quota = 1024 * 1024 * 1024; // 1GB let mut new_user = User::new( username, email, Uuid::new_v4().to_string(), // Random password, not used for OIDC role, quota, )?; // Set external ID new_user.set_external_id(Some(mapped_user_id)); // Save user self.user_repository.create_user(new_user).await? } }; Ok((user, token)) } } #[derive(Clone, Debug, serde::Serialize)] pub struct OidcProviderInfo { pub name: String, pub display_name: String, } ``` ### 3. Update User Entity Modify `src/domain/entities/user.rs` to support external IDs for OIDC users: ```rust #[derive(Debug, Clone)] pub struct User { // Existing fields... external_id: Option, // For OIDC users: "provider:external_id" } impl User { // Existing methods... pub fn external_id(&self) -> Option<&str> { self.external_id.as_deref() } pub fn set_external_id(&mut self, external_id: Option) { self.external_id = external_id; } pub fn is_oidc_user(&self) -> bool { self.external_id.is_some() } } ``` ### 4. Update Database Schema Add a new column to the users table in `db/schema.sql`: ```sql ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS external_id VARCHAR(255) UNIQUE; ``` ### 5. Update the Auth Application Service Modify `src/application/services/auth_application_service.rs` to add OIDC methods: ```rust use crate::domain::services::oidc_service::{OidcService, OidcProviderInfo}; use crate::application::dtos::user_dto::{OidcAuthUrlDto, OidcCallbackDto, OidcProviderDto}; impl AuthApplicationService { // Add OIDC service pub fn with_oidc_service(mut self, oidc_service: Arc) -> Self { self.oidc_service = Some(oidc_service); self } // Get available OIDC providers pub fn get_oidc_providers(&self) -> Result, DomainError> { let oidc_service = self.oidc_service.as_ref() .ok_or_else(|| DomainError::new( ErrorKind::UnsupportedOperation, "Auth", "OIDC is not configured" ))?; let providers = oidc_service.get_providers_info(); Ok(providers.into_iter().map(OidcProviderDto::from).collect()) } // Generate authorization URL pub fn generate_oidc_auth_url(&self, dto: OidcAuthUrlDto) -> Result { let oidc_service = self.oidc_service.as_ref() .ok_or_else(|| DomainError::new( ErrorKind::UnsupportedOperation, "Auth", "OIDC is not configured" ))?; oidc_service.generate_authorization_url(&dto.provider, &dto.state) } // Process OIDC callback pub async fn process_oidc_callback(&self, dto: OidcCallbackDto) -> Result { let oidc_service = self.oidc_service.as_ref() .ok_or_else(|| DomainError::new( ErrorKind::UnsupportedOperation, "Auth", "OIDC is not configured" ))?; let (user, _token) = oidc_service.process_callback( &dto.provider, &dto.code, &dto.state ).await?; // Generate access token let access_token = self.auth_service.generate_access_token(&user) .map_err(DomainError::from)?; // Generate refresh token let refresh_token = self.auth_service.generate_refresh_token(); // Create session let session = Session::new( user.id().to_string(), refresh_token.clone(), None, None, self.auth_service.refresh_token_expiry_days(), ); self.session_storage.create_session(session).await?; // Return auth response Ok(AuthResponseDto { user: UserDto::from(user), access_token, refresh_token, token_type: "Bearer".to_string(), expires_in: self.auth_service.refresh_token_expiry_secs(), }) } } ``` ### 6. Add Auth Handler Routes for OIDC Update `src/interfaces/api/handlers/auth_handler.rs`: ```rust pub fn auth_routes() -> Router> { Router::new() .route("/register", post(register)) .route("/login", post(login)) .route("/refresh", post(refresh_token)) .route("/me", get(get_current_user)) .route("/change-password", put(change_password)) .route("/logout", post(logout)) // Add OIDC routes .route("/oidc/providers", get(get_oidc_providers)) .route("/oidc/auth", post(generate_oidc_auth_url)) .route("/oidc/callback", post(process_oidc_callback)) } // Get available OIDC providers async fn get_oidc_providers( State(state): State>, ) -> Result { let auth_service = state.auth_service.as_ref() .ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?; match auth_service.auth_application_service.get_oidc_providers() { Ok(providers) => Ok((StatusCode::OK, Json(providers))), Err(err) => Err(err.into()), } } // Generate OIDC authorization URL async fn generate_oidc_auth_url( State(state): State>, Json(dto): Json, ) -> Result { let auth_service = state.auth_service.as_ref() .ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?; match auth_service.auth_application_service.generate_oidc_auth_url(dto) { Ok(url) => Ok((StatusCode::OK, Json(json!({ "url": url })))), Err(err) => Err(err.into()), } } // Process OIDC callback async fn process_oidc_callback( State(state): State>, Json(dto): Json, ) -> Result { let auth_service = state.auth_service.as_ref() .ok_or_else(|| AppError::internal_error("Servicio de autenticación no configurado"))?; match auth_service.auth_application_service.process_oidc_callback(dto).await { Ok(auth_response) => Ok((StatusCode::OK, Json(auth_response))), Err(err) => Err(err.into()), } } ``` ### 7. Update DTOs for OIDC Create new DTOs in `src/application/dtos/user_dto.rs`: ```rust use crate::domain::services::oidc_service::OidcProviderInfo; #[derive(Debug, Clone, Serialize)] pub struct OidcProviderDto { pub name: String, pub display_name: String, } impl From for OidcProviderDto { fn from(info: OidcProviderInfo) -> Self { Self { name: info.name, display_name: info.display_name, } } } #[derive(Debug, Clone, Deserialize)] pub struct OidcAuthUrlDto { pub provider: String, pub state: String, pub redirect_uri: Option, } #[derive(Debug, Clone, Deserialize)] pub struct OidcCallbackDto { pub provider: String, pub code: String, pub state: String, } ``` ### 8. Add Frontend Integration Create a new JavaScript file `static/js/oidcAuth.js`: ```javascript // OIDC Authentication Module const oidcAuth = { // Get available OIDC providers async getProviders() { try { const response = await fetch('/api/auth/oidc/providers'); if (!response.ok) { throw new Error(`Failed to get OIDC providers: ${response.statusText}`); } return await response.json(); } catch (error) { console.error('Error fetching OIDC providers:', error); return []; } }, // Generate random state for CSRF protection generateState() { const array = new Uint8Array(16); window.crypto.getRandomValues(array); return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); }, // Start OIDC authentication flow async startAuth(providerName) { try { // Generate and store state const state = this.generateState(); localStorage.setItem('oidc_state', state); // Get authorization URL const response = await fetch('/api/auth/oidc/auth', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ provider: providerName, state: state, }), }); if (!response.ok) { throw new Error(`Failed to get auth URL: ${response.statusText}`); } const data = await response.json(); // Redirect to authorization URL window.location.href = data.url; } catch (error) { console.error('Error starting OIDC auth:', error); alert('Failed to start authentication. Please try again.'); } }, // Handle OIDC callback async handleCallback() { // Parse URL parameters const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get('code'); const state = urlParams.get('state'); const error = urlParams.get('error'); // Check for errors if (error) { console.error('OIDC authentication error:', error); alert(`Authentication failed: ${error}`); window.location.href = '/login.html'; return; } // Verify code and state if (!code || !state) { console.error('Missing code or state in callback'); alert('Authentication failed: Invalid response'); window.location.href = '/login.html'; return; } // Verify state matches const savedState = localStorage.getItem('oidc_state'); if (state !== savedState) { console.error('State mismatch - potential CSRF attack'); alert('Authentication failed: Invalid state'); window.location.href = '/login.html'; return; } // Clear stored state localStorage.removeItem('oidc_state'); try { // Extract provider from URL path or from saved data const pathParts = window.location.pathname.split('/'); const provider = localStorage.getItem('oidc_provider') || (pathParts.length > 2 ? pathParts[2] : 'default'); // Process callback const response = await fetch('/api/auth/oidc/callback', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ provider: provider, code: code, state: state, }), }); if (!response.ok) { throw new Error(`Failed to process callback: ${response.statusText}`); } const authData = await response.json(); // Store auth data and redirect to dashboard localStorage.setItem('auth_token', authData.access_token); localStorage.setItem('refresh_token', authData.refresh_token); localStorage.setItem('user', JSON.stringify(authData.user)); window.location.href = '/index.html'; } catch (error) { console.error('Error handling OIDC callback:', error); alert('Failed to complete authentication. Please try again.'); window.location.href = '/login.html'; } } }; // Check if current page is callback page if (window.location.pathname.includes('/oidc/callback')) { document.addEventListener('DOMContentLoaded', () => { oidcAuth.handleCallback(); }); } ``` ### 9. Update Login Page Add OIDC login buttons to `static/login.html`: ```html ``` ## Configuration Example Here's how to configure OxiCloud to use OIDC with KeyCloak: ```yaml # docker-compose.yml version: '3' services: oxicloud: image: oxicloud:latest environment: OXICLOUD_ENABLE_OIDC: "true" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_NAME: "KeyCloak" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_CLIENT_ID: "oxicloud" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_CLIENT_SECRET: "your-client-secret" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_DISCOVERY_URL: "https://keycloak.example.com/realms/your-realm/.well-known/openid-configuration" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_REDIRECT_URI: "https://oxicloud.example.com/oidc/callback/keycloak" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_SCOPES: "openid profile email" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_USER_ID_ATTRIBUTE: "sub" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_DEFAULT_ROLE: "user" OXICLOUD_OIDC_PROVIDER_KEYCLOAK_AUTO_CREATE_USERS: "true" ports: - "8085:8085" volumes: - ./storage:/app/storage ``` ## Additional Considerations 1. **Security**: OIDC connections should always use HTTPS. Ensure proper TLS configuration. 2. **User mapping**: Consider how user attributes from OIDC map to your application (roles, groups, etc.). 3. **Multiple providers**: The design supports multiple OIDC providers simultaneously. 4. **Session management**: Implement proper session handling for OIDC users. 5. **Access control**: Review how OIDC integration affects your application's permission model. 6. **Testing**: Create separate test IdP configurations for development and testing.