pub mod claude_import;
pub mod codex_import;
pub mod oauth;
pub mod refresh;
pub mod store;
use serde::{Deserialize, Serialize};
pub const CLAUDE_CODE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
#[default]
#[serde(alias = "api_key")]
ApiKey,
OAuth,
Auto,
}
impl AuthMethod {
pub fn from_option(s: Option<&str>) -> Self {
match s {
Some("oauth") => Self::OAuth,
Some("auto") => Self::Auto,
Some("api_key") | Some("apikey") => Self::ApiKey,
_ => Self::ApiKey,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedCredential {
ApiKey(String),
BearerToken {
access_token: String,
expires_at: Option<i64>,
},
}
impl ResolvedCredential {
pub fn value(&self) -> &str {
match self {
Self::ApiKey(key) => key,
Self::BearerToken { access_token, .. } => access_token,
}
}
pub fn is_bearer(&self) -> bool {
matches!(self, Self::BearerToken { .. })
}
pub fn is_api_key(&self) -> bool {
matches!(self, Self::ApiKey(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthTokenSet {
pub provider: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<i64>,
pub token_type: String,
pub scope: Option<String>,
pub obtained_at: i64,
pub client_id: Option<String>,
}
impl OAuthTokenSet {
pub fn is_expired(&self) -> bool {
if let Some(expires_at) = self.expires_at {
let now = chrono::Utc::now().timestamp();
now >= expires_at
} else {
false }
}
pub fn expires_within(&self, seconds: i64) -> bool {
if let Some(expires_at) = self.expires_at {
let now = chrono::Utc::now().timestamp();
now + seconds >= expires_at
} else {
false
}
}
pub fn expires_in_human(&self) -> String {
if let Some(expires_at) = self.expires_at {
let now = chrono::Utc::now().timestamp();
let remaining = expires_at - now;
if remaining <= 0 {
"expired".to_string()
} else if remaining < 60 {
format!("{}s", remaining)
} else if remaining < 3600 {
format!("{}m", remaining / 60)
} else {
format!("{}h {}m", remaining / 3600, (remaining % 3600) / 60)
}
} else {
"no expiry".to_string()
}
}
}
#[derive(Debug, Clone)]
pub struct ProviderOAuthConfig {
pub provider: String,
pub token_url: String,
pub authorize_url: String,
pub client_name: String,
pub scopes: Vec<String>,
}
pub fn provider_oauth_config(provider: &str) -> Option<ProviderOAuthConfig> {
match provider {
"anthropic" => Some(ProviderOAuthConfig {
provider: "anthropic".to_string(),
token_url: "https://console.anthropic.com/v1/oauth/token".to_string(),
authorize_url: "https://console.anthropic.com/oauth/authorize".to_string(),
client_name: "ZeptoClaw".to_string(),
scopes: vec![],
}),
"google" => Some(ProviderOAuthConfig {
provider: "google".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
authorize_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
client_name: "ZeptoClaw".to_string(),
scopes: vec![
"https://www.googleapis.com/auth/gmail.modify".to_string(),
"https://www.googleapis.com/auth/calendar".to_string(),
],
}),
"openai" => Some(ProviderOAuthConfig {
provider: "openai".to_string(),
token_url: "https://auth.openai.com/oauth/token".to_string(),
authorize_url: "https://auth.openai.com/oauth/authorize".to_string(),
client_name: "ZeptoClaw".to_string(),
scopes: vec![
"openid".to_string(),
"email".to_string(),
"profile".to_string(),
],
}),
_ => None,
}
}
pub fn oauth_supported_providers() -> &'static [&'static str] {
&["anthropic", "google", "openai"]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_method_default() {
assert_eq!(AuthMethod::default(), AuthMethod::ApiKey);
}
#[test]
fn test_auth_method_from_option() {
assert_eq!(AuthMethod::from_option(None), AuthMethod::ApiKey);
assert_eq!(AuthMethod::from_option(Some("oauth")), AuthMethod::OAuth);
assert_eq!(AuthMethod::from_option(Some("auto")), AuthMethod::Auto);
assert_eq!(AuthMethod::from_option(Some("api_key")), AuthMethod::ApiKey);
assert_eq!(AuthMethod::from_option(Some("apikey")), AuthMethod::ApiKey);
assert_eq!(AuthMethod::from_option(Some("unknown")), AuthMethod::ApiKey);
}
#[test]
fn test_auth_method_serde_roundtrip() {
let methods = vec![AuthMethod::ApiKey, AuthMethod::OAuth, AuthMethod::Auto];
for method in methods {
let json = serde_json::to_string(&method).unwrap();
let parsed: AuthMethod = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, method);
}
}
#[test]
fn test_resolved_credential_api_key() {
let cred = ResolvedCredential::ApiKey("sk-test".to_string());
assert!(cred.is_api_key());
assert!(!cred.is_bearer());
assert_eq!(cred.value(), "sk-test");
}
#[test]
fn test_resolved_credential_bearer() {
let cred = ResolvedCredential::BearerToken {
access_token: "token-123".to_string(),
expires_at: Some(9999999999),
};
assert!(cred.is_bearer());
assert!(!cred.is_api_key());
assert_eq!(cred.value(), "token-123");
}
#[test]
fn test_token_set_not_expired() {
let token = OAuthTokenSet {
provider: "anthropic".to_string(),
access_token: "test".to_string(),
refresh_token: None,
expires_at: Some(chrono::Utc::now().timestamp() + 3600),
token_type: "Bearer".to_string(),
scope: None,
obtained_at: chrono::Utc::now().timestamp(),
client_id: None,
};
assert!(!token.is_expired());
assert!(!token.expires_within(300));
}
#[test]
fn test_token_set_expired() {
let token = OAuthTokenSet {
provider: "anthropic".to_string(),
access_token: "test".to_string(),
refresh_token: None,
expires_at: Some(chrono::Utc::now().timestamp() - 100),
token_type: "Bearer".to_string(),
scope: None,
obtained_at: chrono::Utc::now().timestamp() - 4000,
client_id: None,
};
assert!(token.is_expired());
}
#[test]
fn test_token_set_expires_within() {
let token = OAuthTokenSet {
provider: "anthropic".to_string(),
access_token: "test".to_string(),
refresh_token: None,
expires_at: Some(chrono::Utc::now().timestamp() + 200),
token_type: "Bearer".to_string(),
scope: None,
obtained_at: chrono::Utc::now().timestamp(),
client_id: None,
};
assert!(!token.is_expired());
assert!(token.expires_within(300)); assert!(!token.expires_within(100)); }
#[test]
fn test_token_set_no_expiry() {
let token = OAuthTokenSet {
provider: "anthropic".to_string(),
access_token: "test".to_string(),
refresh_token: None,
expires_at: None,
token_type: "Bearer".to_string(),
scope: None,
obtained_at: chrono::Utc::now().timestamp(),
client_id: None,
};
assert!(!token.is_expired());
assert!(!token.expires_within(99999));
assert_eq!(token.expires_in_human(), "no expiry");
}
#[test]
fn test_expires_in_human() {
let now = chrono::Utc::now().timestamp();
let token = OAuthTokenSet {
provider: "test".to_string(),
access_token: "t".to_string(),
refresh_token: None,
expires_at: Some(now - 10),
token_type: "Bearer".to_string(),
scope: None,
obtained_at: now,
client_id: None,
};
assert_eq!(token.expires_in_human(), "expired");
let token2 = OAuthTokenSet {
expires_at: Some(now + 7200 + 1800),
..token.clone()
};
assert!(token2.expires_in_human().contains("h"));
}
#[test]
fn test_provider_oauth_config_anthropic() {
let config = provider_oauth_config("anthropic");
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.provider, "anthropic");
assert!(config.token_url.contains("anthropic.com"));
}
#[test]
fn test_provider_oauth_config_unsupported() {
assert!(provider_oauth_config("unknown_provider").is_none());
assert!(provider_oauth_config("github").is_none());
}
#[test]
fn test_oauth_supported_providers() {
let providers = oauth_supported_providers();
assert!(providers.contains(&"anthropic"));
assert!(providers.contains(&"openai"));
assert!(!providers.contains(&"github"));
}
#[test]
fn test_provider_oauth_config_google() {
let config = provider_oauth_config("google");
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.provider, "google");
assert!(config.authorize_url.contains("accounts.google.com"));
assert!(config.token_url.contains("googleapis.com") || config.token_url.contains("google"));
assert!(!config.scopes.is_empty());
}
#[test]
fn test_oauth_supported_providers_includes_google() {
let providers = oauth_supported_providers();
assert!(providers.contains(&"google"));
}
#[test]
fn test_provider_oauth_config_openai() {
let config = provider_oauth_config("openai").expect("openai should have OAuth config");
assert_eq!(config.provider, "openai");
assert_eq!(
config.authorize_url,
"https://auth.openai.com/oauth/authorize"
);
assert_eq!(config.token_url, "https://auth.openai.com/oauth/token");
assert!(!config.scopes.is_empty());
}
#[test]
fn test_oauth_supported_providers_includes_openai() {
let providers = oauth_supported_providers();
assert!(
providers.contains(&"openai"),
"openai must be in oauth_supported_providers"
);
}
}