Files
Oxicloud/src/interfaces/api/handlers/device_auth_handler.rs
T
zjean 18518bedaf fix: resolve clippy warnings and rustfmt issues for CI compliance
Fix all clippy lints (collapsible if, clone on Copy, needless borrow,
redundant bindings, unused params) and apply rustfmt across the codebase.
Update test mocks to match Uuid-based trait signatures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:34:07 +01:00

230 lines
8.2 KiB
Rust
Executable File

//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628).
//!
//! Endpoints:
//! POST /api/auth/device/authorize — Client starts the device flow (public)
//! GET /api/auth/device/verify — Check user_code validity (authenticated)
//! POST /api/auth/device/verify — User approves/denies (authenticated)
//! POST /api/auth/device/token — Client polls for tokens (public)
//! GET /api/auth/device/devices — List user's authorized devices (authenticated)
//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated)
use axum::{
Router,
extract::{Json, Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post},
};
use std::sync::Arc;
use uuid::Uuid;
use crate::application::dtos::device_auth_dto::*;
use crate::application::services::device_auth_service::DeviceAuthService;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::AuthUser;
/// Create the device auth router.
///
/// Public endpoints (no auth middleware): authorize, token
/// Protected endpoints (behind auth middleware): verify (GET+POST), devices
pub fn device_auth_public_routes() -> Router<Arc<AppState>> {
Router::new()
// Client-facing endpoints (no auth needed — the client doesn't have tokens yet)
.route("/authorize", post(device_authorize))
.route("/token", post(device_token))
}
pub fn device_auth_protected_routes() -> Router<Arc<AppState>> {
Router::new()
// User-facing endpoints (require valid session)
.route("/verify", get(device_verify_info))
.route("/verify", post(device_verify_action))
.route("/devices", get(list_devices))
.route("/devices/{id}", delete(revoke_device))
}
// ============================================================================
// POST /api/auth/device/authorize — Client initiates the device flow
// ============================================================================
/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }`
/// Server returns: device_code, user_code, verification_uri, etc.
async fn device_authorize(
State(state): State<Arc<AppState>>,
Json(body): Json<DeviceAuthorizeRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let device_service = get_device_service(&state)?;
let response = device_service.initiate(body).await.map_err(|e| {
tracing::error!("Device authorize failed: {}", e);
AppError::from(e)
})?;
Ok((StatusCode::OK, Json(response)))
}
// ============================================================================
// POST /api/auth/device/token — Client polls for tokens
// ============================================================================
/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }`
/// Returns tokens on success, or RFC 8628 error codes while pending.
async fn device_token(
State(state): State<Arc<AppState>>,
Json(body): Json<DeviceTokenRequestDto>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let device_service = match get_device_service(&state) {
Ok(svc) => svc,
Err(e) => return Err(e.into_response()),
};
// Validate grant_type if provided (RFC compliance)
if !body.grant_type.is_empty()
&& body.grant_type != "urn:ietf:params:oauth:grant-type:device_code"
{
let error_body = serde_json::json!({
"error": "unsupported_grant_type",
"error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code"
});
return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response());
}
match device_service.poll(&body.device_code).await {
Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()),
Err(poll_err) => {
let status =
StatusCode::from_u16(poll_err.http_status()).unwrap_or(StatusCode::BAD_REQUEST);
let error_body = serde_json::json!({
"error": poll_err.error_code(),
"error_description": poll_err.description()
});
Err((status, Json(error_body)).into_response())
}
}
}
// ============================================================================
// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid
// ============================================================================
#[derive(serde::Deserialize)]
pub struct VerifyQuery {
#[serde(default)]
pub code: String,
}
async fn device_verify_info(
State(state): State<Arc<AppState>>,
_auth_user: AuthUser,
Query(query): Query<VerifyQuery>,
) -> Result<impl IntoResponse, AppError> {
let device_service = get_device_service(&state)?;
let info = device_service
.verify_user_code(&query.code)
.await
.map_err(|e| {
tracing::warn!("Device verify lookup failed: {}", e);
AppError::from(e)
})?;
Ok((StatusCode::OK, Json(info)))
}
// ============================================================================
// POST /api/auth/device/verify — User approves or denies
// ============================================================================
async fn device_verify_action(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Json(body): Json<DeviceVerifyRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let device_service = get_device_service(&state)?;
match body.action.to_lowercase().as_str() {
"approve" | "allow" | "accept" => {
device_service
.approve(&body.user_code, auth_user.id)
.await
.map_err(|e| {
tracing::error!("Device approve failed: {}", e);
AppError::from(e)
})?;
Ok((
StatusCode::OK,
Json(serde_json::json!({ "status": "approved" })),
))
}
"deny" | "reject" | "cancel" => {
device_service.deny(&body.user_code).await.map_err(|e| {
tracing::error!("Device deny failed: {}", e);
AppError::from(e)
})?;
Ok((
StatusCode::OK,
Json(serde_json::json!({ "status": "denied" })),
))
}
_ => Err(AppError::bad_request("action must be 'approve' or 'deny'")),
}
}
// ============================================================================
// GET /api/auth/device/devices — List user's authorized devices
// ============================================================================
async fn list_devices(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, AppError> {
let device_service = get_device_service(&state)?;
let devices = device_service
.list_user_devices(auth_user.id)
.await
.map_err(|e| {
tracing::error!("List devices failed: {}", e);
AppError::from(e)
})?;
Ok((StatusCode::OK, Json(devices)))
}
// ============================================================================
// DELETE /api/auth/device/devices/{id} — Revoke a device authorization
// ============================================================================
async fn revoke_device(
State(state): State<Arc<AppState>>,
auth_user: AuthUser,
Path(device_id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let device_service = get_device_service(&state)?;
let device_id =
Uuid::parse_str(&device_id).map_err(|_| AppError::bad_request("Invalid device ID"))?;
device_service
.revoke_device(device_id, auth_user.id)
.await
.map_err(|e| {
tracing::error!("Revoke device failed: {}", e);
AppError::from(e)
})?;
Ok(StatusCode::NO_CONTENT)
}
// ============================================================================
// Helper
// ============================================================================
fn get_device_service(state: &AppState) -> Result<&Arc<DeviceAuthService>, AppError> {
state
.device_auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Device authorization service not configured"))
}