pub mod api_key;
pub mod config;
pub mod gate;
pub mod jwt;
pub mod rate_limit;
pub mod scope;
mod error;
pub use error::ProblemDetail;
use std::sync::Arc;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use self::api_key::{ApiKeyStore, KeyNotValid};
use self::config::{AuthConfig, AuthMode};
use self::jwt::JwtVerifier;
use self::rate_limit::RateLimiter;
use self::scope::Scope;
#[derive(Debug)]
pub enum AuthError {
MissingHeader,
InvalidToken(String),
ExpiredToken,
RateLimited {
retry_after_secs: u64,
},
InsufficientScope {
required: Scope,
},
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match self {
AuthError::MissingHeader => ProblemDetail::from_status(StatusCode::UNAUTHORIZED)
.with_detail("Missing Authorization header")
.into_response(),
AuthError::InvalidToken(reason) => ProblemDetail::from_status(StatusCode::UNAUTHORIZED)
.with_detail(format!("Invalid token: {reason}"))
.into_response(),
AuthError::ExpiredToken => ProblemDetail::from_status(StatusCode::UNAUTHORIZED)
.with_detail("Token has expired")
.into_response(),
AuthError::RateLimited { retry_after_secs } => {
let problem = ProblemDetail::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_detail(format!("Rate limit exceeded. Retry after {retry_after_secs} seconds"));
let mut response = problem.into_response();
response.headers_mut().insert(
"retry-after",
retry_after_secs
.to_string()
.parse()
.expect("integer is valid header value"),
);
response
}
AuthError::InsufficientScope { required } => ProblemDetail::from_status(StatusCode::FORBIDDEN)
.with_detail(format!("Insufficient scope: requires '{required}'"))
.into_response(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Tenant {
pub team_id: Option<String>,
pub org_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AuthenticatedCaller {
pub key_id: String,
pub scopes: Vec<Scope>,
pub tenant: Tenant,
}
impl AuthenticatedCaller {
pub fn can_access_team(&self, team: &str) -> bool {
if self.scopes.contains(&Scope::Admin) {
return true;
}
self.tenant.team_id.as_deref() == Some(team)
}
pub fn can_access_org(&self, org: &str) -> bool {
if self.scopes.contains(&Scope::Admin) {
return true;
}
self.tenant.org_id.as_deref() == Some(org)
}
pub fn storage_tenant_org(&self) -> Option<&str> {
self.tenant.org_id.as_deref()
}
}
const API_KEY_PREFIX: &str = "aa_";
impl<S> FromRequestParts<S> for AuthenticatedCaller
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_config = parts
.extensions
.get::<Arc<AuthConfig>>()
.expect("AuthConfig extension missing — did you forget to add it in build_app?");
if auth_config.mode == AuthMode::Off {
return Ok(AuthenticatedCaller {
key_id: "__bypass__".to_string(),
scopes: vec![Scope::Read, Scope::Write, Scope::Admin],
tenant: Tenant::default(),
});
}
let header_value = parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AuthError::MissingHeader)?;
let token = header_value
.strip_prefix("Bearer ")
.ok_or_else(|| AuthError::InvalidToken("expected 'Bearer <token>' format".into()))?;
let caller = if token.starts_with(API_KEY_PREFIX) {
let key_store = parts
.extensions
.get::<Arc<ApiKeyStore>>()
.expect("ApiKeyStore extension missing");
let entry = match key_store.validate_detailed(token) {
Ok(e) => e,
Err(KeyNotValid::Revoked) => {
return Err(AuthError::InvalidToken("revoked API key".into()));
}
Err(KeyNotValid::NotFound) => {
return Err(AuthError::InvalidToken("invalid API key".into()));
}
};
AuthenticatedCaller {
key_id: entry.id.clone(),
scopes: entry.scopes.clone(),
tenant: Tenant {
team_id: entry.team_id.clone(),
org_id: entry.org_id.clone(),
},
}
} else {
let jwt_verifier = parts
.extensions
.get::<Arc<JwtVerifier>>()
.expect("JwtVerifier extension missing");
let claims = jwt_verifier.verify(token).map_err(|e| {
let msg = e.to_string();
if msg.contains("ExpiredSignature") {
AuthError::ExpiredToken
} else {
AuthError::InvalidToken(msg)
}
})?;
AuthenticatedCaller {
key_id: claims.sub,
scopes: claims.scope,
tenant: Tenant {
team_id: claims.team_id,
org_id: claims.org_id,
},
}
};
let rate_limiter = parts
.extensions
.get::<Arc<RateLimiter>>()
.expect("RateLimiter extension missing");
rate_limiter
.check(&caller.key_id)
.map_err(|retry_after_secs| AuthError::RateLimited { retry_after_secs })?;
Ok(caller)
}
}
#[cfg(test)]
mod tenant_guard_tests {
use super::*;
fn caller_with_org(org: Option<&str>, scopes: Vec<Scope>) -> AuthenticatedCaller {
AuthenticatedCaller {
key_id: "key-1".to_string(),
scopes,
tenant: Tenant {
team_id: None,
org_id: org.map(str::to_string),
},
}
}
#[test]
fn storage_tenant_org_is_the_verified_org() {
let caller = caller_with_org(Some("org-verified"), vec![Scope::Read]);
assert_eq!(caller.storage_tenant_org(), Some("org-verified"));
}
#[test]
fn client_supplied_org_cannot_redirect_storage_scope() {
let caller = caller_with_org(Some("org-A"), vec![Scope::Read]);
let spoofed_client_org = "org-victim";
assert_ne!(
caller.storage_tenant_org(),
Some(spoofed_client_org),
"a client-chosen org must not become the storage tenant"
);
assert_eq!(
caller.storage_tenant_org(),
Some("org-A"),
"the storage tenant is provably the verified org only"
);
}
#[test]
fn no_tenant_scope_yields_none_not_a_client_value() {
let caller = caller_with_org(None, vec![Scope::Read, Scope::Admin]);
assert_eq!(caller.storage_tenant_org(), None);
}
}