1use serde::Deserialize;
12use std::collections::HashMap;
13use std::path::Path;
14use std::sync::OnceLock;
15
16#[derive(Clone, Debug, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct ProviderOAuthSpec {
25 pub client_id: String,
26 pub auth_url: String,
27 pub token_url: String,
28 #[serde(default)]
29 pub scopes: Vec<String>,
30 pub redirect_path: String,
31 #[serde(default = "default_pkce")]
32 pub use_pkce: bool,
33}
34
35fn default_pkce() -> bool {
36 true
37}
38
39#[derive(Clone, Debug, Default)]
41pub struct OAuthMeta {
42 pub specs: HashMap<String, ProviderOAuthSpec>,
43}
44
45static META: OnceLock<OAuthMeta> = OnceLock::new();
49
50pub fn load_meta_from_str(content: &str) -> Result<OAuthMeta, toml::de::Error> {
63 #[derive(Deserialize)]
64 struct Root {
65 #[serde(default)]
66 providers: HashMap<String, ProviderToml>,
67 }
68 #[derive(Deserialize)]
69 struct ProviderToml {
70 #[serde(default)]
71 oauth: Option<ProviderOAuthSpec>,
72 }
73
74 let root: Root = toml::from_str(content)?;
75 let specs = root
76 .providers
77 .into_iter()
78 .filter_map(|(name, p)| p.oauth.map(|spec| (name, spec)))
79 .collect();
80 Ok(OAuthMeta { specs })
81}
82
83pub fn load_meta(path: &Path) -> std::io::Result<OAuthMeta> {
88 let content = std::fs::read_to_string(path)?;
89 load_meta_from_str(&content)
90 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
91}
92
93pub fn oauth_meta() -> &'static OAuthMeta {
100 META.get_or_init(|| {
101 load_meta_from_str(oxicode_catalog::product_meta_toml()).unwrap_or_default()
102 })
103}
104
105pub fn spec_for(provider: &str) -> Option<ProviderOAuthSpec> {
110 oauth_meta().specs.get(provider).cloned()
111}
112#[derive(Debug, Clone)]
117pub struct OAuthTokens {
118 pub access_token: String,
119 pub refresh_token: Option<String>,
120 pub expires_at: i64,
121 pub scopes: Vec<String>,
122}
123
124pub fn pkce_pair() -> (String, String) {
131 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
132 use rand::TryRngCore;
133 use sha2::{Digest, Sha256};
134 let mut bytes = [0u8; 32];
135 rand::rngs::OsRng
136 .try_fill_bytes(&mut bytes)
137 .expect("OsRng is infallible");
138 let verifier = URL_SAFE_NO_PAD.encode(bytes);
139 let mut hasher = Sha256::new();
140 hasher.update(verifier.as_bytes());
141 let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
142 (verifier, challenge)
143}
144
145pub fn build_auth_url(
152 spec: &ProviderOAuthSpec,
153 port: u16,
154 state: &str,
155 code_challenge: &str,
156) -> String {
157 let redirect_uri = format!("http://127.0.0.1:{port}{}", spec.redirect_path);
158 let mut url = url::Url::parse(&spec.auth_url).expect("auth_url must be valid");
159 {
160 let mut q = url.query_pairs_mut();
161 q.append_pair("response_type", "code");
162 q.append_pair("client_id", &spec.client_id);
163 q.append_pair("redirect_uri", &redirect_uri);
164 q.append_pair("scope", &spec.scopes.join(" "));
165 q.append_pair("state", state);
166 if spec.use_pkce {
167 q.append_pair("code_challenge", code_challenge);
168 q.append_pair("code_challenge_method", "S256");
169 }
170 }
171 url.to_string()
172}
173
174pub async fn exchange_code(
180 spec: &ProviderOAuthSpec,
181 port: u16,
182 code: &str,
183 verifier: &str,
184) -> anyhow::Result<OAuthTokens> {
185 use serde::Deserialize;
186
187 #[derive(Deserialize)]
188 struct TokenResponse {
189 access_token: String,
190 #[serde(default)]
191 refresh_token: Option<String>,
192 #[serde(default)]
193 expires_in: Option<i64>,
194 #[serde(default)]
195 scope: Option<String>,
196 }
197
198 #[derive(Deserialize)]
199 struct TokenError {
200 error: String,
201 #[serde(default)]
202 error_description: Option<String>,
203 }
204
205 let redirect_uri = format!("http://127.0.0.1:{port}{}", spec.redirect_path);
206 let client = reqwest::Client::new();
207 let response = client
208 .post(&spec.token_url)
209 .header("Accept", "application/json")
210 .form(&[
211 ("grant_type", "authorization_code"),
212 ("client_id", spec.client_id.as_str()),
213 ("code", code),
214 ("code_verifier", verifier),
215 ("redirect_uri", redirect_uri.as_str()),
216 ])
217 .send()
218 .await?;
219
220 let status = response.status();
221 let body = response.text().await?;
222
223 if status.is_success() {
224 let parsed: TokenResponse = serde_json::from_str(&body)
225 .map_err(|e| anyhow::anyhow!("malformed token response: {e}; body={body}"))?;
226 let expires_in = parsed.expires_in.unwrap_or(0);
227 let scopes = parsed
228 .scope
229 .map(|s| s.split_whitespace().map(str::to_owned).collect())
230 .unwrap_or_default();
231 let expires_at = chrono::Utc::now().timestamp() + expires_in;
232 Ok(OAuthTokens {
233 access_token: parsed.access_token,
234 refresh_token: parsed.refresh_token,
235 expires_at,
236 scopes,
237 })
238 } else {
239 match serde_json::from_str::<TokenError>(&body) {
242 Ok(err) => Err(anyhow::anyhow!(
243 "token exchange failed (status {status}): {} — {}",
244 err.error,
245 err.error_description.unwrap_or_default()
246 )),
247 Err(_) => Err(anyhow::anyhow!(
248 "token exchange failed (status {status}): {body}"
249 )),
250 }
251 }
252}
253
254#[derive(Debug, Clone)]
262pub struct RefreshedTokens {
263 pub access_token: String,
264 pub refresh_token: Option<String>,
265 pub expires_at: i64,
266}
267
268pub async fn refresh_grant(
275 spec: &ProviderOAuthSpec,
276 refresh_token: &str,
277) -> anyhow::Result<RefreshedTokens> {
278 use anyhow::Context;
279
280 let client = reqwest::Client::builder()
281 .timeout(std::time::Duration::from_secs(15))
282 .build()
283 .context("building reqwest client")?;
284 let body = [
285 ("grant_type", "refresh_token"),
286 ("client_id", spec.client_id.as_str()),
287 ("refresh_token", refresh_token),
288 ];
289 let resp = client
290 .post(&spec.token_url)
291 .form(&body)
292 .send()
293 .await
294 .context("refresh request failed")?;
295 let status = resp.status();
296 let json: serde_json::Value = resp.json().await.context("refresh response was not JSON")?;
297 if !status.is_success() {
298 let err = json.get("error").and_then(|v| v.as_str()).unwrap_or("");
299 return Err(anyhow::anyhow!("refresh failed: {status} {err}"));
300 }
301 let access_token = json
302 .get("access_token")
303 .and_then(|v| v.as_str())
304 .ok_or_else(|| anyhow::anyhow!("access_token missing"))?
305 .to_string();
306 let refresh_token_out = json
307 .get("refresh_token")
308 .and_then(|v| v.as_str())
309 .map(|s| s.to_string())
310 .or_else(|| Some(refresh_token.to_string()));
311 let expires_in = json
312 .get("expires_in")
313 .and_then(|v| v.as_i64())
314 .unwrap_or(3600);
315 let now = chrono::Utc::now().timestamp();
316 Ok(RefreshedTokens {
317 access_token,
318 refresh_token: refresh_token_out,
319 expires_at: now + expires_in,
320 })
321}
322
323pub fn open_browser(url: &str) -> anyhow::Result<()> {
328 let parsed = url::Url::parse(url)
329 .map_err(|e| anyhow::anyhow!("open_browser: invalid URL {url:?}: {e}"))?;
330 match parsed.scheme() {
332 "http" | "https" => {}
333 other => {
334 return Err(anyhow::anyhow!(
335 "open_browser: refusing to launch non-web scheme {other:?}"
336 ));
337 }
338 }
339 open::that_detached(url).map_err(|e| anyhow::anyhow!("open_browser: {e}"))?;
340 Ok(())
341}
342
343#[cfg(test)]
344mod tests {
345
346 use super::*;
347
348 #[test]
349 fn loads_openai_and_anthropic_specs() {
350 let meta = load_meta_from_str(
351 r#"
352 [providers.openai.oauth]
353 client_id = "app-x"
354 auth_url = "https://auth.openai.com/oauth/authorize"
355 token_url = "https://auth.openai.com/oauth/token"
356 scopes = ["openid"]
357 redirect_path = "/callback"
358 use_pkce = true
359
360 [providers.anthropic.oauth]
361 client_id = "oxicode"
362 auth_url = "https://console.anthropic.com/oauth/authorize"
363 token_url = "https://console.anthropic.com/oauth/token"
364 scopes = ["user:profile"]
365 redirect_path = "/callback"
366 use_pkce = true
367 "#,
368 )
369 .expect("parse must succeed");
370 let openai = meta.specs.get("openai").expect("openai present");
371 assert_eq!(openai.client_id, "app-x");
372 assert!(openai.use_pkce);
373 let anthropic = meta.specs.get("anthropic").expect("anthropic present");
374 assert_eq!(anthropic.scopes, vec!["user:profile".to_string()]);
375 }
376
377 #[test]
378 fn missing_oauth_table_means_provider_is_key_only() {
379 let meta = load_meta_from_str(
380 r#"
381 [providers.google.some_other_block]
382 foo = "bar"
383 "#,
384 )
385 .expect("parse must succeed");
386 assert!(!meta.specs.contains_key("google"));
387 }
388
389 #[test]
393 fn embedded_catalog_yields_openai_and_anthropic() {
394 let openai = spec_for("openai").expect("openai spec present");
395 assert_eq!(openai.auth_url, "https://auth.openai.com/oauth/authorize");
396 assert!(openai.use_pkce);
397
398 let anthropic = spec_for("anthropic").expect("anthropic spec present");
399 assert_eq!(
400 anthropic.token_url,
401 "https://console.anthropic.com/oauth/token"
402 );
403
404 assert!(!oauth_meta().specs.contains_key("openrouter"));
406 }
407
408 #[test]
409 fn pkce_pair_verifier_is_43_to_128_chars_and_challenge_is_s256() {
410 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
411 use sha2::{Digest, Sha256};
412
413 let (verifier, challenge) = pkce_pair();
414 assert!(
415 verifier.len() >= 43 && verifier.len() <= 128,
416 "verifier length {} out of RFC 7636 range",
417 verifier.len()
418 );
419 let mut hasher = Sha256::new();
421 hasher.update(verifier.as_bytes());
422 let expected = URL_SAFE_NO_PAD.encode(hasher.finalize());
423 assert_eq!(challenge, expected);
424 }
425
426 #[test]
427 fn build_auth_url_includes_pkce_state_and_redirect_uri() {
428 let spec = ProviderOAuthSpec {
429 client_id: "app-x".into(),
430 auth_url: "https://auth.openai.com/oauth/authorize".into(),
431 token_url: "https://auth.openai.com/oauth/token".into(),
432 scopes: vec!["openid".into(), "offline_access".into()],
433 redirect_path: "/callback".into(),
434 use_pkce: true,
435 };
436 let url = build_auth_url(&spec, 12345, "ST", "CC");
437 let parsed = url::Url::parse(&url).expect("must be a valid URL");
438 assert_eq!(parsed.scheme(), "https");
439 assert_eq!(parsed.host_str(), Some("auth.openai.com"));
440 assert_eq!(parsed.path(), "/oauth/authorize");
441 let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
442 assert_eq!(q.get("response_type").map(String::as_str), Some("code"));
443 assert_eq!(q.get("client_id").map(String::as_str), Some("app-x"));
444 assert_eq!(
445 q.get("redirect_uri").map(String::as_str),
446 Some("http://127.0.0.1:12345/callback")
447 );
448 assert_eq!(q.get("state").map(String::as_str), Some("ST"));
449 assert_eq!(q.get("code_challenge").map(String::as_str), Some("CC"));
450 assert_eq!(
451 q.get("code_challenge_method").map(String::as_str),
452 Some("S256")
453 );
454 assert_eq!(
455 q.get("scope").map(String::as_str),
456 Some("openid offline_access")
457 );
458 }
459
460 #[tokio::test]
461 async fn exchange_code_parses_200_response() {
462 use httpmock::MockServer;
463 let server = MockServer::start_async().await;
464 let mock = server.mock(|when, then| {
465 when.method(httpmock::Method::POST).path("/oauth/token");
466 then.status(200).json_body(serde_json::json!({
467 "access_token": "AT",
468 "refresh_token": "RT",
469 "expires_in": 3600,
470 "scope": "openid"
471 }));
472 });
473 let spec = ProviderOAuthSpec {
474 client_id: "app-x".into(),
475 auth_url: "https://auth.example.com/authorize".into(),
476 token_url: format!("{}/oauth/token", server.base_url()),
477 scopes: vec!["openid".into()],
478 redirect_path: "/callback".into(),
479 use_pkce: true,
480 };
481 let tokens = exchange_code(&spec, 12345, "code-1", "verifier")
482 .await
483 .expect("token exchange should succeed");
484 assert_eq!(tokens.access_token, "AT");
485 assert_eq!(tokens.refresh_token.as_deref(), Some("RT"));
486 assert!(tokens.expires_at > 0);
487 assert_eq!(tokens.scopes, vec!["openid".to_string()]);
488 mock.assert_hits(1);
489 }
490
491 #[tokio::test]
492 async fn exchange_code_returns_error_on_4xx() {
493 use httpmock::MockServer;
494 let server = MockServer::start_async().await;
495 let mock = server.mock(|when, then| {
496 when.method(httpmock::Method::POST).path("/oauth/token");
497 then.status(400).json_body(serde_json::json!({
498 "error": "invalid_grant",
499 "error_description": "code already redeemed"
500 }));
501 });
502 let spec = ProviderOAuthSpec {
503 client_id: "app-x".into(),
504 auth_url: "https://example.com/authorize".into(),
505 token_url: format!("{}/oauth/token", server.base_url()),
506 scopes: vec![],
507 redirect_path: "/callback".into(),
508 use_pkce: true,
509 };
510 let err = exchange_code(&spec, 12345, "code-1", "v")
511 .await
512 .expect_err("4xx must surface as error");
513 assert!(
514 format!("{err}").contains("invalid_grant"),
515 "error must include provider's error code: {err}"
516 );
517 mock.assert_hits(1);
518 }
519 #[tokio::test]
520 async fn refresh_grant_parses_200() {
521 use httpmock::MockServer;
522 let server = MockServer::start_async().await;
523 let mock = server.mock(|when, then| {
524 when.method(httpmock::Method::POST).path("/oauth/token");
525 then.status(200).json_body(serde_json::json!({
526 "access_token": "AT2",
527 "refresh_token": "RT2",
528 "expires_in": 7200
529 }));
530 });
531 let spec = ProviderOAuthSpec {
532 client_id: "app-x".into(),
533 auth_url: "https://example.com/oauth/authorize".into(),
534 token_url: format!("{}/oauth/token", server.base_url()),
535 scopes: vec![],
536 redirect_path: "/callback".into(),
537 use_pkce: true,
538 };
539 let tokens = refresh_grant(&spec, "RT")
540 .await
541 .expect("refresh_grant should succeed");
542 assert_eq!(tokens.access_token, "AT2");
543 assert_eq!(tokens.refresh_token.as_deref(), Some("RT2"));
544 assert!(tokens.expires_at > 0);
545 mock.assert_hits(1);
546 }
547
548 #[test]
553 fn open_browser_accepts_a_well_formed_url() {
554 let f: fn(&str) -> anyhow::Result<()> = open_browser;
555 let _ = f;
558 }
559}