#![cfg(feature = "gateway")]
use std::sync::{Mutex, OnceLock};
use road_runner_common::gateway::{
sign, GatewayAuthConfig, RawAuthHeaders, HEADER_ACR, HEADER_API_KEY_ID, HEADER_CAPABILITIES,
HEADER_EMAIL, HEADER_ISSUED_AT, HEADER_PRINCIPAL_TYPE, HEADER_ROLES, HEADER_SCOPES,
HEADER_SIGNATURE, HEADER_USER_SUB, HEADER_USERNAME,
};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn default_config_fails_closed_with_standard_replay_window() {
let cfg = GatewayAuthConfig::default();
assert!(cfg.hmac_secret.is_none());
assert!(cfg.verify_signature);
assert_eq!(cfg.max_age_secs, 300);
}
#[test]
fn raw_headers_canonical_normalizes_principal_and_preserves_signed_values() {
let raw = RawAuthHeaders {
principal_type: Some("api_key".to_string()),
sub: Some("sub".to_string()),
email: Some("e@example.com".to_string()),
username: Some("alice".to_string()),
roles: Some("admin,viewer".to_string()),
scopes: Some("read,spot".to_string()),
capabilities: Some("account.read".to_string()),
api_key_id: Some("key-1".to_string()),
issued_at: Some("1700000000".to_string()),
signature: Some("ignored".to_string()),
acr: Some("aal2".to_string()),
};
let canonical = raw.canonical();
assert_eq!(
canonical,
"v1\nsub\napikey\ne@example.com\nalice\nkey-1\nadmin,viewer\nread,spot\naccount.read\n1700000000"
);
}
#[test]
fn sign_is_stable_and_secret_sensitive() {
let raw = RawAuthHeaders {
sub: Some("11111111-1111-1111-1111-111111111111".to_string()),
roles: Some("trader,viewer".to_string()),
issued_at: Some("1700000000".to_string()),
..Default::default()
};
let signature = sign(b"super-secret", &raw.canonical());
let different = sign(b"other", &raw.canonical());
assert_eq!(signature.len(), 64);
assert_ne!(signature, different);
}
#[test]
fn from_env_requires_secret_when_verification_enabled() {
let _guard = env_lock().lock().unwrap();
std::env::remove_var("AUTH_GATEWAY_HMAC_SECRET");
std::env::set_var("AUTH_GATEWAY_VERIFY_SIGNATURE", "true");
let result = GatewayAuthConfig::from_env();
assert!(result.is_err());
std::env::remove_var("AUTH_GATEWAY_VERIFY_SIGNATURE");
}
#[test]
fn from_env_allows_disabled_signature_and_parses_bool_aliases() {
let _guard = env_lock().lock().unwrap();
std::env::remove_var("AUTH_GATEWAY_HMAC_SECRET");
std::env::set_var("AUTH_GATEWAY_VERIFY_SIGNATURE", "off");
std::env::set_var("AUTH_GATEWAY_MAX_AGE_SECS", "42");
let cfg = GatewayAuthConfig::from_env().unwrap();
assert!(cfg.hmac_secret.is_none());
assert!(!cfg.verify_signature);
assert_eq!(cfg.max_age_secs, 42);
std::env::remove_var("AUTH_GATEWAY_VERIFY_SIGNATURE");
std::env::remove_var("AUTH_GATEWAY_MAX_AGE_SECS");
}
#[cfg(feature = "web")]
mod web_gateway {
use super::*;
use actix_web::test::TestRequest;
use road_runner_common::gateway::{build_user_context, gateway_resolver};
use road_runner_common::error::AppError;
fn signed_request(secret: &[u8], issued_at: &str) -> actix_web::dev::ServiceRequest {
let raw = RawAuthHeaders {
principal_type: Some("service".to_string()),
sub: Some("svc-1".to_string()),
email: Some("svc@example.com".to_string()),
username: Some("svc".to_string()),
roles: Some("admin, viewer,,".to_string()),
scopes: Some("read, spot".to_string()),
capabilities: Some("account.read,spot.trade".to_string()),
api_key_id: Some("key-1".to_string()),
issued_at: Some(issued_at.to_string()),
signature: None,
acr: Some("aal2".to_string()),
};
let signature = sign(secret, &raw.canonical());
TestRequest::default()
.insert_header((HEADER_PRINCIPAL_TYPE, "service"))
.insert_header((HEADER_USER_SUB, "svc-1"))
.insert_header((HEADER_EMAIL, "svc@example.com"))
.insert_header((HEADER_USERNAME, "svc"))
.insert_header((HEADER_ROLES, "admin, viewer,,"))
.insert_header((HEADER_SCOPES, "read, spot"))
.insert_header((HEADER_CAPABILITIES, "account.read,spot.trade"))
.insert_header((HEADER_API_KEY_ID, "key-1"))
.insert_header((HEADER_ISSUED_AT, issued_at))
.insert_header((HEADER_SIGNATURE, signature))
.insert_header((HEADER_ACR, "aal2"))
.to_srv_request()
}
#[test]
fn build_user_context_returns_none_for_anonymous_request() {
let req = TestRequest::default().to_srv_request();
let cfg = GatewayAuthConfig { hmac_secret: Some(b"secret".to_vec()), verify_signature: true, max_age_secs: 300 };
let ctx = build_user_context(&req, &cfg).unwrap();
assert!(ctx.is_none());
}
#[test]
fn build_user_context_maps_headers_without_signature_when_disabled() {
let req = TestRequest::default()
.insert_header((HEADER_PRINCIPAL_TYPE, "apikey"))
.insert_header((HEADER_USER_SUB, "owner-1"))
.insert_header((HEADER_ROLES, "admin, viewer,,"))
.insert_header((HEADER_SCOPES, "read, spot"))
.to_srv_request();
let cfg = GatewayAuthConfig { hmac_secret: None, verify_signature: false, max_age_secs: 300 };
let ctx = build_user_context(&req, &cfg).unwrap().unwrap();
assert_eq!(ctx.subject, "owner-1");
assert!(ctx.is_api_key());
assert_eq!(ctx.roles, vec!["admin", "viewer"]);
assert_eq!(ctx.scopes, vec!["read", "spot"]);
}
#[test]
fn build_user_context_verifies_signature_and_rejects_tampering() {
let cfg = GatewayAuthConfig { hmac_secret: Some(b"secret".to_vec()), verify_signature: true, max_age_secs: u64::MAX };
let req = signed_request(b"secret", "1700000000");
let tampered = signed_request(b"other-secret", "1700000000");
let ctx = build_user_context(&req, &cfg).unwrap().unwrap();
let tampered_result = build_user_context(&tampered, &cfg);
assert_eq!(ctx.subject, "svc-1");
assert!(ctx.is_service());
assert_eq!(ctx.email.as_deref(), Some("svc@example.com"));
assert_eq!(ctx.username.as_deref(), Some("svc"));
assert_eq!(ctx.api_key_id.as_deref(), Some("key-1"));
assert_eq!(ctx.acr.as_deref(), Some("aal2"));
assert!(tampered_result.is_err());
}
#[test]
fn build_user_context_rejects_missing_verification_inputs() {
let subject_only = TestRequest::default()
.insert_header((HEADER_USER_SUB, "sub-1"))
.to_srv_request();
let missing_secret = GatewayAuthConfig { hmac_secret: None, verify_signature: true, max_age_secs: 300 };
let missing_signature = GatewayAuthConfig { hmac_secret: Some(b"secret".to_vec()), verify_signature: true, max_age_secs: 300 };
let missing_secret_result = build_user_context(&subject_only, &missing_secret);
let missing_signature_result = build_user_context(&subject_only, &missing_signature);
assert!(matches!(missing_secret_result, Err(AppError::Unauthorized { message }) if message == "gateway signature secret not configured"));
assert!(matches!(missing_signature_result, Err(AppError::Unauthorized { message }) if message == "missing identity signature"));
}
#[test]
fn build_user_context_rejects_missing_invalid_and_expired_timestamp() {
let secret = b"secret";
let raw_without_timestamp = RawAuthHeaders {
sub: Some("sub-1".to_string()),
..Default::default()
};
let signature_without_timestamp = sign(secret, &raw_without_timestamp.canonical());
let missing_timestamp = TestRequest::default()
.insert_header((HEADER_USER_SUB, "sub-1"))
.insert_header((HEADER_SIGNATURE, signature_without_timestamp))
.to_srv_request();
let invalid_timestamp = TestRequest::default()
.insert_header((HEADER_USER_SUB, "sub-1"))
.insert_header((HEADER_ISSUED_AT, "not-a-number"))
.insert_header((HEADER_SIGNATURE, "00"))
.to_srv_request();
let expired = signed_request(secret, "1");
let cfg = GatewayAuthConfig { hmac_secret: Some(secret.to_vec()), verify_signature: true, max_age_secs: 0 };
let missing_result = build_user_context(&missing_timestamp, &cfg);
let invalid_result = build_user_context(&invalid_timestamp, &cfg);
let expired_result = build_user_context(&expired, &cfg);
assert!(matches!(missing_result, Err(AppError::Unauthorized { message }) if message == "missing identity timestamp"));
assert!(matches!(invalid_result, Err(AppError::Unauthorized { message }) if message == "invalid identity timestamp"));
assert!(matches!(expired_result, Err(AppError::Unauthorized { message }) if message == "identity signature expired"));
}
#[actix_web::test]
async fn gateway_resolver_returns_request_and_context() {
let cfg = GatewayAuthConfig { hmac_secret: None, verify_signature: false, max_age_secs: 300 };
let resolver = gateway_resolver(cfg);
let req = TestRequest::default()
.insert_header((HEADER_USER_SUB, "sub-1"))
.insert_header((HEADER_ROLES, "admin"))
.to_srv_request();
let (_req, result) = resolver(req).await;
let ctx = result.unwrap().unwrap();
assert_eq!(ctx.subject, "sub-1");
assert_eq!(ctx.roles, vec!["admin"]);
}
}