feat: implement OAuth 2.0 Device Authorization Grant (RFC 8628) for WebDAV/CalDAV/CardDAV
Adds full Device Authorization Grant flow so DAV clients (rclone, etc.) can authenticate without browser-based OAuth redirects. New files: - Domain entity: DeviceCode with status lifecycle (pending/authorized/denied/expired) - Port: DeviceCodeStoragePort trait (7 async methods) - DTOs: request/response types for all device auth endpoints - Repository: DeviceCodePgRepository (PostgreSQL implementation) - Service: DeviceAuthService (initiate, verify, approve, deny, poll, cleanup) - Handler: 6 HTTP endpoints (2 public + 4 protected) - Static: device-verify.html verification page served at /device Flow: 1. Client POST /api/auth/device/authorize → device_code + user_code 2. User opens /device?code=XXXX in browser, approves 3. Client polls POST /api/auth/device/token → receives JWT tokens 4. Client uses Bearer token with existing WebDAV/CalDAV/CardDAV middleware Schema: auth.device_codes table + device_code_status enum added to schema.sql Closes #152
This commit is contained in:
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u64, DomainError>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<DeviceCode, DomainError>;
|
||||
|
||||
/// 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<DeviceCode, DomainError>;
|
||||
|
||||
/// 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<DeviceCode, DomainError>;
|
||||
|
||||
/// 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<u64, DomainError>;
|
||||
|
||||
/// List authorized device codes for a user (for UI management)
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<DeviceCode>, DomainError>;
|
||||
|
||||
/// Delete a specific device code by ID (revocation)
|
||||
async fn delete_by_id(&self, id: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
@@ -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<dyn DeviceCodeStoragePort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
/// Base URL of the server (e.g. "https://cloud.example.com")
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl DeviceAuthService {
|
||||
pub fn new(
|
||||
device_code_storage: Arc<dyn DeviceCodeStoragePort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
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<DeviceAuthorizeResponseDto, DomainError> {
|
||||
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<DeviceVerifyInfoDto, DomainError> {
|
||||
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<DeviceTokenSuccessDto, DevicePollError> {
|
||||
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<u64, DomainError> {
|
||||
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<Vec<DeviceInfoDto>, 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<String>,
|
||||
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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<dyn crate::application::ports::auth_ports::UserStoragePort> =
|
||||
Arc::new(crate::infrastructure::repositories::UserPgRepository::new(
|
||||
pool.clone(),
|
||||
));
|
||||
let session_repo: Arc<dyn crate::application::ports::auth_ports::SessionStoragePort> =
|
||||
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<Arc<crate::application::services::wopi_lock_service::WopiLockService>>,
|
||||
pub wopi_discovery_service:
|
||||
Option<Arc<crate::infrastructure::services::wopi_discovery_service::WopiDiscoveryService>>,
|
||||
pub device_auth_service:
|
||||
Option<Arc<crate::application::services::device_auth_service::DeviceAuthService>>,
|
||||
}
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
|
||||
@@ -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<Self> {
|
||||
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<String>,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
verification_uri: String,
|
||||
verification_uri_complete: Option<String>,
|
||||
expires_at: DateTime<Utc>,
|
||||
poll_interval_secs: i32,
|
||||
last_poll_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
authorized_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
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<String>,
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
verification_uri: String,
|
||||
verification_uri_complete: Option<String>,
|
||||
expires_at: DateTime<Utc>,
|
||||
poll_interval_secs: i32,
|
||||
last_poll_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
authorized_at: Option<DateTime<Utc>>,
|
||||
) -> 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<Utc> {
|
||||
self.expires_at
|
||||
}
|
||||
|
||||
pub fn poll_interval_secs(&self) -> i32 {
|
||||
self.poll_interval_secs
|
||||
}
|
||||
|
||||
pub fn last_poll_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.last_poll_at
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
}
|
||||
|
||||
pub fn authorized_at(&self) -> Option<DateTime<Utc>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<PgPool>,
|
||||
}
|
||||
|
||||
impl DeviceCodePgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_row(row: &sqlx::postgres::PgRow) -> Result<DeviceCode, DomainError> {
|
||||
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::<i32, _>("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<DeviceCode, DomainError> {
|
||||
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<DeviceCode, DomainError> {
|
||||
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<DeviceCode, DomainError> {
|
||||
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<u64, DomainError> {
|
||||
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<Vec<DeviceCode>, 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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Arc<AppState>> {
|
||||
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<Arc<AppState>> {
|
||||
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<Arc<AppState>>,
|
||||
Json(body): Json<DeviceAuthorizeRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
Json(body): Json<DeviceTokenRequestDto>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
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<Arc<AppState>>,
|
||||
_auth_user: AuthUser,
|
||||
Query(query): Query<VerifyQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<DeviceVerifyRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(device_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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<DeviceAuthService>, AppError> {
|
||||
state
|
||||
.device_auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Device authorization service not configured"))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ pub fn create_web_routes() -> Router<Arc<AppState>> {
|
||||
.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"))
|
||||
}
|
||||
|
||||
+17
@@ -163,10 +163,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
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<dyn std::error::Error>> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user