Files
Oxicloud/doc/OIDC-INTEGRATION.md
T
2025-04-02 23:14:12 +02:00

22 KiB

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:

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<OidcProviderConfig>,
}

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<String>,      // 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:

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<OidcProvider>,
    user_repository: Arc<dyn UserRepository>,
}

struct OidcProvider {
    config: OidcProviderConfig,
    client: DiscoveredClient,
}

impl OidcService {
    pub async fn new(
        configs: Vec<OidcProviderConfig>,
        user_repository: Arc<dyn UserRepository>,
    ) -> Result<Self, DomainError> {
        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<OidcProviderInfo> {
        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<String, DomainError> {
        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<Discovered, StandardClaims>), 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:

#[derive(Debug, Clone)]
pub struct User {
    // Existing fields...
    external_id: Option<String>, // 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<String>) {
        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:

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:

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<OidcService>) -> Self {
        self.oidc_service = Some(oidc_service);
        self
    }
    
    // Get available OIDC providers
    pub fn get_oidc_providers(&self) -> Result<Vec<OidcProviderDto>, 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<String, DomainError> {
        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<AuthResponseDto, DomainError> {
        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:

pub fn auth_routes() -> Router<Arc<AppState>> {
    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<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
    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<Arc<AppState>>,
    Json(dto): Json<OidcAuthUrlDto>,
) -> Result<impl IntoResponse, AppError> {
    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<Arc<AppState>>,
    Json(dto): Json<OidcCallbackDto>,
) -> Result<impl IntoResponse, AppError> {
    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:

use crate::domain::services::oidc_service::OidcProviderInfo;

#[derive(Debug, Clone, Serialize)]
pub struct OidcProviderDto {
    pub name: String,
    pub display_name: String,
}

impl From<OidcProviderInfo> 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<String>,
}

#[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:

// 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:

<!-- OIDC Login Section -->
<div class="oidc-login">
  <h3>Login with SSO</h3>
  <div id="oidc-providers">
    <!-- OIDC provider buttons will be added here dynamically -->
  </div>
</div>

<script>
  // Load OIDC providers
  async function loadOidcProviders() {
    try {
      const providers = await oidcAuth.getProviders();
      const providersContainer = document.getElementById('oidc-providers');
      
      if (providers.length === 0) {
        providersContainer.innerHTML = '<p>No SSO providers configured.</p>';
        return;
      }
      
      const buttons = providers.map(provider => {
        return `<button 
          class="btn btn-oidc" 
          data-provider="${provider.name}"
          onclick="startOidcAuth('${provider.name}')"
        >
          Login with ${provider.display_name}
        </button>`;
      }).join('');
      
      providersContainer.innerHTML = buttons;
    } catch (error) {
      console.error('Failed to load OIDC providers:', error);
    }
  }
  
  // Start OIDC authentication
  function startOidcAuth(providerName) {
    localStorage.setItem('oidc_provider', providerName);
    oidcAuth.startAuth(providerName);
  }
  
  // Load providers when page loads
  document.addEventListener('DOMContentLoaded', loadOidcProviders);
</script>

Configuration Example

Here's how to configure OxiCloud to use OIDC with KeyCloak:

# 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.