1use std::{
7 collections::{HashMap, HashSet},
8 fmt,
9 str::FromStr,
10};
11
12use reqwest::Client;
13
14use super::OAuthCredentials;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
17pub enum OAuthProviderId {
18 Anthropic,
19 OpenAiCodex,
20 Xai,
21 GitHubCopilot,
22 GoogleGemini,
23 KimiCode,
24 Muse,
25}
26
27impl OAuthProviderId {
28 pub const fn as_str(self) -> &'static str {
29 match self {
30 Self::Anthropic => "anthropic",
31 Self::OpenAiCodex => "openai-codex",
32 Self::Xai => "xai-auth",
33 Self::GitHubCopilot => "github-copilot",
34 Self::GoogleGemini => "google-gemini",
35 Self::KimiCode => "kimi-code",
36 Self::Muse => "muse",
37 }
38 }
39}
40
41impl fmt::Display for OAuthProviderId {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.write_str(self.as_str())
44 }
45}
46
47impl FromStr for OAuthProviderId {
48 type Err = String;
49 fn from_str(value: &str) -> Result<Self, Self::Err> {
50 match value {
51 "anthropic" => Ok(Self::Anthropic),
52 "openai-codex" => Ok(Self::OpenAiCodex),
53 "xai-auth" => Ok(Self::Xai),
54 "github-copilot" => Ok(Self::GitHubCopilot),
55 "google-gemini" => Ok(Self::GoogleGemini),
56 "kimi-code" => Ok(Self::KimiCode),
57 "muse" => Ok(Self::Muse),
58 _ => Err(format!("unknown canonical OAuth provider id: {value}")),
59 }
60 }
61}
62
63impl TryFrom<&str> for OAuthProviderId {
64 type Error = String;
65 fn try_from(value: &str) -> Result<Self, Self::Error> {
66 value.parse()
67 }
68}
69
70impl TryFrom<String> for OAuthProviderId {
71 type Error = String;
72 fn try_from(value: String) -> Result<Self, Self::Error> {
73 value.parse()
74 }
75}
76
77impl TryFrom<&String> for OAuthProviderId {
78 type Error = String;
79 fn try_from(value: &String) -> Result<Self, Self::Error> {
80 value.as_str().parse()
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum BrokerCredentialStrategy {
87 OAuthAccessToken,
90 ProxyOrSign,
92 SameHostStaticKeyCompatibility,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98pub enum ProviderBehavior {
99 Anthropic,
100 OpenAiCodex,
101 Xai,
102 GitHubCopilot,
103 GoogleGemini,
104 KimiCode,
105 Muse,
106}
107
108#[derive(Debug, Clone, Copy)]
109pub struct OAuthProviderDescriptor {
110 pub id: OAuthProviderId,
111 pub display_name: &'static str,
112 pub description: &'static str,
113 pub recommended: bool,
114 pub broker_strategy: BrokerCredentialStrategy,
115 pub behavior: ProviderBehavior,
116}
117
118#[derive(Debug, Clone, Copy)]
119pub struct ProviderReference {
120 pub name: &'static str,
121 pub oauth_provider: OAuthProviderId,
122}
123
124#[derive(Debug)]
125pub struct OAuthProviderRegistry {
126 descriptors: HashMap<OAuthProviderId, OAuthProviderDescriptor>,
127}
128
129impl OAuthProviderRegistry {
130 pub fn validate(
131 descriptors: impl IntoIterator<Item = OAuthProviderDescriptor>,
132 references: impl IntoIterator<Item = ProviderReference>,
133 ) -> Result<Self, String> {
134 let mut map = HashMap::new();
135 let mut behavior = HashSet::new();
136 for descriptor in descriptors {
137 if map.insert(descriptor.id, descriptor).is_some() {
138 return Err(format!("duplicate OAuth provider id: {}", descriptor.id));
139 }
140 if !behavior.insert(descriptor.behavior) {
141 return Err(format!(
142 "duplicate OAuth provider behavior: {:?}",
143 descriptor.behavior
144 ));
145 }
146 }
147 for reference in references {
148 if !map.contains_key(&reference.oauth_provider) {
149 return Err(format!(
150 "{} references missing OAuth provider {}",
151 reference.name, reference.oauth_provider
152 ));
153 }
154 }
155 Ok(Self { descriptors: map })
156 }
157
158 pub fn get(&self, id: OAuthProviderId) -> Option<&OAuthProviderDescriptor> {
159 self.descriptors.get(&id)
160 }
161 pub fn iter(&self) -> impl Iterator<Item = &OAuthProviderDescriptor> {
162 self.descriptors.values()
163 }
164}
165
166pub fn registry() -> OAuthProviderRegistry {
167 OAuthProviderRegistry::validate(DESCRIPTORS, []).expect("built-in OAuth registry must be valid")
168}
169
170pub const DESCRIPTORS: [OAuthProviderDescriptor; 7] = [
171 OAuthProviderDescriptor {
172 id: OAuthProviderId::Anthropic,
173 display_name: "Claude",
174 description: "Claude account OAuth",
175 recommended: true,
176 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
177 behavior: ProviderBehavior::Anthropic,
178 },
179 OAuthProviderDescriptor {
180 id: OAuthProviderId::OpenAiCodex,
181 display_name: "OpenAI Codex",
182 description: "ChatGPT Plus/Pro OAuth",
183 recommended: false,
184 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
185 behavior: ProviderBehavior::OpenAiCodex,
186 },
187 OAuthProviderDescriptor {
188 id: OAuthProviderId::Xai,
189 display_name: "xAI (Grok)",
190 description: "xAI account OAuth",
191 recommended: false,
192 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
193 behavior: ProviderBehavior::Xai,
194 },
195 OAuthProviderDescriptor {
196 id: OAuthProviderId::GitHubCopilot,
197 display_name: "GitHub Copilot",
198 description: "GitHub Copilot device OAuth (experimental)",
199 recommended: false,
200 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
203 behavior: ProviderBehavior::GitHubCopilot,
204 },
205 OAuthProviderDescriptor {
206 id: OAuthProviderId::GoogleGemini,
207 display_name: "Google Gemini (Code Assist)",
208 description: "Google account OAuth for Gemini CLI / Code Assist (experimental)",
209 recommended: false,
210 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
213 behavior: ProviderBehavior::GoogleGemini,
214 },
215 OAuthProviderDescriptor {
216 id: OAuthProviderId::KimiCode,
217 display_name: "Kimi Code",
218 description: "Kimi account device OAuth for Kimi Code (experimental)",
219 recommended: false,
220 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
223 behavior: ProviderBehavior::KimiCode,
224 },
225 OAuthProviderDescriptor {
226 id: OAuthProviderId::Muse,
227 display_name: "Meta Muse",
228 description: "Meta account device OAuth for Muse Code (experimental)",
229 recommended: false,
230 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
235 behavior: ProviderBehavior::Muse,
236 },
237];
238
239pub fn parse_cli_provider(value: &str) -> Result<OAuthProviderId, String> {
241 match value.to_ascii_lowercase().as_str() {
242 "claude" | "anthropic" => Ok(OAuthProviderId::Anthropic),
243 "openai-codex" => Ok(OAuthProviderId::OpenAiCodex),
244 "xai-auth" => Ok(OAuthProviderId::Xai),
245 "github-copilot" | "copilot" | "gh-copilot" => Ok(OAuthProviderId::GitHubCopilot),
247 "google-gemini" | "gemini" | "gemini-cli" | "gemini-code-assist" => {
252 Ok(OAuthProviderId::GoogleGemini)
253 }
254 "kimi-code" | "kimicode" | "kimi-cli" => Ok(OAuthProviderId::KimiCode),
259 "muse" | "muse-code" | "meta" | "meta-ai" | "meta-muse" => Ok(OAuthProviderId::Muse),
261 _ => Err(format!("unknown OAuth provider: {value}")),
262 }
263}
264
265pub async fn login(id: OAuthProviderId) -> Result<OAuthCredentials, String> {
267 login_into(&super::account::CredentialRef::default_for(id)).await
268}
269
270pub async fn login_into(
273 cred: &super::account::CredentialRef,
274) -> Result<OAuthCredentials, String> {
275 login_with_persist(cred.provider, Some(&cred.storage_key())).await
276}
277
278pub async fn login_unsaved(id: OAuthProviderId) -> Result<OAuthCredentials, String> {
282 login_with_persist(id, None).await
283}
284
285async fn login_with_persist(
286 id: OAuthProviderId,
287 persist_key: Option<&str>,
288) -> Result<OAuthCredentials, String> {
289 match registry()
290 .get(id)
291 .expect("typed built-in provider")
292 .behavior
293 {
294 ProviderBehavior::Anthropic => super::providers::anthropic::login_into(persist_key).await,
295 ProviderBehavior::OpenAiCodex => {
296 super::providers::openai_codex::login_into(persist_key).await
297 }
298 ProviderBehavior::Xai => super::providers::xai::login_into(persist_key).await,
299 ProviderBehavior::GitHubCopilot => {
300 super::providers::github_copilot::login_into(persist_key).await
301 }
302 ProviderBehavior::GoogleGemini => {
303 super::providers::google_gemini::login_into(persist_key).await
304 }
305 ProviderBehavior::KimiCode => super::providers::kimi_code::login_into(persist_key).await,
306 ProviderBehavior::Muse => super::providers::muse::login_into(persist_key).await,
307 }
308}
309
310#[derive(Debug, Clone, Default)]
314pub struct RefreshEndpoints {
315 muse_mint_url: Option<String>,
316 anthropic_token_url: Option<String>,
317}
318
319impl RefreshEndpoints {
320 #[doc(hidden)]
323 pub fn with_muse_mint_url_for_tests(mut self, url: impl Into<String>) -> Self {
324 self.muse_mint_url = Some(url.into());
325 self
326 }
327
328 #[doc(hidden)]
331 pub fn with_anthropic_token_url_for_tests(mut self, url: impl Into<String>) -> Self {
332 self.anthropic_token_url = Some(url.into());
333 self
334 }
335}
336
337pub async fn refresh_via(
339 client: &Client,
340 id: OAuthProviderId,
341 refresh: &str,
342 endpoints: &RefreshEndpoints,
343) -> Result<OAuthCredentials, super::token::RefreshFailure> {
344 match (&endpoints.muse_mint_url, &endpoints.anthropic_token_url, id) {
345 (Some(url), _, OAuthProviderId::Muse) => {
346 super::muse::refresh_token_via_test_mint(url, refresh).await
347 }
348 (_, Some(url), OAuthProviderId::Anthropic) => {
349 if !super::usage::is_loopback_url(url) {
350 return Err(super::token::RefreshFailure::other(
351 "test token endpoint must be a loopback URL",
352 ));
353 }
354 super::token::refresh_token_at(client, url, refresh).await
355 }
356 _ => self::refresh(client, id, refresh).await,
357 }
358}
359
360pub async fn refresh(
361 client: &Client,
362 id: OAuthProviderId,
363 refresh: &str,
364) -> Result<OAuthCredentials, super::token::RefreshFailure> {
365 match registry()
366 .get(id)
367 .expect("typed built-in provider")
368 .behavior
369 {
370 ProviderBehavior::Anthropic => super::providers::anthropic::refresh(client, refresh).await,
371 ProviderBehavior::OpenAiCodex => {
372 super::providers::openai_codex::refresh(client, refresh).await
373 }
374 ProviderBehavior::Xai => super::providers::xai::refresh(client, refresh).await,
375 ProviderBehavior::GitHubCopilot => {
376 super::providers::github_copilot::refresh(client, refresh).await
377 }
378 ProviderBehavior::GoogleGemini => {
379 super::providers::google_gemini::refresh(client, refresh).await
380 }
381 ProviderBehavior::KimiCode => super::providers::kimi_code::refresh(client, refresh).await,
382 ProviderBehavior::Muse => super::providers::muse::refresh(client, refresh).await,
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn rejects_duplicates_and_missing_references() {
392 assert!(
393 OAuthProviderRegistry::validate([DESCRIPTORS[0], DESCRIPTORS[0]], [])
394 .unwrap_err()
395 .contains("duplicate")
396 );
397 assert!(OAuthProviderRegistry::validate(
398 [DESCRIPTORS[0]],
399 [ProviderReference {
400 name: "codex-model",
401 oauth_provider: OAuthProviderId::OpenAiCodex
402 }]
403 )
404 .unwrap_err()
405 .contains("missing"));
406 }
407
408 #[test]
409 fn every_descriptor_has_explicit_safe_strategy() {
410 for provider in registry().iter() {
411 assert_ne!(
412 provider.broker_strategy,
413 BrokerCredentialStrategy::SameHostStaticKeyCompatibility
414 );
415 }
416 }
417
418 #[test]
419 fn claude_is_only_a_cli_alias() {
420 assert_eq!(
421 parse_cli_provider("ClAuDe").unwrap(),
422 OAuthProviderId::Anthropic
423 );
424 assert!(OAuthProviderId::from_str("claude").is_err());
425 }
426
427 #[test]
428 fn google_gemini_canonical_id_and_cli_aliases() {
429 assert_eq!(OAuthProviderId::GoogleGemini.as_str(), "google-gemini");
431 assert_eq!(
432 OAuthProviderId::from_str("google-gemini").unwrap(),
433 OAuthProviderId::GoogleGemini
434 );
435 assert!(OAuthProviderId::from_str("gemini").is_err());
437 assert!(OAuthProviderId::from_str("gemini-cli").is_err());
438 assert!(
442 parse_cli_provider("google").is_err(),
443 "'google' must not resolve to google-gemini OAuth"
444 );
445 assert_eq!(
446 parse_cli_provider("Gemini").unwrap(),
447 OAuthProviderId::GoogleGemini
448 );
449 assert_eq!(
450 parse_cli_provider("GEMINI-CLI").unwrap(),
451 OAuthProviderId::GoogleGemini
452 );
453 assert_eq!(
454 parse_cli_provider("gemini-code-assist").unwrap(),
455 OAuthProviderId::GoogleGemini
456 );
457 assert_eq!(
458 parse_cli_provider("google-gemini").unwrap(),
459 OAuthProviderId::GoogleGemini
460 );
461
462 let registry = registry();
463 let desc = registry
464 .get(OAuthProviderId::GoogleGemini)
465 .expect("google-gemini descriptor must be registered");
466 assert_eq!(desc.id, OAuthProviderId::GoogleGemini);
467 assert_eq!(
469 desc.broker_strategy,
470 BrokerCredentialStrategy::OAuthAccessToken
471 );
472 assert_eq!(desc.behavior, ProviderBehavior::GoogleGemini);
473 assert!(
474 !desc.recommended,
475 "gemini is experimental — not recommended"
476 );
477 }
478
479 #[test]
480 fn github_copilot_canonical_id_and_cli_aliases() {
481 assert_eq!(OAuthProviderId::GitHubCopilot.as_str(), "github-copilot");
482 assert_eq!(
483 OAuthProviderId::from_str("github-copilot").unwrap(),
484 OAuthProviderId::GitHubCopilot
485 );
486 assert!(OAuthProviderId::from_str("copilot").is_err());
488 assert!(OAuthProviderId::from_str("gh-copilot").is_err());
489 assert_eq!(
490 parse_cli_provider("copilot").unwrap(),
491 OAuthProviderId::GitHubCopilot
492 );
493 assert_eq!(
494 parse_cli_provider("GH-Copilot").unwrap(),
495 OAuthProviderId::GitHubCopilot
496 );
497 assert_eq!(
498 parse_cli_provider("github-copilot").unwrap(),
499 OAuthProviderId::GitHubCopilot
500 );
501 let registry = registry();
502 let desc = registry
503 .get(OAuthProviderId::GitHubCopilot)
504 .expect("descriptor");
505 assert_eq!(desc.id, OAuthProviderId::GitHubCopilot);
506 assert_eq!(
507 desc.broker_strategy,
508 BrokerCredentialStrategy::OAuthAccessToken
509 );
510 assert_eq!(desc.behavior, ProviderBehavior::GitHubCopilot);
511 assert!(!desc.recommended);
512 }
513
514 #[test]
515 fn kimi_code_canonical_id_and_cli_aliases() {
516 assert_eq!(OAuthProviderId::KimiCode.as_str(), "kimi-code");
517 assert_eq!(
518 OAuthProviderId::from_str("kimi-code").unwrap(),
519 OAuthProviderId::KimiCode
520 );
521 assert!(OAuthProviderId::from_str("kimicode").is_err());
523 assert!(OAuthProviderId::from_str("kimi-cli").is_err());
524 assert_eq!(
525 parse_cli_provider("KimiCode").unwrap(),
526 OAuthProviderId::KimiCode
527 );
528 assert_eq!(
529 parse_cli_provider("kimi-cli").unwrap(),
530 OAuthProviderId::KimiCode
531 );
532 assert_eq!(
533 parse_cli_provider("kimi-code").unwrap(),
534 OAuthProviderId::KimiCode
535 );
536 assert!(
540 parse_cli_provider("kimi").is_err(),
541 "'kimi' must not resolve to kimi-code OAuth"
542 );
543 let registry = registry();
544 let desc = registry.get(OAuthProviderId::KimiCode).expect("descriptor");
545 assert_eq!(desc.id, OAuthProviderId::KimiCode);
546 assert_eq!(
548 desc.broker_strategy,
549 BrokerCredentialStrategy::OAuthAccessToken
550 );
551 assert_eq!(desc.behavior, ProviderBehavior::KimiCode);
552 assert!(!desc.recommended, "kimi-code is experimental");
553 }
554
555 #[test]
556 fn muse_canonical_id_and_cli_aliases() {
557 assert_eq!(OAuthProviderId::Muse.as_str(), "muse");
558 assert_eq!(
559 OAuthProviderId::from_str("muse").unwrap(),
560 OAuthProviderId::Muse
561 );
562 for alias in ["meta", "muse-code", "meta-ai", "meta-muse"] {
564 assert!(OAuthProviderId::from_str(alias).is_err(), "{alias}");
565 assert_eq!(
566 parse_cli_provider(alias).unwrap(),
567 OAuthProviderId::Muse,
568 "{alias}"
569 );
570 }
571 assert_eq!(parse_cli_provider("MUSE").unwrap(), OAuthProviderId::Muse);
572 let registry = registry();
573 let desc = registry.get(OAuthProviderId::Muse).expect("descriptor");
574 assert_eq!(desc.behavior, ProviderBehavior::Muse);
575 assert_eq!(
576 desc.broker_strategy,
577 BrokerCredentialStrategy::OAuthAccessToken
578 );
579 assert!(!desc.recommended, "muse is experimental");
580 }
581}