use crate::types::{AuthError, Credentials, DeviceCodeResponse, DeviceCodeStatus};
use crate::DEFAULT_AUTH_URL;
use chrono::Utc;
use std::time::Duration;
pub struct AuthService {
auth_url: String,
client: reqwest::Client,
}
impl AuthService {
pub fn new() -> Self {
Self::with_auth_url(DEFAULT_AUTH_URL.to_string())
}
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 }
}
pub fn auth_url(&self) -> &str {
&self.auth_url
}
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()),
})
}
}
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 {
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()),
})
}
}
pub async fn run_device_code_flow<F>(&self, on_device_code: F) -> Result<Credentials, AuthError>
where
F: FnOnce(&DeviceCodeResponse),
{
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);
on_device_code(&device_code_response);
loop {
if std::time::Instant::now() > expires_at {
return Err(AuthError::ExpiredCode);
}
tokio::time::sleep(poll_interval).await;
match self.poll_device_code(&device_code_response.device_code).await {
Ok(DeviceCodeStatus::Pending) => {
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 }) => {
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);
}
}