use crate::saas::api_keys::hash_api_key;
use crate::saas::db;
use axum::{
extract::{Request, State},
http::{header, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct AuthContext {
pub tenant_id: Uuid,
pub auth_type: AuthType,
pub scopes: Vec<String>,
}
#[derive(Clone, Debug)]
pub enum AuthType {
Jwt {
user_id: String,
role: Option<String>,
},
ApiKey {
key_id: Uuid,
},
}
pub const KNOWN_SCOPES: &[&str] = &[
"*",
"domains:read",
"domains:write",
"routes:read",
"routes:write",
"upstreams:read",
"upstreams:write",
"api_keys:read",
"api_keys:write",
"tenant:read",
];
pub fn validate_scopes(scopes: &[String]) -> crate::error::ProxyResult<()> {
match scopes.iter().find(|s| !KNOWN_SCOPES.contains(&s.as_str())) {
Some(unknown) => Err(crate::error::ProxyError::Validation(format!(
"unknown scope {unknown:?}; known scopes are {}",
KNOWN_SCOPES.join(", ")
))),
None => Ok(()),
}
}
impl AuthContext {
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope || s == "*")
}
pub fn grants(&self, scope: &str) -> bool {
if self.has_scope("*") {
return true;
}
match scope.rsplit_once(':') {
Some((resource, "read")) => self.can_read(resource),
Some((resource, "write")) => self.can_write(resource),
_ => false,
}
}
pub fn can_read(&self, resource: &str) -> bool {
self.has_scope(&format!("{}:read", resource))
|| self.has_scope(&format!("{}:write", resource))
|| self.has_scope("*")
}
pub fn can_write(&self, resource: &str) -> bool {
self.has_scope(&format!("{}:write", resource)) || self.has_scope("*")
}
}
#[derive(Clone)]
pub struct SaasAuthLayer {
pool: PgPool,
jwt_secret: Option<String>,
jwt_audience: String,
jwt_issuer: Option<String>,
}
impl SaasAuthLayer {
pub fn new(pool: PgPool, jwt_secret: Option<String>) -> Self {
Self {
pool,
jwt_secret,
jwt_audience: crate::saas::options::DEFAULT_JWT_AUDIENCE.to_string(),
jwt_issuer: None,
}
}
pub fn with_jwt_audience(mut self, audience: impl Into<String>) -> Self {
self.jwt_audience = audience.into();
self
}
pub fn with_jwt_issuer(mut self, issuer: Option<String>) -> Self {
self.jwt_issuer = issuer;
self
}
}
pub async fn auth_middleware(
State(auth_layer): State<Arc<SaasAuthLayer>>,
mut request: Request,
next: Next,
) -> Result<Response, AuthError> {
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().ok());
let auth_context = match auth_header {
Some(header) if header.starts_with("Bearer ") => {
let token = header.strip_prefix("Bearer ").unwrap();
validate_jwt(token, &auth_layer).await?
}
Some(header) if header.starts_with("ApiKey ") => {
let key = header.strip_prefix("ApiKey ").unwrap();
validate_api_key(key, &auth_layer.pool).await?
}
Some(header) if header.starts_with("Basic ") => {
return Err(AuthError::UnsupportedAuthMethod);
}
Some(_) => {
return Err(AuthError::InvalidFormat);
}
None => {
return Err(AuthError::MissingHeader);
}
};
if !db::is_tenant_active(&auth_layer.pool, auth_context.tenant_id).await? {
return Err(AuthError::TenantSuspended);
}
request.extensions_mut().insert(auth_context);
Ok(next.run(request).await)
}
async fn validate_jwt(token: &str, auth_layer: &SaasAuthLayer) -> Result<AuthContext, AuthError> {
let secret = auth_layer
.jwt_secret
.as_ref()
.ok_or(AuthError::JwtNotConfigured)?;
let claims = decode_jwt(
token,
secret,
&auth_layer.jwt_audience,
auth_layer.jwt_issuer.as_deref(),
)?;
let tenant_id = claims
.tenant_id
.ok_or_else(|| AuthError::MissingClaim("tenant_id".into()))?;
Ok(AuthContext {
tenant_id,
auth_type: AuthType::Jwt {
user_id: claims.sub,
role: claims.role,
},
scopes: claims.scopes.unwrap_or_default(),
})
}
async fn validate_api_key(key: &str, pool: &PgPool) -> Result<AuthContext, AuthError> {
let key_hash = hash_api_key(key);
let validation = db::validate_api_key_by_hash(pool, &key_hash)
.await?
.ok_or(AuthError::InvalidApiKey)?;
if !validation.enabled {
return Err(AuthError::ApiKeyDisabled);
}
let pool = pool.clone();
let key_id = validation.id;
tokio::spawn(async move {
let _ = db::update_last_used(&pool, key_id).await;
});
Ok(AuthContext {
tenant_id: validation.tenant_id,
auth_type: AuthType::ApiKey {
key_id: validation.id,
},
scopes: validation.scopes,
})
}
#[derive(Debug, Deserialize)]
struct JwtClaims {
sub: String,
tenant_id: Option<Uuid>,
role: Option<String>,
scopes: Option<Vec<String>>,
}
fn decode_jwt(
token: &str,
secret: &str,
audience: &str,
issuer: Option<&str>,
) -> Result<JwtClaims, AuthError> {
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
let mut validation = Validation::new(Algorithm::HS256);
validation.set_required_spec_claims(&["exp", "aud"]);
validation.validate_exp = true;
validation.set_audience(&[audience]);
if let Some(issuer) = issuer {
validation.set_issuer(&[issuer]);
validation.required_spec_claims.insert("iss".to_string());
}
let token_data = decode::<JwtClaims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
_ => AuthError::InvalidToken(e.to_string()),
})?;
Ok(token_data.claims)
}
#[derive(Debug)]
pub enum AuthError {
MissingHeader,
InvalidFormat,
InvalidToken(String),
TokenExpired,
MissingClaim(String),
InvalidApiKey,
ApiKeyDisabled,
ApiKeyExpired,
TenantSuspended,
JwtNotConfigured,
UnsupportedAuthMethod,
Database(sqlx::Error),
Internal(String),
}
impl From<sqlx::Error> for AuthError {
fn from(err: sqlx::Error) -> Self {
AuthError::Database(err)
}
}
impl From<crate::error::ProxyError> for AuthError {
fn from(err: crate::error::ProxyError) -> Self {
match err {
crate::error::ProxyError::Database(e) => AuthError::Database(e),
other => AuthError::Internal(other.to_string()),
}
}
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match &self {
AuthError::Database(error) => {
tracing::error!(%error, "authentication lookup failed");
}
AuthError::Internal(error) => {
tracing::error!(%error, "authentication failed internally");
}
AuthError::InvalidToken(reason) => {
tracing::debug!(%reason, "refused a JWT");
}
_ => {}
}
let (status, message) = match self {
AuthError::MissingHeader => (
StatusCode::UNAUTHORIZED,
"Missing Authorization header".to_string(),
),
AuthError::InvalidFormat => (
StatusCode::UNAUTHORIZED,
"Invalid Authorization header format".to_string(),
),
AuthError::InvalidToken(_) => (StatusCode::UNAUTHORIZED, "Invalid token".to_string()),
AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token has expired".to_string()),
AuthError::MissingClaim(claim) => (
StatusCode::UNAUTHORIZED,
format!("Missing required claim: {}", claim),
),
AuthError::InvalidApiKey => (StatusCode::UNAUTHORIZED, "Invalid API key".to_string()),
AuthError::ApiKeyDisabled => {
(StatusCode::UNAUTHORIZED, "API key is disabled".to_string())
}
AuthError::ApiKeyExpired => {
(StatusCode::UNAUTHORIZED, "API key has expired".to_string())
}
AuthError::TenantSuspended => (
StatusCode::FORBIDDEN,
"Tenant account is suspended".to_string(),
),
AuthError::JwtNotConfigured => (
StatusCode::INTERNAL_SERVER_ERROR,
"JWT authentication not configured".to_string(),
),
AuthError::UnsupportedAuthMethod => (
StatusCode::UNAUTHORIZED,
"Unsupported authentication method".to_string(),
),
AuthError::Database(_) | AuthError::Internal(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
),
};
let body = serde_json::json!({
"success": false,
"error": message
});
(status, axum::Json(body)).into_response()
}
}
#[derive(Clone, Debug)]
pub struct Auth(
pub AuthContext,
);
impl<S> axum::extract::FromRequestParts<S> for Auth
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<AuthContext>()
.cloned()
.map(Auth)
.ok_or(AuthError::MissingHeader)
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use jsonwebtoken::{encode, EncodingKey, Header};
const SECRET: &str = "test-secret";
const TENANT_ID: &str = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
fn sign(claims: &serde_json::Value, secret: &str) -> String {
encode(
&Header::default(),
claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
fn valid_claims() -> serde_json::Value {
serde_json::json!({
"sub": "user-1",
"tenant_id": TENANT_ID,
"role": "admin",
"scopes": ["domains:read"],
"aud": AUDIENCE,
"exp": chrono::Utc::now().timestamp() + 3600,
})
}
const AUDIENCE: &str = "postrust-proxy";
fn decode(token: &str, secret: &str) -> Result<JwtClaims, AuthError> {
decode_jwt(token, secret, AUDIENCE, None)
}
fn b64url(data: &[u8]) -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}
#[test]
fn valid_token_decodes() {
let token = sign(&valid_claims(), SECRET);
let claims = decode(&token, SECRET).unwrap();
assert_eq!(claims.sub, "user-1");
assert_eq!(claims.tenant_id, Some(TENANT_ID.parse().unwrap()));
assert_eq!(claims.role.as_deref(), Some("admin"));
assert_eq!(claims.scopes, Some(vec!["domains:read".to_string()]));
}
#[test]
fn token_without_exp_is_rejected() {
let mut claims = valid_claims();
claims.as_object_mut().unwrap().remove("exp");
let token = sign(&claims, SECRET);
assert!(matches!(
decode(&token, SECRET),
Err(AuthError::InvalidToken(_))
));
}
#[test]
fn token_without_an_audience_is_rejected() {
let mut claims = valid_claims();
claims.as_object_mut().unwrap().remove("aud");
let token = sign(&claims, SECRET);
assert!(matches!(
decode(&token, SECRET),
Err(AuthError::InvalidToken(_))
));
}
#[test]
fn token_for_another_audience_is_rejected() {
let mut claims = valid_claims();
claims["aud"] = serde_json::json!("postrust");
let token = sign(&claims, SECRET);
assert!(matches!(
decode(&token, SECRET),
Err(AuthError::InvalidToken(_))
));
let token = sign(&valid_claims(), SECRET);
assert!(decode_jwt(&token, SECRET, "elsewhere", None).is_err());
}
#[test]
fn a_configured_issuer_is_required() {
let token = sign(&valid_claims(), SECRET);
assert!(
decode_jwt(&token, SECRET, AUDIENCE, Some("https://id.example")).is_err(),
"a token with no iss passed an issuer check"
);
let mut claims = valid_claims();
claims["iss"] = serde_json::json!("https://id.example");
let token = sign(&claims, SECRET);
assert!(decode_jwt(&token, SECRET, AUDIENCE, Some("https://id.example")).is_ok());
assert!(decode_jwt(&token, SECRET, AUDIENCE, Some("https://other.example")).is_err());
}
#[tokio::test]
async fn a_token_without_scopes_has_none() {
let mut claims = valid_claims();
claims.as_object_mut().unwrap().remove("scopes");
let token = sign(&claims, SECRET);
let pool = PgPool::connect_lazy("postgres://localhost/unused").unwrap();
let layer = SaasAuthLayer::new(pool, Some(SECRET.to_string()));
let context = validate_jwt(&token, &layer).await.expect("valid token");
assert!(context.scopes.is_empty(), "got {:?}", context.scopes);
assert!(!context.can_read("domains"));
}
fn context(scopes: &[&str]) -> AuthContext {
AuthContext {
tenant_id: Uuid::nil(),
auth_type: AuthType::ApiKey {
key_id: Uuid::nil(),
},
scopes: scopes.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn only_a_wildcard_grants_a_wildcard() {
assert!(context(&["*"]).grants("*"));
assert!(!context(&["domains:write", "api_keys:write"]).grants("*"));
assert!(!context(&[]).grants("domains:read"));
}
#[test]
fn write_grants_read_but_not_the_reverse() {
let writer = context(&["domains:write"]);
assert!(writer.grants("domains:read"));
assert!(writer.grants("domains:write"));
assert!(!writer.grants("routes:read"));
let reader = context(&["domains:read"]);
assert!(reader.grants("domains:read"));
assert!(!reader.grants("domains:write"));
assert!(!reader.grants("bogus"));
}
#[test]
fn unknown_scopes_are_refused() {
assert!(validate_scopes(&["domains:read".into(), "tenant:read".into()]).is_ok());
assert!(validate_scopes(&["domain:read".into()]).is_err());
assert!(validate_scopes(&["domains:admin".into()]).is_err());
assert!(validate_scopes(&["".into()]).is_err());
}
#[test]
fn token_signed_with_wrong_secret_is_rejected() {
let token = sign(&valid_claims(), "some-other-secret");
assert!(matches!(
decode(&token, SECRET),
Err(AuthError::InvalidToken(_))
));
}
#[test]
fn tampered_payload_is_rejected() {
let token = sign(&valid_claims(), SECRET);
let parts: Vec<&str> = token.split('.').collect();
let mut claims = valid_claims();
claims["tenant_id"] = serde_json::json!("00000000-0000-0000-0000-000000000001");
let forged_payload = b64url(serde_json::to_vec(&claims).unwrap().as_slice());
let forged = format!("{}.{}.{}", parts[0], forged_payload, parts[2]);
assert!(matches!(
decode(&forged, SECRET),
Err(AuthError::InvalidToken(_))
));
}
#[test]
fn unsigned_alg_none_token_is_rejected() {
let header = b64url(br#"{"alg":"none","typ":"JWT"}"#);
let payload = b64url(serde_json::to_vec(&valid_claims()).unwrap().as_slice());
for token in [
format!("{header}.{payload}."),
format!("{header}.{payload}"),
] {
assert!(
matches!(decode(&token, SECRET), Err(AuthError::InvalidToken(_))),
"token {token:?} should be rejected"
);
}
}
#[test]
fn expired_token_is_rejected() {
let mut claims = valid_claims();
claims["exp"] = serde_json::json!(chrono::Utc::now().timestamp() - 3600);
let token = sign(&claims, SECRET);
assert!(matches!(
decode(&token, SECRET),
Err(AuthError::TokenExpired)
));
}
#[test]
fn malformed_token_is_rejected() {
assert!(matches!(
decode("not-a-jwt", SECRET),
Err(AuthError::InvalidToken(_))
));
}
}