diff --git a/db/migrations/003_add_device_codes.sql b/db/migrations/003_add_device_codes.sql new file mode 100644 index 00000000..fdb3fe95 --- /dev/null +++ b/db/migrations/003_add_device_codes.sql @@ -0,0 +1,99 @@ +-- ============================================================ +-- Migration 003: OAuth 2.0 Device Authorization Grant (RFC 8628) +-- ============================================================ +-- Adds the device_codes table to support the Device Authorization +-- Grant flow for WebDAV/CalDAV/CardDAV client authentication. +-- +-- Flow: +-- 1. Client POSTs to /api/auth/device/authorize → receives device_code + user_code +-- 2. User opens verification_uri in browser, authenticates, enters user_code +-- 3. Client polls /api/auth/device/token with device_code +-- 4. Once approved, client receives access_token + refresh_token +-- ============================================================ + +-- Device code status enum +DO $BODY$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'device_code_status' AND n.nspname = 'auth' + ) THEN + CREATE TYPE auth.device_code_status AS ENUM ( + 'pending', -- Waiting for user to authorize + 'authorized', -- User approved, tokens ready for polling client + 'denied', -- User denied the request + 'expired' -- TTL exceeded without user action + ); + END IF; +END $BODY$; + +-- Device authorization codes table +CREATE TABLE IF NOT EXISTS auth.device_codes ( + -- Unique row ID + id VARCHAR(36) PRIMARY KEY, + + -- RFC 8628 §3.2: device_code — long opaque token sent to the client for polling + device_code VARCHAR(128) UNIQUE NOT NULL, + + -- RFC 8628 §3.2: user_code — short human-readable code shown on the client + -- and entered by the user on the verification page (e.g. "ABCD-1234") + user_code VARCHAR(16) UNIQUE NOT NULL, + + -- Name/description of the client requesting access (shown to user) + client_name VARCHAR(255) NOT NULL DEFAULT 'Unknown Client', + + -- Comma-separated scopes requested (e.g. "webdav,caldav,carddav") + scopes VARCHAR(512) NOT NULL DEFAULT 'webdav,caldav,carddav', + + -- Current status of the device flow + status auth.device_code_status NOT NULL DEFAULT 'pending', + + -- User who authorized the request (NULL until status = 'authorized') + user_id VARCHAR(36) REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Tokens generated after authorization (NULL until status = 'authorized') + -- Stored encrypted/hashed depending on sensitivity + access_token TEXT, + refresh_token TEXT, + + -- RFC 8628 §3.2: verification_uri — full URL the user must visit + verification_uri TEXT NOT NULL, + + -- RFC 8628 §3.2: verification_uri_complete — URL with user_code pre-filled + verification_uri_complete TEXT, + + -- RFC 8628 §3.2: expires_in — encoded as an absolute timestamp + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + + -- RFC 8628 §3.2: interval — minimum polling interval in seconds + poll_interval_secs INTEGER NOT NULL DEFAULT 5, + + -- Last time the client polled (for slow_down enforcement) + last_poll_at TIMESTAMP WITH TIME ZONE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + authorized_at TIMESTAMP WITH TIME ZONE +); + +-- Index for client polling by device_code (hot path) +CREATE INDEX IF NOT EXISTS idx_device_codes_device_code + ON auth.device_codes(device_code); + +-- Index for user verification page lookup by user_code +CREATE INDEX IF NOT EXISTS idx_device_codes_user_code + ON auth.device_codes(user_code) + WHERE status = 'pending'; + +-- Index for cleanup of expired entries +CREATE INDEX IF NOT EXISTS idx_device_codes_expires_at + ON auth.device_codes(expires_at) + WHERE status = 'pending'; + +-- Index for user's authorized devices +CREATE INDEX IF NOT EXISTS idx_device_codes_user_id + ON auth.device_codes(user_id) + WHERE status = 'authorized'; + +COMMENT ON TABLE auth.device_codes IS 'OAuth 2.0 Device Authorization Grant (RFC 8628) codes for DAV client authentication'; diff --git a/db/schema.sql b/db/schema.sql index 5cd84f7c..e7c6a584 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -143,6 +143,54 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON auth.users(oidc_provider, oi -- NOTE: No default users are created. The first user to register through -- the admin setup wizard will become the administrator. +-- Device Authorization Grant (RFC 8628) +-- Used for WebDAV/CalDAV/CardDAV client authentication via the device flow. +DO $BODY$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'device_code_status' AND n.nspname = 'auth' + ) THEN + CREATE TYPE auth.device_code_status AS ENUM ( + 'pending', -- Waiting for user to authorize + 'authorized', -- User approved, tokens ready for polling client + 'denied', -- User denied the request + 'expired' -- TTL exceeded without user action + ); + END IF; +END $BODY$; + +CREATE TABLE IF NOT EXISTS auth.device_codes ( + id VARCHAR(36) PRIMARY KEY, + device_code VARCHAR(128) UNIQUE NOT NULL, + user_code VARCHAR(16) UNIQUE NOT NULL, + client_name VARCHAR(255) NOT NULL DEFAULT 'Unknown Client', + scopes VARCHAR(512) NOT NULL DEFAULT 'webdav,caldav,carddav', + status auth.device_code_status NOT NULL DEFAULT 'pending', + user_id VARCHAR(36) REFERENCES auth.users(id) ON DELETE CASCADE, + access_token TEXT, + refresh_token TEXT, + verification_uri TEXT NOT NULL, + verification_uri_complete TEXT, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + poll_interval_secs INTEGER NOT NULL DEFAULT 5, + last_poll_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + authorized_at TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX IF NOT EXISTS idx_device_codes_device_code + ON auth.device_codes(device_code); +CREATE INDEX IF NOT EXISTS idx_device_codes_user_code + ON auth.device_codes(user_code) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_device_codes_expires_at + ON auth.device_codes(expires_at) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_device_codes_user_id + ON auth.device_codes(user_id) WHERE status = 'authorized'; + +COMMENT ON TABLE auth.device_codes IS 'OAuth 2.0 Device Authorization Grant (RFC 8628) codes for DAV client authentication'; + -- ============================================================ -- 2. CALDAV SCHEMA (RFC 4791) -- ============================================================ diff --git a/src/application/dtos/device_auth_dto.rs b/src/application/dtos/device_auth_dto.rs new file mode 100644 index 00000000..6f5fb53e --- /dev/null +++ b/src/application/dtos/device_auth_dto.rs @@ -0,0 +1,96 @@ +//! DTOs for OAuth 2.0 Device Authorization Grant (RFC 8628). + +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// Request DTOs +// ============================================================================ + +/// POST /api/auth/device/authorize — request body +#[derive(Debug, Deserialize)] +pub struct DeviceAuthorizeRequestDto { + /// Human-readable name of the client (e.g. "rclone", "DAVx⁵") + #[serde(default = "default_client_name")] + pub client_name: String, + /// Comma-separated scopes (e.g. "webdav,caldav,carddav") + #[serde(default = "default_scopes")] + pub scope: String, +} + +fn default_client_name() -> String { + "Unknown Client".to_string() +} + +fn default_scopes() -> String { + "webdav,caldav,carddav".to_string() +} + +/// POST /api/auth/device/verify — user submits the code from the browser +#[derive(Debug, Deserialize)] +pub struct DeviceVerifyRequestDto { + /// The user_code displayed on the client device + pub user_code: String, + /// Whether the user approves ("approve") or denies ("deny") + pub action: String, +} + +/// POST /api/auth/device/token — client polls for tokens +#[derive(Debug, Deserialize)] +pub struct DeviceTokenRequestDto { + /// The device_code received from the initial authorize call + pub device_code: String, + /// Must be "urn:ietf:params:oauth:grant-type:device_code" + #[serde(default)] + pub grant_type: String, +} + +// ============================================================================ +// Response DTOs +// ============================================================================ + +/// Response to POST /api/auth/device/authorize (RFC 8628 §3.2) +#[derive(Debug, Serialize)] +pub struct DeviceAuthorizeResponseDto { + /// The device verification code + pub device_code: String, + /// The end-user verification code (short, human-readable) + pub user_code: String, + /// The end-user verification URI + pub verification_uri: String, + /// Optional: verification URI with user_code pre-filled + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_uri_complete: Option, + /// Lifetime in seconds of the device_code and user_code + pub expires_in: i64, + /// Minimum polling interval in seconds + pub interval: i32, +} + +/// Response to POST /api/auth/device/token when authorization is still pending +#[derive(Debug, Serialize)] +pub struct DeviceTokenPendingDto { + pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_description: Option, +} + +/// Response to POST /api/auth/device/token when authorization is complete +#[derive(Debug, Serialize)] +pub struct DeviceTokenSuccessDto { + pub access_token: String, + pub token_type: String, + pub refresh_token: String, + pub expires_in: i64, + pub scope: String, +} + +/// GET /api/auth/device/verify — info about the pending device code +#[derive(Debug, Serialize)] +pub struct DeviceVerifyInfoDto { + /// The client name requesting access + pub client_name: String, + /// Scopes being requested + pub scopes: String, + /// Whether the user_code is valid and pending + pub valid: bool, +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index bf2718d9..76931960 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -1,6 +1,7 @@ pub mod address_book_dto; pub mod calendar_dto; pub mod contact_dto; +pub mod device_auth_dto; pub mod display_helpers; pub mod favorites_dto; pub mod file_dto; diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index e419bd3a..19d722eb 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -1,4 +1,5 @@ use crate::common::errors::DomainError; +use crate::domain::entities::device_code::DeviceCode; use crate::domain::entities::session::Session; use crate::domain::entities::user::User; use async_trait::async_trait; @@ -202,3 +203,31 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// Revokes all sessions of a user async fn revoke_all_user_sessions(&self, user_id: &str) -> Result; } + +// ============================================================================ +// Device Authorization Grant Port (RFC 8628) +// ============================================================================ + +#[async_trait] +pub trait DeviceCodeStoragePort: Send + Sync + 'static { + /// Persist a new device code flow + async fn create_device_code(&self, device_code: DeviceCode) -> Result; + + /// Find a device code by its opaque device_code token (used by client polling) + async fn get_by_device_code(&self, device_code: &str) -> Result; + + /// Find a pending device code by the short user_code (used on verification page) + async fn get_pending_by_user_code(&self, user_code: &str) -> Result; + + /// Update a device code (status change, token storage, poll timestamp, etc.) + async fn update_device_code(&self, device_code: DeviceCode) -> Result<(), DomainError>; + + /// Delete expired device codes (cleanup job) + async fn delete_expired(&self) -> Result; + + /// List authorized device codes for a user (for UI management) + async fn list_by_user(&self, user_id: &str) -> Result, DomainError>; + + /// Delete a specific device code by ID (revocation) + async fn delete_by_id(&self, id: &str) -> Result<(), DomainError>; +} diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs new file mode 100644 index 00000000..8981216a --- /dev/null +++ b/src/application/services/device_auth_service.rs @@ -0,0 +1,437 @@ +//! OAuth 2.0 Device Authorization Grant service (RFC 8628). +//! +//! Orchestrates the full device flow: +//! 1. `initiate` — generates device_code + user_code, stores in DB +//! 2. `verify_user_code` — looks up pending code for the verification page +//! 3. `approve` — user approves, tokens are generated and stored +//! 4. `deny` — user denies the request +//! 5. `poll` — client polls by device_code; returns tokens or status error +//! 6. `cleanup_expired` — background job to purge stale entries + +use std::sync::Arc; + +use crate::application::dtos::device_auth_dto::*; +use crate::application::ports::auth_ports::{DeviceCodeStoragePort, TokenServicePort, UserStoragePort}; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus}; +use crate::domain::entities::session::Session; +use crate::application::ports::auth_ports::SessionStoragePort; + +/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min). +const DEVICE_CODE_LIFETIME_SECS: i64 = 900; + +/// Default polling interval in seconds (RFC 8628 §3.2 recommends 5s). +const DEFAULT_POLL_INTERVAL: i32 = 5; + +/// Length of the device_code (hex-encoded, 64 chars = 32 bytes). +const DEVICE_CODE_BYTES: usize = 32; + +/// User code format: 4 uppercase letters + hyphen + 4 digits → "ABCD-1234" +/// Short enough to type, long enough to avoid collisions with 26^4 * 10^4 = ~4.5 billion combos. +const USER_CODE_LETTER_LEN: usize = 4; +const USER_CODE_DIGIT_LEN: usize = 4; + +pub struct DeviceAuthService { + device_code_storage: Arc, + token_service: Arc, + user_storage: Arc, + session_storage: Arc, + /// Base URL of the server (e.g. "https://cloud.example.com") + base_url: String, +} + +impl DeviceAuthService { + pub fn new( + device_code_storage: Arc, + token_service: Arc, + user_storage: Arc, + session_storage: Arc, + base_url: String, + ) -> Self { + Self { + device_code_storage, + token_service, + user_storage, + session_storage, + base_url, + } + } + + // ======================================================================== + // 1. Initiate — called by the DAV client + // ======================================================================== + + /// Start a new device authorization flow. + /// + /// Returns the response that the client displays to the user. + pub async fn initiate( + &self, + req: DeviceAuthorizeRequestDto, + ) -> Result { + let device_code_token = generate_device_code(); + let user_code = generate_user_code(); + + let verification_uri = format!("{}/device", self.base_url.trim_end_matches('/')); + let verification_uri_complete = format!("{}?code={}", verification_uri, user_code); + + let dc = DeviceCode::new( + device_code_token.clone(), + user_code.clone(), + req.client_name, + req.scope, + verification_uri.clone(), + Some(verification_uri_complete.clone()), + DEVICE_CODE_LIFETIME_SECS, + DEFAULT_POLL_INTERVAL, + ); + + let dc = self.device_code_storage.create_device_code(dc).await?; + + tracing::info!( + "Device auth flow initiated: user_code={}, expires_in={}s", + user_code, + DEVICE_CODE_LIFETIME_SECS + ); + + Ok(DeviceAuthorizeResponseDto { + device_code: device_code_token, + user_code, + verification_uri, + verification_uri_complete: Some(verification_uri_complete), + expires_in: dc.seconds_remaining(), + interval: DEFAULT_POLL_INTERVAL, + }) + } + + // ======================================================================== + // 2. Verify — user opens the verification page, looks up pending code + // ======================================================================== + + /// Look up a pending device code by user_code for the verification page. + pub async fn verify_user_code( + &self, + user_code: &str, + ) -> Result { + let normalized = user_code.trim().to_uppercase().replace(' ', ""); + + match self + .device_code_storage + .get_pending_by_user_code(&normalized) + .await + { + Ok(dc) => { + if dc.is_expired() { + return Ok(DeviceVerifyInfoDto { + client_name: dc.client_name().to_string(), + scopes: dc.scopes().to_string(), + valid: false, + }); + } + Ok(DeviceVerifyInfoDto { + client_name: dc.client_name().to_string(), + scopes: dc.scopes().to_string(), + valid: true, + }) + } + Err(_) => Ok(DeviceVerifyInfoDto { + client_name: String::new(), + scopes: String::new(), + valid: false, + }), + } + } + + // ======================================================================== + // 3. Approve — authenticated user approves the device code + // ======================================================================== + + /// Approve a device code, generating tokens for the polling client. + /// + /// * `user_code` — the code from the verification page + /// * `user_id` — the authenticated user's ID (from session/JWT) + pub async fn approve( + &self, + user_code: &str, + user_id: &str, + ) -> Result<(), DomainError> { + let normalized = user_code.trim().to_uppercase().replace(' ', ""); + + let mut dc = self + .device_code_storage + .get_pending_by_user_code(&normalized) + .await?; + + if dc.is_expired() { + return Err(DomainError::new( + ErrorKind::AccessDenied, + "DeviceCode", + "Device code has expired. Please start a new authorization flow.", + )); + } + + // Fetch user to generate tokens + let user = self.user_storage.get_user_by_id(user_id).await?; + + // Generate internal JWT access token + refresh token + let access_token = self.token_service.generate_access_token(&user)?; + let refresh_token = self.token_service.generate_refresh_token(); + + // Persist refresh token as a session + let session = Session::new( + user_id.to_string(), + refresh_token.clone(), + None, // ip_address + Some(format!("device:{}", dc.client_name())), // user_agent + self.token_service.refresh_token_expiry_days(), + ); + self.session_storage.create_session(session).await?; + + // Store tokens on the device code entity + dc.authorize(user_id.to_string(), access_token, refresh_token); + self.device_code_storage.update_device_code(dc).await?; + + tracing::info!( + "Device code approved by user {} (user_code={})", + user_id, + normalized + ); + + Ok(()) + } + + // ======================================================================== + // 4. Deny — authenticated user denies the device code + // ======================================================================== + + pub async fn deny(&self, user_code: &str) -> Result<(), DomainError> { + let normalized = user_code.trim().to_uppercase().replace(' ', ""); + + let mut dc = self + .device_code_storage + .get_pending_by_user_code(&normalized) + .await?; + + dc.deny(); + self.device_code_storage.update_device_code(dc).await?; + + tracing::info!("Device code denied (user_code={})", normalized); + + Ok(()) + } + + // ======================================================================== + // 5. Poll — client polls by device_code for tokens + // ======================================================================== + + /// Client polls for tokens. Returns: + /// - `Ok(DeviceTokenSuccessDto)` if authorized + /// - `Err` with specific RFC 8628 error codes for pending/slow_down/expired/denied + pub async fn poll( + &self, + device_code: &str, + ) -> Result { + let mut dc = self + .device_code_storage + .get_by_device_code(device_code) + .await + .map_err(|_| DevicePollError::InvalidDeviceCode)?; + + // Check expiry first + if dc.is_expired() && dc.status() == DeviceCodeStatus::Pending { + let mut expired_dc = dc.clone(); + expired_dc.mark_expired(); + let _ = self.device_code_storage.update_device_code(expired_dc).await; + return Err(DevicePollError::ExpiredToken); + } + + match dc.status() { + DeviceCodeStatus::Pending => { + // Check for slow_down (polling too fast) + if dc.is_polling_too_fast() { + return Err(DevicePollError::SlowDown); + } + // Record this poll + dc.record_poll(); + let _ = self.device_code_storage.update_device_code(dc).await; + Err(DevicePollError::AuthorizationPending) + } + DeviceCodeStatus::Authorized => { + let access_token = dc.access_token().unwrap_or_default().to_string(); + let refresh_token = dc.refresh_token().unwrap_or_default().to_string(); + let scope = dc.scopes().to_string(); + + Ok(DeviceTokenSuccessDto { + access_token, + token_type: "Bearer".to_string(), + refresh_token, + expires_in: self.token_service.refresh_token_expiry_secs(), + scope, + }) + } + DeviceCodeStatus::Denied => Err(DevicePollError::AccessDenied), + DeviceCodeStatus::Expired => Err(DevicePollError::ExpiredToken), + } + } + + // ======================================================================== + // 6. Cleanup — purge expired entries + // ======================================================================== + + pub async fn cleanup_expired(&self) -> Result { + let deleted = self.device_code_storage.delete_expired().await?; + if deleted > 0 { + tracing::info!("Device code cleanup: {} expired entries removed", deleted); + } + Ok(deleted) + } + + // ======================================================================== + // 7. List — user's authorized devices (for UI) + // ======================================================================== + + pub async fn list_user_devices( + &self, + user_id: &str, + ) -> Result, DomainError> { + let codes = self.device_code_storage.list_by_user(user_id).await?; + Ok(codes + .into_iter() + .map(|dc| DeviceInfoDto { + id: dc.id().to_string(), + client_name: dc.client_name().to_string(), + scopes: dc.scopes().to_string(), + status: dc.status().as_str().to_string(), + created_at: dc.created_at().to_rfc3339(), + authorized_at: dc.authorized_at().map(|t| t.to_rfc3339()), + expires_at: dc.expires_at().to_rfc3339(), + }) + .collect()) + } + + // ======================================================================== + // 8. Revoke — user revokes a device authorization + // ======================================================================== + + pub async fn revoke_device(&self, device_id: &str, user_id: &str) -> Result<(), DomainError> { + // Verify ownership before deleting + let devices = self.device_code_storage.list_by_user(user_id).await?; + let found = devices.iter().any(|d| d.id() == device_id); + if !found { + return Err(DomainError::new( + ErrorKind::NotFound, + "DeviceCode", + "Device authorization not found or not owned by you", + )); + } + self.device_code_storage.delete_by_id(device_id).await + } +} + +// ============================================================================ +// Poll error (typed for RFC 8628 error responses) +// ============================================================================ + +/// Typed errors for the device token polling endpoint (RFC 8628 §3.5). +#[derive(Debug)] +pub enum DevicePollError { + /// The authorization request is still pending (user hasn't acted yet). + AuthorizationPending, + /// The client is polling too fast; increase the interval. + SlowDown, + /// The user denied the authorization request. + AccessDenied, + /// The device_code has expired. + ExpiredToken, + /// The device_code is not recognized. + InvalidDeviceCode, +} + +impl DevicePollError { + /// RFC 8628 error string for the JSON response. + pub fn error_code(&self) -> &'static str { + match self { + Self::AuthorizationPending => "authorization_pending", + Self::SlowDown => "slow_down", + Self::AccessDenied => "access_denied", + Self::ExpiredToken => "expired_token", + Self::InvalidDeviceCode => "invalid_grant", + } + } + + pub fn description(&self) -> &'static str { + match self { + Self::AuthorizationPending => { + "The authorization request is still pending. Continue polling." + } + Self::SlowDown => "You are polling too frequently. Please slow down.", + Self::AccessDenied => "The user denied the authorization request.", + Self::ExpiredToken => { + "The device_code has expired. Please start a new authorization flow." + } + Self::InvalidDeviceCode => "The device_code is not recognized.", + } + } + + /// HTTP status code per RFC 8628 §3.5: + /// - authorization_pending and slow_down: 400 + /// - access_denied: 403 + /// - expired_token: 400 + pub fn http_status(&self) -> u16 { + match self { + Self::AuthorizationPending | Self::SlowDown | Self::ExpiredToken => 400, + Self::AccessDenied => 403, + Self::InvalidDeviceCode => 400, + } + } +} + +// ============================================================================ +// Additional DTOs (used by service, not in the handler module) +// ============================================================================ + +/// DTO for listing authorized devices in the user's profile. +#[derive(Debug, serde::Serialize)] +pub struct DeviceInfoDto { + pub id: String, + pub client_name: String, + pub scopes: String, + pub status: String, + pub created_at: String, + pub authorized_at: Option, + pub expires_at: String, +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Generate a cryptographically random device_code (hex-encoded). +fn generate_device_code() -> String { + use rand_core::{OsRng, RngCore}; + let mut bytes = [0u8; DEVICE_CODE_BYTES]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Generate a human-readable user_code in the format "ABCD-1234". +fn generate_user_code() -> String { + use rand_core::{OsRng, RngCore}; + let mut rng_bytes = [0u8; 8]; + OsRng.fill_bytes(&mut rng_bytes); + + let letters: String = (0..USER_CODE_LETTER_LEN) + .map(|i| { + let b = rng_bytes[i] % 26; + (b'A' + b) as char + }) + .collect(); + + let digits: String = (0..USER_CODE_DIGIT_LEN) + .map(|i| { + let b = rng_bytes[USER_CODE_LETTER_LEN + i] % 10; + (b'0' + b) as char + }) + .collect(); + + format!("{}-{}", letters, digits) +} diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index de320ebb..d8547878 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -2,6 +2,7 @@ pub mod admin_settings_service; pub mod auth_application_service; pub mod batch_operations; pub mod calendar_service; +pub mod device_auth_service; pub mod contact_service; pub mod favorites_service; pub mod file_management_service; diff --git a/src/common/di.rs b/src/common/di.rs index 9d3974d5..82a7e5b1 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -540,6 +540,7 @@ impl AppServiceFactory { wopi_token_service: None, wopi_lock_service: None, wopi_discovery_service: None, + device_auth_service: None, }; // 9b. Wire admin settings service when auth is available @@ -589,6 +590,33 @@ impl AppServiceFactory { } app_state.admin_settings_service = Some(admin_svc); + + // 9c. Wire Device Authorization Grant (RFC 8628) service + { + use crate::application::services::device_auth_service::DeviceAuthService; + use crate::infrastructure::repositories::DeviceCodePgRepository; + + let device_code_repo = Arc::new(DeviceCodePgRepository::new(pool.clone())); + let user_repo: Arc = + Arc::new(crate::infrastructure::repositories::UserPgRepository::new( + pool.clone(), + )); + let session_repo: Arc = + Arc::new(crate::infrastructure::repositories::SessionPgRepository::new( + pool.clone(), + )); + let base_url = self.config.base_url(); + + let device_auth_svc = Arc::new(DeviceAuthService::new( + device_code_repo, + auth_svc.token_service.clone(), + user_repo, + session_repo, + base_url, + )); + app_state.device_auth_service = Some(device_auth_svc); + tracing::info!("Device Authorization Grant (RFC 8628) service initialized"); + } } // 10. Wire CalDAV/CardDAV services @@ -782,6 +810,8 @@ pub struct AppState { Option>, pub wopi_discovery_service: Option>, + pub device_auth_service: + Option>, } // All AppState construction is done via struct literal in build_app_state(). diff --git a/src/domain/entities/device_code.rs b/src/domain/entities/device_code.rs new file mode 100644 index 00000000..25395920 --- /dev/null +++ b/src/domain/entities/device_code.rs @@ -0,0 +1,267 @@ +//! Device Authorization Code entity (RFC 8628). +//! +//! Represents a pending or completed OAuth 2.0 Device Authorization Grant flow. + +use chrono::{DateTime, Duration, Utc}; +use uuid::Uuid; + +/// Status of a device authorization flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceCodeStatus { + /// Waiting for the user to authorize on the verification page. + Pending, + /// User approved — tokens are ready for the polling client. + Authorized, + /// User explicitly denied the request. + Denied, + /// The code expired before the user acted. + Expired, +} + +impl DeviceCodeStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Authorized => "authorized", + Self::Denied => "denied", + Self::Expired => "expired", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "pending" => Some(Self::Pending), + "authorized" => Some(Self::Authorized), + "denied" => Some(Self::Denied), + "expired" => Some(Self::Expired), + _ => None, + } + } +} + +impl std::fmt::Display for DeviceCodeStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Domain entity for a Device Authorization flow. +#[derive(Debug, Clone)] +pub struct DeviceCode { + id: String, + device_code: String, + user_code: String, + client_name: String, + scopes: String, + status: DeviceCodeStatus, + user_id: Option, + access_token: Option, + refresh_token: Option, + verification_uri: String, + verification_uri_complete: Option, + expires_at: DateTime, + poll_interval_secs: i32, + last_poll_at: Option>, + created_at: DateTime, + authorized_at: Option>, +} + +impl DeviceCode { + /// Create a new pending device code flow. + /// + /// * `device_code` — opaque token for client polling (64 hex chars) + /// * `user_code` — short human-readable code (e.g. "ABCD-1234") + /// * `client_name` — display name of the requesting client + /// * `scopes` — requested scopes (e.g. "webdav,caldav,carddav") + /// * `verification_uri` — URL the user must visit + /// * `expires_in_secs` — TTL for the device code + /// * `poll_interval_secs` — minimum polling interval + pub fn new( + device_code: String, + user_code: String, + client_name: String, + scopes: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in_secs: i64, + poll_interval_secs: i32, + ) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + device_code, + user_code, + client_name, + scopes, + status: DeviceCodeStatus::Pending, + user_id: None, + access_token: None, + refresh_token: None, + verification_uri, + verification_uri_complete, + expires_at: now + Duration::seconds(expires_in_secs), + poll_interval_secs, + last_poll_at: None, + created_at: now, + authorized_at: None, + } + } + + /// Reconstruct from database row. + #[allow(clippy::too_many_arguments)] + pub fn from_raw( + id: String, + device_code: String, + user_code: String, + client_name: String, + scopes: String, + status: DeviceCodeStatus, + user_id: Option, + access_token: Option, + refresh_token: Option, + verification_uri: String, + verification_uri_complete: Option, + expires_at: DateTime, + poll_interval_secs: i32, + last_poll_at: Option>, + created_at: DateTime, + authorized_at: Option>, + ) -> Self { + Self { + id, + device_code, + user_code, + client_name, + scopes, + status, + user_id, + access_token, + refresh_token, + verification_uri, + verification_uri_complete, + expires_at, + poll_interval_secs, + last_poll_at, + created_at, + authorized_at, + } + } + + // ── Getters ────────────────────────────────────────────────── + + pub fn id(&self) -> &str { + &self.id + } + + pub fn device_code(&self) -> &str { + &self.device_code + } + + pub fn user_code(&self) -> &str { + &self.user_code + } + + pub fn client_name(&self) -> &str { + &self.client_name + } + + pub fn scopes(&self) -> &str { + &self.scopes + } + + pub fn status(&self) -> DeviceCodeStatus { + self.status + } + + pub fn user_id(&self) -> Option<&str> { + self.user_id.as_deref() + } + + pub fn access_token(&self) -> Option<&str> { + self.access_token.as_deref() + } + + pub fn refresh_token(&self) -> Option<&str> { + self.refresh_token.as_deref() + } + + pub fn verification_uri(&self) -> &str { + &self.verification_uri + } + + pub fn verification_uri_complete(&self) -> Option<&str> { + self.verification_uri_complete.as_deref() + } + + pub fn expires_at(&self) -> DateTime { + self.expires_at + } + + pub fn poll_interval_secs(&self) -> i32 { + self.poll_interval_secs + } + + pub fn last_poll_at(&self) -> Option> { + self.last_poll_at + } + + pub fn created_at(&self) -> DateTime { + self.created_at + } + + pub fn authorized_at(&self) -> Option> { + self.authorized_at + } + + // ── Business logic ─────────────────────────────────────────── + + /// Whether the device code has expired. + pub fn is_expired(&self) -> bool { + Utc::now() > self.expires_at + } + + /// Seconds remaining until expiry (clamped to 0). + pub fn seconds_remaining(&self) -> i64 { + let remaining = (self.expires_at - Utc::now()).num_seconds(); + remaining.max(0) + } + + /// Whether the client is polling too fast (within poll_interval_secs). + pub fn is_polling_too_fast(&self) -> bool { + if let Some(last) = self.last_poll_at { + let elapsed = (Utc::now() - last).num_seconds(); + elapsed < self.poll_interval_secs as i64 + } else { + false + } + } + + /// Record a poll attempt timestamp. + pub fn record_poll(&mut self) { + self.last_poll_at = Some(Utc::now()); + } + + /// Authorize this device code for a specific user, storing the tokens. + pub fn authorize( + &mut self, + user_id: String, + access_token: String, + refresh_token: String, + ) { + self.status = DeviceCodeStatus::Authorized; + self.user_id = Some(user_id); + self.access_token = Some(access_token); + self.refresh_token = Some(refresh_token); + self.authorized_at = Some(Utc::now()); + } + + /// Deny this device code. + pub fn deny(&mut self) { + self.status = DeviceCodeStatus::Denied; + } + + /// Mark as expired. + pub fn mark_expired(&mut self) { + self.status = DeviceCodeStatus::Expired; + } +} diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index 2f3ba64a..97220649 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -1,6 +1,7 @@ pub mod calendar; pub mod calendar_event; pub mod contact; +pub mod device_code; pub mod entity_errors; pub mod file; pub mod folder; diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index d79dc0e0..3ea4faaa 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -3,6 +3,6 @@ pub mod pg; // Re-exportar para facilitar acceso pub use pg::{ - FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, SessionPgRepository, - TrashDbRepository, UserPgRepository, + DeviceCodePgRepository, FileBlobReadRepository, FileBlobWriteRepository, + FolderDbRepository, SessionPgRepository, TrashDbRepository, UserPgRepository, }; diff --git a/src/infrastructure/repositories/pg/device_code_pg_repository.rs b/src/infrastructure/repositories/pg/device_code_pg_repository.rs new file mode 100644 index 00000000..f52ecacf --- /dev/null +++ b/src/infrastructure/repositories/pg/device_code_pg_repository.rs @@ -0,0 +1,261 @@ +//! PostgreSQL repository for Device Authorization Grant (RFC 8628) codes. + +use async_trait::async_trait; +use sqlx::{PgPool, Row}; +use std::sync::Arc; + +use crate::application::ports::auth_ports::DeviceCodeStoragePort; +use crate::common::errors::{DomainError, ErrorKind}; +use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus}; + +pub struct DeviceCodePgRepository { + pool: Arc, +} + +impl DeviceCodePgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn map_row(row: &sqlx::postgres::PgRow) -> Result { + let status_str: String = row.try_get("status").map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to read status: {}", e), + ) + })?; + + let status = DeviceCodeStatus::from_str(&status_str).unwrap_or(DeviceCodeStatus::Expired); + + Ok(DeviceCode::from_raw( + row.try_get("id").unwrap_or_default(), + row.try_get("device_code").unwrap_or_default(), + row.try_get("user_code").unwrap_or_default(), + row.try_get("client_name").unwrap_or_default(), + row.try_get("scopes").unwrap_or_default(), + status, + row.try_get("user_id").ok(), + row.try_get("access_token").ok(), + row.try_get("refresh_token").ok(), + row.try_get("verification_uri").unwrap_or_default(), + row.try_get("verification_uri_complete").ok(), + row.try_get("expires_at").unwrap_or_default(), + row.try_get::("poll_interval_secs").unwrap_or(5), + row.try_get("last_poll_at").ok(), + row.try_get("created_at").unwrap_or_default(), + row.try_get("authorized_at").ok(), + )) + } +} + +#[async_trait] +impl DeviceCodeStoragePort for DeviceCodePgRepository { + async fn create_device_code(&self, dc: DeviceCode) -> Result { + sqlx::query( + r#" + INSERT INTO auth.device_codes ( + id, device_code, user_code, client_name, scopes, status, + user_id, access_token, refresh_token, + verification_uri, verification_uri_complete, + expires_at, poll_interval_secs, last_poll_at, + created_at, authorized_at + ) VALUES ( + $1, $2, $3, $4, $5, $6::auth.device_code_status, + $7, $8, $9, + $10, $11, + $12, $13, $14, + $15, $16 + ) + "#, + ) + .bind(dc.id()) + .bind(dc.device_code()) + .bind(dc.user_code()) + .bind(dc.client_name()) + .bind(dc.scopes()) + .bind(dc.status().as_str()) + .bind(dc.user_id()) + .bind(dc.access_token()) + .bind(dc.refresh_token()) + .bind(dc.verification_uri()) + .bind(dc.verification_uri_complete()) + .bind(dc.expires_at()) + .bind(dc.poll_interval_secs()) + .bind(dc.last_poll_at()) + .bind(dc.created_at()) + .bind(dc.authorized_at()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to create device code: {}", e), + ) + })?; + + Ok(dc) + } + + async fn get_by_device_code(&self, device_code: &str) -> Result { + let row = sqlx::query( + r#" + SELECT id, device_code, user_code, client_name, scopes, + status::text AS status, user_id, access_token, refresh_token, + verification_uri, verification_uri_complete, + expires_at, poll_interval_secs, last_poll_at, + created_at, authorized_at + FROM auth.device_codes + WHERE device_code = $1 + "#, + ) + .bind(device_code) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => DomainError::new( + ErrorKind::NotFound, + "DeviceCode", + "Device code not found", + ), + _ => DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to fetch device code: {}", e), + ), + })?; + + Self::map_row(&row) + } + + async fn get_pending_by_user_code(&self, user_code: &str) -> Result { + let row = sqlx::query( + r#" + SELECT id, device_code, user_code, client_name, scopes, + status::text AS status, user_id, access_token, refresh_token, + verification_uri, verification_uri_complete, + expires_at, poll_interval_secs, last_poll_at, + created_at, authorized_at + FROM auth.device_codes + WHERE user_code = $1 + AND status = 'pending' + AND expires_at > NOW() + "#, + ) + .bind(user_code) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => DomainError::new( + ErrorKind::NotFound, + "DeviceCode", + "User code not found or expired", + ), + _ => DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to fetch by user code: {}", e), + ), + })?; + + Self::map_row(&row) + } + + async fn update_device_code(&self, dc: DeviceCode) -> Result<(), DomainError> { + sqlx::query( + r#" + UPDATE auth.device_codes SET + status = $2::auth.device_code_status, + user_id = $3, + access_token = $4, + refresh_token = $5, + last_poll_at = $6, + authorized_at = $7 + WHERE id = $1 + "#, + ) + .bind(dc.id()) + .bind(dc.status().as_str()) + .bind(dc.user_id()) + .bind(dc.access_token()) + .bind(dc.refresh_token()) + .bind(dc.last_poll_at()) + .bind(dc.authorized_at()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to update device code: {}", e), + ) + })?; + + Ok(()) + } + + async fn delete_expired(&self) -> Result { + let result = sqlx::query( + r#" + DELETE FROM auth.device_codes + WHERE expires_at < NOW() + AND status IN ('pending', 'expired') + "#, + ) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to delete expired device codes: {}", e), + ) + })?; + + Ok(result.rows_affected()) + } + + async fn list_by_user(&self, user_id: &str) -> Result, DomainError> { + let rows = sqlx::query( + r#" + SELECT id, device_code, user_code, client_name, scopes, + status::text AS status, user_id, access_token, refresh_token, + verification_uri, verification_uri_complete, + expires_at, poll_interval_secs, last_poll_at, + created_at, authorized_at + FROM auth.device_codes + WHERE user_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to list device codes: {}", e), + ) + })?; + + rows.iter().map(Self::map_row).collect() + } + + async fn delete_by_id(&self, id: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM auth.device_codes WHERE id = $1") + .bind(id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::new( + ErrorKind::DatabaseError, + "DeviceCode", + format!("Failed to delete device code: {}", e), + ) + })?; + + Ok(()) + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 408749d2..a697b329 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -4,6 +4,7 @@ mod calendar_pg_repository; mod contact_group_pg_repository; mod contact_persistence_dto; mod contact_pg_repository; +mod device_code_pg_repository; mod favorites_pg_repository; mod recent_items_pg_repository; mod session_pg_repository; @@ -24,6 +25,7 @@ pub use calendar_pg_repository::CalendarPgRepository; pub use contact_group_pg_repository::ContactGroupPgRepository; pub use contact_persistence_dto::*; pub use contact_pg_repository::ContactPgRepository; +pub use device_code_pg_repository::DeviceCodePgRepository; pub use favorites_pg_repository::FavoritesPgRepository; pub use file_blob_read_repository::FileBlobReadRepository; pub use file_blob_write_repository::FileBlobWriteRepository; diff --git a/src/interfaces/api/handlers/device_auth_handler.rs b/src/interfaces/api/handlers/device_auth_handler.rs new file mode 100644 index 00000000..a93864c9 --- /dev/null +++ b/src/interfaces/api/handlers/device_auth_handler.rs @@ -0,0 +1,227 @@ +//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628). +//! +//! Endpoints: +//! POST /api/auth/device/authorize — Client starts the device flow (public) +//! GET /api/auth/device/verify — Check user_code validity (authenticated) +//! POST /api/auth/device/verify — User approves/denies (authenticated) +//! POST /api/auth/device/token — Client polls for tokens (public) +//! GET /api/auth/device/devices — List user's authorized devices (authenticated) +//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated) + +use axum::{ + Router, + extract::{Json, Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post}, +}; +use std::sync::Arc; + +use crate::application::dtos::device_auth_dto::*; +use crate::application::services::device_auth_service::DeviceAuthService; +use crate::common::di::AppState; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::AuthUser; + +/// Create the device auth router. +/// +/// Public endpoints (no auth middleware): authorize, token +/// Protected endpoints (behind auth middleware): verify (GET+POST), devices +pub fn device_auth_public_routes() -> Router> { + Router::new() + // Client-facing endpoints (no auth needed — the client doesn't have tokens yet) + .route("/authorize", post(device_authorize)) + .route("/token", post(device_token)) +} + +pub fn device_auth_protected_routes() -> Router> { + Router::new() + // User-facing endpoints (require valid session) + .route("/verify", get(device_verify_info)) + .route("/verify", post(device_verify_action)) + .route("/devices", get(list_devices)) + .route("/devices/{id}", delete(revoke_device)) +} + +// ============================================================================ +// POST /api/auth/device/authorize — Client initiates the device flow +// ============================================================================ + +/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }` +/// Server returns: device_code, user_code, verification_uri, etc. +async fn device_authorize( + State(state): State>, + Json(body): Json, +) -> Result { + let device_service = get_device_service(&state)?; + + let response = device_service.initiate(body).await.map_err(|e| { + tracing::error!("Device authorize failed: {}", e); + AppError::from(e) + })?; + + Ok((StatusCode::OK, Json(response))) +} + +// ============================================================================ +// POST /api/auth/device/token — Client polls for tokens +// ============================================================================ + +/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }` +/// Returns tokens on success, or RFC 8628 error codes while pending. +async fn device_token( + State(state): State>, + Json(body): Json, +) -> Result { + let device_service = match get_device_service(&state) { + Ok(svc) => svc, + Err(e) => return Err(e.into_response()), + }; + + // Validate grant_type if provided (RFC compliance) + if !body.grant_type.is_empty() + && body.grant_type != "urn:ietf:params:oauth:grant-type:device_code" + { + let error_body = serde_json::json!({ + "error": "unsupported_grant_type", + "error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code" + }); + return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response()); + } + + match device_service.poll(&body.device_code).await { + Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()), + Err(poll_err) => { + let status = StatusCode::from_u16(poll_err.http_status()) + .unwrap_or(StatusCode::BAD_REQUEST); + let error_body = serde_json::json!({ + "error": poll_err.error_code(), + "error_description": poll_err.description() + }); + Err((status, Json(error_body)).into_response()) + } + } +} + +// ============================================================================ +// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid +// ============================================================================ + +#[derive(serde::Deserialize)] +pub struct VerifyQuery { + #[serde(default)] + pub code: String, +} + +async fn device_verify_info( + State(state): State>, + _auth_user: AuthUser, + Query(query): Query, +) -> Result { + let device_service = get_device_service(&state)?; + + let info = device_service + .verify_user_code(&query.code) + .await + .map_err(|e| { + tracing::warn!("Device verify lookup failed: {}", e); + AppError::from(e) + })?; + + Ok((StatusCode::OK, Json(info))) +} + +// ============================================================================ +// POST /api/auth/device/verify — User approves or denies +// ============================================================================ + +async fn device_verify_action( + State(state): State>, + auth_user: AuthUser, + Json(body): Json, +) -> Result { + let device_service = get_device_service(&state)?; + + match body.action.to_lowercase().as_str() { + "approve" | "allow" | "accept" => { + device_service + .approve(&body.user_code, &auth_user.id) + .await + .map_err(|e| { + tracing::error!("Device approve failed: {}", e); + AppError::from(e) + })?; + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "status": "approved" })), + )) + } + "deny" | "reject" | "cancel" => { + device_service.deny(&body.user_code).await.map_err(|e| { + tracing::error!("Device deny failed: {}", e); + AppError::from(e) + })?; + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "status": "denied" })), + )) + } + _ => Err(AppError::bad_request( + "action must be 'approve' or 'deny'", + )), + } +} + +// ============================================================================ +// GET /api/auth/device/devices — List user's authorized devices +// ============================================================================ + +async fn list_devices( + State(state): State>, + auth_user: AuthUser, +) -> Result { + let device_service = get_device_service(&state)?; + + let devices = device_service + .list_user_devices(&auth_user.id) + .await + .map_err(|e| { + tracing::error!("List devices failed: {}", e); + AppError::from(e) + })?; + + Ok((StatusCode::OK, Json(devices))) +} + +// ============================================================================ +// DELETE /api/auth/device/devices/{id} — Revoke a device authorization +// ============================================================================ + +async fn revoke_device( + State(state): State>, + auth_user: AuthUser, + Path(device_id): Path, +) -> Result { + let device_service = get_device_service(&state)?; + + device_service + .revoke_device(&device_id, &auth_user.id) + .await + .map_err(|e| { + tracing::error!("Revoke device failed: {}", e); + AppError::from(e) + })?; + + Ok(StatusCode::NO_CONTENT) +} + +// ============================================================================ +// Helper +// ============================================================================ + +fn get_device_service(state: &AppState) -> Result<&Arc, AppError> { + state + .device_auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Device authorization service not configured")) +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index c0abb3d4..45fe37dd 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -1,6 +1,7 @@ pub mod admin_handler; pub mod auth_handler; pub mod batch_handler; +pub mod device_auth_handler; pub mod caldav_handler; pub mod carddav_handler; pub mod chunked_upload_handler; diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index 87d061e7..0b3a68fb 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -24,6 +24,7 @@ pub fn create_web_routes() -> Router> { .route("/login", get(serve_login_page)) .route("/profile", get(serve_profile_page)) .route("/admin", get(serve_admin_page)) + .route("/device", get(serve_device_verify_page)) // Serve static files with compression + cache headers .fallback_service(static_service) .layer(CompressionLayer::new().br(true).gzip(true)) @@ -47,3 +48,8 @@ async fn serve_profile_page() -> Html<&'static str> { async fn serve_admin_page() -> Html<&'static str> { Html(include_str!("../../../static/admin.html")) } + +/// Serve the device verification page (RFC 8628 Device Authorization Grant) +async fn serve_device_verify_page() -> Html<&'static str> { + Html(include_str!("../../../static/device-verify.html")) +} diff --git a/src/main.rs b/src/main.rs index 7d363037..46dad9f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -163,10 +163,23 @@ async fn main() -> Result<(), Box> { } if config.features.enable_auth { use interfaces::api::handlers::auth_handler::auth_routes; + use oxicloud::interfaces::api::handlers::device_auth_handler; use oxicloud::interfaces::middleware::auth::auth_middleware; let auth_router = auth_routes().with_state(app_state.clone()); + // Device Authorization Grant (RFC 8628) + // Public endpoints: /api/auth/device/authorize + /api/auth/device/token + let device_public = device_auth_handler::device_auth_public_routes() + .with_state(app_state.clone()); + // Protected endpoints: /api/auth/device/verify, /api/auth/device/devices + let device_protected = device_auth_handler::device_auth_protected_routes() + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )) + .with_state(app_state.clone()); + // Protected API routes — require valid JWT token let protected_api = api_routes.layer(axum::middleware::from_fn_with_state( app_state.clone(), @@ -190,6 +203,10 @@ async fn main() -> Result<(), Box> { app = Router::new() // Auth endpoints (login, register, refresh) are public — no middleware .nest("/api/auth", auth_router) + // Device Auth Grant public endpoints (authorize + token polling) + .nest("/api/auth/device", device_public) + // Device Auth Grant protected endpoints (verify + device management) + .nest("/api/auth/device", device_protected) // Public API routes (share access, i18n) — no auth required .nest("/api", public_api_routes) // All other API routes are protected by auth middleware diff --git a/static/device-verify.html b/static/device-verify.html new file mode 100644 index 00000000..95062da9 --- /dev/null +++ b/static/device-verify.html @@ -0,0 +1,264 @@ + + + + + + OxiCloud — Authorize Device + + + +
+ + + +
+

Authorize Device

+

Enter the code displayed on your WebDAV/CalDAV client to grant access.

+ + +
+ +
+
+ Client + — +
+
+ Scopes + — +
+
+ + +
+ + +
+ Device authorized successfully! You can close this page. +
+
+ Authorization denied. The client will not receive access. +
+
+
+ + + +