road-runner-common 0.11.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Gateway-trust identity propagation.
//!
//! The edge gateway authenticates the caller (Keycloak / session / API key) and forwards
//! the resolved identity to downstream services as a set of `x-auth-*` headers, signed with
//! a shared HMAC secret. Downstream services do **not** re-validate tokens; they verify the
//! signature (defense-in-depth against header spoofing) and build a [`UserContext`].
//!
//! ## Header contract (v1)
//!
//! | Header                   | Meaning                                             |
//! |--------------------------|-----------------------------------------------------|
//! | `x-auth-principal-type`  | `user` \| `apikey` \| `service` (default `user`)    |
//! | `x-auth-user-sub`        | canonical subject UUID (owner UUID for `apikey`)    |
//! | `x-auth-user-email`      | email (optional)                                    |
//! | `x-auth-user-username`   | username (optional)                                 |
//! | `x-auth-roles`           | comma-separated roles                               |
//! | `x-auth-scopes`          | comma-separated API-key permissions                 |
//! | `x-auth-capabilities`    | comma-separated account capabilities                |
//! | `x-auth-apikey-id`       | API key id (only for `apikey`)                      |
//! | `x-auth-issued-at`       | unix seconds, for replay protection                 |
//! | `x-auth-signature`       | lowercase hex HMAC-SHA256 over the canonical string |
//!
//! ## Canonical signing string (v1)
//!
//! ```text
//! v1\n<sub>\n<principal>\n<email>\n<username>\n<api_key_id>\n<roles>\n<scopes>\n<capabilities>\n<issued_at>
//! ```
//!
//! Missing optional fields are the empty string. `roles`/`scopes`/`issued_at` are the exact
//! raw header values. The gateway MUST build this string identically.

#![cfg(feature = "gateway")]

pub const HEADER_PRINCIPAL_TYPE: &str = "x-auth-principal-type";
pub const HEADER_USER_SUB: &str = "x-auth-user-sub";
pub const HEADER_EMAIL: &str = "x-auth-user-email";
pub const HEADER_USERNAME: &str = "x-auth-user-username";
pub const HEADER_ROLES: &str = "x-auth-roles";
pub const HEADER_SCOPES: &str = "x-auth-scopes";
pub const HEADER_CAPABILITIES: &str = "x-auth-capabilities";
pub const HEADER_API_KEY_ID: &str = "x-auth-apikey-id";
pub const HEADER_ISSUED_AT: &str = "x-auth-issued-at";
pub const HEADER_SIGNATURE: &str = "x-auth-signature";
/// Session ACR (step-up assurance). NOTE: deliberately NOT part of the v1 canonical
/// signing string — adding it there would break every admin call during a mixed-version
/// gateway/service rollout. Read unsigned in 0.7; a signed canonical-v2 follows once all
/// consumers are on ≥0.7.
pub const HEADER_ACR: &str = "x-auth-acr";

const SIGNATURE_VERSION: &str = "v1";
const DEFAULT_MAX_AGE_SECS: u64 = 300;

/// Configuration for verifying gateway identity headers.
#[derive(Debug, Clone)]
pub struct GatewayAuthConfig {
    /// Shared HMAC secret. `None` means signatures cannot be verified.
    pub hmac_secret: Option<Vec<u8>>,
    /// Fail closed: reject any identity that is missing/invalidly signed.
    pub verify_signature: bool,
    /// Replay window in seconds for `x-auth-issued-at`.
    pub max_age_secs: u64,
}

impl Default for GatewayAuthConfig {
    fn default() -> Self {
        Self {
            hmac_secret: None,
            verify_signature: true,
            max_age_secs: DEFAULT_MAX_AGE_SECS,
        }
    }
}

impl GatewayAuthConfig {
    /// Build from environment:
    /// - `AUTH_GATEWAY_HMAC_SECRET`     — shared secret (required when verifying)
    /// - `AUTH_GATEWAY_VERIFY_SIGNATURE`— `true`/`false`, default `true`
    /// - `AUTH_GATEWAY_MAX_AGE_SECS`    — replay window, default 300
    ///
    /// Fails closed: verification on with no secret is a hard configuration error.
    pub fn from_env() -> anyhow::Result<Self> {
        let verify_signature = std::env::var("AUTH_GATEWAY_VERIFY_SIGNATURE")
            .ok()
            .map(|v| parse_bool(&v))
            .unwrap_or(true);

        let hmac_secret = std::env::var("AUTH_GATEWAY_HMAC_SECRET")
            .ok()
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .map(|s| s.into_bytes());

        let max_age_secs = std::env::var("AUTH_GATEWAY_MAX_AGE_SECS")
            .ok()
            .and_then(|v| v.parse::<u64>().ok())
            .unwrap_or(DEFAULT_MAX_AGE_SECS);

        if verify_signature && hmac_secret.is_none() {
            anyhow::bail!(
                "AUTH_GATEWAY_VERIFY_SIGNATURE is on but AUTH_GATEWAY_HMAC_SECRET is not set"
            );
        }

        Ok(Self {
            hmac_secret,
            verify_signature,
            max_age_secs,
        })
    }
}

/// Raw, trimmed values pulled from the identity headers.
#[derive(Debug, Default, Clone)]
pub struct RawAuthHeaders {
    pub principal_type: Option<String>,
    pub sub: Option<String>,
    pub email: Option<String>,
    pub username: Option<String>,
    pub roles: Option<String>,
    pub scopes: Option<String>,
    pub capabilities: Option<String>,
    pub api_key_id: Option<String>,
    pub issued_at: Option<String>,
    pub signature: Option<String>,
    /// Unsigned in v1 (see [`HEADER_ACR`]); not part of `canonical()`.
    pub acr: Option<String>,
}

impl RawAuthHeaders {
    /// Reconstruct the canonical signing string from the received header values.
    pub fn canonical(&self) -> String {
        let principal = self
            .principal_type
            .as_deref()
            .map(|s| crate::auth::PrincipalKind::parse(s).as_str().to_string())
            .unwrap_or_else(|| crate::auth::PrincipalKind::default().as_str().to_string());

        format!(
            "{ver}\n{sub}\n{principal}\n{email}\n{username}\n{api_key}\n{roles}\n{scopes}\n{caps}\n{issued}",
            ver = SIGNATURE_VERSION,
            sub = self.sub.as_deref().unwrap_or_default(),
            principal = principal,
            email = self.email.as_deref().unwrap_or_default(),
            username = self.username.as_deref().unwrap_or_default(),
            api_key = self.api_key_id.as_deref().unwrap_or_default(),
            roles = self.roles.as_deref().unwrap_or_default(),
            scopes = self.scopes.as_deref().unwrap_or_default(),
            caps = self.capabilities.as_deref().unwrap_or_default(),
            issued = self.issued_at.as_deref().unwrap_or_default(),
        )
    }
}

/// Compute the lowercase-hex HMAC-SHA256 signature of `message` with `secret`.
pub fn sign(secret: &[u8], message: &str) -> String {
    use hmac::{Hmac, Mac};
    use sha2::Sha256;

    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(secret)
        .expect("HMAC accepts keys of any size");
    mac.update(message.as_bytes());
    hex::encode(mac.finalize().into_bytes())
}

/// Constant-time comparison of two hex signatures.
fn signatures_match(expected_hex: &str, provided_hex: &str) -> bool {
    use subtle::ConstantTimeEq;

    let (Ok(a), Ok(b)) = (hex::decode(expected_hex), hex::decode(provided_hex)) else {
        return false;
    };
    if a.len() != b.len() {
        return false;
    }
    a.ct_eq(&b).into()
}

fn parse_bool(v: &str) -> bool {
    matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "on")
}

fn now_unix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

fn split_csv(raw: Option<&str>) -> Vec<String> {
    raw.map(|s| {
        s.split(',')
            .map(str::trim)
            .filter(|v| !v.is_empty())
            .map(ToString::to_string)
            .collect()
    })
    .unwrap_or_default()
}

#[cfg(feature = "web")]
mod web_impl {
    use super::*;
    use crate::auth::{PrincipalKind, UserContext};
    use crate::error::AppError;
    use actix_web::dev::ServiceRequest;
    use futures_util::future::{ready, LocalBoxFuture};

    fn header(req: &ServiceRequest, name: &str) -> Option<String> {
        req.headers()
            .get(name)
            .and_then(|v| v.to_str().ok())
            .map(str::trim)
            .filter(|v| !v.is_empty())
            .map(ToString::to_string)
    }

    fn raw_headers(req: &ServiceRequest) -> RawAuthHeaders {
        RawAuthHeaders {
            principal_type: header(req, HEADER_PRINCIPAL_TYPE),
            sub: header(req, HEADER_USER_SUB),
            email: header(req, HEADER_EMAIL),
            username: header(req, HEADER_USERNAME),
            roles: header(req, HEADER_ROLES),
            scopes: header(req, HEADER_SCOPES),
            capabilities: header(req, HEADER_CAPABILITIES),
            api_key_id: header(req, HEADER_API_KEY_ID),
            issued_at: header(req, HEADER_ISSUED_AT),
            signature: header(req, HEADER_SIGNATURE),
            acr: header(req, HEADER_ACR),
        }
    }

    fn unauthorized(message: &str) -> AppError {
        AppError::Unauthorized {
            message: message.to_string(),
        }
    }

    fn verify(raw: &RawAuthHeaders, cfg: &GatewayAuthConfig) -> Result<(), AppError> {
        let Some(secret) = cfg.hmac_secret.as_deref() else {
            return Err(unauthorized("gateway signature secret not configured"));
        };
        let signature = raw
            .signature
            .as_deref()
            .ok_or_else(|| unauthorized("missing identity signature"))?;
        let issued_raw = raw
            .issued_at
            .as_deref()
            .ok_or_else(|| unauthorized("missing identity timestamp"))?;
        let issued_at = issued_raw
            .parse::<u64>()
            .map_err(|_| unauthorized("invalid identity timestamp"))?;

        let now = now_unix();
        let skew = now.abs_diff(issued_at);
        if skew > cfg.max_age_secs {
            return Err(unauthorized("identity signature expired"));
        }

        let expected = sign(secret, &raw.canonical());
        if !signatures_match(&expected, signature) {
            return Err(unauthorized("invalid identity signature"));
        }
        Ok(())
    }

    /// Build a [`UserContext`] from the signed `x-auth-*` headers on a request (verifying the
    /// HMAC signature per `cfg`). Stateless, no I/O. Returns `Ok(None)` for anonymous requests.
    ///
    /// Use this when composing your own resolver (e.g. a service that does extra I/O) so the
    /// identity/signature handling stays identical everywhere.
    pub fn build_user_context(
        req: &ServiceRequest,
        cfg: &GatewayAuthConfig,
    ) -> Result<Option<UserContext>, AppError> {
        resolve_sync(raw_headers(req), cfg)
    }

    /// Pure header → identity mapping (no I/O). Shared by the resolver.
    fn resolve_sync(
        raw: RawAuthHeaders,
        cfg: &GatewayAuthConfig,
    ) -> Result<Option<UserContext>, AppError> {
        // No subject → anonymous request; signature is irrelevant here.
        let Some(sub) = raw.sub.clone() else {
            return Ok(None);
        };

        if cfg.verify_signature {
            verify(&raw, cfg)?;
        }

        let principal = raw
            .principal_type
            .as_deref()
            .map(PrincipalKind::parse)
            .unwrap_or_default();

        Ok(Some(UserContext {
            subject: sub,
            principal,
            email: raw.email,
            username: raw.username,
            roles: split_csv(raw.roles.as_deref()),
            scopes: split_csv(raw.scopes.as_deref()),
            capabilities: split_csv(raw.capabilities.as_deref()),
            api_key_id: raw.api_key_id,
            acr: raw.acr,
        }))
    }

    /// Build a [`UserContextResolver`]-compatible resolver that turns signed gateway headers
    /// into a [`UserContext`].
    ///
    /// Stateless: it does no I/O, so it returns an already-ready future (zero cost beyond the
    /// boxed future the shared middleware uses for every service). Yields `Ok(None)` when no
    /// identity is present (the middleware's `required` flag decides), and `Err(Unauthorized)`
    /// when an identity is present but malformed or has an invalid signature.
    pub fn gateway_resolver(
        cfg: GatewayAuthConfig,
    ) -> impl Fn(
        ServiceRequest,
    )
        -> LocalBoxFuture<'static, (ServiceRequest, Result<Option<UserContext>, AppError>)>
           + 'static {
        move |req: ServiceRequest| {
            let raw = raw_headers(&req);
            let result = resolve_sync(raw, &cfg);
            Box::pin(ready((req, result)))
        }
    }
}

#[cfg(feature = "web")]
pub use web_impl::{build_user_context, gateway_resolver};