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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user