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    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/// How a runtime may obtain a usable credential from the broker.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum BrokerCredentialStrategy {
87    /// Broker may vend only an access token and its expiry. Refresh credentials
88    /// remain broker-owned.
89    OAuthAccessToken,
90    /// Requests are executed/signed at the broker; no raw static key is vended.
91    ProxyOrSign,
92    /// Temporary local-only compatibility. Implementations MUST reject remote
93    /// callers and audit use. No provider currently opts into this mode.
94    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        // Vends only the short-lived Copilot session token via AccessToken.
201        // Long-lived GitHub user token remains in refresh, broker-owned.
202        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 keeps the Google refresh token; runtime only ever sees the
211        // short-lived access token via AccessToken.
212        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        // Access tokens are ~15-minute JWTs; the rotating refresh token stays
221        // broker-owned and is never vended.
222        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        // The stored "access token" is the minted Model API key; the Meta
231        // OAuth access token that re-mints it stays broker-owned. The remote
232        // broker's `/token` refuses Muse explicitly (#216 I2): it is used
233        // only through `/proxy`.
234        broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
235        behavior: ProviderBehavior::Muse,
236    },
237];
238
239/// CLI-only normalization. Internal callers must carry the canonical typed ID.
240pub 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        // Aliases normalize only at CLI parsing; storage key remains github-copilot.
246        "github-copilot" | "copilot" | "gh-copilot" => Ok(OAuthProviderId::GitHubCopilot),
247        // CLI aliases only; canonical storage key is "google-gemini".
248        // `google` is NOT accepted here: the static provider key "google"
249        // (Google AI Studio, API-key) already claims that CLI token and we
250        // must not silently redirect it to the Code Assist OAuth flow.
251        "google-gemini" | "gemini" | "gemini-cli" | "gemini-code-assist" => {
252            Ok(OAuthProviderId::GoogleGemini)
253        }
254        // CLI aliases only; canonical storage key is "kimi-code".
255        // `kimi` is NOT accepted here: the static provider key "kimi"
256        // (Moonshot platform, API-key) already claims that CLI token and we
257        // must not silently redirect it to the Kimi Code OAuth flow.
258        "kimi-code" | "kimicode" | "kimi-cli" => Ok(OAuthProviderId::KimiCode),
259        // CLI aliases only; canonical storage key is "muse".
260        "muse" | "muse-code" | "meta" | "meta-ai" | "meta-muse" => Ok(OAuthProviderId::Muse),
261        _ => Err(format!("unknown OAuth provider: {value}")),
262    }
263}
264
265/// Interactive login into the provider's default slot.
266pub async fn login(id: OAuthProviderId) -> Result<OAuthCredentials, String> {
267    login_into(&super::account::CredentialRef::default_for(id)).await
268}
269
270/// Interactive login persisting ONLY into the addressed slot. A login into a
271/// named account never touches the default slot (or any other).
272pub 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
278/// Interactive login that returns the credential WITHOUT persisting it, so
279/// the caller can run pre-persistence checks (duplicate identity) and then
280/// store it with `save_credential`.
281pub 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/// Overrides for the endpoints a refresh talks to. Empty in production;
311/// tests point the Muse key mint and the Anthropic token endpoint at
312/// loopback fakes so a broker-level test can never reach the real ones.
313#[derive(Debug, Clone, Default)]
314pub struct RefreshEndpoints {
315    muse_mint_url: Option<String>,
316    anthropic_token_url: Option<String>,
317}
318
319impl RefreshEndpoints {
320    /// Loopback `http://127.0.0.1` / `localhost` mint URL only; anything
321    /// else is refused at mint time.
322    #[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    /// Loopback Anthropic token endpoint only; anything else is refused at
329    /// refresh time (never a fallback to the real endpoint).
330    #[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
337/// [`refresh`] through `endpoints`.
338pub 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        // Canonical wire/storage id is exactly "google-gemini".
430        assert_eq!(OAuthProviderId::GoogleGemini.as_str(), "google-gemini");
431        assert_eq!(
432            OAuthProviderId::from_str("google-gemini").unwrap(),
433            OAuthProviderId::GoogleGemini
434        );
435        // CLI aliases are CLI-only; canonical FromStr must reject them.
436        assert!(OAuthProviderId::from_str("gemini").is_err());
437        assert!(OAuthProviderId::from_str("gemini-cli").is_err());
438        // `google` MUST remain reserved for the static AI-Studio API-key
439        // provider; treating it as a Gemini OAuth alias would silently
440        // redirect existing users' `synaps login --provider google` flow.
441        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        // Broker keeps refresh; only vends access token + expiry.
468        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        // Aliases are CLI-only — canonical FromStr rejects them.
487        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        // Aliases are CLI-only — canonical FromStr rejects them.
522        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        // `kimi` MUST remain reserved for the static Moonshot-platform
537        // API-key provider; treating it as a Kimi Code OAuth alias would
538        // silently redirect existing users' `synaps login --provider kimi`.
539        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        // Broker keeps the rotating refresh token; only vends access + expiry.
547        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        // Aliases are CLI-only — canonical FromStr rejects them.
563        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}