1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13use thiserror::Error;
14
15#[derive(Error, Debug)]
16pub enum OAuthError {
17 #[error("HTTP request failed: {0}")]
18 HttpError(String),
19
20 #[error("Invalid response: {0}")]
21 InvalidResponse(String),
22
23 #[error("Authentication timeout")]
24 Timeout,
25
26 #[error("User denied authorization")]
27 Denied,
28
29 #[error("Token expired")]
30 TokenExpired,
31
32 #[error("Unsupported provider: {0}")]
33 UnsupportedProvider(String),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum OAuthProvider {
40 Anthropic,
41 OpenAI,
42 GitHubCopilot,
43 OpenRouter,
44 Google,
45 Azure,
46}
47
48impl std::fmt::Display for OAuthProvider {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 OAuthProvider::Anthropic => write!(f, "anthropic"),
52 OAuthProvider::OpenAI => write!(f, "openai"),
53 OAuthProvider::GitHubCopilot => write!(f, "github-copilot"),
54 OAuthProvider::OpenRouter => write!(f, "openrouter"),
55 OAuthProvider::Google => write!(f, "google"),
56 OAuthProvider::Azure => write!(f, "azure"),
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct OAuthToken {
64 pub access_token: String,
65 pub refresh_token: Option<String>,
66 pub token_type: String,
67 pub expires_at: Option<u64>,
68 pub scope: Option<String>,
69}
70
71impl OAuthToken {
72 pub fn is_expired(&self) -> bool {
74 if let Some(expires_at) = self.expires_at {
75 let now = SystemTime::now()
76 .duration_since(UNIX_EPOCH)
77 .unwrap()
78 .as_secs();
79 now >= expires_at
80 } else {
81 false
82 }
83 }
84
85 pub fn expires_soon(&self, within: Duration) -> bool {
87 if let Some(expires_at) = self.expires_at {
88 let now = SystemTime::now()
89 .duration_since(UNIX_EPOCH)
90 .unwrap()
91 .as_secs();
92 let threshold = within.as_secs();
93 expires_at.saturating_sub(now) <= threshold
94 } else {
95 false
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct DeviceCodeResponse {
103 pub device_code: String,
104 pub user_code: String,
105 pub verification_uri: String,
106 #[serde(alias = "verification_uri_complete")]
107 pub verification_uri_complete: Option<String>,
108 pub expires_in: u64,
109 pub interval: u64,
110}
111
112#[derive(Debug, Clone)]
114pub struct OAuthConfig {
115 pub client_id: String,
116 pub client_secret: Option<String>,
117 pub auth_url: String,
118 pub token_url: String,
119 pub redirect_uri: Option<String>,
120 pub scopes: Vec<String>,
121}
122
123pub struct OAuthClient {
125 config: OAuthConfig,
126 provider: OAuthProvider,
127 http_client: reqwest::Client,
128}
129
130impl OAuthClient {
131 pub fn new(provider: OAuthProvider, config: OAuthConfig) -> Self {
133 Self {
134 config,
135 provider,
136 http_client: reqwest::Client::new(),
137 }
138 }
139
140 pub fn with_defaults(provider: OAuthProvider) -> Result<Self, OAuthError> {
142 let config = match provider {
143 OAuthProvider::Anthropic => OAuthConfig {
144 client_id: "pi-cli".to_string(),
145 client_secret: None,
146 auth_url: "https://claude.ai/oauth/authorize".to_string(),
147 token_url: "https://claude.ai/oauth/token".to_string(),
148 redirect_uri: Some("http://localhost:8080/callback".to_string()),
149 scopes: vec!["read".to_string(), "write".to_string()],
150 },
151 OAuthProvider::OpenAI => OAuthConfig {
152 client_id: "pi-cli".to_string(),
153 client_secret: None,
154 auth_url: "https://auth.openai.com/authorize".to_string(),
155 token_url: "https://auth.openai.com/oauth/token".to_string(),
156 redirect_uri: Some("http://localhost:8080/callback".to_string()),
157 scopes: vec!["openai.public".to_string()],
158 },
159 OAuthProvider::GitHubCopilot => OAuthConfig {
160 client_id: "Iv1.b507a08c30306e6a".to_string(), client_secret: None,
162 auth_url: "https://github.com/login/oauth/authorize".to_string(),
163 token_url: "https://github.com/login/oauth/access_token".to_string(),
164 redirect_uri: Some("http://localhost:8080/callback".to_string()),
165 scopes: vec![],
166 },
167 OAuthProvider::OpenRouter => OAuthConfig {
168 client_id: "pi-cli".to_string(),
169 client_secret: None,
170 auth_url: "https://openrouter.ai/auth/authorize".to_string(),
171 token_url: "https://openrouter.ai/auth/token".to_string(),
172 redirect_uri: Some("http://localhost:8080/callback".to_string()),
173 scopes: vec![],
174 },
175 OAuthProvider::Google => OAuthConfig {
176 client_id: "pi-cli".to_string(),
177 client_secret: None,
178 auth_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
179 token_url: "https://oauth2.googleapis.com/token".to_string(),
180 redirect_uri: Some("http://localhost:8080/callback".to_string()),
181 scopes: vec!["https://www.googleapis.com/auth/generative-language".to_string()],
182 },
183 OAuthProvider::Azure => OAuthConfig {
184 client_id: "pi-cli".to_string(),
185 client_secret: None,
186 auth_url: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
187 .to_string(),
188 token_url: "https://login.microsoftonline.com/common/oauth2/v2.0/token".to_string(),
189 redirect_uri: Some("http://localhost:8080/callback".to_string()),
190 scopes: vec!["https://cognitiveservices.azure.com/.default".to_string()],
191 },
192 };
193
194 Ok(Self::new(provider, config))
195 }
196
197 pub async fn start_device_code_flow(&self) -> Result<DeviceCodeResponse, OAuthError> {
199 let mut params = HashMap::new();
200 params.insert("client_id", self.config.client_id.as_str());
201
202 let scope_str = self.config.scopes.join(" ");
203 if !scope_str.is_empty() {
204 params.insert("scope", scope_str.as_str());
205 }
206
207 let response = self
208 .http_client
209 .post(&self.config.auth_url)
210 .form(¶ms)
211 .send()
212 .await
213 .map_err(|e| OAuthError::HttpError(e.to_string()))?;
214
215 if !response.status().is_success() {
216 let status = response.status();
217 let text = response.text().await.unwrap_or_default();
218 return Err(OAuthError::HttpError(format!("{}: {}", status, text)));
219 }
220
221 let device_code: DeviceCodeResponse = response
222 .json()
223 .await
224 .map_err(|e| OAuthError::InvalidResponse(e.to_string()))?;
225
226 Ok(device_code)
227 }
228
229 pub async fn poll_for_token(
231 &self,
232 device_code: &str,
233 interval: Duration,
234 timeout: Duration,
235 ) -> Result<OAuthToken, OAuthError> {
236 let start = std::time::Instant::now();
237
238 loop {
239 if start.elapsed() > timeout {
240 return Err(OAuthError::Timeout);
241 }
242
243 tokio::time::sleep(interval).await;
244
245 let mut params = HashMap::new();
246 params.insert("client_id", self.config.client_id.as_str());
247 params.insert("device_code", device_code);
248 params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
249
250 let response = self
251 .http_client
252 .post(&self.config.token_url)
253 .form(¶ms)
254 .send()
255 .await
256 .map_err(|e| OAuthError::HttpError(e.to_string()))?;
257
258 let status = response.status();
259 let text = response.text().await.unwrap_or_default();
260
261 if status.is_success() {
262 let token_response: serde_json::Value = serde_json::from_str(&text)
264 .map_err(|e| OAuthError::InvalidResponse(e.to_string()))?;
265
266 let access_token = token_response["access_token"]
267 .as_str()
268 .ok_or_else(|| OAuthError::InvalidResponse("missing access_token".to_string()))?
269 .to_string();
270
271 let refresh_token = token_response["refresh_token"]
272 .as_str()
273 .map(|s| s.to_string());
274
275 let token_type = token_response["token_type"]
276 .as_str()
277 .unwrap_or("Bearer")
278 .to_string();
279
280 let expires_at = token_response["expires_in"].as_u64().map(|expires_in| {
281 SystemTime::now()
282 .duration_since(UNIX_EPOCH)
283 .unwrap()
284 .as_secs()
285 + expires_in
286 });
287
288 let scope = token_response["scope"].as_str().map(|s| s.to_string());
289
290 return Ok(OAuthToken {
291 access_token,
292 refresh_token,
293 token_type,
294 expires_at,
295 scope,
296 });
297 }
298
299 if let Ok(error_response) = serde_json::from_str::<serde_json::Value>(&text) {
301 if let Some(error) = error_response["error"].as_str() {
302 match error {
303 "authorization_pending" => continue,
304 "slow_down" => {
305 tokio::time::sleep(Duration::from_secs(5)).await;
306 continue;
307 }
308 "access_denied" => return Err(OAuthError::Denied),
309 "expired_token" => return Err(OAuthError::Timeout),
310 _ => {
311 return Err(OAuthError::InvalidResponse(format!(
312 "unknown error: {}",
313 error
314 )))
315 }
316 }
317 }
318 }
319 }
320 }
321
322 pub async fn refresh_token(&self, refresh_token: &str) -> Result<OAuthToken, OAuthError> {
324 let mut params = HashMap::new();
325 params.insert("client_id", self.config.client_id.as_str());
326 params.insert("refresh_token", refresh_token);
327 params.insert("grant_type", "refresh_token");
328
329 if let Some(secret) = &self.config.client_secret {
330 params.insert("client_secret", secret.as_str());
331 }
332
333 let response = self
334 .http_client
335 .post(&self.config.token_url)
336 .form(¶ms)
337 .send()
338 .await
339 .map_err(|e| OAuthError::HttpError(e.to_string()))?;
340
341 if !response.status().is_success() {
342 let status = response.status();
343 let text = response.text().await.unwrap_or_default();
344 return Err(OAuthError::HttpError(format!("{}: {}", status, text)));
345 }
346
347 let token_response: serde_json::Value = response
348 .json()
349 .await
350 .map_err(|e| OAuthError::InvalidResponse(e.to_string()))?;
351
352 let access_token = token_response["access_token"]
353 .as_str()
354 .ok_or_else(|| OAuthError::InvalidResponse("missing access_token".to_string()))?
355 .to_string();
356
357 let new_refresh_token = token_response["refresh_token"]
358 .as_str()
359 .map(|s| s.to_string())
360 .unwrap_or_else(|| refresh_token.to_string());
361
362 let token_type = token_response["token_type"]
363 .as_str()
364 .unwrap_or("Bearer")
365 .to_string();
366
367 let expires_at = token_response["expires_in"].as_u64().map(|expires_in| {
368 SystemTime::now()
369 .duration_since(UNIX_EPOCH)
370 .unwrap()
371 .as_secs()
372 + expires_in
373 });
374
375 let scope = token_response["scope"].as_str().map(|s| s.to_string());
376
377 Ok(OAuthToken {
378 access_token,
379 refresh_token: Some(new_refresh_token),
380 token_type,
381 expires_at,
382 scope,
383 })
384 }
385
386 pub fn provider(&self) -> OAuthProvider {
388 self.provider
389 }
390}
391
392pub struct OAuthTokenStore {
394 tokens: HashMap<OAuthProvider, OAuthToken>,
395}
396
397impl OAuthTokenStore {
398 pub fn new() -> Self {
400 Self {
401 tokens: HashMap::new(),
402 }
403 }
404
405 pub fn store(&mut self, provider: OAuthProvider, token: OAuthToken) {
407 self.tokens.insert(provider, token);
408 }
409
410 pub fn get(&self, provider: OAuthProvider) -> Option<&OAuthToken> {
412 self.tokens.get(&provider)
413 }
414
415 pub fn remove(&mut self, provider: OAuthProvider) -> Option<OAuthToken> {
417 self.tokens.remove(&provider)
418 }
419
420 pub fn is_valid(&self, provider: OAuthProvider) -> bool {
422 self.tokens
423 .get(&provider)
424 .map(|token| !token.is_expired())
425 .unwrap_or(false)
426 }
427}
428
429impl Default for OAuthTokenStore {
430 fn default() -> Self {
431 Self::new()
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_oauth_token_expiry() {
441 let token = OAuthToken {
442 access_token: "test".to_string(),
443 refresh_token: None,
444 token_type: "Bearer".to_string(),
445 expires_at: Some(
446 SystemTime::now()
447 .duration_since(UNIX_EPOCH)
448 .unwrap()
449 .as_secs()
450 + 3600,
451 ),
452 scope: None,
453 };
454
455 assert!(!token.is_expired());
456 assert!(!token.expires_soon(Duration::from_secs(1800)));
457 assert!(token.expires_soon(Duration::from_secs(7200)));
458 }
459
460 #[test]
461 fn test_oauth_token_store() {
462 let mut store = OAuthTokenStore::new();
463 let token = OAuthToken {
464 access_token: "test".to_string(),
465 refresh_token: None,
466 token_type: "Bearer".to_string(),
467 expires_at: None,
468 scope: None,
469 };
470
471 store.store(OAuthProvider::Anthropic, token.clone());
472 assert!(store.is_valid(OAuthProvider::Anthropic));
473 assert!(!store.is_valid(OAuthProvider::OpenAI));
474
475 let retrieved = store.get(OAuthProvider::Anthropic).unwrap();
476 assert_eq!(retrieved.access_token, "test");
477
478 store.remove(OAuthProvider::Anthropic);
479 assert!(!store.is_valid(OAuthProvider::Anthropic));
480 }
481}