use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::Response;
use jsonwebtoken::{Algorithm, DecodingKey, TokenData, Validation};
use serde::{Deserialize, Serialize};
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
let len_diff = a.len() ^ b.len();
let mut result: u8 = 0;
for i in 0..a.len().max(b.len()) {
let x = if i < a.len() { a[i] } else { 0 };
let y = if i < b.len() { b[i] } else { 0 };
result |= x ^ y;
}
result == 0 && len_diff == 0
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct AuthConfig {
pub enabled: bool,
pub secret: String,
pub jwt: Option<JwtConfig>,
}
impl AuthConfig {
pub fn with_secret(secret: impl Into<String>) -> Self {
Self {
enabled: true,
secret: secret.into(),
jwt: None,
}
}
pub fn with_jwt(jwt: JwtConfig) -> Self {
Self {
enabled: true,
secret: String::new(),
jwt: Some(jwt),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct JwtConfig {
pub public_key_pem: String,
pub issuer: Option<String>,
pub audience: Option<String>,
}
impl JwtConfig {
pub fn new(public_key_pem: impl Into<String>) -> Self {
Self {
public_key_pem: public_key_pem.into(),
issuer: None,
audience: None,
}
}
pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuer = Some(issuer.into());
self
}
pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
self.audience = Some(audience.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: Option<String>,
pub iss: Option<String>,
pub aud: Option<String>,
pub exp: Option<u64>,
pub iat: Option<u64>,
#[serde(default)]
pub scope: Option<String>,
}
fn validate_jwt(token: &str, config: &JwtConfig) -> Result<TokenData<Claims>, StatusCode> {
let key = DecodingKey::from_rsa_pem(config.public_key_pem.as_bytes()).map_err(|e| {
tracing::error!(error = %e, "JWT public key PEM is invalid — check AGNOSAI_JWT_PUBLIC_KEY");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut validation = Validation::new(Algorithm::RS256);
validation.validate_exp = true;
if let Some(ref iss) = config.issuer {
validation.set_issuer(&[iss]);
}
if let Some(ref aud) = config.audience {
validation.set_audience(&[aud]);
} else {
validation.validate_aud = false;
}
let token_data = jsonwebtoken::decode::<Claims>(token, &key, &validation).map_err(|e| {
tracing::warn!(error = %e, "JWT validation failed");
StatusCode::UNAUTHORIZED
})?;
if token_data.claims.exp.is_none() {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(token_data)
}
pub async fn auth_middleware(
config: AuthConfig,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
if !config.enabled {
return Ok(next.run(request).await);
}
let auth_header = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let token = match auth_header {
Some(header) if header.starts_with("Bearer ") => {
let t = &header["Bearer ".len()..];
if t.is_empty() {
tracing::warn!("auth rejected: empty bearer token");
return Err(StatusCode::UNAUTHORIZED);
}
t
}
_ => {
tracing::warn!("auth rejected: missing or malformed authorization header");
return Err(StatusCode::UNAUTHORIZED);
}
};
if let Some(ref jwt_config) = config.jwt {
validate_jwt(token, jwt_config)?;
} else {
if !constant_time_eq(token.as_bytes(), config.secret.as_bytes()) {
tracing::warn!("auth rejected: invalid shared secret");
return Err(StatusCode::UNAUTHORIZED);
}
}
Ok(next.run(request).await)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::body::Body;
use axum::http::Request;
use axum::middleware;
use axum::routing::get;
use tower::ServiceExt;
async fn handler() -> &'static str {
"ok"
}
fn test_router(config: AuthConfig) -> Router {
Router::new()
.route("/test", get(handler))
.layer(middleware::from_fn(
move |req: Request<Body>, next: Next| {
let cfg = config.clone();
async move { auth_middleware(cfg, req, next).await }
},
))
}
#[tokio::test]
async fn passes_when_disabled() {
let app = test_router(AuthConfig {
enabled: false,
secret: String::new(),
jwt: None,
});
let resp = app
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn rejects_missing_token_when_enabled() {
let app = test_router(AuthConfig {
enabled: true,
secret: "my-secret".into(),
jwt: None,
});
let resp = app
.oneshot(Request::get("/test").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_wrong_token_when_enabled() {
let app = test_router(AuthConfig {
enabled: true,
secret: "my-secret".into(),
jwt: None,
});
let resp = app
.oneshot(
Request::get("/test")
.header("Authorization", "Bearer wrong-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn passes_with_correct_token() {
let app = test_router(AuthConfig {
enabled: true,
secret: "my-secret".into(),
jwt: None,
});
let resp = app
.oneshot(
Request::get("/test")
.header("Authorization", "Bearer my-secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn rejects_empty_bearer_token() {
let app = test_router(AuthConfig {
enabled: true,
secret: "my-secret".into(),
jwt: None,
});
let resp = app
.oneshot(
Request::get("/test")
.header("Authorization", "Bearer ")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
const TEST_RSA_PRIVATE_PEM: &str = include_str!("../../tests/fixtures/test_rsa_private.pem");
const TEST_RSA_PUBLIC_PEM: &str = include_str!("../../tests/fixtures/test_rsa_public.pem");
fn make_jwt(claims: &Claims) -> String {
let key = jsonwebtoken::EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_PEM.as_bytes())
.expect("valid test key");
let header = jsonwebtoken::Header::new(Algorithm::RS256);
jsonwebtoken::encode(&header, claims, &key).expect("encode JWT")
}
fn jwt_config() -> JwtConfig {
JwtConfig {
public_key_pem: TEST_RSA_PUBLIC_PEM.to_string(),
issuer: Some("test-issuer".to_string()),
audience: Some("agnosai".to_string()),
}
}
fn valid_claims() -> Claims {
Claims {
sub: Some("user-123".to_string()),
iss: Some("test-issuer".to_string()),
aud: Some("agnosai".to_string()),
exp: Some(u64::MAX), iat: Some(0),
scope: None,
}
}
#[tokio::test]
async fn jwt_valid_token_passes() {
let config = AuthConfig {
enabled: true,
secret: String::new(),
jwt: Some(jwt_config()),
};
let token = make_jwt(&valid_claims());
let app = test_router(config);
let resp = app
.oneshot(
Request::get("/test")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn jwt_expired_token_rejected() {
let config = AuthConfig {
enabled: true,
secret: String::new(),
jwt: Some(jwt_config()),
};
let mut claims = valid_claims();
claims.exp = Some(0); let token = make_jwt(&claims);
let app = test_router(config);
let resp = app
.oneshot(
Request::get("/test")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn jwt_wrong_issuer_rejected() {
let config = AuthConfig {
enabled: true,
secret: String::new(),
jwt: Some(jwt_config()),
};
let mut claims = valid_claims();
claims.iss = Some("wrong-issuer".to_string());
let token = make_jwt(&claims);
let app = test_router(config);
let resp = app
.oneshot(
Request::get("/test")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn jwt_wrong_audience_rejected() {
let config = AuthConfig {
enabled: true,
secret: String::new(),
jwt: Some(jwt_config()),
};
let mut claims = valid_claims();
claims.aud = Some("wrong-audience".to_string());
let token = make_jwt(&claims);
let app = test_router(config);
let resp = app
.oneshot(
Request::get("/test")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn jwt_garbage_token_rejected() {
let config = AuthConfig {
enabled: true,
secret: String::new(),
jwt: Some(jwt_config()),
};
let app = test_router(config);
let resp = app
.oneshot(
Request::get("/test")
.header("Authorization", "Bearer not.a.jwt")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}