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