Skip to main content

agent_core/core/auth/
provider.rs

1//! Typed OAuth provider identity, descriptors, registry validation, and dispatch.
2//!
3//! This registry is deliberately about authentication policy, not runtime wire
4//! routing. CLI aliases are normalized before a value enters this module.
5
6use 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/// How a runtime may obtain a usable credential from the broker.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum BrokerCredentialStrategy {
84    /// Broker may vend only an access token and its expiry. Refresh credentials
85    /// remain broker-owned.
86    OAuthAccessToken,
87    /// Requests are executed/signed at the broker; no raw static key is vended.
88    ProxyOrSign,
89    /// Temporary local-only compatibility. Implementations MUST reject remote
90    /// callers and audit use. No provider currently opts into this mode.
91    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        // Vends only the short-lived Copilot session token via AccessToken.
197        // Long-lived GitHub user token remains in refresh, broker-owned.
198        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 keeps the Google refresh token; runtime only ever sees the
207        // short-lived access token via AccessToken.
208        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        // Access tokens are ~15-minute JWTs; the rotating refresh token stays
217        // broker-owned and is never vended.
218        broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
219        behavior: ProviderBehavior::KimiCode,
220    },
221];
222
223/// CLI-only normalization. Internal callers must carry the canonical typed ID.
224pub 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        // Aliases normalize only at CLI parsing; storage key remains github-copilot.
230        "github-copilot" | "copilot" | "gh-copilot" => Ok(OAuthProviderId::GitHubCopilot),
231        // CLI aliases only; canonical storage key is "google-gemini".
232        // `google` is NOT accepted here: the static provider key "google"
233        // (Google AI Studio, API-key) already claims that CLI token and we
234        // must not silently redirect it to the Code Assist OAuth flow.
235        "google-gemini" | "gemini" | "gemini-cli" | "gemini-code-assist" => {
236            Ok(OAuthProviderId::GoogleGemini)
237        }
238        // CLI aliases only; canonical storage key is "kimi-code".
239        // `kimi` is NOT accepted here: the static provider key "kimi"
240        // (Moonshot platform, API-key) already claims that CLI token and we
241        // must not silently redirect it to the Kimi Code OAuth flow.
242        "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        // Canonical wire/storage id is exactly "google-gemini".
331        assert_eq!(OAuthProviderId::GoogleGemini.as_str(), "google-gemini");
332        assert_eq!(
333            OAuthProviderId::from_str("google-gemini").unwrap(),
334            OAuthProviderId::GoogleGemini
335        );
336        // CLI aliases are CLI-only; canonical FromStr must reject them.
337        assert!(OAuthProviderId::from_str("gemini").is_err());
338        assert!(OAuthProviderId::from_str("gemini-cli").is_err());
339        // `google` MUST remain reserved for the static AI-Studio API-key
340        // provider; treating it as a Gemini OAuth alias would silently
341        // redirect existing users' `synaps login --provider google` flow.
342        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        // Broker keeps refresh; only vends access token + expiry.
369        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        // Aliases are CLI-only — canonical FromStr rejects them.
388        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        // Aliases are CLI-only — canonical FromStr rejects them.
423        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        // `kimi` MUST remain reserved for the static Moonshot-platform
438        // API-key provider; treating it as a Kimi Code OAuth alias would
439        // silently redirect existing users' `synaps login --provider kimi`.
440        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        // Broker keeps the rotating refresh token; only vends access + expiry.
448        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}