use std::sync::Arc;
use chrono::{DateTime, Utc};
use secrecy::{ExposeSecret, SecretString};
use wyrd_spec::auth::{SecretBearer, TokenAudience};
use crate::auth::AuthMiddleware;
use crate::environment::Environment;
use crate::error::WyrdClientError;
#[derive(Debug)]
pub struct MintedAccessToken {
pub access_token: SecretBearer,
pub expires_at: DateTime<Utc>,
}
pub trait AccessTokenSource: Send + Sync {
fn identity(&self) -> &str;
fn mint(&self) -> Result<MintedAccessToken, WyrdClientError>;
}
#[derive(Clone)]
pub enum ResolvedCredential {
Renewable(Arc<dyn AccessTokenSource>),
BearerToken(SecretString),
WorkloadJwt {
jwt: SecretString,
tenant: String,
},
ApiKey(SecretString),
Delegated {
subject_token: SecretString,
audience: TokenAudience,
actor: Arc<AuthMiddleware>,
},
}
impl std::fmt::Debug for ResolvedCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Renewable(source) => f
.debug_struct("Renewable")
.field("identity", &source.identity())
.finish(),
Self::BearerToken(_) => f.debug_tuple("BearerToken").field(&"[REDACTED]").finish(),
Self::WorkloadJwt { tenant, .. } => f
.debug_struct("WorkloadJwt")
.field("jwt", &"[REDACTED]")
.field("tenant", tenant)
.finish(),
Self::ApiKey(_) => f.debug_tuple("ApiKey").field(&"[REDACTED]").finish(),
Self::Delegated { audience, .. } => f
.debug_struct("Delegated")
.field("subject_token", &"[REDACTED]")
.field("audience", audience)
.finish_non_exhaustive(),
}
}
}
#[derive(Clone)]
pub enum CredentialSource {
ExplicitToken {
token: SecretString,
},
WorkloadToken {
jwt: SecretString,
tenant: String,
},
ApiKey {
key: SecretString,
},
}
pub const API_KEY_PREFIX: &str = "wyrd_sk_";
impl CredentialSource {
#[must_use]
pub fn explicit(credential: SecretString) -> Self {
if credential.expose_secret().starts_with(API_KEY_PREFIX) {
Self::ApiKey { key: credential }
} else {
Self::ExplicitToken { token: credential }
}
}
}
impl std::fmt::Debug for CredentialSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ExplicitToken { .. } => f
.debug_struct("ExplicitToken")
.field("token", &"[REDACTED]")
.finish(),
Self::WorkloadToken { tenant, .. } => f
.debug_struct("WorkloadToken")
.field("jwt", &"[REDACTED]")
.field("tenant", tenant)
.finish(),
Self::ApiKey { .. } => f
.debug_struct("ApiKey")
.field("key", &"[REDACTED]")
.finish(),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct CredentialChain {
sources: Vec<CredentialSource>,
}
impl CredentialChain {
#[must_use]
pub fn from_env() -> Self {
Self::from_env_with_tenant(None)
}
#[must_use]
pub fn from_env_with_tenant(tenant_override: Option<&str>) -> Self {
let mut chain = Self::env_only(&Environment::Process, tenant_override);
chain.extend(Self::credentials_file(&Environment::Process));
chain
}
#[must_use]
pub fn env_only(environment: &Environment, tenant_override: Option<&str>) -> Self {
Self {
sources: [
explicit_token_from_env(environment),
workload_token_from_env(environment, tenant_override),
api_key_from_env(environment),
]
.into_iter()
.flatten()
.collect(),
}
}
#[must_use]
pub fn credentials_file(environment: &Environment) -> Self {
Self {
sources: api_key_from_credentials_file(environment)
.into_iter()
.collect(),
}
}
pub fn push(&mut self, source: CredentialSource) {
self.sources.push(source);
}
pub fn extend(&mut self, other: CredentialChain) {
self.sources.extend(other.sources);
}
pub fn resolve(&self) -> Result<ResolvedCredential, WyrdClientError> {
self.sources
.first()
.map_or(Err(WyrdClientError::NoCredentials), |source| match source {
CredentialSource::ExplicitToken { token } => {
Ok(ResolvedCredential::BearerToken(token.clone()))
}
CredentialSource::WorkloadToken { jwt, tenant } => {
Ok(ResolvedCredential::WorkloadJwt {
jwt: jwt.clone(),
tenant: tenant.clone(),
})
}
CredentialSource::ApiKey { key } => Ok(ResolvedCredential::ApiKey(key.clone())),
})
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
}
fn explicit_token_from_env(environment: &Environment) -> Option<CredentialSource> {
let token = environment.var("WYRD_ACCESS_TOKEN")?;
Some(CredentialSource::ExplicitToken {
token: SecretString::from(token),
})
}
fn workload_token_from_env(
environment: &Environment,
tenant_override: Option<&str>,
) -> Option<CredentialSource> {
let jwt = environment.var("WYRD_WORKLOAD_TOKEN")?;
let tenant = tenant_override
.filter(|value| !value.is_empty())
.map(str::to_owned)
.or_else(|| environment.var("WYRD_TENANT"))?;
Some(CredentialSource::WorkloadToken {
jwt: SecretString::from(jwt),
tenant,
})
}
fn api_key_from_env(environment: &Environment) -> Option<CredentialSource> {
let key = environment.var("WYRD_API_KEY")?;
Some(CredentialSource::ApiKey {
key: SecretString::from(key),
})
}
fn api_key_from_credentials_file(environment: &Environment) -> Option<CredentialSource> {
let key = read_credentials_toml_api_key(environment)?;
Some(CredentialSource::ApiKey {
key: SecretString::from(key),
})
}
fn read_credentials_toml_api_key(environment: &Environment) -> Option<String> {
#[derive(serde::Deserialize)]
struct CredentialsFile {
default: Option<DefaultProfile>,
}
#[derive(serde::Deserialize)]
struct DefaultProfile {
api_key: Option<String>,
}
let path = environment.config_dir()?.join("credentials.toml");
let content = std::fs::read_to_string(path).ok()?;
let parsed: CredentialsFile = toml::from_str(&content).ok()?;
parsed.default?.api_key.filter(|k| !k.is_empty())
}
#[cfg(test)]
mod tests {
use secrecy::ExposeSecret;
use super::{CredentialChain, CredentialSource, ResolvedCredential};
use crate::environment::Environment;
#[test]
fn explicit_credential_routes_by_its_own_prefix() {
let mut chain = CredentialChain::default();
chain.push(CredentialSource::explicit(
"wyrd_sk_tenant_visible_secret".to_owned().into(),
));
match chain.resolve().expect("an API key resolves") {
ResolvedCredential::ApiKey(key) => {
assert_eq!(key.expose_secret(), "wyrd_sk_tenant_visible_secret");
}
other => panic!("a wyrd_sk_ credential is an API key, got {other:?}"),
}
let mut chain = CredentialChain::default();
chain.push(CredentialSource::explicit(
"eyJhbGciOi.token".to_owned().into(),
));
match chain.resolve().expect("an access token resolves") {
ResolvedCredential::BearerToken(token) => {
assert_eq!(token.expose_secret(), "eyJhbGciOi.token");
}
other => panic!("an unprefixed credential is a bearer token, got {other:?}"),
}
}
#[test]
fn empty_chain_returns_no_credentials() {
let chain = CredentialChain::default();
assert!(chain.resolve().is_err());
}
#[test]
fn explicit_token_tier_resolves_first() {
let mut chain = CredentialChain::default();
chain.push(CredentialSource::ExplicitToken {
token: "access-tok".to_owned().into(),
});
chain.push(CredentialSource::ApiKey {
key: "api-key".to_owned().into(),
});
let cred = chain.resolve().expect("chain resolves");
match cred {
ResolvedCredential::BearerToken(t) => {
assert_eq!(
t.expose_secret(),
"access-tok",
"explicit token wins over api key"
);
}
_ => panic!("expected BearerToken"),
}
}
#[test]
fn api_key_resolves_when_no_token() {
let mut chain = CredentialChain::default();
chain.push(CredentialSource::ApiKey {
key: "k_abc".to_owned().into(),
});
let cred = chain.resolve().expect("chain resolves");
match cred {
ResolvedCredential::ApiKey(k) => {
assert_eq!(k.expose_secret(), "k_abc", "api key resolves");
}
_ => panic!("expected ApiKey"),
}
}
#[test]
fn first_source_wins_when_multiple_tokens() {
let mut chain = CredentialChain::default();
chain.push(CredentialSource::ExplicitToken {
token: "first".to_owned().into(),
});
chain.push(CredentialSource::ExplicitToken {
token: "second".to_owned().into(),
});
let cred = chain.resolve().expect("resolves");
match cred {
ResolvedCredential::BearerToken(t) => {
assert_eq!(t.expose_secret(), "first");
}
_ => panic!("expected BearerToken"),
}
}
#[test]
fn is_empty_reflects_chain_state() {
let mut chain = CredentialChain::default();
assert!(chain.is_empty());
chain.push(CredentialSource::ApiKey {
key: "k".to_owned().into(),
});
assert!(!chain.is_empty());
}
#[test]
fn workload_token_tier_resolves_before_api_key() {
let mut chain = CredentialChain::default();
chain.push(CredentialSource::WorkloadToken {
jwt: "workload.jwt.token".to_owned().into(),
tenant: "acme".to_owned(),
});
chain.push(CredentialSource::ApiKey {
key: "api-key-fallback".to_owned().into(),
});
let cred = chain.resolve().expect("chain resolves");
match cred {
ResolvedCredential::WorkloadJwt { jwt, tenant } => {
assert_eq!(
jwt.expose_secret(),
"workload.jwt.token",
"workload token wins over api key"
);
assert_eq!(tenant, "acme");
}
_ => panic!("expected WorkloadJwt"),
}
}
#[test]
fn explicit_token_wins_over_workload_and_api_key() {
let mut chain = CredentialChain::default();
chain.push(CredentialSource::ExplicitToken {
token: "explicit".to_owned().into(),
});
chain.push(CredentialSource::WorkloadToken {
jwt: "workload.jwt".to_owned().into(),
tenant: "t".to_owned(),
});
chain.push(CredentialSource::ApiKey {
key: "api-key".to_owned().into(),
});
let cred = chain.resolve().expect("chain resolves");
match cred {
ResolvedCredential::BearerToken(t) => {
assert_eq!(
t.expose_secret(),
"explicit",
"explicit token wins over workload and api key"
);
}
_ => panic!("expected BearerToken"),
}
}
#[test]
fn redacted_debug_does_not_leak_secrets() {
let mut chain = CredentialChain::default();
chain.push(CredentialSource::ExplicitToken {
token: "super-secret-token".to_owned().into(),
});
let cred = chain.resolve().expect("resolves");
let src_debug = format!("{chain:?}");
assert!(!src_debug.contains("super-secret-token"));
assert!(src_debug.contains("REDACTED"));
let cred_debug = format!("{cred:?}");
assert!(!cred_debug.contains("super-secret-token"));
assert!(cred_debug.contains("REDACTED"));
}
#[test]
fn credentials_toml_floor_resolves_api_key() {
let home = tempfile::tempdir().expect("tempdir");
std::fs::write(
home.path().join("credentials.toml"),
"[default]\napi_key = \"file_floor_key\"\n",
)
.expect("writes credentials.toml");
let environment = Environment::from([(
"WYRD_CONFIG_HOME",
home.path().to_str().expect("utf-8 path"),
)]);
let cred = CredentialChain::credentials_file(&environment)
.resolve()
.expect("file floor resolves");
match cred {
ResolvedCredential::ApiKey(k) => assert_eq!(k.expose_secret(), "file_floor_key"),
_ => panic!("expected ApiKey from credentials.toml floor"),
}
}
#[test]
fn xdg_config_home_resolves_credentials_file() {
let xdg = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(xdg.path().join("wyrd")).expect("creates wyrd dir");
std::fs::write(
xdg.path().join("wyrd/credentials.toml"),
"[default]\napi_key = \"xdg_key\"\n",
)
.expect("writes credentials.toml");
let environment =
Environment::from([("XDG_CONFIG_HOME", xdg.path().to_str().expect("utf-8 path"))]);
let credential = CredentialChain::credentials_file(&environment)
.resolve()
.expect("XDG credentials file resolves");
match credential {
ResolvedCredential::ApiKey(key) => assert_eq!(key.expose_secret(), "xdg_key"),
other => panic!("expected ApiKey from XDG credentials.toml, got {other:?}"),
}
}
}