use std::{
collections::{HashMap, HashSet},
fmt,
str::FromStr,
};
use reqwest::Client;
use super::OAuthCredentials;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum OAuthProviderId {
Anthropic,
OpenAiCodex,
Xai,
GitHubCopilot,
GoogleGemini,
KimiCode,
Muse,
}
impl OAuthProviderId {
pub const fn as_str(self) -> &'static str {
match self {
Self::Anthropic => "anthropic",
Self::OpenAiCodex => "openai-codex",
Self::Xai => "xai-auth",
Self::GitHubCopilot => "github-copilot",
Self::GoogleGemini => "google-gemini",
Self::KimiCode => "kimi-code",
Self::Muse => "muse",
}
}
}
impl fmt::Display for OAuthProviderId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for OAuthProviderId {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"anthropic" => Ok(Self::Anthropic),
"openai-codex" => Ok(Self::OpenAiCodex),
"xai-auth" => Ok(Self::Xai),
"github-copilot" => Ok(Self::GitHubCopilot),
"google-gemini" => Ok(Self::GoogleGemini),
"kimi-code" => Ok(Self::KimiCode),
"muse" => Ok(Self::Muse),
_ => Err(format!("unknown canonical OAuth provider id: {value}")),
}
}
}
impl TryFrom<&str> for OAuthProviderId {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
value.parse()
}
}
impl TryFrom<String> for OAuthProviderId {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
impl TryFrom<&String> for OAuthProviderId {
type Error = String;
fn try_from(value: &String) -> Result<Self, Self::Error> {
value.as_str().parse()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrokerCredentialStrategy {
OAuthAccessToken,
ProxyOrSign,
SameHostStaticKeyCompatibility,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProviderBehavior {
Anthropic,
OpenAiCodex,
Xai,
GitHubCopilot,
GoogleGemini,
KimiCode,
Muse,
}
#[derive(Debug, Clone, Copy)]
pub struct OAuthProviderDescriptor {
pub id: OAuthProviderId,
pub display_name: &'static str,
pub description: &'static str,
pub recommended: bool,
pub broker_strategy: BrokerCredentialStrategy,
pub behavior: ProviderBehavior,
}
#[derive(Debug, Clone, Copy)]
pub struct ProviderReference {
pub name: &'static str,
pub oauth_provider: OAuthProviderId,
}
#[derive(Debug)]
pub struct OAuthProviderRegistry {
descriptors: HashMap<OAuthProviderId, OAuthProviderDescriptor>,
}
impl OAuthProviderRegistry {
pub fn validate(
descriptors: impl IntoIterator<Item = OAuthProviderDescriptor>,
references: impl IntoIterator<Item = ProviderReference>,
) -> Result<Self, String> {
let mut map = HashMap::new();
let mut behavior = HashSet::new();
for descriptor in descriptors {
if map.insert(descriptor.id, descriptor).is_some() {
return Err(format!("duplicate OAuth provider id: {}", descriptor.id));
}
if !behavior.insert(descriptor.behavior) {
return Err(format!(
"duplicate OAuth provider behavior: {:?}",
descriptor.behavior
));
}
}
for reference in references {
if !map.contains_key(&reference.oauth_provider) {
return Err(format!(
"{} references missing OAuth provider {}",
reference.name, reference.oauth_provider
));
}
}
Ok(Self { descriptors: map })
}
pub fn get(&self, id: OAuthProviderId) -> Option<&OAuthProviderDescriptor> {
self.descriptors.get(&id)
}
pub fn iter(&self) -> impl Iterator<Item = &OAuthProviderDescriptor> {
self.descriptors.values()
}
}
pub fn registry() -> OAuthProviderRegistry {
OAuthProviderRegistry::validate(DESCRIPTORS, []).expect("built-in OAuth registry must be valid")
}
pub const DESCRIPTORS: [OAuthProviderDescriptor; 7] = [
OAuthProviderDescriptor {
id: OAuthProviderId::Anthropic,
display_name: "Claude",
description: "Claude account OAuth",
recommended: true,
broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
behavior: ProviderBehavior::Anthropic,
},
OAuthProviderDescriptor {
id: OAuthProviderId::OpenAiCodex,
display_name: "OpenAI Codex",
description: "ChatGPT Plus/Pro OAuth",
recommended: false,
broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
behavior: ProviderBehavior::OpenAiCodex,
},
OAuthProviderDescriptor {
id: OAuthProviderId::Xai,
display_name: "xAI (Grok)",
description: "xAI account OAuth",
recommended: false,
broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
behavior: ProviderBehavior::Xai,
},
OAuthProviderDescriptor {
id: OAuthProviderId::GitHubCopilot,
display_name: "GitHub Copilot",
description: "GitHub Copilot device OAuth (experimental)",
recommended: false,
broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
behavior: ProviderBehavior::GitHubCopilot,
},
OAuthProviderDescriptor {
id: OAuthProviderId::GoogleGemini,
display_name: "Google Gemini (Code Assist)",
description: "Google account OAuth for Gemini CLI / Code Assist (experimental)",
recommended: false,
broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
behavior: ProviderBehavior::GoogleGemini,
},
OAuthProviderDescriptor {
id: OAuthProviderId::KimiCode,
display_name: "Kimi Code",
description: "Kimi account device OAuth for Kimi Code (experimental)",
recommended: false,
broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
behavior: ProviderBehavior::KimiCode,
},
OAuthProviderDescriptor {
id: OAuthProviderId::Muse,
display_name: "Meta Muse",
description: "Meta account device OAuth for Muse Code (experimental)",
recommended: false,
broker_strategy: BrokerCredentialStrategy::OAuthAccessToken,
behavior: ProviderBehavior::Muse,
},
];
pub fn parse_cli_provider(value: &str) -> Result<OAuthProviderId, String> {
match value.to_ascii_lowercase().as_str() {
"claude" | "anthropic" => Ok(OAuthProviderId::Anthropic),
"openai-codex" => Ok(OAuthProviderId::OpenAiCodex),
"xai-auth" => Ok(OAuthProviderId::Xai),
"github-copilot" | "copilot" | "gh-copilot" => Ok(OAuthProviderId::GitHubCopilot),
"google-gemini" | "gemini" | "gemini-cli" | "gemini-code-assist" => {
Ok(OAuthProviderId::GoogleGemini)
}
"kimi-code" | "kimicode" | "kimi-cli" => Ok(OAuthProviderId::KimiCode),
"muse" | "muse-code" | "meta" | "meta-ai" | "meta-muse" => Ok(OAuthProviderId::Muse),
_ => Err(format!("unknown OAuth provider: {value}")),
}
}
pub async fn login(id: OAuthProviderId) -> Result<OAuthCredentials, String> {
login_into(&super::account::CredentialRef::default_for(id)).await
}
pub async fn login_into(
cred: &super::account::CredentialRef,
) -> Result<OAuthCredentials, String> {
login_with_persist(cred.provider, Some(&cred.storage_key())).await
}
pub async fn login_unsaved(id: OAuthProviderId) -> Result<OAuthCredentials, String> {
login_with_persist(id, None).await
}
async fn login_with_persist(
id: OAuthProviderId,
persist_key: Option<&str>,
) -> Result<OAuthCredentials, String> {
match registry()
.get(id)
.expect("typed built-in provider")
.behavior
{
ProviderBehavior::Anthropic => super::providers::anthropic::login_into(persist_key).await,
ProviderBehavior::OpenAiCodex => {
super::providers::openai_codex::login_into(persist_key).await
}
ProviderBehavior::Xai => super::providers::xai::login_into(persist_key).await,
ProviderBehavior::GitHubCopilot => {
super::providers::github_copilot::login_into(persist_key).await
}
ProviderBehavior::GoogleGemini => {
super::providers::google_gemini::login_into(persist_key).await
}
ProviderBehavior::KimiCode => super::providers::kimi_code::login_into(persist_key).await,
ProviderBehavior::Muse => super::providers::muse::login_into(persist_key).await,
}
}
#[derive(Debug, Clone, Default)]
pub struct RefreshEndpoints {
muse_mint_url: Option<String>,
anthropic_token_url: Option<String>,
}
impl RefreshEndpoints {
#[doc(hidden)]
pub fn with_muse_mint_url_for_tests(mut self, url: impl Into<String>) -> Self {
self.muse_mint_url = Some(url.into());
self
}
#[doc(hidden)]
pub fn with_anthropic_token_url_for_tests(mut self, url: impl Into<String>) -> Self {
self.anthropic_token_url = Some(url.into());
self
}
}
pub async fn refresh_via(
client: &Client,
id: OAuthProviderId,
refresh: &str,
endpoints: &RefreshEndpoints,
) -> Result<OAuthCredentials, super::token::RefreshFailure> {
match (&endpoints.muse_mint_url, &endpoints.anthropic_token_url, id) {
(Some(url), _, OAuthProviderId::Muse) => {
super::muse::refresh_token_via_test_mint(url, refresh).await
}
(_, Some(url), OAuthProviderId::Anthropic) => {
if !super::usage::is_loopback_url(url) {
return Err(super::token::RefreshFailure::other(
"test token endpoint must be a loopback URL",
));
}
super::token::refresh_token_at(client, url, refresh).await
}
_ => self::refresh(client, id, refresh).await,
}
}
pub async fn refresh(
client: &Client,
id: OAuthProviderId,
refresh: &str,
) -> Result<OAuthCredentials, super::token::RefreshFailure> {
match registry()
.get(id)
.expect("typed built-in provider")
.behavior
{
ProviderBehavior::Anthropic => super::providers::anthropic::refresh(client, refresh).await,
ProviderBehavior::OpenAiCodex => {
super::providers::openai_codex::refresh(client, refresh).await
}
ProviderBehavior::Xai => super::providers::xai::refresh(client, refresh).await,
ProviderBehavior::GitHubCopilot => {
super::providers::github_copilot::refresh(client, refresh).await
}
ProviderBehavior::GoogleGemini => {
super::providers::google_gemini::refresh(client, refresh).await
}
ProviderBehavior::KimiCode => super::providers::kimi_code::refresh(client, refresh).await,
ProviderBehavior::Muse => super::providers::muse::refresh(client, refresh).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_duplicates_and_missing_references() {
assert!(
OAuthProviderRegistry::validate([DESCRIPTORS[0], DESCRIPTORS[0]], [])
.unwrap_err()
.contains("duplicate")
);
assert!(OAuthProviderRegistry::validate(
[DESCRIPTORS[0]],
[ProviderReference {
name: "codex-model",
oauth_provider: OAuthProviderId::OpenAiCodex
}]
)
.unwrap_err()
.contains("missing"));
}
#[test]
fn every_descriptor_has_explicit_safe_strategy() {
for provider in registry().iter() {
assert_ne!(
provider.broker_strategy,
BrokerCredentialStrategy::SameHostStaticKeyCompatibility
);
}
}
#[test]
fn claude_is_only_a_cli_alias() {
assert_eq!(
parse_cli_provider("ClAuDe").unwrap(),
OAuthProviderId::Anthropic
);
assert!(OAuthProviderId::from_str("claude").is_err());
}
#[test]
fn google_gemini_canonical_id_and_cli_aliases() {
assert_eq!(OAuthProviderId::GoogleGemini.as_str(), "google-gemini");
assert_eq!(
OAuthProviderId::from_str("google-gemini").unwrap(),
OAuthProviderId::GoogleGemini
);
assert!(OAuthProviderId::from_str("gemini").is_err());
assert!(OAuthProviderId::from_str("gemini-cli").is_err());
assert!(
parse_cli_provider("google").is_err(),
"'google' must not resolve to google-gemini OAuth"
);
assert_eq!(
parse_cli_provider("Gemini").unwrap(),
OAuthProviderId::GoogleGemini
);
assert_eq!(
parse_cli_provider("GEMINI-CLI").unwrap(),
OAuthProviderId::GoogleGemini
);
assert_eq!(
parse_cli_provider("gemini-code-assist").unwrap(),
OAuthProviderId::GoogleGemini
);
assert_eq!(
parse_cli_provider("google-gemini").unwrap(),
OAuthProviderId::GoogleGemini
);
let registry = registry();
let desc = registry
.get(OAuthProviderId::GoogleGemini)
.expect("google-gemini descriptor must be registered");
assert_eq!(desc.id, OAuthProviderId::GoogleGemini);
assert_eq!(
desc.broker_strategy,
BrokerCredentialStrategy::OAuthAccessToken
);
assert_eq!(desc.behavior, ProviderBehavior::GoogleGemini);
assert!(
!desc.recommended,
"gemini is experimental — not recommended"
);
}
#[test]
fn github_copilot_canonical_id_and_cli_aliases() {
assert_eq!(OAuthProviderId::GitHubCopilot.as_str(), "github-copilot");
assert_eq!(
OAuthProviderId::from_str("github-copilot").unwrap(),
OAuthProviderId::GitHubCopilot
);
assert!(OAuthProviderId::from_str("copilot").is_err());
assert!(OAuthProviderId::from_str("gh-copilot").is_err());
assert_eq!(
parse_cli_provider("copilot").unwrap(),
OAuthProviderId::GitHubCopilot
);
assert_eq!(
parse_cli_provider("GH-Copilot").unwrap(),
OAuthProviderId::GitHubCopilot
);
assert_eq!(
parse_cli_provider("github-copilot").unwrap(),
OAuthProviderId::GitHubCopilot
);
let registry = registry();
let desc = registry
.get(OAuthProviderId::GitHubCopilot)
.expect("descriptor");
assert_eq!(desc.id, OAuthProviderId::GitHubCopilot);
assert_eq!(
desc.broker_strategy,
BrokerCredentialStrategy::OAuthAccessToken
);
assert_eq!(desc.behavior, ProviderBehavior::GitHubCopilot);
assert!(!desc.recommended);
}
#[test]
fn kimi_code_canonical_id_and_cli_aliases() {
assert_eq!(OAuthProviderId::KimiCode.as_str(), "kimi-code");
assert_eq!(
OAuthProviderId::from_str("kimi-code").unwrap(),
OAuthProviderId::KimiCode
);
assert!(OAuthProviderId::from_str("kimicode").is_err());
assert!(OAuthProviderId::from_str("kimi-cli").is_err());
assert_eq!(
parse_cli_provider("KimiCode").unwrap(),
OAuthProviderId::KimiCode
);
assert_eq!(
parse_cli_provider("kimi-cli").unwrap(),
OAuthProviderId::KimiCode
);
assert_eq!(
parse_cli_provider("kimi-code").unwrap(),
OAuthProviderId::KimiCode
);
assert!(
parse_cli_provider("kimi").is_err(),
"'kimi' must not resolve to kimi-code OAuth"
);
let registry = registry();
let desc = registry.get(OAuthProviderId::KimiCode).expect("descriptor");
assert_eq!(desc.id, OAuthProviderId::KimiCode);
assert_eq!(
desc.broker_strategy,
BrokerCredentialStrategy::OAuthAccessToken
);
assert_eq!(desc.behavior, ProviderBehavior::KimiCode);
assert!(!desc.recommended, "kimi-code is experimental");
}
#[test]
fn muse_canonical_id_and_cli_aliases() {
assert_eq!(OAuthProviderId::Muse.as_str(), "muse");
assert_eq!(
OAuthProviderId::from_str("muse").unwrap(),
OAuthProviderId::Muse
);
for alias in ["meta", "muse-code", "meta-ai", "meta-muse"] {
assert!(OAuthProviderId::from_str(alias).is_err(), "{alias}");
assert_eq!(
parse_cli_provider(alias).unwrap(),
OAuthProviderId::Muse,
"{alias}"
);
}
assert_eq!(parse_cli_provider("MUSE").unwrap(), OAuthProviderId::Muse);
let registry = registry();
let desc = registry.get(OAuthProviderId::Muse).expect("descriptor");
assert_eq!(desc.behavior, ProviderBehavior::Muse);
assert_eq!(
desc.broker_strategy,
BrokerCredentialStrategy::OAuthAccessToken
);
assert!(!desc.recommended, "muse is experimental");
}
}