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}
25
26impl OAuthProviderId {
27 pub const fn as_str(self) -> &'static str {
28 match self {
29 Self::Anthropic => "anthropic",
30 Self::OpenAiCodex => "openai-codex",
31 Self::Xai => "xai-auth",
32 Self::GitHubCopilot => "github-copilot",
33 Self::GoogleGemini => "google-gemini",
34 Self::KimiCode => "kimi-code",
35 }
36 }
37}
38
39impl fmt::Display for OAuthProviderId {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 f.write_str(self.as_str())
42 }
43}
44
45impl FromStr for OAuthProviderId {
46 type Err = String;
47 fn from_str(value: &str) -> Result<Self, Self::Err> {
48 match value {
49 "anthropic" => Ok(Self::Anthropic),
50 "openai-codex" => Ok(Self::OpenAiCodex),
51 "xai-auth" => Ok(Self::Xai),
52 "github-copilot" => Ok(Self::GitHubCopilot),
53 "google-gemini" => Ok(Self::GoogleGemini),
54 "kimi-code" => Ok(Self::KimiCode),
55 _ => Err(format!("unknown canonical OAuth provider id: {value}")),
56 }
57 }
58}
59
60impl TryFrom<&str> for OAuthProviderId {
61 type Error = String;
62 fn try_from(value: &str) -> Result<Self, Self::Error> {
63 value.parse()
64 }
65}
66
67impl TryFrom<String> for OAuthProviderId {
68 type Error = String;
69 fn try_from(value: String) -> Result<Self, Self::Error> {
70 value.parse()
71 }
72}
73
74impl TryFrom<&String> for OAuthProviderId {
75 type Error = String;
76 fn try_from(value: &String) -> Result<Self, Self::Error> {
77 value.as_str().parse()
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum BrokerCredentialStrategy {
84 OAuthAccessToken,
87 ProxyOrSign,
89 SameHostStaticKeyCompatibility,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95pub enum ProviderBehavior {
96 Anthropic,
97 OpenAiCodex,
98 Xai,
99 GitHubCopilot,
100 GoogleGemini,
101 KimiCode,
102}
103
104#[derive(Debug, Clone, Copy)]
105pub struct OAuthProviderDescriptor {
106 pub id: OAuthProviderId,
107 pub display_name: &'static str,
108 pub description: &'static str,
109 pub recommended: bool,
110 pub broker_strategy: BrokerCredentialStrategy,
111 pub behavior: ProviderBehavior,
112}
113
114#[derive(Debug, Clone, Copy)]
115pub struct ProviderReference {
116 pub name: &'static str,
117 pub oauth_provider: OAuthProviderId,
118}
119
120#[derive(Debug)]
121pub struct OAuthProviderRegistry {
122 descriptors: HashMap<OAuthProviderId, OAuthProviderDescriptor>,
123}
124
125impl OAuthProviderRegistry {
126 pub fn validate(
127 descriptors: impl IntoIterator<Item = OAuthProviderDescriptor>,
128 references: impl IntoIterator<Item = ProviderReference>,
129 ) -> Result<Self, String> {
130 let mut map = HashMap::new();
131 let mut behavior = HashSet::new();
132 for descriptor in descriptors {
133 if map.insert(descriptor.id, descriptor).is_some() {
134 return Err(format!("duplicate OAuth provider id: {}", descriptor.id));
135 }
136 if !behavior.insert(descriptor.behavior) {
137 return Err(format!(
138 "duplicate OAuth provider behavior: {:?}",
139 descriptor.behavior
140 ));
141 }
142 }
143 for reference in references {
144 if !map.contains_key(&reference.oauth_provider) {
145 return Err(format!(
146 "{} references missing OAuth provider {}",
147 reference.name, reference.oauth_provider
148 ));
149 }
150 }
151 Ok(Self { descriptors: map })
152 }
153
154 pub fn get(&self, id: OAuthProviderId) -> Option<&OAuthProviderDescriptor> {
155 self.descriptors.get(&id)
156 }
157 pub fn iter(&self) -> impl Iterator<Item = &OAuthProviderDescriptor> {
158 self.descriptors.values()
159 }
160}
161
162pub fn registry() -> OAuthProviderRegistry {
163 OAuthProviderRegistry::validate(DESCRIPTORS, []).expect("built-in OAuth registry must be valid")
164}
165
166pub const DESCRIPTORS: [OAuthProviderDescriptor; 6] = [
167 OAuthProviderDescriptor {
168 id: OAuthProviderId::Anthropic,
169 display_name: "Claude",
170 description: "Claude account OAuth",
171 recommended: true,
172 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
173 behavior: ProviderBehavior::Anthropic,
174 },
175 OAuthProviderDescriptor {
176 id: OAuthProviderId::OpenAiCodex,
177 display_name: "OpenAI Codex",
178 description: "ChatGPT Plus/Pro OAuth",
179 recommended: false,
180 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
181 behavior: ProviderBehavior::OpenAiCodex,
182 },
183 OAuthProviderDescriptor {
184 id: OAuthProviderId::Xai,
185 display_name: "xAI (Grok)",
186 description: "xAI account OAuth",
187 recommended: false,
188 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
189 behavior: ProviderBehavior::Xai,
190 },
191 OAuthProviderDescriptor {
192 id: OAuthProviderId::GitHubCopilot,
193 display_name: "GitHub Copilot",
194 description: "GitHub Copilot device OAuth (experimental)",
195 recommended: false,
196 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
199 behavior: ProviderBehavior::GitHubCopilot,
200 },
201 OAuthProviderDescriptor {
202 id: OAuthProviderId::GoogleGemini,
203 display_name: "Google Gemini (Code Assist)",
204 description: "Google account OAuth for Gemini CLI / Code Assist (experimental)",
205 recommended: false,
206 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
209 behavior: ProviderBehavior::GoogleGemini,
210 },
211 OAuthProviderDescriptor {
212 id: OAuthProviderId::KimiCode,
213 display_name: "Kimi Code",
214 description: "Kimi account device OAuth for Kimi Code (experimental)",
215 recommended: false,
216 broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
219 behavior: ProviderBehavior::KimiCode,
220 },
221];
222
223pub fn parse_cli_provider(value: &str) -> Result<OAuthProviderId, String> {
225 match value.to_ascii_lowercase().as_str() {
226 "claude" | "anthropic" => Ok(OAuthProviderId::Anthropic),
227 "openai-codex" => Ok(OAuthProviderId::OpenAiCodex),
228 "xai-auth" => Ok(OAuthProviderId::Xai),
229 "github-copilot" | "copilot" | "gh-copilot" => Ok(OAuthProviderId::GitHubCopilot),
231 "google-gemini" | "gemini" | "gemini-cli" | "gemini-code-assist" => {
236 Ok(OAuthProviderId::GoogleGemini)
237 }
238 "kimi-code" | "kimicode" | "kimi-cli" => Ok(OAuthProviderId::KimiCode),
243 _ => Err(format!("unknown OAuth provider: {value}")),
244 }
245}
246
247pub async fn login(id: OAuthProviderId) -> Result<OAuthCredentials, String> {
248 match registry()
249 .get(id)
250 .expect("typed built-in provider")
251 .behavior
252 {
253 ProviderBehavior::Anthropic => super::providers::anthropic::login().await,
254 ProviderBehavior::OpenAiCodex => super::providers::openai_codex::login().await,
255 ProviderBehavior::Xai => super::providers::xai::login().await,
256 ProviderBehavior::GitHubCopilot => super::providers::github_copilot::login().await,
257 ProviderBehavior::GoogleGemini => super::providers::google_gemini::login().await,
258 ProviderBehavior::KimiCode => super::providers::kimi_code::login().await,
259 }
260}
261
262pub async fn refresh(
263 client: &Client,
264 id: OAuthProviderId,
265 refresh: &str,
266) -> Result<OAuthCredentials, String> {
267 match registry()
268 .get(id)
269 .expect("typed built-in provider")
270 .behavior
271 {
272 ProviderBehavior::Anthropic => super::providers::anthropic::refresh(client, refresh).await,
273 ProviderBehavior::OpenAiCodex => {
274 super::providers::openai_codex::refresh(client, refresh).await
275 }
276 ProviderBehavior::Xai => super::providers::xai::refresh(client, refresh).await,
277 ProviderBehavior::GitHubCopilot => {
278 super::providers::github_copilot::refresh(client, refresh).await
279 }
280 ProviderBehavior::GoogleGemini => {
281 super::providers::google_gemini::refresh(client, refresh).await
282 }
283 ProviderBehavior::KimiCode => super::providers::kimi_code::refresh(client, refresh).await,
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn rejects_duplicates_and_missing_references() {
293 assert!(
294 OAuthProviderRegistry::validate([DESCRIPTORS[0], DESCRIPTORS[0]], [])
295 .unwrap_err()
296 .contains("duplicate")
297 );
298 assert!(OAuthProviderRegistry::validate(
299 [DESCRIPTORS[0]],
300 [ProviderReference {
301 name: "codex-model",
302 oauth_provider: OAuthProviderId::OpenAiCodex
303 }]
304 )
305 .unwrap_err()
306 .contains("missing"));
307 }
308
309 #[test]
310 fn every_descriptor_has_explicit_safe_strategy() {
311 for provider in registry().iter() {
312 assert_ne!(
313 provider.broker_strategy,
314 BrokerCredentialStrategy::SameHostStaticKeyCompatibility
315 );
316 }
317 }
318
319 #[test]
320 fn claude_is_only_a_cli_alias() {
321 assert_eq!(
322 parse_cli_provider("ClAuDe").unwrap(),
323 OAuthProviderId::Anthropic
324 );
325 assert!(OAuthProviderId::from_str("claude").is_err());
326 }
327
328 #[test]
329 fn google_gemini_canonical_id_and_cli_aliases() {
330 assert_eq!(OAuthProviderId::GoogleGemini.as_str(), "google-gemini");
332 assert_eq!(
333 OAuthProviderId::from_str("google-gemini").unwrap(),
334 OAuthProviderId::GoogleGemini
335 );
336 assert!(OAuthProviderId::from_str("gemini").is_err());
338 assert!(OAuthProviderId::from_str("gemini-cli").is_err());
339 assert!(
343 parse_cli_provider("google").is_err(),
344 "'google' must not resolve to google-gemini OAuth"
345 );
346 assert_eq!(
347 parse_cli_provider("Gemini").unwrap(),
348 OAuthProviderId::GoogleGemini
349 );
350 assert_eq!(
351 parse_cli_provider("GEMINI-CLI").unwrap(),
352 OAuthProviderId::GoogleGemini
353 );
354 assert_eq!(
355 parse_cli_provider("gemini-code-assist").unwrap(),
356 OAuthProviderId::GoogleGemini
357 );
358 assert_eq!(
359 parse_cli_provider("google-gemini").unwrap(),
360 OAuthProviderId::GoogleGemini
361 );
362
363 let registry = registry();
364 let desc = registry
365 .get(OAuthProviderId::GoogleGemini)
366 .expect("google-gemini descriptor must be registered");
367 assert_eq!(desc.id, OAuthProviderId::GoogleGemini);
368 assert_eq!(
370 desc.broker_strategy,
371 BrokerCredentialStrategy::OAuthAccessToken
372 );
373 assert_eq!(desc.behavior, ProviderBehavior::GoogleGemini);
374 assert!(
375 !desc.recommended,
376 "gemini is experimental — not recommended"
377 );
378 }
379
380 #[test]
381 fn github_copilot_canonical_id_and_cli_aliases() {
382 assert_eq!(OAuthProviderId::GitHubCopilot.as_str(), "github-copilot");
383 assert_eq!(
384 OAuthProviderId::from_str("github-copilot").unwrap(),
385 OAuthProviderId::GitHubCopilot
386 );
387 assert!(OAuthProviderId::from_str("copilot").is_err());
389 assert!(OAuthProviderId::from_str("gh-copilot").is_err());
390 assert_eq!(
391 parse_cli_provider("copilot").unwrap(),
392 OAuthProviderId::GitHubCopilot
393 );
394 assert_eq!(
395 parse_cli_provider("GH-Copilot").unwrap(),
396 OAuthProviderId::GitHubCopilot
397 );
398 assert_eq!(
399 parse_cli_provider("github-copilot").unwrap(),
400 OAuthProviderId::GitHubCopilot
401 );
402 let registry = registry();
403 let desc = registry
404 .get(OAuthProviderId::GitHubCopilot)
405 .expect("descriptor");
406 assert_eq!(desc.id, OAuthProviderId::GitHubCopilot);
407 assert_eq!(
408 desc.broker_strategy,
409 BrokerCredentialStrategy::OAuthAccessToken
410 );
411 assert_eq!(desc.behavior, ProviderBehavior::GitHubCopilot);
412 assert!(!desc.recommended);
413 }
414
415 #[test]
416 fn kimi_code_canonical_id_and_cli_aliases() {
417 assert_eq!(OAuthProviderId::KimiCode.as_str(), "kimi-code");
418 assert_eq!(
419 OAuthProviderId::from_str("kimi-code").unwrap(),
420 OAuthProviderId::KimiCode
421 );
422 assert!(OAuthProviderId::from_str("kimicode").is_err());
424 assert!(OAuthProviderId::from_str("kimi-cli").is_err());
425 assert_eq!(
426 parse_cli_provider("KimiCode").unwrap(),
427 OAuthProviderId::KimiCode
428 );
429 assert_eq!(
430 parse_cli_provider("kimi-cli").unwrap(),
431 OAuthProviderId::KimiCode
432 );
433 assert_eq!(
434 parse_cli_provider("kimi-code").unwrap(),
435 OAuthProviderId::KimiCode
436 );
437 assert!(
441 parse_cli_provider("kimi").is_err(),
442 "'kimi' must not resolve to kimi-code OAuth"
443 );
444 let registry = registry();
445 let desc = registry.get(OAuthProviderId::KimiCode).expect("descriptor");
446 assert_eq!(desc.id, OAuthProviderId::KimiCode);
447 assert_eq!(
449 desc.broker_strategy,
450 BrokerCredentialStrategy::OAuthAccessToken
451 );
452 assert_eq!(desc.behavior, ProviderBehavior::KimiCode);
453 assert!(!desc.recommended, "kimi-code is experimental");
454 }
455}