mod claude_code;
mod env_resolver;
pub mod oauth_resolver;
pub use claude_code::ClaudeCodeResolver;
pub use env_resolver::EnvVarResolver;
pub use oauth_resolver::CodineerOAuthResolver;
use std::fmt;
#[derive(Clone, PartialEq, Eq)]
pub enum ResolvedCredential {
ApiKey(String),
BearerToken(String),
ApiKeyAndBearer {
api_key: String,
bearer_token: String,
},
}
impl fmt::Debug for ResolvedCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ApiKey(_) => write!(f, "ResolvedCredential::ApiKey(***)"),
Self::BearerToken(_) => write!(f, "ResolvedCredential::BearerToken(***)"),
Self::ApiKeyAndBearer { .. } => {
write!(f, "ResolvedCredential::ApiKeyAndBearer(***)")
}
}
}
}
#[derive(Debug)]
pub enum CredentialError {
NoCredentials {
provider: &'static str,
tried: Vec<String>,
},
ResolverFailed {
resolver_id: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
}
impl fmt::Display for CredentialError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoCredentials { provider, tried } => {
write!(
f,
"no credentials found for {provider} (tried: {})",
tried.join(", ")
)
}
Self::ResolverFailed {
resolver_id,
source,
} => {
write!(f, "credential resolver '{resolver_id}' failed: {source}")
}
}
}
}
impl std::error::Error for CredentialError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::ResolverFailed { source, .. } => Some(source.as_ref()),
_ => None,
}
}
}
pub trait CredentialResolver: fmt::Debug + Send + Sync {
fn id(&self) -> &str;
fn display_name(&self) -> &str;
fn priority(&self) -> u16;
fn resolve(&self) -> Result<Option<ResolvedCredential>, CredentialError>;
fn supports_login(&self) -> bool {
false
}
fn login(&self) -> Result<(), Box<dyn std::error::Error>> {
Err("login not supported by this credential source".into())
}
fn logout(&self) -> Result<(), Box<dyn std::error::Error>> {
Err("logout not supported by this credential source".into())
}
}
#[derive(Debug, Clone)]
pub struct CredentialStatus {
pub id: String,
pub display_name: String,
pub available: bool,
pub supports_login: bool,
}
pub struct CredentialChain {
provider_name: &'static str,
resolvers: Vec<Box<dyn CredentialResolver>>,
}
impl fmt::Debug for CredentialChain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CredentialChain")
.field("provider", &self.provider_name)
.field("resolvers", &self.resolvers.len())
.finish()
}
}
impl CredentialChain {
pub fn new(
provider_name: &'static str,
mut resolvers: Vec<Box<dyn CredentialResolver>>,
) -> Self {
resolvers.sort_by_key(|r| r.priority());
Self {
provider_name,
resolvers,
}
}
#[must_use]
pub fn empty(provider_name: &'static str) -> Self {
Self {
provider_name,
resolvers: Vec::new(),
}
}
pub fn resolve(&self) -> Result<ResolvedCredential, CredentialError> {
let mut tried = Vec::new();
for resolver in &self.resolvers {
tried.push(resolver.display_name().to_string());
match resolver.resolve() {
Ok(Some(credential)) => return Ok(credential),
Ok(None) => continue,
Err(CredentialError::ResolverFailed { .. }) => continue,
Err(error) => return Err(error),
}
}
Err(CredentialError::NoCredentials {
provider: self.provider_name,
tried,
})
}
#[must_use]
pub fn status(&self) -> Vec<CredentialStatus> {
self.resolvers
.iter()
.map(|r| CredentialStatus {
id: r.id().to_string(),
display_name: r.display_name().to_string(),
available: matches!(r.resolve(), Ok(Some(_))),
supports_login: r.supports_login(),
})
.collect()
}
pub fn login_sources(&self) -> Vec<&dyn CredentialResolver> {
self.resolvers
.iter()
.filter(|r| r.supports_login())
.map(|r| r.as_ref())
.collect()
}
pub fn get_resolver(&self, id: &str) -> Option<&dyn CredentialResolver> {
self.resolvers
.iter()
.find(|r| r.id() == id)
.map(|r| r.as_ref())
}
pub fn resolver_ids(&self) -> impl Iterator<Item = &str> {
self.resolvers.iter().map(|r| r.id())
}
#[must_use]
pub fn provider_name(&self) -> &str {
self.provider_name
}
#[must_use]
pub fn len(&self) -> usize {
self.resolvers.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.resolvers.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug)]
struct StubResolver {
id: &'static str,
priority: u16,
credential: Option<ResolvedCredential>,
login_supported: bool,
}
impl CredentialResolver for StubResolver {
fn id(&self) -> &str {
self.id
}
fn display_name(&self) -> &str {
self.id
}
fn priority(&self) -> u16 {
self.priority
}
fn resolve(&self) -> Result<Option<ResolvedCredential>, CredentialError> {
Ok(self.credential.clone())
}
fn supports_login(&self) -> bool {
self.login_supported
}
}
#[test]
fn chain_resolves_first_available() {
let chain = CredentialChain::new(
"test",
vec![
Box::new(StubResolver {
id: "a",
priority: 200,
credential: Some(ResolvedCredential::ApiKey("key-a".into())),
login_supported: false,
}),
Box::new(StubResolver {
id: "b",
priority: 100,
credential: None,
login_supported: false,
}),
],
);
let cred = chain.resolve().expect("should resolve");
assert_eq!(cred, ResolvedCredential::ApiKey("key-a".into()));
}
#[test]
fn chain_sorts_by_priority() {
let chain = CredentialChain::new(
"test",
vec![
Box::new(StubResolver {
id: "high",
priority: 300,
credential: Some(ResolvedCredential::BearerToken("tok-high".into())),
login_supported: false,
}),
Box::new(StubResolver {
id: "low",
priority: 100,
credential: Some(ResolvedCredential::BearerToken("tok-low".into())),
login_supported: false,
}),
],
);
let cred = chain.resolve().expect("should resolve");
assert_eq!(cred, ResolvedCredential::BearerToken("tok-low".into()));
}
#[test]
fn empty_chain_returns_no_credentials() {
let chain = CredentialChain::empty("test");
let err = chain.resolve().unwrap_err();
assert!(matches!(err, CredentialError::NoCredentials { .. }));
assert!(chain.is_empty());
}
#[test]
fn chain_skips_none_resolvers() {
let chain = CredentialChain::new(
"test",
vec![
Box::new(StubResolver {
id: "empty1",
priority: 100,
credential: None,
login_supported: false,
}),
Box::new(StubResolver {
id: "empty2",
priority: 200,
credential: None,
login_supported: false,
}),
Box::new(StubResolver {
id: "found",
priority: 300,
credential: Some(ResolvedCredential::ApiKey("k".into())),
login_supported: false,
}),
],
);
let cred = chain.resolve().expect("should resolve");
assert_eq!(cred, ResolvedCredential::ApiKey("k".into()));
}
#[test]
fn status_reports_all_resolvers() {
let chain = CredentialChain::new(
"test",
vec![
Box::new(StubResolver {
id: "env",
priority: 100,
credential: Some(ResolvedCredential::ApiKey("k".into())),
login_supported: false,
}),
Box::new(StubResolver {
id: "oauth",
priority: 200,
credential: None,
login_supported: true,
}),
],
);
let statuses = chain.status();
assert_eq!(statuses.len(), 2);
assert!(statuses[0].available);
assert!(!statuses[0].supports_login);
assert!(!statuses[1].available);
assert!(statuses[1].supports_login);
}
#[test]
fn login_sources_filters_correctly() {
let chain = CredentialChain::new(
"test",
vec![
Box::new(StubResolver {
id: "env",
priority: 100,
credential: None,
login_supported: false,
}),
Box::new(StubResolver {
id: "oauth",
priority: 200,
credential: None,
login_supported: true,
}),
],
);
let sources = chain.login_sources();
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].id(), "oauth");
}
#[test]
fn get_resolver_finds_by_id() {
let chain = CredentialChain::new(
"test",
vec![Box::new(StubResolver {
id: "env",
priority: 100,
credential: None,
login_supported: false,
})],
);
assert!(chain.get_resolver("env").is_some());
assert!(chain.get_resolver("nonexistent").is_none());
}
#[test]
fn resolved_credential_debug_redacts() {
let key = ResolvedCredential::ApiKey("secret".into());
let debug = format!("{key:?}");
assert!(!debug.contains("secret"));
assert!(debug.contains("***"));
}
#[test]
fn credential_error_display() {
let err = CredentialError::NoCredentials {
provider: "Anthropic",
tried: vec!["env".into(), "oauth".into()],
};
let msg = err.to_string();
assert!(msg.contains("Anthropic"));
assert!(msg.contains("env, oauth"));
}
}