436 lines
16 KiB
Rust
436 lines
16 KiB
Rust
//! 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::SessionStoragePort;
|
|
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;
|
|
|
|
/// 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)
|
|
}
|