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:
- Allow users to authenticate using their existing identity provider (IdP) credentials
- Reduce the need for separate username/password management in OxiCloud
- Enhance security by leveraging modern authentication best practices
- 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
-
Security: OIDC connections should always use HTTPS. Ensure proper TLS configuration.
-
User mapping: Consider how user attributes from OIDC map to your application (roles, groups, etc.).
-
Multiple providers: The design supports multiple OIDC providers simultaneously.
-
Session management: Implement proper session handling for OIDC users.
-
Access control: Review how OIDC integration affects your application's permission model.
-
Testing: Create separate test IdP configurations for development and testing.