use async_trait::async_trait;
use camel_api::CamelError;
use camel_api::security_policy::Principal;
use std::fmt;
use tracing::warn;
pub struct NativeCredential {
pub secret: NativeCredentialSecret,
pub principal: Principal,
}
#[derive(Clone)]
pub enum NativeCredentialSecret {
Env { name: String },
Plaintext { value: String },
}
#[derive(Clone)]
struct ResolvedCredential {
secret_value: String,
principal: Principal,
}
pub struct NativeCredentialStore {
credentials: Vec<ResolvedCredential>,
}
impl fmt::Debug for NativeCredentialSecret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NativeCredentialSecret::Env { name } => {
write!(f, "Env {{ name: \"{name}\" }}") }
NativeCredentialSecret::Plaintext { .. } => {
write!(f, "Plaintext {{ value: \"[REDACTED]\" }}") }
}
}
}
impl fmt::Debug for NativeCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NativeCredential")
.field("secret", &self.secret)
.field("principal", &self.principal.subject)
.finish()
}
}
impl fmt::Debug for ResolvedCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ResolvedCredential")
.field("secret_value", &"[REDACTED]")
.field("principal", &self.principal.subject)
.finish()
}
}
impl fmt::Debug for NativeCredentialStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NativeCredentialStore")
.field("credential_count", &self.credentials.len())
.finish()
}
}
impl NativeCredentialStore {
pub fn try_new(credentials: Vec<NativeCredential>) -> Result<Self, CamelError> {
let mut resolved = Vec::with_capacity(credentials.len());
for c in credentials {
let secret_value = match &c.secret {
NativeCredentialSecret::Env { name } => {
let val = std::env::var(name).map_err(|_| {
CamelError::Config(format!("native auth env var not set: {name}"))
})?;
if val.is_empty() {
return Err(CamelError::Config(format!(
"native auth env var is empty: {name}"
)));
}
val
}
NativeCredentialSecret::Plaintext { value } => {
if value.is_empty() {
return Err(CamelError::Config(
"native auth plaintext secret is empty".into(),
));
}
warn!("native credential uses plaintext secret — use env vars in production");
value.clone()
}
};
resolved.push(ResolvedCredential {
secret_value,
principal: c.principal,
});
}
Ok(Self {
credentials: resolved,
})
}
pub fn lookup(&self, presented: &str) -> Option<&Principal> {
if presented.is_empty() {
return None;
}
for c in &self.credentials {
let a = c.secret_value.as_bytes();
let b = presented.as_bytes();
let mut acc: u8 = if a.len() != b.len() { 1 } else { 0 };
let max_len = a.len().max(b.len());
for i in 0..max_len {
let x = if i < a.len() { a[i] } else { 0 };
let y = if i < b.len() { b[i] } else { 0 };
acc |= x ^ y;
}
if acc == 0 {
return Some(&c.principal);
}
}
None
}
}
pub struct StaticTokenAuthenticator {
store: NativeCredentialStore,
}
impl fmt::Debug for StaticTokenAuthenticator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StaticTokenAuthenticator")
.field("store", &"[REDACTED]")
.finish()
}
}
impl StaticTokenAuthenticator {
pub fn new(store: NativeCredentialStore) -> Self {
Self { store }
}
}
#[async_trait]
impl crate::TokenAuthenticator for StaticTokenAuthenticator {
async fn authenticate_bearer(&self, token: &str) -> Result<Principal, CamelError> {
self.store
.lookup(token)
.cloned()
.ok_or_else(|| CamelError::Unauthenticated("invalid credential".into()))
}
}
pub struct ApiKeyAuthenticator {
header: String,
store: NativeCredentialStore,
}
impl fmt::Debug for ApiKeyAuthenticator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiKeyAuthenticator")
.field("header", &self.header)
.field("store", &"[REDACTED]")
.finish()
}
}
impl ApiKeyAuthenticator {
pub fn new(header: String, store: NativeCredentialStore) -> Self {
Self { header, store }
}
pub fn header(&self) -> &str {
&self.header
}
pub async fn authenticate_api_key(&self, key: &str) -> Result<Principal, CamelError> {
self.store
.lookup(key)
.cloned()
.ok_or_else(|| CamelError::Unauthenticated("invalid credential".into()))
}
pub async fn authenticate_exchange(
&self,
exchange: &mut camel_api::Exchange,
) -> Result<Principal, CamelError> {
let key = exchange
.input
.header_ic(&self.header)
.and_then(|v| v.as_str())
.ok_or_else(|| {
CamelError::Unauthenticated(format!("missing header: {}", self.header))
})?;
self.authenticate_api_key(key).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TokenAuthenticator;
use crate::built_in::RolePolicy;
use crate::built_in::ScopePolicy;
use camel_api::security_policy::SecurityPolicy;
use camel_api::{Exchange, Message};
fn test_principal(subject: &str, roles: Vec<&str>, scopes: Vec<&str>) -> Principal {
Principal {
subject: subject.to_string(),
issuer: "native".to_string(),
audience: vec![],
scopes: scopes.iter().map(|s| s.to_string()).collect(),
roles: roles.iter().map(|s| s.to_string()).collect(),
claims: serde_json::Value::Null,
}
}
#[test]
fn test_store_finds_matching_plaintext_credential() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "secret-key-123".to_string(),
},
principal: test_principal("admin", vec!["admin"], vec![]),
}])
.unwrap();
let found = store.lookup("secret-key-123");
assert!(found.is_some());
assert_eq!(found.unwrap().subject, "admin");
}
#[test]
fn test_store_returns_none_on_no_match() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "secret-key-123".to_string(),
},
principal: test_principal("admin", vec!["admin"], vec![]),
}])
.unwrap();
let found = store.lookup("wrong-key");
assert!(found.is_none());
}
#[test]
fn test_store_returns_none_on_empty_input() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "secret-key-123".to_string(),
},
principal: test_principal("admin", vec!["admin"], vec![]),
}])
.unwrap();
let found = store.lookup("");
assert!(found.is_none());
}
#[test]
fn test_store_resolves_env_var() {
let key = format!("TEST_NATIVE_AUTH_KEY_{}", std::process::id());
unsafe { std::env::set_var(&key, "env-secret-value") };
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Env { name: key.clone() },
principal: test_principal("env-user", vec!["user"], vec![]),
}])
.unwrap();
let found = store.lookup("env-secret-value");
assert!(found.is_some());
assert_eq!(found.unwrap().subject, "env-user");
unsafe { std::env::remove_var(&key) };
}
#[test]
fn test_store_rejects_missing_env_var() {
let result = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Env {
name: "SURELY_MISSING_ENV_VAR_XYZ_12345".to_string(),
},
principal: test_principal("bad", vec![], vec![]),
}]);
assert!(result.is_err());
}
#[test]
fn test_store_rejects_empty_plaintext() {
let result = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "".to_string(),
},
principal: test_principal("bad", vec![], vec![]),
}]);
assert!(result.is_err());
}
#[test]
fn test_store_accepts_plaintext_for_dev() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "insecure".to_string(),
},
principal: test_principal("dev", vec![], vec![]),
}])
.unwrap();
assert!(store.lookup("insecure").is_some());
}
#[tokio::test]
async fn test_static_token_authenticator_valid_token() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "my-bearer-token".to_string(),
},
principal: test_principal("svc-account", vec!["service"], vec![]),
}])
.unwrap();
let auth = StaticTokenAuthenticator::new(store);
let result = auth.authenticate_bearer("my-bearer-token").await;
assert!(result.is_ok());
assert_eq!(result.unwrap().subject, "svc-account");
}
#[tokio::test]
async fn test_static_token_authenticator_invalid_token() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "my-bearer-token".to_string(),
},
principal: test_principal("svc-account", vec!["service"], vec![]),
}])
.unwrap();
let auth = StaticTokenAuthenticator::new(store);
let result = auth.authenticate_bearer("wrong-token").await;
assert!(result.is_err());
match result.unwrap_err() {
CamelError::Unauthenticated(msg) => {
assert!(msg.contains("invalid credential"))
}
e => panic!("expected Unauthenticated, got: {e:?}"),
}
}
#[tokio::test]
async fn test_api_key_authenticator_valid_key() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "ak-12345".to_string(),
},
principal: test_principal("api-user", vec!["read"], vec!["api:read"]),
}])
.unwrap();
let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
let result = auth.authenticate_api_key("ak-12345").await;
assert!(result.is_ok());
assert_eq!(result.unwrap().subject, "api-user");
}
#[tokio::test]
async fn test_api_key_authenticator_invalid_key() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "ak-12345".to_string(),
},
principal: test_principal("api-user", vec!["read"], vec![]),
}])
.unwrap();
let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
let result = auth.authenticate_api_key("wrong").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_api_key_authenticate_exchange() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "ak-exchange".to_string(),
},
principal: test_principal("ex-user", vec!["read"], vec![]),
}])
.unwrap();
let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
let mut exchange = Exchange::new(Message::default());
exchange.input.set_header("x-api-key", "ak-exchange");
let result = auth.authenticate_exchange(&mut exchange).await;
assert!(result.is_ok());
assert_eq!(result.unwrap().subject, "ex-user");
}
#[tokio::test]
async fn test_api_key_authenticate_exchange_missing_header() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "ak-exchange".to_string(),
},
principal: test_principal("ex-user", vec!["read"], vec![]),
}])
.unwrap();
let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
let mut exchange = Exchange::new(Message::default());
let result = auth.authenticate_exchange(&mut exchange).await;
assert!(result.is_err());
}
#[test]
fn test_api_key_authenticator_exposes_header() {
let store = NativeCredentialStore::try_new(vec![]).unwrap();
let auth = ApiKeyAuthenticator::new("x-api-key".to_string(), store);
assert_eq!(auth.header(), "x-api-key");
}
#[tokio::test]
async fn test_static_token_works_with_role_policy() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "test-token".to_string(),
},
principal: test_principal("admin-user", vec!["admin"], vec![]),
}])
.unwrap();
let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
std::sync::Arc::new(StaticTokenAuthenticator::new(store));
let policy = RolePolicy::new(vec!["admin".to_string()], true, authenticator);
let mut exchange = Exchange::new(Message::default());
exchange
.input
.set_header("authorization", "Bearer test-token");
let decision = policy.evaluate(&mut exchange).await.unwrap();
assert!(matches!(
decision,
camel_api::security_policy::AuthorizationDecision::Granted { .. }
));
}
#[tokio::test]
async fn test_static_token_works_with_scope_policy() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "scoped-token".to_string(),
},
principal: test_principal("reader", vec![], vec!["api:read"]),
}])
.unwrap();
let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
std::sync::Arc::new(StaticTokenAuthenticator::new(store));
let policy = ScopePolicy::new(vec!["api:read".to_string()], true, authenticator);
let mut exchange = Exchange::new(Message::default());
exchange
.input
.set_header("authorization", "Bearer scoped-token");
let decision = policy.evaluate(&mut exchange).await.unwrap();
assert!(matches!(
decision,
camel_api::security_policy::AuthorizationDecision::Granted { .. }
));
}
#[tokio::test]
async fn test_static_token_denied_by_role_policy() {
let store = NativeCredentialStore::try_new(vec![NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: "user-token".to_string(),
},
principal: test_principal("user", vec!["user"], vec![]),
}])
.unwrap();
let authenticator: std::sync::Arc<dyn TokenAuthenticator> =
std::sync::Arc::new(StaticTokenAuthenticator::new(store));
let policy = RolePolicy::new(vec!["admin".to_string()], true, authenticator);
let mut exchange = Exchange::new(Message::default());
exchange
.input
.set_header("authorization", "Bearer user-token");
let decision = policy.evaluate(&mut exchange).await.unwrap();
assert!(matches!(
decision,
camel_api::security_policy::AuthorizationDecision::Denied { .. }
));
}
}