#![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";
pub const HEADER_ACR: &str = "x-auth-acr";
const SIGNATURE_VERSION: &str = "v1";
const DEFAULT_MAX_AGE_SECS: u64 = 300;
#[derive(Debug, Clone)]
pub struct GatewayAuthConfig {
pub hmac_secret: Option<Vec<u8>>,
pub verify_signature: bool,
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 {
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,
})
}
}
#[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>,
pub acr: Option<String>,
}
impl RawAuthHeaders {
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(),
)
}
}
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())
}
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(())
}
pub fn build_user_context(
req: &ServiceRequest,
cfg: &GatewayAuthConfig,
) -> Result<Option<UserContext>, AppError> {
resolve_sync(raw_headers(req), cfg)
}
fn resolve_sync(
raw: RawAuthHeaders,
cfg: &GatewayAuthConfig,
) -> Result<Option<UserContext>, AppError> {
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,
}))
}
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};