mecha10-auth 0.6.3

Authentication services for Mecha10 - shared between CLI and launcher
Documentation
//! Authentication service for device code flow
//!
//! Handles the OAuth 2.0 Device Authorization Grant (RFC 8628) flow
//! for authenticating users via browser.

use crate::types::{AuthError, Credentials, DeviceCodeResponse, DeviceCodeStatus};
use crate::DEFAULT_AUTH_URL;
use chrono::Utc;
use std::time::Duration;

/// Service for handling authentication flows
pub struct AuthService {
    /// Base URL for auth API
    auth_url: String,
    /// HTTP client
    client: reqwest::Client,
}

impl AuthService {
    /// Create a new AuthService with default auth URL
    pub fn new() -> Self {
        Self::with_auth_url(DEFAULT_AUTH_URL.to_string())
    }

    /// Create a new AuthService with custom auth URL
    pub fn with_auth_url(auth_url: String) -> Self {
        let client = reqwest::Client::builder()
            .timeout(Duration::from_secs(30))
            .build()
            .expect("Failed to create HTTP client");

        Self { auth_url, client }
    }

    /// Get the auth URL
    pub fn auth_url(&self) -> &str {
        &self.auth_url
    }

    /// Request a new device code for authentication
    ///
    /// Returns device code info including user_code and verification_uri
    pub async fn request_device_code(&self) -> Result<DeviceCodeResponse, AuthError> {
        let url = format!("{}/device/code", self.auth_url);

        let response = self
            .client
            .post(&url)
            .header("Content-Type", "application/json")
            .send()
            .await
            .map_err(|e| AuthError::NetworkError { message: e.to_string() })?;

        let status = response.status();

        if status.is_success() {
            response.json().await.map_err(|e| AuthError::ServerError {
                message: format!("Failed to parse response: {}", e),
                status_code: Some(status.as_u16()),
            })
        } else if status.as_u16() == 429 {
            let retry_after = response
                .headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .and_then(|s| s.parse().ok());

            Err(AuthError::RateLimited { retry_after })
        } else {
            let message = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());

            Err(AuthError::ServerError {
                message,
                status_code: Some(status.as_u16()),
            })
        }
    }

    /// Poll for device code status
    ///
    /// Should be called at the interval specified in DeviceCodeResponse
    pub async fn poll_device_code(&self, device_code: &str) -> Result<DeviceCodeStatus, AuthError> {
        let url = format!("{}/device/code/{}/status", self.auth_url, device_code);

        let response = self
            .client
            .get(&url)
            .send()
            .await
            .map_err(|e| AuthError::NetworkError { message: e.to_string() })?;

        let status = response.status();

        if status.is_success() {
            response.json().await.map_err(|e| AuthError::ServerError {
                message: format!("Failed to parse status: {}", e),
                status_code: Some(status.as_u16()),
            })
        } else if status.as_u16() == 429 {
            let retry_after = response
                .headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .and_then(|s| s.parse().ok());

            Err(AuthError::RateLimited { retry_after })
        } else if status.as_u16() == 404 {
            // Device code not found or expired
            Err(AuthError::ExpiredCode)
        } else {
            let message = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());

            Err(AuthError::ServerError {
                message,
                status_code: Some(status.as_u16()),
            })
        }
    }

    /// Run the complete device code flow
    ///
    /// This method:
    /// 1. Requests a device code
    /// 2. Returns immediately with device code info (caller should display to user)
    /// 3. Polls until authorized, denied, or expired
    ///
    /// Returns credentials on success
    pub async fn run_device_code_flow<F>(&self, on_device_code: F) -> Result<Credentials, AuthError>
    where
        F: FnOnce(&DeviceCodeResponse),
    {
        // Step 1: Request device code
        let device_code_response = self.request_device_code().await?;
        let poll_interval = Duration::from_secs(device_code_response.interval as u64);
        let expires_at = std::time::Instant::now() + Duration::from_secs(device_code_response.expires_in as u64);

        // Step 2: Notify caller with device code info
        on_device_code(&device_code_response);

        // Step 3: Poll until terminal state
        loop {
            // Check if expired
            if std::time::Instant::now() > expires_at {
                return Err(AuthError::ExpiredCode);
            }

            // Wait before polling
            tokio::time::sleep(poll_interval).await;

            // Poll for status
            match self.poll_device_code(&device_code_response.device_code).await {
                Ok(DeviceCodeStatus::Pending) => {
                    // Continue polling
                    continue;
                }
                Ok(DeviceCodeStatus::Authorized {
                    api_key,
                    user_id,
                    email,
                    name,
                }) => {
                    return Ok(Credentials {
                        api_key,
                        user_id,
                        email,
                        name,
                        authenticated_at: Utc::now(),
                        auth_url: self.auth_url.clone(),
                    });
                }
                Ok(DeviceCodeStatus::Denied) => {
                    return Err(AuthError::AccessDenied);
                }
                Ok(DeviceCodeStatus::Expired) => {
                    return Err(AuthError::ExpiredCode);
                }
                Err(AuthError::RateLimited { retry_after }) => {
                    // Back off and retry
                    let wait_time = retry_after.map(|s| s as u64).unwrap_or(10);
                    tokio::time::sleep(Duration::from_secs(wait_time)).await;
                    continue;
                }
                Err(e) => {
                    return Err(e);
                }
            }
        }
    }
}

impl Default for AuthService {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_auth_service_creation() {
        let service = AuthService::new();
        assert_eq!(service.auth_url(), DEFAULT_AUTH_URL);
    }

    #[test]
    fn test_custom_auth_url() {
        let custom_url = "https://custom.auth.example.com";
        let service = AuthService::with_auth_url(custom_url.to_string());
        assert_eq!(service.auth_url(), custom_url);
    }
}