pub mod authz;
#[cfg(feature = "builtin_jwt")]
mod cache;
#[cfg(feature = "builtin_jwt")]
pub mod crypto;
pub mod forward;
#[cfg(feature = "builtin_jwt")]
pub mod jwks;
pub mod policy;
#[cfg(feature = "builtin_jwt")]
mod verifier;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use axum::extract::State;
use axum::http::header::{HeaderName, HeaderValue};
use axum::http::HeaderMap;
use axum::middleware::Next;
use axum::response::Response;
use serde_json::Value;
use crate::config::{default_roles_claim, AuthConfig};
use crate::hooks::TokenVerifier;
use policy::Policies;
enum Verifier {
#[cfg(feature = "builtin_jwt")]
Builtin(Box<verifier::ConfigVerifier>, Option<cache::ClaimsCache>),
Injected(Arc<dyn TokenVerifier>),
}
pub struct Auth {
verifier: Verifier,
claims_headers: HashMap<String, String>,
roles_claim: String,
policies: Policies,
#[cfg(all(test, feature = "builtin_jwt"))]
builtin_verifications: std::sync::atomic::AtomicUsize,
}
impl Auth {
pub fn build(
config: &AuthConfig,
verifier: Option<Arc<dyn TokenVerifier>>,
) -> Result<Option<Arc<Self>>, String> {
if config.mode != "jwt" {
return Ok(None);
}
let verifier = match verifier {
Some(v) => {
if let Some(jwt) = &config.jwt {
if jwt.jwks_uri.is_some() || jwt.public_key_pem_file.is_some() {
tracing::warn!(
"auth.jwt names a key source, but an injected TokenVerifier is in use; \
the configured keys are ignored"
);
}
}
Verifier::Injected(v)
}
None => builtin_verifier(config)?,
};
let policies = match &config.forward_auth {
Some(fa) => Policies::compile(&fa.policies)?,
None => Policies::default(),
};
let (claims_headers, roles_claim) = match &config.jwt {
Some(jwt) => (jwt.claims_headers.clone(), jwt.roles_claim.clone()),
None => (HashMap::new(), default_roles_claim()),
};
if let Some(header) = claims_headers
.values()
.find(|header| crate::client_address::owns(header))
{
return Err(format!(
"auth.jwt.claims_headers maps a claim onto {header:?}, which the proxy \
sets from the resolved client address"
));
}
Ok(Some(Arc::new(Self {
verifier,
claims_headers,
roles_claim,
policies,
#[cfg(all(test, feature = "builtin_jwt"))]
builtin_verifications: std::sync::atomic::AtomicUsize::new(0),
})))
}
async fn verify(&self, token: &str) -> Option<Arc<Value>> {
match &self.verifier {
#[cfg(feature = "builtin_jwt")]
Verifier::Builtin(v, Some(cache)) if cache.admits(token) => {
let key = cache::token_key(token);
let now = cache.now();
if let Some(claims) = cache.get(&key, now) {
return Some(claims);
}
let claims = Arc::new(self.verify_builtin(v, token).await?);
cache.insert(key, &claims, now);
Some(claims)
}
#[cfg(feature = "builtin_jwt")]
Verifier::Builtin(v, _) => self.verify_builtin(v, token).await.map(Arc::new),
Verifier::Injected(v) => v.verify(token).await.map(Arc::new),
}
}
#[cfg(feature = "builtin_jwt")]
async fn verify_builtin(&self, v: &verifier::ConfigVerifier, token: &str) -> Option<Value> {
#[cfg(test)]
self.builtin_verifications
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
v.verify(token).await
}
#[cfg(all(test, feature = "builtin_jwt"))]
fn cached_tokens(&self) -> usize {
match &self.verifier {
Verifier::Builtin(_, Some(cache)) => cache.len(),
_ => 0,
}
}
}
#[cfg(feature = "builtin_jwt")]
fn builtin_verifier(config: &AuthConfig) -> Result<Verifier, String> {
let jwt = config
.jwt
.as_ref()
.ok_or("auth.mode is \"jwt\" but auth.jwt is not set")?;
Ok(Verifier::Builtin(
Box::new(verifier::ConfigVerifier::build(jwt)?),
cache::ClaimsCache::build(&jwt.cache)?,
))
}
#[cfg(not(feature = "builtin_jwt"))]
fn builtin_verifier(_config: &AuthConfig) -> Result<Verifier, String> {
Err(
"auth.mode is \"jwt\" but this build has no JWT crypto backend: enable the \
`rust_crypto` or `aws_lc_rs` feature, or inject a verifier with \
ProxyServer::with_token_verifier"
.to_string(),
)
}
pub(crate) enum AuthDecision {
Allow(HeaderMap, Option<Arc<Value>>),
Unauthenticated(&'static str),
Forbidden(&'static str),
}
#[derive(Clone)]
pub(crate) struct ValidatedClaims(pub(crate) std::sync::Arc<Value>);
impl Auth {
pub(crate) async fn decide(
&self,
headers: &HeaderMap,
path: &str,
method: &str,
) -> AuthDecision {
let claims = match bearer_token(headers) {
Some(token) => match self.verify(token).await {
Some(c) => Some(c),
None => return AuthDecision::Unauthenticated("invalid or expired token"),
},
None => None,
};
if let Some(policy) = self.policies.match_rule(path, method) {
if policy.require_auth && claims.is_none() {
return AuthDecision::Unauthenticated("authentication required");
}
if !policy.required_roles.is_empty() {
let Some(claims) = claims.as_ref() else {
return AuthDecision::Unauthenticated("authentication required");
};
let roles = extract_roles(claims, &self.roles_claim);
if !policy.required_roles.iter().all(|r| roles.contains(r)) {
return AuthDecision::Forbidden("insufficient role");
}
}
}
let mut claim_headers = HeaderMap::new();
if let Some(claims) = &claims {
inject_claim_headers(&mut claim_headers, claims, &self.claims_headers);
}
AuthDecision::Allow(claim_headers, claims)
}
}
pub async fn middleware(
State(auth): State<Arc<Auth>>,
mut request: axum::extract::Request,
next: Next,
) -> Response {
let path = request.uri().path().to_string();
let method = request.method().as_str().to_ascii_uppercase();
strip_claim_headers(request.headers_mut(), &auth.claims_headers);
match auth.decide(request.headers(), &path, &method).await {
AuthDecision::Unauthenticated(msg) => unauthorized(msg),
AuthDecision::Forbidden(msg) => forbidden(msg),
AuthDecision::Allow(claim_headers, claims) => {
let dst = request.headers_mut();
for (name, value) in &claim_headers {
dst.insert(name.clone(), value.clone());
}
if let Some(claims) = claims {
request.extensions_mut().insert(ValidatedClaims(claims));
}
next.run(request).await
}
}
}
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let value = headers.get("authorization")?.to_str().ok()?;
let token = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))?;
let token = token.trim();
(!token.is_empty()).then_some(token)
}
fn claim_at<'a>(claims: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = claims;
for seg in path.split('.') {
cur = cur.get(seg)?;
}
Some(cur)
}
fn extract_roles(claims: &Value, roles_claim: &str) -> HashSet<String> {
claim_at(claims, roles_claim)
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn strip_claim_headers(headers: &mut HeaderMap, mapping: &HashMap<String, String>) {
for header in mapping.values() {
if let Ok(name) = HeaderName::try_from(header.as_str()) {
while headers.remove(&name).is_some() {}
}
}
}
fn inject_claim_headers(
headers: &mut HeaderMap,
claims: &Value,
mapping: &HashMap<String, String>,
) {
for (claim, header) in mapping {
let Some(value) = claim_at(claims, claim) else {
continue;
};
let rendered = match value {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => continue,
};
if let (Ok(name), Ok(val)) = (
HeaderName::try_from(header.as_str()),
HeaderValue::try_from(rendered),
) {
headers.insert(name, val);
}
}
}
fn unauthorized(message: &'static str) -> Response {
crate::guard::reject(tonic::Code::Unauthenticated, message)
}
fn forbidden(message: &'static str) -> Response {
crate::guard::reject(tonic::Code::PermissionDenied, message)
}