use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use parking_lot::RwLock;
use ring::rand::{SecureRandom, SystemRandom};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::time::{Duration, Instant, SystemTime};
use thiserror::Error;
use tracing::{debug, info, warn};
#[derive(Error, Debug)]
pub enum ServiceAuthError {
#[error("Invalid API key format")]
InvalidKeyFormat,
#[error("API key not found: {0}")]
KeyNotFound(String),
#[error("API key expired")]
KeyExpired,
#[error("API key revoked")]
KeyRevoked,
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Certificate error: {0}")]
CertificateError(String),
#[error("Service account not found: {0}")]
ServiceAccountNotFound(String),
#[error("Service account disabled: {0}")]
ServiceAccountDisabled(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Rate limited: too many authentication attempts")]
RateLimited,
#[error("Internal error: {0}")]
Internal(String),
}
pub type ServiceAuthResult<T> = Result<T, ServiceAuthError>;
#[derive(Clone, Serialize, Deserialize)]
pub struct ApiKey {
pub key_id: String,
pub secret_hash: String,
pub service_account: String,
pub description: Option<String>,
pub created_at: SystemTime,
pub expires_at: Option<SystemTime>,
pub last_used_at: Option<SystemTime>,
pub revoked: bool,
pub allowed_ips: Vec<String>,
pub permissions: Vec<String>,
}
impl std::fmt::Debug for ApiKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiKey")
.field("key_id", &self.key_id)
.field("secret_hash", &"[REDACTED]")
.field("service_account", &self.service_account)
.field("description", &self.description)
.field("created_at", &self.created_at)
.field("expires_at", &self.expires_at)
.field("last_used_at", &self.last_used_at)
.field("revoked", &self.revoked)
.field("allowed_ips", &self.allowed_ips)
.field("permissions", &self.permissions)
.finish()
}
}
impl ApiKey {
pub fn generate(
service_account: &str,
description: Option<&str>,
expires_in: Option<Duration>,
permissions: Vec<String>,
) -> ServiceAuthResult<(Self, String)> {
let rng = SystemRandom::new();
let mut key_id_bytes = [0u8; 8];
rng.fill(&mut key_id_bytes)
.map_err(|_| ServiceAuthError::Internal("RNG failed".into()))?;
let key_id = hex::encode(key_id_bytes);
let mut secret_bytes = [0u8; 32];
rng.fill(&mut secret_bytes)
.map_err(|_| ServiceAuthError::Internal("RNG failed".into()))?;
let secret = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD_NO_PAD,
secret_bytes,
);
let secret_hash = Self::hash_secret(&secret);
let full_key = format!("rvn.v1.{}.{}", key_id, secret);
let now = SystemTime::now();
let expires_at = expires_in.map(|d| now + d);
let api_key = Self {
key_id,
secret_hash,
service_account: service_account.to_string(),
description: description.map(|s| s.to_string()),
created_at: now,
expires_at,
last_used_at: None,
revoked: false,
allowed_ips: vec![],
permissions,
};
Ok((api_key, full_key))
}
fn hash_secret(secret: &str) -> String {
let rng = SystemRandom::new();
let mut salt_bytes = [0u8; 16];
rng.fill(&mut salt_bytes)
.expect("SystemRandom fill should not fail");
let salt = SaltString::encode_b64(&salt_bytes).expect("salt encoding should not fail");
let argon2 = Argon2::default();
argon2
.hash_password(secret.as_bytes(), &salt)
.expect("Argon2 hashing should not fail")
.to_string()
}
fn hash_secret_sha256(secret: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
hex::encode(hasher.finalize())
}
pub fn parse_key(key: &str) -> ServiceAuthResult<(String, String)> {
let parts: Vec<&str> = key.split('.').collect();
if parts.len() != 4 || parts[0] != "rvn" || parts[1] != "v1" {
return Err(ServiceAuthError::InvalidKeyFormat);
}
let key_id = parts[2].to_string();
let secret = parts[3].to_string();
Ok((key_id, secret))
}
pub fn verify_secret(&self, secret: &str) -> bool {
if self.secret_hash.starts_with("$argon2") {
match PasswordHash::new(&self.secret_hash) {
Ok(parsed_hash) => Argon2::default()
.verify_password(secret.as_bytes(), &parsed_hash)
.is_ok(),
Err(_) => false,
}
} else {
warn!(
key_id = %self.key_id,
"API key is using legacy SHA-256 hash — schedule upgrade to Argon2id"
);
let provided_hash = Self::hash_secret_sha256(secret);
if provided_hash.len() != self.secret_hash.len() {
return false;
}
let mut result = 0u8;
for (a, b) in provided_hash
.as_bytes()
.iter()
.zip(self.secret_hash.as_bytes())
{
result |= a ^ b;
}
result == 0
}
}
pub fn needs_rehash(&self) -> bool {
!self.secret_hash.starts_with("$argon2")
}
pub fn upgrade_to_argon2(&mut self, secret: &str) {
let new_hash = Self::hash_secret(secret);
info!(
key_id = %self.key_id,
"Upgraded API key hash from legacy SHA-256 to Argon2id"
);
self.secret_hash = new_hash;
}
pub fn is_valid(&self) -> bool {
if self.revoked {
return false;
}
if let Some(expires_at) = self.expires_at {
if SystemTime::now() > expires_at {
return false;
}
}
true
}
pub fn is_ip_allowed(&self, ip: &str) -> bool {
if self.allowed_ips.is_empty() {
return true;
}
self.allowed_ips.iter().any(|allowed| {
if allowed.contains('/') {
Self::ip_in_cidr(ip, allowed)
} else {
allowed == ip
}
})
}
fn ip_in_cidr(ip: &str, cidr: &str) -> bool {
use std::net::IpAddr;
let parts: Vec<&str> = cidr.split('/').collect();
if parts.len() != 2 {
return false;
}
let prefix_len: u32 = match parts[1].parse() {
Ok(v) => v,
Err(_) => return false,
};
let ip_addr: IpAddr = match ip.parse() {
Ok(v) => v,
Err(_) => return false,
};
let net_addr: IpAddr = match parts[0].parse() {
Ok(v) => v,
Err(_) => return false,
};
match (ip_addr, net_addr) {
(IpAddr::V4(ip4), IpAddr::V4(net4)) => {
if prefix_len > 32 {
return false;
}
let mask = if prefix_len == 0 {
0u32
} else {
!0u32 << (32 - prefix_len)
};
let ip_num = u32::from(ip4);
let net_num = u32::from(net4);
(ip_num & mask) == (net_num & mask)
}
(IpAddr::V6(ip6), IpAddr::V6(net6)) => {
if prefix_len > 128 {
return false;
}
let ip_bits = u128::from(ip6);
let net_bits = u128::from(net6);
let mask = if prefix_len == 0 {
0u128
} else {
!0u128 << (128 - prefix_len)
};
(ip_bits & mask) == (net_bits & mask)
}
_ => false, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceAccount {
pub name: String,
pub description: Option<String>,
pub enabled: bool,
pub created_at: SystemTime,
pub roles: Vec<String>,
pub metadata: HashMap<String, String>,
pub certificate_subject: Option<String>,
pub oidc_client_id: Option<String>,
}
impl ServiceAccount {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
enabled: true,
created_at: SystemTime::now(),
roles: vec![],
metadata: HashMap::new(),
certificate_subject: None,
oidc_client_id: None,
}
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_roles(mut self, roles: Vec<String>) -> Self {
self.roles = roles;
self
}
pub fn with_certificate_subject(mut self, subject: impl Into<String>) -> Self {
self.certificate_subject = Some(subject.into());
self
}
pub fn with_oidc_client_id(mut self, client_id: impl Into<String>) -> Self {
self.oidc_client_id = Some(client_id.into());
self
}
}
#[derive(Debug, Clone)]
pub struct ServiceSession {
pub id: String,
pub service_account: String,
pub auth_method: AuthMethod,
expires_at: Instant,
pub created_timestamp: SystemTime,
pub permissions: Vec<String>,
pub client_ip: String,
pub api_key_id: Option<String>,
}
impl crate::auth::Session for ServiceSession {
fn session_id(&self) -> &str {
&self.id
}
fn principal(&self) -> &str {
&self.service_account
}
fn is_expired(&self) -> bool {
Instant::now() > self.expires_at
}
fn client_ip(&self) -> &str {
&self.client_ip
}
}
impl ServiceSession {
pub fn is_expired(&self) -> bool {
<Self as crate::auth::Session>::is_expired(self)
}
pub fn time_until_expiration(&self) -> Duration {
self.expires_at.saturating_duration_since(Instant::now())
}
pub fn expires_in_secs(&self) -> u64 {
self.time_until_expiration().as_secs()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AuthMethod {
ApiKey,
MutualTls,
OidcClientCredentials,
SaslScram,
}
struct AuthRateLimiter {
attempts: HashMap<String, Vec<Instant>>,
max_attempts: usize,
window: Duration,
}
impl AuthRateLimiter {
fn new(max_attempts: usize, window: Duration) -> Self {
Self {
attempts: HashMap::new(),
max_attempts,
window,
}
}
fn check_and_record(&mut self, key: &str) -> bool {
let now = Instant::now();
let cutoff = now - self.window;
let attempts = self.attempts.entry(key.to_string()).or_default();
attempts.retain(|&t| t > cutoff);
if attempts.len() >= self.max_attempts {
return false;
}
attempts.push(now);
true
}
fn clear(&mut self, key: &str) {
self.attempts.remove(key);
}
}
pub struct ServiceAuthManager {
api_keys: RwLock<HashMap<String, ApiKey>>,
service_accounts: RwLock<HashMap<String, ServiceAccount>>,
sessions: RwLock<HashMap<String, ServiceSession>>,
rate_limiter: RwLock<AuthRateLimiter>,
session_duration: Duration,
}
impl ServiceAuthManager {
pub fn new() -> Self {
Self {
api_keys: RwLock::new(HashMap::new()),
service_accounts: RwLock::new(HashMap::new()),
sessions: RwLock::new(HashMap::new()),
rate_limiter: RwLock::new(AuthRateLimiter::new(10, Duration::from_secs(60))),
session_duration: Duration::from_secs(3600), }
}
pub fn with_config(config: &ServiceAuthConfig) -> Self {
let manager = Self {
api_keys: RwLock::new(HashMap::new()),
service_accounts: RwLock::new(HashMap::new()),
sessions: RwLock::new(HashMap::new()),
rate_limiter: RwLock::new(AuthRateLimiter::new(
config.max_auth_attempts,
Duration::from_secs(60),
)),
session_duration: Duration::from_secs(config.session_duration_secs),
};
for account_config in &config.service_accounts {
let mut account = ServiceAccount::new(&account_config.name);
account.description = account_config.description.clone();
account.roles = account_config.roles.clone();
account.certificate_subject = account_config.certificate_subject.clone();
account.oidc_client_id = account_config.oidc_client_id.clone();
let _ = manager.create_service_account(account);
}
manager
}
pub fn with_session_duration(mut self, duration: Duration) -> Self {
self.session_duration = duration;
self
}
pub fn create_service_account(&self, account: ServiceAccount) -> ServiceAuthResult<()> {
let mut accounts = self.service_accounts.write();
if accounts.contains_key(&account.name) {
return Err(ServiceAuthError::Internal(format!(
"Service account '{}' already exists",
account.name
)));
}
info!("Created service account: {}", account.name);
accounts.insert(account.name.clone(), account);
Ok(())
}
pub fn get_service_account(&self, name: &str) -> Option<ServiceAccount> {
self.service_accounts.read().get(name).cloned()
}
pub fn disable_service_account(&self, name: &str) -> ServiceAuthResult<()> {
let mut accounts = self.service_accounts.write();
let account = accounts
.get_mut(name)
.ok_or_else(|| ServiceAuthError::ServiceAccountNotFound(name.to_string()))?;
account.enabled = false;
let mut sessions = self.sessions.write();
sessions.retain(|_, s| s.service_account != name);
info!("Disabled service account: {}", name);
Ok(())
}
pub fn create_api_key(
&self,
service_account: &str,
description: Option<&str>,
expires_in: Option<Duration>,
permissions: Vec<String>,
) -> ServiceAuthResult<String> {
{
let accounts = self.service_accounts.read();
if !accounts.contains_key(service_account) {
return Err(ServiceAuthError::ServiceAccountNotFound(
service_account.to_string(),
));
}
}
let (api_key, full_key) =
ApiKey::generate(service_account, description, expires_in, permissions)?;
let key_id = api_key.key_id.clone();
self.api_keys.write().insert(key_id.clone(), api_key);
info!(
"Created API key '{}' for service account '{}'",
key_id, service_account
);
Ok(full_key)
}
pub fn revoke_api_key(&self, key_id: &str) -> ServiceAuthResult<()> {
let mut keys = self.api_keys.write();
let key = keys
.get_mut(key_id)
.ok_or_else(|| ServiceAuthError::KeyNotFound(key_id.to_string()))?;
key.revoked = true;
let mut sessions = self.sessions.write();
sessions.retain(|_, s| s.api_key_id.as_deref() != Some(key_id));
info!("Revoked API key: {}", key_id);
Ok(())
}
pub fn list_api_keys(&self, service_account: &str) -> Vec<ApiKey> {
self.api_keys
.read()
.values()
.filter(|k| k.service_account == service_account)
.cloned()
.collect()
}
pub fn authenticate_api_key(
&self,
key_string: &str,
client_ip: &str,
) -> ServiceAuthResult<ServiceSession> {
{
let mut limiter = self.rate_limiter.write();
if !limiter.check_and_record(client_ip) {
warn!("Rate limited auth attempt from {}", client_ip);
return Err(ServiceAuthError::RateLimited);
}
}
let (key_id, secret) = ApiKey::parse_key(key_string)?;
let keys = self.api_keys.read();
let api_key = keys
.get(&key_id)
.ok_or_else(|| ServiceAuthError::KeyNotFound(key_id.clone()))?;
if !api_key.verify_secret(&secret) {
warn!("Invalid API key secret for key_id={}", key_id);
return Err(ServiceAuthError::InvalidCredentials);
}
let needs_rehash = api_key.needs_rehash();
drop(keys);
if needs_rehash {
if let Some(mut keys) = self.api_keys.try_write() {
if let Some(api_key) = keys.get_mut(&key_id) {
api_key.upgrade_to_argon2(&secret);
}
} else {
warn!(
"Could not acquire write lock to upgrade hash for key_id={}",
key_id
);
}
}
let keys = self.api_keys.read();
let api_key = keys
.get(&key_id)
.ok_or_else(|| ServiceAuthError::KeyNotFound(key_id.clone()))?;
if !api_key.is_valid() {
if api_key.revoked {
return Err(ServiceAuthError::KeyRevoked);
} else {
return Err(ServiceAuthError::KeyExpired);
}
}
if !api_key.is_ip_allowed(client_ip) {
warn!("API key {} used from non-allowed IP {}", key_id, client_ip);
return Err(ServiceAuthError::PermissionDenied(
"IP not in allowlist".to_string(),
));
}
{
let accounts = self.service_accounts.read();
let account = accounts.get(&api_key.service_account).ok_or_else(|| {
ServiceAuthError::ServiceAccountNotFound(api_key.service_account.clone())
})?;
if !account.enabled {
return Err(ServiceAuthError::ServiceAccountDisabled(
api_key.service_account.clone(),
));
}
}
let service_account = api_key.service_account.clone();
let permissions = api_key.permissions.clone();
drop(keys);
if let Some(mut keys) = self.api_keys.try_write() {
if let Some(api_key) = keys.get_mut(&key_id) {
api_key.last_used_at = Some(SystemTime::now());
}
}
let session = self.create_session(
&service_account,
AuthMethod::ApiKey,
client_ip,
permissions,
Some(key_id.clone()),
);
self.rate_limiter.write().clear(client_ip);
info!(
"Authenticated service '{}' via API key '{}' from {}",
service_account, key_id, client_ip
);
Ok(session)
}
pub fn authenticate_certificate(
&self,
cert_subject: &str,
client_ip: &str,
) -> ServiceAuthResult<ServiceSession> {
let accounts = self.service_accounts.read();
let account = accounts
.values()
.find(|a| a.certificate_subject.as_deref() == Some(cert_subject))
.ok_or_else(|| {
ServiceAuthError::CertificateError(format!(
"No service account for certificate: {}",
cert_subject
))
})?;
if !account.enabled {
return Err(ServiceAuthError::ServiceAccountDisabled(
account.name.clone(),
));
}
let permissions = account.roles.clone();
let session = self.create_session(
&account.name,
AuthMethod::MutualTls,
client_ip,
permissions,
None,
);
info!(
"Authenticated service '{}' via mTLS certificate from {}",
account.name, client_ip
);
Ok(session)
}
fn create_session(
&self,
service_account: &str,
auth_method: AuthMethod,
client_ip: &str,
permissions: Vec<String>,
api_key_id: Option<String>,
) -> ServiceSession {
let rng = SystemRandom::new();
let mut session_id_bytes = [0u8; 16];
rng.fill(&mut session_id_bytes).expect("RNG failed");
let session_id = hex::encode(session_id_bytes);
let now = Instant::now();
let session = ServiceSession {
id: session_id.clone(),
service_account: service_account.to_string(),
auth_method,
expires_at: now + self.session_duration,
created_timestamp: SystemTime::now(),
permissions,
client_ip: client_ip.to_string(),
api_key_id,
};
self.sessions.write().insert(session_id, session.clone());
session
}
pub fn validate_session(&self, session_id: &str) -> Option<ServiceSession> {
let sessions = self.sessions.read();
let session = sessions.get(session_id)?;
if session.is_expired() {
return None;
}
let accounts = self.service_accounts.read();
let account = accounts.get(&session.service_account)?;
if !account.enabled {
return None;
}
Some(session.clone())
}
pub fn invalidate_session(&self, session_id: &str) {
self.sessions.write().remove(session_id);
}
pub fn cleanup_expired_sessions(&self) {
let mut sessions = self.sessions.write();
let before = sessions.len();
sessions.retain(|_, s| !s.is_expired());
let removed = before - sessions.len();
if removed > 0 {
debug!("Cleaned up {} expired service sessions", removed);
}
}
}
impl Default for ServiceAuthManager {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServiceAuthRequest {
ApiKey { key: String },
MutualTls { certificate_subject: String },
OidcClientCredentials {
client_id: String,
client_secret: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServiceAuthResponse {
Success {
session_id: String,
expires_in_secs: u64,
permissions: Vec<String>,
},
Failure { error: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceAuthConfig {
#[serde(default = "default_true")]
pub api_key_enabled: bool,
#[serde(default)]
pub mtls_enabled: bool,
#[serde(default)]
pub oidc_enabled: bool,
#[serde(default = "default_session_duration")]
pub session_duration_secs: u64,
#[serde(default = "default_max_attempts")]
pub max_auth_attempts: usize,
#[serde(default)]
pub service_accounts: Vec<ServiceAccountConfig>,
}
fn default_true() -> bool {
true
}
fn default_session_duration() -> u64 {
3600
}
fn default_max_attempts() -> usize {
10
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceAccountConfig {
pub name: String,
pub description: Option<String>,
pub roles: Vec<String>,
pub certificate_subject: Option<String>,
pub oidc_client_id: Option<String>,
}
impl Default for ServiceAuthConfig {
fn default() -> Self {
Self {
api_key_enabled: true,
mtls_enabled: false,
oidc_enabled: false,
session_duration_secs: 3600,
max_auth_attempts: 10,
service_accounts: vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_key_generation() {
let (api_key, full_key) =
ApiKey::generate("test-service", Some("Test key"), None, vec![]).unwrap();
assert!(!api_key.revoked);
assert!(api_key.is_valid());
assert!(full_key.starts_with("rvn.v1."));
let (key_id, secret) = ApiKey::parse_key(&full_key).unwrap();
assert_eq!(key_id, api_key.key_id);
assert!(api_key.verify_secret(&secret));
}
#[test]
fn test_api_key_expiration() {
let (mut api_key, _) = ApiKey::generate(
"test-service",
None,
Some(Duration::from_secs(0)), vec![],
)
.unwrap();
std::thread::sleep(Duration::from_millis(10));
assert!(!api_key.is_valid());
api_key.revoked = true;
assert!(!api_key.is_valid());
}
#[test]
fn test_ip_allowlist() {
let mut api_key = ApiKey::generate("test-service", None, None, vec![])
.unwrap()
.0;
assert!(api_key.is_ip_allowed("192.168.1.1"));
api_key.allowed_ips = vec!["192.168.1.0/24".to_string()];
assert!(api_key.is_ip_allowed("192.168.1.100"));
assert!(!api_key.is_ip_allowed("10.0.0.1"));
}
#[test]
fn test_service_auth_manager() {
let manager = ServiceAuthManager::new();
let account = ServiceAccount::new("connector-postgres")
.with_description("PostgreSQL CDC connector")
.with_roles(vec!["connector".to_string()]);
manager.create_service_account(account).unwrap();
let full_key = manager
.create_api_key(
"connector-postgres",
Some("Production key"),
None,
vec!["topic:read".to_string(), "topic:write".to_string()],
)
.unwrap();
let session = manager
.authenticate_api_key(&full_key, "127.0.0.1")
.unwrap();
assert_eq!(session.service_account, "connector-postgres");
assert_eq!(session.auth_method, AuthMethod::ApiKey);
assert!(!session.is_expired());
let validated = manager.validate_session(&session.id).unwrap();
assert_eq!(validated.id, session.id);
}
#[test]
fn test_invalid_api_key() {
let manager = ServiceAuthManager::new();
manager
.create_service_account(ServiceAccount::new("test"))
.unwrap();
let result = manager.authenticate_api_key(
"rvn.v1.invalid1.secretsecretsecretsecretsecretsecr",
"127.0.0.1",
);
assert!(matches!(result, Err(ServiceAuthError::KeyNotFound(_))));
}
#[test]
fn test_rate_limiting() {
let manager = ServiceAuthManager::new();
for _ in 0..15 {
let _ = manager.authenticate_api_key(
"rvn.v1.invalid1.secretsecretsecretsecretsecretsecr",
"1.2.3.4",
);
}
let result = manager.authenticate_api_key(
"rvn.v1.invalid1.secretsecretsecretsecretsecretsecr",
"1.2.3.4",
);
assert!(matches!(result, Err(ServiceAuthError::RateLimited)));
}
#[test]
fn test_service_account_disable() {
let manager = ServiceAuthManager::new();
manager
.create_service_account(ServiceAccount::new("test-service"))
.unwrap();
let key = manager
.create_api_key("test-service", None, None, vec![])
.unwrap();
let session = manager.authenticate_api_key(&key, "127.0.0.1").unwrap();
manager.disable_service_account("test-service").unwrap();
assert!(manager.validate_session(&session.id).is_none());
let result = manager.authenticate_api_key(&key, "127.0.0.1");
assert!(matches!(
result,
Err(ServiceAuthError::ServiceAccountDisabled(_))
));
}
#[test]
fn test_certificate_auth() {
let manager = ServiceAuthManager::new();
let account = ServiceAccount::new("connector-orders")
.with_certificate_subject("CN=connector-orders,O=Rivven")
.with_roles(vec!["connector".to_string()]);
manager.create_service_account(account).unwrap();
let session = manager
.authenticate_certificate("CN=connector-orders,O=Rivven", "127.0.0.1")
.unwrap();
assert_eq!(session.service_account, "connector-orders");
assert_eq!(session.auth_method, AuthMethod::MutualTls);
}
#[test]
fn test_api_key_debug_redacts_secret_hash() {
let (api_key, _) = ApiKey::generate(
"test-service",
Some("Test key"),
None,
vec!["read".to_string()],
)
.unwrap();
let debug_output = format!("{:?}", api_key);
assert!(
debug_output.contains("[REDACTED]"),
"Debug output should contain [REDACTED]: {}",
debug_output
);
assert!(
!debug_output.contains(&api_key.secret_hash),
"Debug output should not contain the secret hash"
);
assert!(
debug_output.contains("key_id"),
"Debug output should show key_id field"
);
assert!(
debug_output.contains("test-service"),
"Debug output should show service_account"
);
}
}