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