use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::sync::OnceLock;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderOAuthSpec {
pub client_id: String,
pub auth_url: String,
pub token_url: String,
#[serde(default)]
pub scopes: Vec<String>,
pub redirect_path: String,
#[serde(default = "default_pkce")]
pub use_pkce: bool,
}
fn default_pkce() -> bool {
true
}
#[derive(Clone, Debug, Default)]
pub struct OAuthMeta {
pub specs: HashMap<String, ProviderOAuthSpec>,
}
static META: OnceLock<OAuthMeta> = OnceLock::new();
pub fn load_meta_from_str(content: &str) -> Result<OAuthMeta, toml::de::Error> {
#[derive(Deserialize)]
struct Root {
#[serde(default)]
providers: HashMap<String, ProviderToml>,
}
#[derive(Deserialize)]
struct ProviderToml {
#[serde(default)]
oauth: Option<ProviderOAuthSpec>,
}
let root: Root = toml::from_str(content)?;
let specs = root
.providers
.into_iter()
.filter_map(|(name, p)| p.oauth.map(|spec| (name, spec)))
.collect();
Ok(OAuthMeta { specs })
}
pub fn load_meta(path: &Path) -> std::io::Result<OAuthMeta> {
let content = std::fs::read_to_string(path)?;
load_meta_from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn oauth_meta() -> &'static OAuthMeta {
META.get_or_init(|| {
load_meta_from_str(oxicode_catalog::product_meta_toml()).unwrap_or_default()
})
}
pub fn spec_for(provider: &str) -> Option<ProviderOAuthSpec> {
oauth_meta().specs.get(provider).cloned()
}
#[derive(Debug, Clone)]
pub struct OAuthTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: i64,
pub scopes: Vec<String>,
}
pub fn pkce_pair() -> (String, String) {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::TryRngCore;
use sha2::{Digest, Sha256};
let mut bytes = [0u8; 32];
rand::rngs::OsRng
.try_fill_bytes(&mut bytes)
.expect("OsRng is infallible");
let verifier = URL_SAFE_NO_PAD.encode(bytes);
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
(verifier, challenge)
}
pub fn build_auth_url(
spec: &ProviderOAuthSpec,
port: u16,
state: &str,
code_challenge: &str,
) -> String {
let redirect_uri = format!("http://127.0.0.1:{port}{}", spec.redirect_path);
let mut url = url::Url::parse(&spec.auth_url).expect("auth_url must be valid");
{
let mut q = url.query_pairs_mut();
q.append_pair("response_type", "code");
q.append_pair("client_id", &spec.client_id);
q.append_pair("redirect_uri", &redirect_uri);
q.append_pair("scope", &spec.scopes.join(" "));
q.append_pair("state", state);
if spec.use_pkce {
q.append_pair("code_challenge", code_challenge);
q.append_pair("code_challenge_method", "S256");
}
}
url.to_string()
}
pub async fn exchange_code(
spec: &ProviderOAuthSpec,
port: u16,
code: &str,
verifier: &str,
) -> anyhow::Result<OAuthTokens> {
use serde::Deserialize;
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<i64>,
#[serde(default)]
scope: Option<String>,
}
#[derive(Deserialize)]
struct TokenError {
error: String,
#[serde(default)]
error_description: Option<String>,
}
let redirect_uri = format!("http://127.0.0.1:{port}{}", spec.redirect_path);
let client = reqwest::Client::new();
let response = client
.post(&spec.token_url)
.header("Accept", "application/json")
.form(&[
("grant_type", "authorization_code"),
("client_id", spec.client_id.as_str()),
("code", code),
("code_verifier", verifier),
("redirect_uri", redirect_uri.as_str()),
])
.send()
.await?;
let status = response.status();
let body = response.text().await?;
if status.is_success() {
let parsed: TokenResponse = serde_json::from_str(&body)
.map_err(|e| anyhow::anyhow!("malformed token response: {e}; body={body}"))?;
let expires_in = parsed.expires_in.unwrap_or(0);
let scopes = parsed
.scope
.map(|s| s.split_whitespace().map(str::to_owned).collect())
.unwrap_or_default();
let expires_at = chrono::Utc::now().timestamp() + expires_in;
Ok(OAuthTokens {
access_token: parsed.access_token,
refresh_token: parsed.refresh_token,
expires_at,
scopes,
})
} else {
match serde_json::from_str::<TokenError>(&body) {
Ok(err) => Err(anyhow::anyhow!(
"token exchange failed (status {status}): {} — {}",
err.error,
err.error_description.unwrap_or_default()
)),
Err(_) => Err(anyhow::anyhow!(
"token exchange failed (status {status}): {body}"
)),
}
}
}
#[derive(Debug, Clone)]
pub struct RefreshedTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: i64,
}
pub async fn refresh_grant(
spec: &ProviderOAuthSpec,
refresh_token: &str,
) -> anyhow::Result<RefreshedTokens> {
use anyhow::Context;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.context("building reqwest client")?;
let body = [
("grant_type", "refresh_token"),
("client_id", spec.client_id.as_str()),
("refresh_token", refresh_token),
];
let resp = client
.post(&spec.token_url)
.form(&body)
.send()
.await
.context("refresh request failed")?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.context("refresh response was not JSON")?;
if !status.is_success() {
let err = json.get("error").and_then(|v| v.as_str()).unwrap_or("");
return Err(anyhow::anyhow!("refresh failed: {status} {err}"));
}
let access_token = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("access_token missing"))?
.to_string();
let refresh_token_out = json
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| Some(refresh_token.to_string()));
let expires_in = json
.get("expires_in")
.and_then(|v| v.as_i64())
.unwrap_or(3600);
let now = chrono::Utc::now().timestamp();
Ok(RefreshedTokens {
access_token,
refresh_token: refresh_token_out,
expires_at: now + expires_in,
})
}
pub fn open_browser(url: &str) -> anyhow::Result<()> {
let parsed = url::Url::parse(url)
.map_err(|e| anyhow::anyhow!("open_browser: invalid URL {url:?}: {e}"))?;
match parsed.scheme() {
"http" | "https" => {}
other => {
return Err(anyhow::anyhow!(
"open_browser: refusing to launch non-web scheme {other:?}"
));
}
}
open::that_detached(url).map_err(|e| anyhow::anyhow!("open_browser: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_openai_and_anthropic_specs() {
let meta = load_meta_from_str(
r#"
[providers.openai.oauth]
client_id = "app-x"
auth_url = "https://auth.openai.com/oauth/authorize"
token_url = "https://auth.openai.com/oauth/token"
scopes = ["openid"]
redirect_path = "/callback"
use_pkce = true
[providers.anthropic.oauth]
client_id = "oxicode"
auth_url = "https://console.anthropic.com/oauth/authorize"
token_url = "https://console.anthropic.com/oauth/token"
scopes = ["user:profile"]
redirect_path = "/callback"
use_pkce = true
"#,
)
.expect("parse must succeed");
let openai = meta.specs.get("openai").expect("openai present");
assert_eq!(openai.client_id, "app-x");
assert!(openai.use_pkce);
let anthropic = meta.specs.get("anthropic").expect("anthropic present");
assert_eq!(anthropic.scopes, vec!["user:profile".to_string()]);
}
#[test]
fn missing_oauth_table_means_provider_is_key_only() {
let meta = load_meta_from_str(
r#"
[providers.google.some_other_block]
foo = "bar"
"#,
)
.expect("parse must succeed");
assert!(!meta.specs.contains_key("google"));
}
#[test]
fn embedded_catalog_yields_openai_and_anthropic() {
let openai = spec_for("openai").expect("openai spec present");
assert_eq!(openai.auth_url, "https://auth.openai.com/oauth/authorize");
assert!(openai.use_pkce);
let anthropic = spec_for("anthropic").expect("anthropic spec present");
assert_eq!(
anthropic.token_url,
"https://console.anthropic.com/oauth/token"
);
assert!(!oauth_meta().specs.contains_key("openrouter"));
}
#[test]
fn pkce_pair_verifier_is_43_to_128_chars_and_challenge_is_s256() {
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
let (verifier, challenge) = pkce_pair();
assert!(
verifier.len() >= 43 && verifier.len() <= 128,
"verifier length {} out of RFC 7636 range",
verifier.len()
);
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let expected = URL_SAFE_NO_PAD.encode(hasher.finalize());
assert_eq!(challenge, expected);
}
#[test]
fn build_auth_url_includes_pkce_state_and_redirect_uri() {
let spec = ProviderOAuthSpec {
client_id: "app-x".into(),
auth_url: "https://auth.openai.com/oauth/authorize".into(),
token_url: "https://auth.openai.com/oauth/token".into(),
scopes: vec!["openid".into(), "offline_access".into()],
redirect_path: "/callback".into(),
use_pkce: true,
};
let url = build_auth_url(&spec, 12345, "ST", "CC");
let parsed = url::Url::parse(&url).expect("must be a valid URL");
assert_eq!(parsed.scheme(), "https");
assert_eq!(parsed.host_str(), Some("auth.openai.com"));
assert_eq!(parsed.path(), "/oauth/authorize");
let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
assert_eq!(q.get("response_type").map(String::as_str), Some("code"));
assert_eq!(q.get("client_id").map(String::as_str), Some("app-x"));
assert_eq!(
q.get("redirect_uri").map(String::as_str),
Some("http://127.0.0.1:12345/callback")
);
assert_eq!(q.get("state").map(String::as_str), Some("ST"));
assert_eq!(q.get("code_challenge").map(String::as_str), Some("CC"));
assert_eq!(
q.get("code_challenge_method").map(String::as_str),
Some("S256")
);
assert_eq!(
q.get("scope").map(String::as_str),
Some("openid offline_access")
);
}
#[tokio::test]
async fn exchange_code_parses_200_response() {
use httpmock::MockServer;
let server = MockServer::start_async().await;
let mock = server.mock(|when, then| {
when.method(httpmock::Method::POST).path("/oauth/token");
then.status(200).json_body(serde_json::json!({
"access_token": "AT",
"refresh_token": "RT",
"expires_in": 3600,
"scope": "openid"
}));
});
let spec = ProviderOAuthSpec {
client_id: "app-x".into(),
auth_url: "https://auth.example.com/authorize".into(),
token_url: format!("{}/oauth/token", server.base_url()),
scopes: vec!["openid".into()],
redirect_path: "/callback".into(),
use_pkce: true,
};
let tokens = exchange_code(&spec, 12345, "code-1", "verifier")
.await
.expect("token exchange should succeed");
assert_eq!(tokens.access_token, "AT");
assert_eq!(tokens.refresh_token.as_deref(), Some("RT"));
assert!(tokens.expires_at > 0);
assert_eq!(tokens.scopes, vec!["openid".to_string()]);
mock.assert_hits(1);
}
#[tokio::test]
async fn exchange_code_returns_error_on_4xx() {
use httpmock::MockServer;
let server = MockServer::start_async().await;
let mock = server.mock(|when, then| {
when.method(httpmock::Method::POST).path("/oauth/token");
then.status(400).json_body(serde_json::json!({
"error": "invalid_grant",
"error_description": "code already redeemed"
}));
});
let spec = ProviderOAuthSpec {
client_id: "app-x".into(),
auth_url: "https://example.com/authorize".into(),
token_url: format!("{}/oauth/token", server.base_url()),
scopes: vec![],
redirect_path: "/callback".into(),
use_pkce: true,
};
let err = exchange_code(&spec, 12345, "code-1", "v")
.await
.expect_err("4xx must surface as error");
assert!(
format!("{err}").contains("invalid_grant"),
"error must include provider's error code: {err}"
);
mock.assert_hits(1);
}
#[tokio::test]
async fn refresh_grant_parses_200() {
use httpmock::MockServer;
let server = MockServer::start_async().await;
let mock = server.mock(|when, then| {
when.method(httpmock::Method::POST).path("/oauth/token");
then.status(200).json_body(serde_json::json!({
"access_token": "AT2",
"refresh_token": "RT2",
"expires_in": 7200
}));
});
let spec = ProviderOAuthSpec {
client_id: "app-x".into(),
auth_url: "https://example.com/oauth/authorize".into(),
token_url: format!("{}/oauth/token", server.base_url()),
scopes: vec![],
redirect_path: "/callback".into(),
use_pkce: true,
};
let tokens = refresh_grant(&spec, "RT")
.await
.expect("refresh_grant should succeed");
assert_eq!(tokens.access_token, "AT2");
assert_eq!(tokens.refresh_token.as_deref(), Some("RT2"));
assert!(tokens.expires_at > 0);
mock.assert_hits(1);
}
#[test]
fn open_browser_accepts_a_well_formed_url() {
let f: fn(&str) -> anyhow::Result<()> = open_browser;
let _ = f;
}
}