oauth_handlers.rs 358 lignes · 12523 octets
use axum::{
    Extension, Json,
    extract::{Path, Query, State},
    http::{HeaderMap, StatusCode},
    response::{IntoResponse, Redirect, Response},
};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use std::sync::Arc;
use tower_sessions::Session;

use crate::auth::jwt::JwtService;
use crate::config::auth::AuthConfig;
use crate::config::oauth::OAuthConfig;
use crate::error::AppError;
use crate::hooks::RustwardenHooks;
use crate::services::oauth::OAuthService;
use crate::services::oauth::providers::ProviderRegistry;
use crate::services::refresh_token_service::RefreshTokenService;
use crate::services::user_preferences_service::UserPreferencesService;
use crate::services::user_service::UserService;

const OAUTH_STATE_KEY: &str = "oauth_state";
const OAUTH_NONCE_KEY: &str = "oauth_nonce";
const OAUTH_PKCE_KEY: &str = "oauth_pkce_verifier";
const OAUTH_PROVIDER_KEY: &str = "oauth_provider";

/// Query parameters received on the OAuth callback URL.
#[derive(Deserialize)]
pub struct OAuthCallbackQuery {
    /// Authorization code returned by the provider on success.
    pub code: Option<String>,
    /// CSRF state value to verify against the session.
    pub state: Option<String>,
    /// OAuth error code returned by the provider on failure.
    pub error: Option<String>,
    /// Human-readable description of the OAuth error.
    pub error_description: Option<String>,
}

/// GET /api/v1/auth/oauth/:provider/redirect
///
/// Initiates the OAuth flow by redirecting the user to the provider's auth page.
/// Stores CSRF state, nonce, and PKCE verifier in the session.
pub async fn oauth_redirect(
    State(db): State<DatabaseConnection>,
    session: Session,
    Extension(registry): Extension<Arc<ProviderRegistry>>,
    Path(provider_name): Path<String>,
) -> Result<Response, AppError> {
    let oauth_config = OAuthConfig::load(&db).await;
    if !oauth_config.enabled {
        return Err(AppError::Validation("OAuth is not enabled".to_string()));
    }

    let provider = registry.get(&provider_name).ok_or_else(|| {
        AppError::NotFound(format!("OAuth provider '{}' not found", provider_name))
    })?;

    let redirect_uri = oauth_config.callback_url(&provider_name);
    let auth_url = provider.authorization_url(&redirect_uri)?;

    // Store security params in session
    session
        .insert(OAUTH_STATE_KEY, &auth_url.csrf_state)
        .await
        .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
    session
        .insert(OAUTH_PROVIDER_KEY, &provider_name)
        .await
        .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;

    if let Some(ref nonce) = auth_url.nonce {
        session
            .insert(OAUTH_NONCE_KEY, nonce)
            .await
            .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
    }
    if let Some(ref verifier) = auth_url.pkce_verifier {
        session
            .insert(OAUTH_PKCE_KEY, verifier)
            .await
            .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
    }

    tracing::info!(
        "OAuth redirect to {} for provider {}",
        &auth_url.url[..50.min(auth_url.url.len())],
        provider_name
    );
    Ok(Redirect::temporary(&auth_url.url).into_response())
}

/// GET /api/v1/auth/oauth/:provider/callback
///
/// Handles the OAuth callback from the provider.
/// Validates state, exchanges code for tokens, finds or creates user,
/// generates JWT + refresh token, sets cookies, redirects to /home.
// Axum handler: each argument is a distinct extractor; factoring them into a
// combined extractor would obscure the contract for marginal benefit.
#[allow(clippy::too_many_arguments)]
pub async fn oauth_callback(
    State(db): State<DatabaseConnection>,
    headers: HeaderMap,
    session: Session,
    Extension(registry): Extension<Arc<ProviderRegistry>>,
    Extension(auth_config): Extension<Arc<AuthConfig>>,
    hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
    Path(provider_name): Path<String>,
    Query(params): Query<OAuthCallbackQuery>,
) -> Result<Response, AppError> {
    // SEC-C2: Rate limiting — surface the proper 429 + Retry-After response
    // instead of masking it as a generic 400.
    if let Err(resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
        return Ok(resp);
    }

    // Check for error from provider
    if let Some(ref error) = params.error {
        tracing::warn!(
            "OAuth error from {}: {} - {}",
            provider_name,
            error,
            params.error_description.as_deref().unwrap_or("no details")
        );
        return Ok(Redirect::to("/login?error=oauth_denied").into_response());
    }

    let code = params
        .code
        .as_deref()
        .ok_or_else(|| AppError::Validation("Missing authorization code".to_string()))?;

    // Validate CSRF state
    let session_state: Option<String> = session
        .get(OAUTH_STATE_KEY)
        .await
        .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;
    let session_provider: Option<String> = session
        .get(OAUTH_PROVIDER_KEY)
        .await
        .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?;

    let expected_state = session_state
        .ok_or_else(|| AppError::Validation("Missing OAuth state in session".to_string()))?;
    let received_state = params
        .state
        .as_deref()
        .ok_or_else(|| AppError::Validation("Missing state parameter".to_string()))?;

    // SEC: Constant-time comparison for state
    use subtle::ConstantTimeEq;
    let state_match: bool = expected_state
        .as_bytes()
        .ct_eq(received_state.as_bytes())
        .into();
    if !state_match {
        tracing::warn!("OAuth state mismatch for provider {}", provider_name);
        return Err(AppError::Validation(
            "OAuth state mismatch (possible CSRF attack)".to_string(),
        ));
    }

    if session_provider.as_deref() != Some(&provider_name) {
        return Err(AppError::Validation("Provider mismatch".to_string()));
    }

    // Retrieve nonce and PKCE verifier from session
    let nonce: Option<String> = session.get(OAUTH_NONCE_KEY).await.unwrap_or(None);
    let pkce_verifier: Option<String> = session.get(OAUTH_PKCE_KEY).await.unwrap_or(None);

    // Clean up session oauth data
    session.remove::<String>(OAUTH_STATE_KEY).await.ok();
    session.remove::<String>(OAUTH_NONCE_KEY).await.ok();
    session.remove::<String>(OAUTH_PKCE_KEY).await.ok();
    session.remove::<String>(OAUTH_PROVIDER_KEY).await.ok();

    let oauth_config = OAuthConfig::load(&db).await;
    let redirect_uri = oauth_config.callback_url(&provider_name);

    let provider = registry
        .get(&provider_name)
        .ok_or_else(|| AppError::NotFound(format!("Provider '{}' not found", provider_name)))?;

    // Exchange code for user info
    let user_info = provider
        .exchange_code(
            code,
            &redirect_uri,
            pkce_verifier.as_deref(),
            nonce.as_deref(),
        )
        .await?;

    // Find or create user
    let (user, is_new_user) =
        OAuthService::find_or_create_user(&db, &provider_name, &user_info).await?;

    // Generate JWT
    let roles = UserService::get_user_roles(&db, user.id).await?;
    let permissions =
        crate::services::permission_service::PermissionService::get_permissions_for_user(
            &db, user.id,
        )
        .await?;

    let access_token = JwtService::generate_token(
        &auth_config,
        user.id,
        &user.username,
        roles.clone(),
        permissions,
    )?;

    // Generate refresh token
    let _created_rt = RefreshTokenService::create_refresh_token(
        &db,
        user.id,
        auth_config.refresh_token_expiration_days,
    )
    .await?;

    // SEC-M1: Regenerate session ID
    if let Err(e) = session.cycle_id().await {
        tracing::warn!("Failed to cycle session ID: {}", e);
    }

    // Build cookies
    let expires_in_seconds = auth_config.jwt_expiration_minutes * 60;
    let same_site = match auth_config.cookie_same_site.to_lowercase().as_str() {
        "strict" => axum_extra::extract::cookie::SameSite::Strict,
        "lax" => axum_extra::extract::cookie::SameSite::Lax,
        "none" => axum_extra::extract::cookie::SameSite::None,
        _ => axum_extra::extract::cookie::SameSite::Lax,
    };

    let jwt_cookie = axum_extra::extract::cookie::Cookie::build(("jwt_token", access_token))
        .http_only(true)
        .same_site(same_site)
        .secure(auth_config.cookie_secure)
        .path("/")
        .max_age(time::Duration::seconds(expires_in_seconds));

    let mut response = Redirect::to("/home").into_response();
    if let Ok(hv) = jwt_cookie.to_string().parse() {
        response
            .headers_mut()
            .insert(axum::http::header::SET_COOKIE, hv);
    }

    // Set theme/locale cookies from preferences
    if let Ok(Some(prefs)) = UserPreferencesService::get_preferences(&db, user.id).await {
        let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", prefs.theme))
            .http_only(false)
            .same_site(axum_extra::extract::cookie::SameSite::Lax)
            .path("/")
            .max_age(time::Duration::seconds(31536000));
        if let Ok(hv) = theme_cookie.to_string().parse() {
            response
                .headers_mut()
                .append(axum::http::header::SET_COOKIE, hv);
        }

        let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language))
            .http_only(false)
            .same_site(axum_extra::extract::cookie::SameSite::Lax)
            .path("/")
            .max_age(time::Duration::seconds(31536000));
        if let Ok(hv) = locale_cookie.to_string().parse() {
            response
                .headers_mut()
                .append(axum::http::header::SET_COOKIE, hv);
        }
    }

    tracing::info!(
        "User {} logged in via OAuth ({}) [new_user={}]",
        user.username,
        provider_name,
        is_new_user
    );

    // Fire hooks
    if let Some(Extension(ref hooks)) = hooks {
        if let Err(e) = hooks.on_user_login(&db, user.id, &user.username).await {
            tracing::warn!("on_user_login hook error: {}", e);
        }
        if let Err(e) = hooks
            .on_oauth_login(&db, user.id, &provider_name, is_new_user)
            .await
        {
            tracing::warn!("on_oauth_login hook error: {}", e);
        }
    }

    Ok(response)
}

/// GET /api/v1/auth/oauth/providers
///
/// Returns the list of enabled OAuth providers (for the login page to display buttons).
pub async fn list_providers(
    State(db): State<DatabaseConnection>,
    Extension(registry): Extension<Arc<ProviderRegistry>>,
) -> Json<Vec<serde_json::Value>> {
    let oauth_config = OAuthConfig::load(&db).await;
    if !oauth_config.enabled {
        return Json(vec![]);
    }

    let providers: Vec<serde_json::Value> = registry
        .list()
        .into_iter()
        .map(|(name, display)| {
            serde_json::json!({
                "name": name,
                "display_name": display
            })
        })
        .collect();

    Json(providers)
}

/// GET /api/v1/auth/oauth/accounts
///
/// Returns the list of OAuth accounts linked to the authenticated user.
pub async fn list_user_oauth_accounts(
    State(db): State<DatabaseConnection>,
    auth_user: crate::auth::extractors::AuthUser,
) -> Result<Json<Vec<serde_json::Value>>, AppError> {
    let accounts = OAuthService::find_user_oauth_accounts(&db, auth_user.user_id).await?;
    let result: Vec<serde_json::Value> = accounts
        .into_iter()
        .map(|a| {
            serde_json::json!({
                "id": a.id,
                "provider": a.provider,
                "email": a.email,
                "display_name": a.display_name,
                "created_at": a.created_at.to_string(),
            })
        })
        .collect();
    Ok(Json(result))
}

/// DELETE /api/v1/auth/oauth/accounts/:id
///
/// Unlinks an OAuth account from the authenticated user.
pub async fn unlink_oauth_account(
    State(db): State<DatabaseConnection>,
    auth_user: crate::auth::extractors::AuthUser,
    Path(account_id): Path<uuid::Uuid>,
) -> Result<StatusCode, AppError> {
    OAuthService::unlink_account_by_id(&db, auth_user.user_id, account_id).await?;
    Ok(StatusCode::NO_CONTENT)
}