Files
Oxicloud/src/domain/entities/device_code.rs
T

264 lines
7.4 KiB
Rust
Raw Normal View History

2026-03-03 01:49:18 +01:00
//! 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 parse(s: &str) -> Option<Self> {
2026-03-03 01:49:18 +01:00
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
#[allow(clippy::too_many_arguments)]
2026-03-03 01:49:18 +01:00
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;
}
}