road-runner-common 0.11.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
#![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() {
    // Arrange / Act
    let cfg = GatewayAuthConfig::default();

    // Assert
    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() {
    // Arrange
    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()),
    };

    // Act
    let canonical = raw.canonical();

    // Assert
    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() {
    // Arrange
    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()
    };

    // Act
    let signature = sign(b"super-secret", &raw.canonical());
    let different = sign(b"other", &raw.canonical());

    // Assert
    assert_eq!(signature.len(), 64);
    assert_ne!(signature, different);
}

#[test]
fn from_env_requires_secret_when_verification_enabled() {
    let _guard = env_lock().lock().unwrap();
    // Arrange
    std::env::remove_var("AUTH_GATEWAY_HMAC_SECRET");
    std::env::set_var("AUTH_GATEWAY_VERIFY_SIGNATURE", "true");

    // Act
    let result = GatewayAuthConfig::from_env();

    // Assert
    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();
    // Arrange
    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");

    // Act
    let cfg = GatewayAuthConfig::from_env().unwrap();

    // Assert
    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() {
        // Arrange
        let req = TestRequest::default().to_srv_request();
        let cfg = GatewayAuthConfig { hmac_secret: Some(b"secret".to_vec()), verify_signature: true, max_age_secs: 300 };

        // Act
        let ctx = build_user_context(&req, &cfg).unwrap();

        // Assert
        assert!(ctx.is_none());
    }

    #[test]
    fn build_user_context_maps_headers_without_signature_when_disabled() {
        // Arrange
        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 };

        // Act
        let ctx = build_user_context(&req, &cfg).unwrap().unwrap();

        // Assert
        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() {
        // Arrange
        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");

        // Act
        let ctx = build_user_context(&req, &cfg).unwrap().unwrap();
        let tampered_result = build_user_context(&tampered, &cfg);

        // Assert
        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() {
        // Arrange
        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 };

        // Act
        let missing_secret_result = build_user_context(&subject_only, &missing_secret);
        let missing_signature_result = build_user_context(&subject_only, &missing_signature);

        // Assert
        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() {
        // Arrange
        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 };

        // Act
        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
        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() {
        // Arrange
        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();

        // Act
        let (_req, result) = resolver(req).await;

        // Assert
        let ctx = result.unwrap().unwrap();
        assert_eq!(ctx.subject, "sub-1");
        assert_eq!(ctx.roles, vec!["admin"]);
    }
}