use std::future::Future;
use std::pin::Pin;
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Credential {
ApiKey {
key: String,
},
Bearer {
token: String,
#[serde(default)]
expires_at: Option<chrono::DateTime<chrono::Utc>>,
},
OAuth2 {
access_token: String,
refresh_token: Option<String>,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
token_url: String,
client_id: String,
client_secret: Option<String>,
#[serde(default)]
scopes: Vec<String>,
},
}
impl std::fmt::Debug for Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ApiKey { .. } => f
.debug_struct("Credential::ApiKey")
.field("key", &"[REDACTED]")
.finish(),
Self::Bearer { expires_at, .. } => f
.debug_struct("Credential::Bearer")
.field("token", &"[REDACTED]")
.field("expires_at", expires_at)
.finish(),
Self::OAuth2 {
expires_at,
token_url,
client_id,
scopes,
..
} => f
.debug_struct("Credential::OAuth2")
.field("access_token", &"[REDACTED]")
.field("refresh_token", &"[REDACTED]")
.field("expires_at", expires_at)
.field("token_url", token_url)
.field("client_id", client_id)
.field("client_secret", &"[REDACTED]")
.field("scopes", scopes)
.finish(),
}
}
}
impl Credential {
#[must_use]
pub const fn credential_type(&self) -> CredentialType {
match self {
Self::ApiKey { .. } => CredentialType::ApiKey,
Self::Bearer { .. } => CredentialType::Bearer,
Self::OAuth2 { .. } => CredentialType::OAuth2,
}
}
}
#[derive(Clone)]
pub enum ResolvedCredential {
ApiKey(String),
Bearer(String),
OAuth2AccessToken(String),
}
impl std::fmt::Debug for ResolvedCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ApiKey(_) => f
.debug_tuple("ResolvedCredential::ApiKey")
.field(&"[REDACTED]")
.finish(),
Self::Bearer(_) => f
.debug_tuple("ResolvedCredential::Bearer")
.field(&"[REDACTED]")
.finish(),
Self::OAuth2AccessToken(_) => f
.debug_tuple("ResolvedCredential::OAuth2AccessToken")
.field(&"[REDACTED]")
.finish(),
}
}
}
#[derive(Debug, Clone)]
pub struct AuthConfig {
pub credential_key: String,
pub auth_scheme: AuthScheme,
pub credential_type: CredentialType,
}
#[derive(Debug, Clone)]
pub enum AuthScheme {
BearerHeader,
ApiKeyHeader(String),
ApiKeyQuery(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialType {
ApiKey,
Bearer,
OAuth2,
}
#[derive(Debug, thiserror::Error)]
pub enum CredentialError {
#[error("credential not found: {key}")]
NotFound {
key: String,
},
#[error("credential expired: {key}")]
Expired {
key: String,
},
#[error("credential refresh failed for {key}: {reason}")]
RefreshFailed {
key: String,
reason: String,
},
#[error("credential type mismatch for {key}: expected {expected:?}, got {actual:?}")]
TypeMismatch {
key: String,
expected: CredentialType,
actual: CredentialType,
},
#[error("credential store error: {0}")]
StoreError(Box<dyn std::error::Error + Send + Sync>),
#[error("credential resolution timed out for {key}")]
Timeout {
key: String,
},
}
impl Clone for CredentialError {
fn clone(&self) -> Self {
match self {
Self::NotFound { key } => Self::NotFound { key: key.clone() },
Self::Expired { key } => Self::Expired { key: key.clone() },
Self::RefreshFailed { key, reason } => Self::RefreshFailed {
key: key.clone(),
reason: reason.clone(),
},
Self::TypeMismatch {
key,
expected,
actual,
} => Self::TypeMismatch {
key: key.clone(),
expected: *expected,
actual: *actual,
},
Self::StoreError(error) => {
Self::StoreError(Box::new(std::io::Error::other(error.to_string())))
}
Self::Timeout { key } => Self::Timeout { key: key.clone() },
}
}
}
pub type CredentialFuture<'a, T> =
Pin<Box<dyn Future<Output = Result<T, CredentialError>> + Send + 'a>>;
pub trait CredentialStore: Send + Sync {
fn get(&self, key: &str) -> CredentialFuture<'_, Option<Credential>>;
fn set(&self, key: &str, credential: Credential) -> CredentialFuture<'_, ()>;
fn delete(&self, key: &str) -> CredentialFuture<'_, ()>;
}
pub trait CredentialResolver: Send + Sync {
fn resolve(&self, key: &str) -> CredentialFuture<'_, ResolvedCredential>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credential_serde_roundtrip_api_key() {
let cred = Credential::ApiKey {
key: "sk-test-123".into(),
};
let json = serde_json::to_string(&cred).unwrap();
let decoded: Credential = serde_json::from_str(&json).unwrap();
match decoded {
Credential::ApiKey { key } => assert_eq!(key, "sk-test-123"),
other => panic!("expected ApiKey, got {other:?}"),
}
}
#[test]
fn credential_serde_roundtrip_bearer() {
let cred = Credential::Bearer {
token: "tok-abc".into(),
expires_at: Some(chrono::Utc::now()),
};
let json = serde_json::to_string(&cred).unwrap();
let decoded: Credential = serde_json::from_str(&json).unwrap();
match decoded {
Credential::Bearer { token, expires_at } => {
assert_eq!(token, "tok-abc");
assert!(expires_at.is_some());
}
other => panic!("expected Bearer, got {other:?}"),
}
}
#[test]
fn credential_serde_roundtrip_oauth2() {
let cred = Credential::OAuth2 {
access_token: "access-123".into(),
refresh_token: Some("refresh-456".into()),
expires_at: None,
token_url: "https://auth.example.com/token".into(),
client_id: "client-1".into(),
client_secret: Some("secret".into()),
scopes: vec!["read".into(), "write".into()],
};
let json = serde_json::to_string(&cred).unwrap();
let decoded: Credential = serde_json::from_str(&json).unwrap();
match decoded {
Credential::OAuth2 {
access_token,
refresh_token,
client_id,
scopes,
..
} => {
assert_eq!(access_token, "access-123");
assert_eq!(refresh_token.as_deref(), Some("refresh-456"));
assert_eq!(client_id, "client-1");
assert_eq!(scopes, vec!["read", "write"]);
}
other => panic!("expected OAuth2, got {other:?}"),
}
}
#[test]
fn credential_error_display_no_secrets() {
let errors = vec![
CredentialError::NotFound {
key: "my-key".into(),
},
CredentialError::Expired {
key: "my-key".into(),
},
CredentialError::RefreshFailed {
key: "my-key".into(),
reason: "bad response".into(),
},
CredentialError::TypeMismatch {
key: "my-key".into(),
expected: CredentialType::Bearer,
actual: CredentialType::ApiKey,
},
CredentialError::Timeout {
key: "my-key".into(),
},
];
let secret_values = [
"sk-test-123",
"tok-abc",
"access-123",
"refresh-456",
"secret",
];
for err in &errors {
let display = format!("{err}");
for secret in &secret_values {
assert!(
!display.contains(secret),
"Display of {err:?} leaks secret {secret}"
);
}
assert!(
display.contains("my-key"),
"Display of {err:?} should contain key name"
);
}
}
#[test]
fn credential_type_helper() {
let api_key = Credential::ApiKey { key: "k".into() };
assert_eq!(api_key.credential_type(), CredentialType::ApiKey);
let bearer = Credential::Bearer {
token: "t".into(),
expires_at: None,
};
assert_eq!(bearer.credential_type(), CredentialType::Bearer);
let oauth2 = Credential::OAuth2 {
access_token: "a".into(),
refresh_token: None,
expires_at: None,
token_url: "https://example.com/token".into(),
client_id: "c".into(),
client_secret: None,
scopes: vec![],
};
assert_eq!(oauth2.credential_type(), CredentialType::OAuth2);
}
#[test]
fn debug_impl_redacts_secrets() {
let cred = Credential::ApiKey {
key: "super-secret".into(),
};
let debug = format!("{cred:?}");
assert!(!debug.contains("super-secret"), "Debug leaks secret");
assert!(debug.contains("[REDACTED]"));
let resolved = ResolvedCredential::ApiKey("my-secret".into());
let debug = format!("{resolved:?}");
assert!(!debug.contains("my-secret"), "Debug leaks secret");
assert!(debug.contains("[REDACTED]"));
}
}