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:
Dionisio
2026-03-01 11:54:43 +01:00
parent 2421724b80
commit 48d853360e
18 changed files with 1789 additions and 2 deletions
+96
View File
@@ -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
View File
@@ -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;
+29
View File
@@ -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)
}
+1
View File
@@ -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;
+30
View File
@@ -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().
+267
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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;
+6
View File
@@ -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
View File
@@ -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