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",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 23:55:08 +01:00
|
|
|
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
|
2026-03-04 23:55:08 +01:00
|
|
|
#[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;
|
|
|
|
|
}
|
|
|
|
|
}
|