Skip to main content

mecha10_auth/
service.rs

1//! Authentication service for device code flow
2//!
3//! Handles the OAuth 2.0 Device Authorization Grant (RFC 8628) flow
4//! for authenticating users via browser.
5
6use crate::types::{AuthError, Credentials, DeviceCodeResponse, DeviceCodeStatus};
7use crate::DEFAULT_AUTH_URL;
8use chrono::Utc;
9use std::time::Duration;
10
11/// Service for handling authentication flows
12pub struct AuthService {
13    /// Base URL for auth API
14    auth_url: String,
15    /// HTTP client
16    client: reqwest::Client,
17}
18
19impl AuthService {
20    /// Create a new AuthService with default auth URL
21    pub fn new() -> Self {
22        Self::with_auth_url(DEFAULT_AUTH_URL.to_string())
23    }
24
25    /// Create a new AuthService with custom auth URL
26    pub fn with_auth_url(auth_url: String) -> Self {
27        let client = reqwest::Client::builder()
28            .timeout(Duration::from_secs(30))
29            .build()
30            .expect("Failed to create HTTP client");
31
32        Self { auth_url, client }
33    }
34
35    /// Get the auth URL
36    pub fn auth_url(&self) -> &str {
37        &self.auth_url
38    }
39
40    /// Request a new device code for authentication
41    ///
42    /// Returns device code info including user_code and verification_uri
43    pub async fn request_device_code(&self) -> Result<DeviceCodeResponse, AuthError> {
44        let url = format!("{}/device/code", self.auth_url);
45
46        let response = self
47            .client
48            .post(&url)
49            .header("Content-Type", "application/json")
50            .send()
51            .await
52            .map_err(|e| AuthError::NetworkError { message: e.to_string() })?;
53
54        let status = response.status();
55
56        if status.is_success() {
57            response.json().await.map_err(|e| AuthError::ServerError {
58                message: format!("Failed to parse response: {}", e),
59                status_code: Some(status.as_u16()),
60            })
61        } else if status.as_u16() == 429 {
62            let retry_after = response
63                .headers()
64                .get("retry-after")
65                .and_then(|v| v.to_str().ok())
66                .and_then(|s| s.parse().ok());
67
68            Err(AuthError::RateLimited { retry_after })
69        } else {
70            let message = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
71
72            Err(AuthError::ServerError {
73                message,
74                status_code: Some(status.as_u16()),
75            })
76        }
77    }
78
79    /// Poll for device code status
80    ///
81    /// Should be called at the interval specified in DeviceCodeResponse
82    pub async fn poll_device_code(&self, device_code: &str) -> Result<DeviceCodeStatus, AuthError> {
83        let url = format!("{}/device/code/{}/status", self.auth_url, device_code);
84
85        let response = self
86            .client
87            .get(&url)
88            .send()
89            .await
90            .map_err(|e| AuthError::NetworkError { message: e.to_string() })?;
91
92        let status = response.status();
93
94        if status.is_success() {
95            response.json().await.map_err(|e| AuthError::ServerError {
96                message: format!("Failed to parse status: {}", e),
97                status_code: Some(status.as_u16()),
98            })
99        } else if status.as_u16() == 429 {
100            let retry_after = response
101                .headers()
102                .get("retry-after")
103                .and_then(|v| v.to_str().ok())
104                .and_then(|s| s.parse().ok());
105
106            Err(AuthError::RateLimited { retry_after })
107        } else if status.as_u16() == 404 {
108            Err(AuthError::ExpiredCode)
109        } else {
110            let message = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
111
112            Err(AuthError::ServerError {
113                message,
114                status_code: Some(status.as_u16()),
115            })
116        }
117    }
118
119    /// Run the complete device code flow
120    ///
121    /// This method:
122    /// 1. Requests a device code
123    /// 2. Returns immediately with device code info (caller should display to user)
124    /// 3. Polls until authorized, denied, or expired
125    ///
126    /// Returns credentials on success
127    pub async fn run_device_code_flow<F>(&self, on_device_code: F) -> Result<Credentials, AuthError>
128    where
129        F: FnOnce(&DeviceCodeResponse),
130    {
131        let device_code_response = self.request_device_code().await?;
132        let poll_interval = Duration::from_secs(device_code_response.interval as u64);
133        let expires_at = std::time::Instant::now() + Duration::from_secs(device_code_response.expires_in as u64);
134
135        on_device_code(&device_code_response);
136
137        loop {
138            if std::time::Instant::now() > expires_at {
139                return Err(AuthError::ExpiredCode);
140            }
141
142            tokio::time::sleep(poll_interval).await;
143
144            match self.poll_device_code(&device_code_response.device_code).await {
145                Ok(DeviceCodeStatus::Pending) => {
146                    continue;
147                }
148                Ok(DeviceCodeStatus::Authorized {
149                    api_key,
150                    user_id,
151                    email,
152                    name,
153                }) => {
154                    return Ok(Credentials {
155                        api_key,
156                        user_id,
157                        email,
158                        name,
159                        authenticated_at: Utc::now(),
160                        auth_url: self.auth_url.clone(),
161                    });
162                }
163                Ok(DeviceCodeStatus::Denied) => {
164                    return Err(AuthError::AccessDenied);
165                }
166                Ok(DeviceCodeStatus::Expired) => {
167                    return Err(AuthError::ExpiredCode);
168                }
169                Err(AuthError::RateLimited { retry_after }) => {
170                    let wait_time = retry_after.map(|s| s as u64).unwrap_or(10);
171                    tokio::time::sleep(Duration::from_secs(wait_time)).await;
172                    continue;
173                }
174                Err(e) => {
175                    return Err(e);
176                }
177            }
178        }
179    }
180}
181
182impl Default for AuthService {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_auth_service_creation() {
194        let service = AuthService::new();
195        assert_eq!(service.auth_url(), DEFAULT_AUTH_URL);
196    }
197
198    #[test]
199    fn test_custom_auth_url() {
200        let custom_url = "https://custom.auth.example.com";
201        let service = AuthService::with_auth_url(custom_url.to_string());
202        assert_eq!(service.auth_url(), custom_url);
203    }
204}