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}
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/// How a runtime may obtain a usable credential from the broker.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum BrokerCredentialStrategy {
81    /// Broker may vend only an access token and its expiry. Refresh credentials
82    /// remain broker-owned.
83    OAuthAccessToken,
84    /// Requests are executed/signed at the broker; no raw static key is vended.
85    ProxyOrSign,
86    /// Temporary local-only compatibility. Implementations MUST reject remote
87    /// callers and audit use. No provider currently opts into this mode.
88    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        // Vends only the short-lived Copilot session token via AccessToken.
193        // Long-lived GitHub user token remains in refresh, broker-owned.
194        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 keeps the Google refresh token; runtime only ever sees the
203        // short-lived access token via AccessToken.
204        broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
205        behavior: ProviderBehavior::GoogleGemini,
206    },
207];
208
209/// CLI-only normalization. Internal callers must carry the canonical typed ID.
210pub 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        // Aliases normalize only at CLI parsing; storage key remains github-copilot.
216        "github-copilot" | "copilot" | "gh-copilot" => Ok(OAuthProviderId::GitHubCopilot),
217        // CLI aliases only; canonical storage key is "google-gemini".
218        // `google` is NOT accepted here: the static provider key "google"
219        // (Google AI Studio, API-key) already claims that CLI token and we
220        // must not silently redirect it to the Code Assist OAuth flow.
221        "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        // Canonical wire/storage id is exactly "google-gemini".
310        assert_eq!(OAuthProviderId::GoogleGemini.as_str(), "google-gemini");
311        assert_eq!(
312            OAuthProviderId::from_str("google-gemini").unwrap(),
313            OAuthProviderId::GoogleGemini
314        );
315        // CLI aliases are CLI-only; canonical FromStr must reject them.
316        assert!(OAuthProviderId::from_str("gemini").is_err());
317        assert!(OAuthProviderId::from_str("gemini-cli").is_err());
318        // `google` MUST remain reserved for the static AI-Studio API-key
319        // provider; treating it as a Gemini OAuth alias would silently
320        // redirect existing users' `synaps login --provider google` flow.
321        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        // Broker keeps refresh; only vends access token + expiry.
348        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        // Aliases are CLI-only — canonical FromStr rejects them.
367        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}