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
+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;