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

702 lines
22 KiB
Markdown

# 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<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`:
```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<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:
```rust
#[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`:
```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<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`:
```rust
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`:
```rust
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`:
```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
<!-- 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:
```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.