Skip to main content

oxicode/
provider_oauth.rs

1//! OAuth `authorization_code` support for LLM providers.
2//!
3//! Specs are loaded from `oxicode-catalog/data/catalog/product-meta.toml`
4//! (`[providers.<name>.oauth]` tables). Empty/missing table = key-only.
5//!
6//! The catalog file is embedded at compile time so the cached loader does not
7//! depend on filesystem layout at runtime. A runtime [`load_meta`] helper is
8//! also exposed for callers that want to source the file from a custom
9//! location (tests, user overrides).
10
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::path::Path;
14use std::sync::OnceLock;
15
16/// One provider's OAuth configuration parsed from `product-meta.toml`.
17///
18/// Public PKCE clients only — there is no `client_secret` field by design.
19/// `deny_unknown_fields` guards against accidentally adding one: a TOML entry
20/// carrying `client_secret = "..."` will fail to parse instead of silently
21/// loading a confidential-client flow this CLI cannot support.
22#[derive(Clone, Debug, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct ProviderOAuthSpec {
25    pub client_id: String,
26    pub auth_url: String,
27    pub token_url: String,
28    #[serde(default)]
29    pub scopes: Vec<String>,
30    pub redirect_path: String,
31    #[serde(default = "default_pkce")]
32    pub use_pkce: bool,
33}
34
35fn default_pkce() -> bool {
36    true
37}
38
39/// Container for every provider's OAuth spec parsed from `product-meta.toml`.
40#[derive(Clone, Debug, Default)]
41pub struct OAuthMeta {
42    pub specs: HashMap<String, ProviderOAuthSpec>,
43}
44
45/// Process-wide cache of the parsed `product-meta.toml` OAuth sections.
46///
47/// Populated lazily on first call to [`oauth_meta`] (or [`spec_for`]).
48static META: OnceLock<OAuthMeta> = OnceLock::new();
49
50/// Parse the OAuth sections from raw TOML.
51///
52/// The wrapping shape is:
53///
54/// ```toml
55/// [providers.<name>.oauth]
56/// client_id = "..."
57/// # ...
58/// ```
59///
60/// Providers without an `[providers.<name>.oauth]` table are dropped from
61/// the result — empty/missing block = key-only provider.
62pub fn load_meta_from_str(content: &str) -> Result<OAuthMeta, toml::de::Error> {
63    #[derive(Deserialize)]
64    struct Root {
65        #[serde(default)]
66        providers: HashMap<String, ProviderToml>,
67    }
68    #[derive(Deserialize)]
69    struct ProviderToml {
70        #[serde(default)]
71        oauth: Option<ProviderOAuthSpec>,
72    }
73
74    let root: Root = toml::from_str(content)?;
75    let specs = root
76        .providers
77        .into_iter()
78        .filter_map(|(name, p)| p.oauth.map(|spec| (name, spec)))
79        .collect();
80    Ok(OAuthMeta { specs })
81}
82
83/// Load and parse `path` as a `product-meta.toml` containing OAuth sections.
84///
85/// A parse failure is reported as `io::ErrorKind::InvalidData` so callers can
86/// branch on `ErrorKind` rather than threading TOML's error type.
87pub fn load_meta(path: &Path) -> std::io::Result<OAuthMeta> {
88    let content = std::fs::read_to_string(path)?;
89    load_meta_from_str(&content)
90        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
91}
92
93/// Return the cached process-wide `OAuthMeta`, parsing the embedded
94/// `product-meta.toml` on first call.
95///
96/// Missing or malformed sections degrade to an empty map so a broken catalog
97/// file does not panic the CLI; downstream code treats "no spec" as
98/// "key-only provider".
99pub fn oauth_meta() -> &'static OAuthMeta {
100    META.get_or_init(|| {
101        load_meta_from_str(oxicode_catalog::product_meta_toml()).unwrap_or_default()
102    })
103}
104
105/// Look up the OAuth spec for `provider` (e.g. `"openai"`, `"anthropic"`).
106///
107/// Returns `None` when the provider has no `[providers.<name>.oauth]`
108/// block in `product-meta.toml` (i.e. it is key-only).
109pub fn spec_for(provider: &str) -> Option<ProviderOAuthSpec> {
110    oauth_meta().specs.get(provider).cloned()
111}
112/// Tokens returned from a successful OAuth token exchange.
113///
114/// `expires_at` is an absolute Unix epoch in seconds (`now + expires_in`),
115/// matching how callers want to compare against the system clock.
116#[derive(Debug, Clone)]
117pub struct OAuthTokens {
118    pub access_token: String,
119    pub refresh_token: Option<String>,
120    pub expires_at: i64,
121    pub scopes: Vec<String>,
122}
123
124/// Generate a random PKCE verifier and matching S256 code challenge
125/// (RFC 7636 §4.1, §4.2).
126///
127/// The verifier is 32 bytes of CSPRNG output encoded as base64url-no-pad,
128/// landing at 43 ASCII characters — within the RFC's 43..=128 range.
129/// The challenge is `BASE64URL-NO-PAD(SHA256(verifier))`.
130pub fn pkce_pair() -> (String, String) {
131    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
132    use rand::TryRngCore;
133    use sha2::{Digest, Sha256};
134    let mut bytes = [0u8; 32];
135    rand::rngs::OsRng
136        .try_fill_bytes(&mut bytes)
137        .expect("OsRng is infallible");
138    let verifier = URL_SAFE_NO_PAD.encode(bytes);
139    let mut hasher = Sha256::new();
140    hasher.update(verifier.as_bytes());
141    let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
142    (verifier, challenge)
143}
144
145/// Build the authorization URL for the OAuth `authorization_code` flow.
146///
147/// `port` is the local-loopback port the CLI has bound to capture the
148/// provider's redirect — providers will reject the call if the
149/// `redirect_uri` host:port does not match a registered value, so it must
150/// be threaded through (not assumed to be 0).
151pub fn build_auth_url(
152    spec: &ProviderOAuthSpec,
153    port: u16,
154    state: &str,
155    code_challenge: &str,
156) -> String {
157    let redirect_uri = format!("http://127.0.0.1:{port}{}", spec.redirect_path);
158    let mut url = url::Url::parse(&spec.auth_url).expect("auth_url must be valid");
159    {
160        let mut q = url.query_pairs_mut();
161        q.append_pair("response_type", "code");
162        q.append_pair("client_id", &spec.client_id);
163        q.append_pair("redirect_uri", &redirect_uri);
164        q.append_pair("scope", &spec.scopes.join(" "));
165        q.append_pair("state", state);
166        if spec.use_pkce {
167            q.append_pair("code_challenge", code_challenge);
168            q.append_pair("code_challenge_method", "S256");
169        }
170    }
171    url.to_string()
172}
173
174/// Exchange an authorization code for tokens at `spec.token_url`.
175///
176/// Sends the PKCE verifier (`code_verifier`) and the same `redirect_uri`
177/// that was used in the authorization request, so the provider can pair
178/// them up. Returns the parsed [`OAuthTokens`].
179pub async fn exchange_code(
180    spec: &ProviderOAuthSpec,
181    port: u16,
182    code: &str,
183    verifier: &str,
184) -> anyhow::Result<OAuthTokens> {
185    use serde::Deserialize;
186
187    #[derive(Deserialize)]
188    struct TokenResponse {
189        access_token: String,
190        #[serde(default)]
191        refresh_token: Option<String>,
192        #[serde(default)]
193        expires_in: Option<i64>,
194        #[serde(default)]
195        scope: Option<String>,
196    }
197
198    #[derive(Deserialize)]
199    struct TokenError {
200        error: String,
201        #[serde(default)]
202        error_description: Option<String>,
203    }
204
205    let redirect_uri = format!("http://127.0.0.1:{port}{}", spec.redirect_path);
206    let client = reqwest::Client::new();
207    let response = client
208        .post(&spec.token_url)
209        .header("Accept", "application/json")
210        .form(&[
211            ("grant_type", "authorization_code"),
212            ("client_id", spec.client_id.as_str()),
213            ("code", code),
214            ("code_verifier", verifier),
215            ("redirect_uri", redirect_uri.as_str()),
216        ])
217        .send()
218        .await?;
219
220    let status = response.status();
221    let body = response.text().await?;
222
223    if status.is_success() {
224        let parsed: TokenResponse = serde_json::from_str(&body)
225            .map_err(|e| anyhow::anyhow!("malformed token response: {e}; body={body}"))?;
226        let expires_in = parsed.expires_in.unwrap_or(0);
227        let scopes = parsed
228            .scope
229            .map(|s| s.split_whitespace().map(str::to_owned).collect())
230            .unwrap_or_default();
231        let expires_at = chrono::Utc::now().timestamp() + expires_in;
232        Ok(OAuthTokens {
233            access_token: parsed.access_token,
234            refresh_token: parsed.refresh_token,
235            expires_at,
236            scopes,
237        })
238    } else {
239        // Surface the provider's OAuth error code so callers can branch on
240        // `invalid_grant`, `invalid_request`, etc.
241        match serde_json::from_str::<TokenError>(&body) {
242            Ok(err) => Err(anyhow::anyhow!(
243                "token exchange failed (status {status}): {} — {}",
244                err.error,
245                err.error_description.unwrap_or_default()
246            )),
247            Err(_) => Err(anyhow::anyhow!(
248                "token exchange failed (status {status}): {body}"
249            )),
250        }
251    }
252}
253
254/// Tokens returned from a successful OAuth token-refresh grant.
255///
256/// `expires_at` is an absolute Unix epoch in seconds (`now + expires_in`).
257/// `refresh_token` is preserved when the provider returns one in the
258/// response and falls back to the input token when omitted (RFC 6749 §6:
259/// "the authorization server MAY issue a new refresh token, in which case
260/// the client MUST replace the old refresh token").
261#[derive(Debug, Clone)]
262pub struct RefreshedTokens {
263    pub access_token: String,
264    pub refresh_token: Option<String>,
265    pub expires_at: i64,
266}
267
268/// Exchange a stored `refresh_token` for fresh access tokens at
269/// `spec.token_url`.
270///
271/// Sends `grant_type=refresh_token` per RFC 6749 §6 with only the public
272/// `client_id` — this CLI is a public PKCE client by design and never
273/// carries a `client_secret`. Returns the parsed [`RefreshedTokens`].
274pub async fn refresh_grant(
275    spec: &ProviderOAuthSpec,
276    refresh_token: &str,
277) -> anyhow::Result<RefreshedTokens> {
278    use anyhow::Context;
279
280    let client = reqwest::Client::builder()
281        .timeout(std::time::Duration::from_secs(15))
282        .build()
283        .context("building reqwest client")?;
284    let body = [
285        ("grant_type", "refresh_token"),
286        ("client_id", spec.client_id.as_str()),
287        ("refresh_token", refresh_token),
288    ];
289    let resp = client
290        .post(&spec.token_url)
291        .form(&body)
292        .send()
293        .await
294        .context("refresh request failed")?;
295    let status = resp.status();
296    let json: serde_json::Value = resp.json().await.context("refresh response was not JSON")?;
297    if !status.is_success() {
298        let err = json.get("error").and_then(|v| v.as_str()).unwrap_or("");
299        return Err(anyhow::anyhow!("refresh failed: {status} {err}"));
300    }
301    let access_token = json
302        .get("access_token")
303        .and_then(|v| v.as_str())
304        .ok_or_else(|| anyhow::anyhow!("access_token missing"))?
305        .to_string();
306    let refresh_token_out = json
307        .get("refresh_token")
308        .and_then(|v| v.as_str())
309        .map(|s| s.to_string())
310        .or_else(|| Some(refresh_token.to_string()));
311    let expires_in = json
312        .get("expires_in")
313        .and_then(|v| v.as_i64())
314        .unwrap_or(3600);
315    let now = chrono::Utc::now().timestamp();
316    Ok(RefreshedTokens {
317        access_token,
318        refresh_token: refresh_token_out,
319        expires_at: now + expires_in,
320    })
321}
322
323/// Hand a URL to the OS so the user's default browser opens it.
324///
325/// Validates the URL is parseable before handing it off — we never want
326/// a malformed string reaching `xdg-open`/`open`/the Windows shell.
327pub fn open_browser(url: &str) -> anyhow::Result<()> {
328    let parsed = url::Url::parse(url)
329        .map_err(|e| anyhow::anyhow!("open_browser: invalid URL {url:?}: {e}"))?;
330    // Only http(s) schemes are safe to launch externally.
331    match parsed.scheme() {
332        "http" | "https" => {}
333        other => {
334            return Err(anyhow::anyhow!(
335                "open_browser: refusing to launch non-web scheme {other:?}"
336            ));
337        }
338    }
339    open::that_detached(url).map_err(|e| anyhow::anyhow!("open_browser: {e}"))?;
340    Ok(())
341}
342
343#[cfg(test)]
344mod tests {
345
346    use super::*;
347
348    #[test]
349    fn loads_openai_and_anthropic_specs() {
350        let meta = load_meta_from_str(
351            r#"
352            [providers.openai.oauth]
353            client_id = "app-x"
354            auth_url = "https://auth.openai.com/oauth/authorize"
355            token_url = "https://auth.openai.com/oauth/token"
356            scopes = ["openid"]
357            redirect_path = "/callback"
358            use_pkce = true
359
360            [providers.anthropic.oauth]
361            client_id = "oxicode"
362            auth_url = "https://console.anthropic.com/oauth/authorize"
363            token_url = "https://console.anthropic.com/oauth/token"
364            scopes = ["user:profile"]
365            redirect_path = "/callback"
366            use_pkce = true
367            "#,
368        )
369        .expect("parse must succeed");
370        let openai = meta.specs.get("openai").expect("openai present");
371        assert_eq!(openai.client_id, "app-x");
372        assert!(openai.use_pkce);
373        let anthropic = meta.specs.get("anthropic").expect("anthropic present");
374        assert_eq!(anthropic.scopes, vec!["user:profile".to_string()]);
375    }
376
377    #[test]
378    fn missing_oauth_table_means_provider_is_key_only() {
379        let meta = load_meta_from_str(
380            r#"
381            [providers.google.some_other_block]
382            foo = "bar"
383            "#,
384        )
385        .expect("parse must succeed");
386        assert!(!meta.specs.contains_key("google"));
387    }
388
389    /// Embedded `product-meta.toml` must yield at least the two seeded
390    /// providers so `spec_for("openai")` / `spec_for("anthropic")` work
391    /// without any runtime IO.
392    #[test]
393    fn embedded_catalog_yields_openai_and_anthropic() {
394        let openai = spec_for("openai").expect("openai spec present");
395        assert_eq!(openai.auth_url, "https://auth.openai.com/oauth/authorize");
396        assert!(openai.use_pkce);
397
398        let anthropic = spec_for("anthropic").expect("anthropic spec present");
399        assert_eq!(
400            anthropic.token_url,
401            "https://console.anthropic.com/oauth/token"
402        );
403
404        // A key-only provider (no oauth block) must not be present.
405        assert!(!oauth_meta().specs.contains_key("openrouter"));
406    }
407
408    #[test]
409    fn pkce_pair_verifier_is_43_to_128_chars_and_challenge_is_s256() {
410        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
411        use sha2::{Digest, Sha256};
412
413        let (verifier, challenge) = pkce_pair();
414        assert!(
415            verifier.len() >= 43 && verifier.len() <= 128,
416            "verifier length {} out of RFC 7636 range",
417            verifier.len()
418        );
419        // Recompute the challenge from the verifier and compare.
420        let mut hasher = Sha256::new();
421        hasher.update(verifier.as_bytes());
422        let expected = URL_SAFE_NO_PAD.encode(hasher.finalize());
423        assert_eq!(challenge, expected);
424    }
425
426    #[test]
427    fn build_auth_url_includes_pkce_state_and_redirect_uri() {
428        let spec = ProviderOAuthSpec {
429            client_id: "app-x".into(),
430            auth_url: "https://auth.openai.com/oauth/authorize".into(),
431            token_url: "https://auth.openai.com/oauth/token".into(),
432            scopes: vec!["openid".into(), "offline_access".into()],
433            redirect_path: "/callback".into(),
434            use_pkce: true,
435        };
436        let url = build_auth_url(&spec, 12345, "ST", "CC");
437        let parsed = url::Url::parse(&url).expect("must be a valid URL");
438        assert_eq!(parsed.scheme(), "https");
439        assert_eq!(parsed.host_str(), Some("auth.openai.com"));
440        assert_eq!(parsed.path(), "/oauth/authorize");
441        let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
442        assert_eq!(q.get("response_type").map(String::as_str), Some("code"));
443        assert_eq!(q.get("client_id").map(String::as_str), Some("app-x"));
444        assert_eq!(
445            q.get("redirect_uri").map(String::as_str),
446            Some("http://127.0.0.1:12345/callback")
447        );
448        assert_eq!(q.get("state").map(String::as_str), Some("ST"));
449        assert_eq!(q.get("code_challenge").map(String::as_str), Some("CC"));
450        assert_eq!(
451            q.get("code_challenge_method").map(String::as_str),
452            Some("S256")
453        );
454        assert_eq!(
455            q.get("scope").map(String::as_str),
456            Some("openid offline_access")
457        );
458    }
459
460    #[tokio::test]
461    async fn exchange_code_parses_200_response() {
462        use httpmock::MockServer;
463        let server = MockServer::start_async().await;
464        let mock = server.mock(|when, then| {
465            when.method(httpmock::Method::POST).path("/oauth/token");
466            then.status(200).json_body(serde_json::json!({
467                "access_token": "AT",
468                "refresh_token": "RT",
469                "expires_in": 3600,
470                "scope": "openid"
471            }));
472        });
473        let spec = ProviderOAuthSpec {
474            client_id: "app-x".into(),
475            auth_url: "https://auth.example.com/authorize".into(),
476            token_url: format!("{}/oauth/token", server.base_url()),
477            scopes: vec!["openid".into()],
478            redirect_path: "/callback".into(),
479            use_pkce: true,
480        };
481        let tokens = exchange_code(&spec, 12345, "code-1", "verifier")
482            .await
483            .expect("token exchange should succeed");
484        assert_eq!(tokens.access_token, "AT");
485        assert_eq!(tokens.refresh_token.as_deref(), Some("RT"));
486        assert!(tokens.expires_at > 0);
487        assert_eq!(tokens.scopes, vec!["openid".to_string()]);
488        mock.assert_hits(1);
489    }
490
491    #[tokio::test]
492    async fn exchange_code_returns_error_on_4xx() {
493        use httpmock::MockServer;
494        let server = MockServer::start_async().await;
495        let mock = server.mock(|when, then| {
496            when.method(httpmock::Method::POST).path("/oauth/token");
497            then.status(400).json_body(serde_json::json!({
498                "error": "invalid_grant",
499                "error_description": "code already redeemed"
500            }));
501        });
502        let spec = ProviderOAuthSpec {
503            client_id: "app-x".into(),
504            auth_url: "https://example.com/authorize".into(),
505            token_url: format!("{}/oauth/token", server.base_url()),
506            scopes: vec![],
507            redirect_path: "/callback".into(),
508            use_pkce: true,
509        };
510        let err = exchange_code(&spec, 12345, "code-1", "v")
511            .await
512            .expect_err("4xx must surface as error");
513        assert!(
514            format!("{err}").contains("invalid_grant"),
515            "error must include provider's error code: {err}"
516        );
517        mock.assert_hits(1);
518    }
519    #[tokio::test]
520    async fn refresh_grant_parses_200() {
521        use httpmock::MockServer;
522        let server = MockServer::start_async().await;
523        let mock = server.mock(|when, then| {
524            when.method(httpmock::Method::POST).path("/oauth/token");
525            then.status(200).json_body(serde_json::json!({
526                "access_token": "AT2",
527                "refresh_token": "RT2",
528                "expires_in": 7200
529            }));
530        });
531        let spec = ProviderOAuthSpec {
532            client_id: "app-x".into(),
533            auth_url: "https://example.com/oauth/authorize".into(),
534            token_url: format!("{}/oauth/token", server.base_url()),
535            scopes: vec![],
536            redirect_path: "/callback".into(),
537            use_pkce: true,
538        };
539        let tokens = refresh_grant(&spec, "RT")
540            .await
541            .expect("refresh_grant should succeed");
542        assert_eq!(tokens.access_token, "AT2");
543        assert_eq!(tokens.refresh_token.as_deref(), Some("RT2"));
544        assert!(tokens.expires_at > 0);
545        mock.assert_hits(1);
546    }
547
548    /// Smoke test: the function must exist, accept a `&str`, and return a
549    /// `Result` compatible with `anyhow::Error`. The brief notes that
550    /// actually invoking `open_browser` would spawn a real browser, so the
551    /// test only pins the signature without calling it.
552    #[test]
553    fn open_browser_accepts_a_well_formed_url() {
554        let f: fn(&str) -> anyhow::Result<()> = open_browser;
555        // The signature is what we care about — referencing `f` keeps it
556        // live so dead-code elimination does not strip it.
557        let _ = f;
558    }
559}