use std::fmt;
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,
client_id,
scopes,
..
} => f
.debug_struct("Credential::OAuth2")
.field("access_token", &"[REDACTED]")
.field("refresh_token", &"[REDACTED]")
.field("expires_at", expires_at)
.field("token_url", &"[REDACTED]")
.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, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CredentialType {
ApiKey,
Bearer,
OAuth2,
}
pub enum CredentialError {
NotFound {
key: String,
},
Expired {
key: String,
},
RefreshFailed {
key: String,
reason: String,
},
TypeMismatch {
key: String,
expected: CredentialType,
actual: CredentialType,
},
StoreError(Box<dyn std::error::Error + Send + Sync>),
Timeout {
key: String,
},
AuthorizationFailed {
key: String,
reason: String,
},
AuthorizationTimeout {
key: String,
},
}
impl fmt::Debug for CredentialError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound { key } => f
.debug_struct("CredentialError::NotFound")
.field("key", key)
.finish(),
Self::Expired { key } => f
.debug_struct("CredentialError::Expired")
.field("key", key)
.finish(),
Self::RefreshFailed { key, reason } => f
.debug_struct("CredentialError::RefreshFailed")
.field("key", key)
.field("reason", reason)
.finish(),
Self::TypeMismatch {
key,
expected,
actual,
} => f
.debug_struct("CredentialError::TypeMismatch")
.field("key", key)
.field("expected", expected)
.field("actual", actual)
.finish(),
Self::StoreError(_) => f
.debug_tuple("CredentialError::StoreError")
.field(&"[REDACTED]")
.finish(),
Self::Timeout { key } => f
.debug_struct("CredentialError::Timeout")
.field("key", key)
.finish(),
Self::AuthorizationFailed { key, reason } => f
.debug_struct("CredentialError::AuthorizationFailed")
.field("key", key)
.field("reason", reason)
.finish(),
Self::AuthorizationTimeout { key } => f
.debug_struct("CredentialError::AuthorizationTimeout")
.field("key", key)
.finish(),
}
}
}
impl std::fmt::Display for CredentialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound { key } => write!(f, "credential not found: {key}"),
Self::Expired { key } => write!(f, "credential expired: {key}"),
Self::RefreshFailed { key, reason } => {
write!(f, "credential refresh failed for {key}: {reason}")
}
Self::TypeMismatch {
key,
expected,
actual,
} => write!(
f,
"credential type mismatch for {key}: expected {expected:?}, got {actual:?}"
),
Self::StoreError(_) => f.write_str("credential store error"),
Self::Timeout { key } => write!(f, "credential resolution timed out for {key}"),
Self::AuthorizationFailed { key, reason } => {
write!(f, "authorization failed for {key}: {reason}")
}
Self::AuthorizationTimeout { key } => {
write!(f, "authorization timed out for {key}")
}
}
}
}
impl std::error::Error for CredentialError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::StoreError(error) => Some(&**error),
_ => None,
}
}
}
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() },
Self::AuthorizationFailed { key, reason } => Self::AuthorizationFailed {
key: key.clone(),
reason: reason.clone(),
},
Self::AuthorizationTimeout { key } => Self::AuthorizationTimeout { 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>;
}
pub trait AuthorizationHandler: Send + Sync {
fn authorize(&self, auth_url: &str, state: &str) -> CredentialFuture<'_, String>;
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as _;
#[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(),
},
CredentialError::AuthorizationFailed {
key: "my-key".into(),
reason: "user denied access".into(),
},
CredentialError::AuthorizationTimeout {
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_store_error_display_redacts_backend_details() {
let err = CredentialError::StoreError(Box::new(std::io::Error::other(
"backend exploded with token=secret-value",
)));
assert_eq!(err.to_string(), "credential store error");
let source = err.source().expect("store errors should retain the source");
assert!(
source.to_string().contains("token=secret-value"),
"store error source should keep the backend detail for internal diagnostics"
);
}
#[test]
fn credential_store_error_debug_redacts_backend_details() {
let err = CredentialError::StoreError(Box::new(std::io::Error::other(
"backend exploded with token=secret-value",
)));
let debug = format!("{err:?}");
assert!(
!debug.contains("token=secret-value"),
"Debug leaks backend secret"
);
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn oauth2_debug_redacts_token_url() {
let cred = Credential::OAuth2 {
access_token: "access-secret".into(),
refresh_token: Some("refresh-secret".into()),
expires_at: None,
token_url: "https://client:token-secret@auth.example.com/token?api_key=query-secret"
.into(),
client_id: "client-1".into(),
client_secret: Some("client-secret".into()),
scopes: vec!["read".into()],
};
let debug = format!("{cred:?}");
for secret in [
"access-secret",
"refresh-secret",
"client-secret",
"token-secret",
"query-secret",
] {
assert!(
!debug.contains(secret),
"Debug leaks OAuth2 secret {secret}"
);
}
assert!(debug.contains("token_url"));
assert!(debug.contains("[REDACTED]"));
}
#[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]"));
}
#[test]
fn authorization_failed_display_and_debug_contain_no_secrets() {
let err = CredentialError::AuthorizationFailed {
key: "google-calendar".into(),
reason: "token endpoint rejected code: HTTP 400 (invalid_grant)".into(),
};
let display = format!("{err}");
let debug = format!("{err:?}");
assert!(display.contains("google-calendar"));
assert!(debug.contains("google-calendar"));
assert!(!display.contains("access_token"));
assert!(!debug.contains("access_token"));
}
#[test]
fn authorization_timeout_display_and_debug_contain_key() {
let err = CredentialError::AuthorizationTimeout {
key: "google-calendar".into(),
};
assert!(format!("{err}").contains("google-calendar"));
assert!(format!("{err:?}").contains("google-calendar"));
}
#[test]
#[allow(clippy::redundant_clone)]
fn authorization_error_clone_preserves_fields() {
let failed = CredentialError::AuthorizationFailed {
key: "k".into(),
reason: "denied".into(),
};
match failed.clone() {
CredentialError::AuthorizationFailed { key, reason } => {
assert_eq!(key, "k");
assert_eq!(reason, "denied");
}
other => panic!("expected AuthorizationFailed, got {other:?}"),
}
let timed_out = CredentialError::AuthorizationTimeout { key: "k".into() };
match timed_out.clone() {
CredentialError::AuthorizationTimeout { key } => assert_eq!(key, "k"),
other => panic!("expected AuthorizationTimeout, got {other:?}"),
}
}
}