From de1645b6c3cca204eaf13c6d83c164577b99f519 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 28 May 2026 00:20:21 +0200 Subject: [PATCH] feat(openapi): add /api/auth to openapi declaration --- src/interfaces/api/handlers/auth_handler.rs | 229 +++++++++++++++++--- src/interfaces/api/mod.rs | 39 +++- 2 files changed, 229 insertions(+), 39 deletions(-) diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 550385ab..d6e22995 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -6,11 +6,12 @@ use axum::{ routing::{get, post, put}, }; use std::sync::Arc; +use utoipa::ToSchema; use uuid::Uuid; use crate::application::dtos::user_dto::{ - ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, - RefreshTokenDto, RegisterDto, SetupAdminDto, + AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, + OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto, }; use crate::application::services::auth_application_service::OidcCallbackResult; use crate::common::di::AppState; @@ -60,7 +61,20 @@ pub fn setup_route() -> Router> { Router::new().route("/setup", post(setup_admin)) } -async fn register( +/// Register a new user account. +#[utoipa::path( + post, + path = "/api/auth/register", + request_body = RegisterDto, + responses( + (status = 201, description = "User registered successfully", body = UserDto), + (status = 400, description = "Validation error"), + (status = 403, description = "Registration disabled"), + (status = 409, description = "Username or email already taken"), + ), + tag = "auth" +)] +pub async fn register( State(state): State>, Json(dto): Json, ) -> Result { @@ -122,7 +136,23 @@ async fn register( } } -async fn login( +/// Authenticate with username and password. +/// +/// On success, sets `oxicloud_access`, `oxicloud_refresh`, and `oxicloud_csrf` +/// HttpOnly cookies in addition to returning the tokens in the JSON body. +#[utoipa::path( + post, + path = "/api/auth/login", + request_body = LoginDto, + responses( + (status = 200, description = "Login successful — tokens in body and cookies", body = AuthResponseDto), + (status = 401, description = "Invalid credentials or password login disabled"), + (status = 403, description = "Account disabled"), + (status = 429, description = "Account temporarily locked (too many failed attempts)"), + ), + tag = "auth" +)] +pub async fn login( State(state): State>, headers: HeaderMap, Json(dto): Json, @@ -238,10 +268,25 @@ async fn login( } } -/// Token refresh — accepts the refresh token from **either**: -/// 1. JSON body `{ "refresh_token": "..." }` (API clients, backward compat) -/// 2. HttpOnly cookie `oxicloud_refresh` (browsers) -async fn refresh_token( +/// Refresh an access token. +/// +/// Accepts the refresh token from **either**: +/// 1. JSON body `{ "refresh_token": "..." }` (API clients / backward compat) +/// 2. HttpOnly `oxicloud_refresh` cookie (browsers) +/// +/// Issues new access + refresh tokens and rotates all three auth cookies. +#[utoipa::path( + post, + path = "/api/auth/refresh", + request_body(content = inline(RefreshTokenDto), + description = "Optional — omit when using the HttpOnly cookie"), + responses( + (status = 200, description = "New tokens issued", body = AuthResponseDto), + (status = 401, description = "Refresh token missing, expired, or revoked"), + ), + tag = "auth" +)] +pub async fn refresh_token( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, @@ -283,7 +328,18 @@ async fn refresh_token( Ok(response) } -async fn get_current_user( +/// Return the authenticated user's profile, including live storage usage. +#[utoipa::path( + get, + path = "/api/auth/me", + responses( + (status = 200, description = "Current user profile", body = UserDto), + (status = 401, description = "Not authenticated"), + ), + security(("bearerAuth" = [])), + tag = "auth" +)] +pub async fn get_current_user( State(state): State>, CurrentUserId(user_id): CurrentUserId, ) -> Result { @@ -330,7 +386,20 @@ pub struct UpdateUserImageDto { pub image: Option, } -async fn change_password( +/// Change the current user's password. +#[utoipa::path( + put, + path = "/api/auth/change-password", + request_body = ChangePasswordDto, + responses( + (status = 200, description = "Password changed successfully"), + (status = 400, description = "New password does not meet requirements"), + (status = 401, description = "Not authenticated or current password incorrect"), + ), + security(("bearerAuth" = [])), + tag = "auth" +)] +pub async fn change_password( State(state): State>, CurrentUserId(user_id): CurrentUserId, Json(dto): Json, @@ -348,6 +417,7 @@ async fn change_password( Ok(StatusCode::OK) } +// TODO: add utoipa pub async fn update_user_image( State(state): State>, CurrentUserId(user_id): CurrentUserId, @@ -371,7 +441,24 @@ pub async fn update_user_image( } } -async fn logout( +/// Revoke the current session and clear auth cookies. +/// +/// Accepts the refresh token from **either** a JSON body +/// `{ "refresh_token": "..." }` (API clients) or the `oxicloud_refresh` +/// HttpOnly cookie (browsers). +#[utoipa::path( + post, + path = "/api/auth/logout", + request_body(content = inline(RefreshTokenDto), + description = "Optional — omit when using the HttpOnly cookie"), + responses( + (status = 200, description = "Logged out, auth cookies cleared"), + (status = 401, description = "Not authenticated or refresh token missing"), + ), + security(("bearerAuth" = [])), + tag = "auth" +)] +pub async fn logout( State(state): State>, CurrentUserId(user_id): CurrentUserId, headers: HeaderMap, @@ -405,16 +492,23 @@ async fn logout( Ok(response) } -/// POST /api/setup — One-time endpoint to create the first admin user. +/// One-time endpoint to create the first admin user. /// /// Available only when the system is not yet initialized (no admin exists). -/// Once the admin is created, the system is marked as initialized and this -/// endpoint returns 403 for all subsequent requests. -/// -/// Uses an atomic "claim" operation to prevent race conditions: even if two -/// requests arrive simultaneously, only one will succeed in marking the -/// system as initialized and creating the admin. -async fn setup_admin( +/// Once the admin is created the endpoint permanently returns 403. +/// Uses an atomic "claim" operation so concurrent requests cannot both succeed. +#[utoipa::path( + post, + path = "/api/setup", + request_body = SetupAdminDto, + responses( + (status = 201, description = "First admin created and system initialized", body = UserDto), + (status = 403, description = "System already initialized"), + (status = 503, description = "Auth service not configured"), + ), + tag = "auth" +)] +pub async fn setup_admin( State(state): State>, Json(dto): Json, ) -> Result { @@ -498,19 +592,28 @@ async fn setup_admin( Ok((StatusCode::CREATED, Json(user))) } -/// Get system status - returns whether admin is configured -/// This is a public endpoint used to determine if setup is needed -#[derive(serde::Serialize)] -struct SystemStatus { - /// Whether the system has been set up with an admin +/// System initialisation state, returned by `GET /api/auth/status`. +#[derive(serde::Serialize, ToSchema)] +pub struct SystemStatus { + /// Whether the system has been set up with an admin. initialized: bool, - /// Number of admin users in the system + /// Number of admin users in the system. admin_count: i64, - /// Whether registration is allowed (only if admin exists) + /// Whether self-registration is allowed. registration_allowed: bool, } -async fn get_system_status( +/// Return the system initialisation state (used by the UI before setup). +#[utoipa::path( + get, + path = "/api/auth/status", + responses( + (status = 200, description = "System status", body = SystemStatus), + (status = 503, description = "Auth service not configured"), + ), + tag = "auth" +)] +pub async fn get_system_status( State(state): State>, ) -> Result { let auth_service = state @@ -552,8 +655,19 @@ async fn get_system_status( // OIDC Handlers // ============================================================================ -/// GET /api/auth/oidc/providers — Returns OIDC provider info for the UI -async fn oidc_providers(State(state): State>) -> Result { +/// Return OIDC provider information for the login UI. +/// +/// Returns `enabled: false` when OIDC is not configured. +#[utoipa::path( + get, + path = "/api/auth/oidc/providers", + responses( + (status = 200, description = "OIDC provider info (enabled=false when OIDC not configured)", body = OidcProviderInfoDto), + (status = 503, description = "Auth service not configured"), + ), + tag = "auth" +)] +pub async fn oidc_providers(State(state): State>) -> Result { let auth_service = state .auth_service .as_ref() @@ -580,8 +694,21 @@ async fn oidc_providers(State(state): State>) -> Result>) -> Result { +/// Initiate OIDC authorization — redirects to the configured identity provider. +/// +/// Generates PKCE, CSRF state, and nonce then issues a 302 redirect to the +/// provider's authorization endpoint. +#[utoipa::path( + get, + path = "/api/auth/oidc/authorize", + responses( + (status = 302, description = "Redirect to OIDC provider authorization URL"), + (status = 404, description = "OIDC not enabled"), + (status = 503, description = "Auth service not configured"), + ), + tag = "auth" +)] +pub async fn oidc_authorize(State(state): State>) -> Result { let auth_service = state .auth_service .as_ref() @@ -605,8 +732,26 @@ async fn oidc_authorize(State(state): State>) -> Result>, Query(query): Query, ) -> Result { @@ -694,9 +839,21 @@ async fn oidc_callback( } } -/// POST /api/auth/oidc/exchange — Exchange one-time code for auth tokens -/// Request body: { "code": "" } -async fn oidc_exchange( +/// Exchange a one-time OIDC code for access + refresh tokens. +/// +/// The frontend calls this after being redirected back with `?oidc_code=…`. +/// The exchange code is valid for a single use and expires in 60 s. +#[utoipa::path( + post, + path = "/api/auth/oidc/exchange", + request_body = OidcExchangeDto, + responses( + (status = 200, description = "Tokens issued, auth cookies set", body = AuthResponseDto), + (status = 401, description = "Exchange code invalid or expired"), + ), + tag = "auth" +)] +pub async fn oidc_exchange( State(state): State>, Json(body): Json, ) -> Result { diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 11ed606e..1abdc793 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -7,7 +7,8 @@ pub use routes::create_api_routes; pub use routes::create_health_routes; pub use routes::create_public_api_routes; -use utoipa::OpenApi; +use utoipa::{Modify, OpenApi}; +use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; use crate::application::dtos::contact_dto::{ AddressDto, ContactDto, ContactGroupDto, EmailDto, PhoneDto, @@ -40,8 +41,8 @@ use crate::application::dtos::trash_dto::{ DeletePermanentlyRequest, MoveToTrashRequest, RestoreFromTrashRequest, TrashedItemDto, }; use crate::application::dtos::user_dto::{ - AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, SetupAdminDto, - UserDto, + AuthResponseDto, ChangePasswordDto, LoginDto, OidcExchangeDto, OidcProviderInfoDto, + RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto, }; use crate::application::ports::chunked_upload_ports::{ ChunkUploadResponseDto, CreateUploadResponseDto, UploadStatusResponseDto, @@ -56,11 +57,26 @@ use crate::interfaces::api::handlers::contacts_handler::{ use crate::interfaces::api::handlers::dedup_handler::{ DedupUploadResponse, HashCheckResponse, StatsResponse, }; +use crate::interfaces::api::handlers::auth_handler::SystemStatus; use crate::interfaces::api::handlers::file_handler::MoveFilePayload; #[derive(OpenApi)] #[openapi( + modifiers(&SecurityAddon), paths( + // Auth handlers (public, protected, OIDC) + handlers::auth_handler::register, + handlers::auth_handler::login, + handlers::auth_handler::refresh_token, + handlers::auth_handler::get_current_user, + handlers::auth_handler::change_password, + handlers::auth_handler::logout, + handlers::auth_handler::setup_admin, + handlers::auth_handler::get_system_status, + handlers::auth_handler::oidc_providers, + handlers::auth_handler::oidc_authorize, + handlers::auth_handler::oidc_callback, + handlers::auth_handler::oidc_exchange, // File handlers (free functions — see file_handler.rs for why) handlers::file_handler::list_files_query, handlers::file_handler::upload_file_with_thumbnails, @@ -236,6 +252,9 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; AuthResponseDto, ChangePasswordDto, RefreshTokenDto, + SystemStatus, + OidcProviderInfoDto, + OidcExchangeDto, // Share schemas ShareDto, SharePermissionsDto, @@ -302,6 +321,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; ) ), tags( + (name = "auth", description = "Authentication and session management endpoints"), (name = "files", description = "File management endpoints"), (name = "folders", description = "Folder management endpoints"), (name = "trash", description = "Trash / recycle bin endpoints"), @@ -328,6 +348,19 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; )] pub struct ApiDoc; +/// Injects the `bearerAuth` HTTP Bearer security scheme into the generated spec. +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearerAuth", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + } +} + #[cfg(test)] mod tests { use super::*;