use std::sync::Arc;
use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use crate::extractors::Tenant;
use crate::sql::FetcherPool as _;
use crate::tenancy::jwt_lifecycle::JwtLifecycle;
#[derive(Clone)]
pub struct Config {
pub prefix: String,
pub access_ttl_secs: i64,
pub refresh_ttl_secs: i64,
pub session_secret: Option<Vec<u8>>,
pub jti_store: Option<Arc<dyn crate::jti_store::JtiStore>>,
pub extra_claims: Option<ClaimsHook>,
}
pub type ClaimsHook =
Arc<dyn Fn(&ClaimsContext<'_>) -> serde_json::Map<String, serde_json::Value> + Send + Sync>;
#[derive(Debug)]
#[non_exhaustive]
pub struct ClaimsContext<'a> {
pub user_id: i64,
pub username: &'a str,
pub is_superuser: bool,
pub tenant_slug: &'a str,
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("prefix", &self.prefix)
.field("access_ttl_secs", &self.access_ttl_secs)
.field("refresh_ttl_secs", &self.refresh_ttl_secs)
.field("session_secret", &self.session_secret.is_some())
.field("jti_store", &self.jti_store.is_some())
.field("extra_claims", &self.extra_claims.is_some())
.finish()
}
}
impl Default for Config {
fn default() -> Self {
Self {
prefix: "/api/auth".to_owned(),
access_ttl_secs: 900,
refresh_ttl_secs: 7 * 86400,
session_secret: None,
jti_store: None,
extra_claims: None,
}
}
}
const MIN_HMAC_KEY_LEN: usize = 32;
impl Config {
fn build_jwt(&self) -> JwtLifecycle {
let secret = match &self.session_secret {
Some(explicit) => explicit.clone(),
None => {
let raw = std::env::var("RUSTANGO_SESSION_SECRET").unwrap_or_default();
crate::session::SessionSecret::from_b64(&raw)
.map(|s| s.key().to_vec())
.unwrap_or_default()
}
};
assert!(
secret.len() >= MIN_HMAC_KEY_LEN,
"JWT signing key is {} bytes; need >= {MIN_HMAC_KEY_LEN}. Set \
RUSTANGO_SESSION_SECRET to a base64-encoded 32+ byte value \
(e.g. `openssl rand -base64 32`) or pass an explicit \
auth_routes::Config::session_secret. Refusing to start with a \
guessable key (would allow JWT forgery).",
secret.len(),
);
let jwt = JwtLifecycle::new(secret)
.with_access_ttl(self.access_ttl_secs)
.with_refresh_ttl(self.refresh_ttl_secs);
match &self.jti_store {
Some(store) => jwt.with_jti_store(Arc::clone(store)),
None => jwt,
}
}
#[cfg(feature = "config")]
#[must_use]
pub fn with_jwt_settings(mut self, s: &crate::config::JwtSettings) -> Self {
if let Some(v) = s.access_ttl_secs {
self.access_ttl_secs = i64::try_from(v).unwrap_or(i64::MAX);
}
if let Some(v) = s.refresh_ttl_secs {
self.refresh_ttl_secs = i64::try_from(v).unwrap_or(i64::MAX);
}
self
}
}
#[derive(Clone)]
pub struct JwtAuth(Arc<AuthState>);
struct AuthState {
jwt: JwtLifecycle,
extra_claims: Option<ClaimsHook>,
prefix: String,
}
impl JwtAuth {
#[must_use]
pub fn new(cfg: Config) -> Self {
Self(Arc::new(AuthState {
jwt: cfg.build_jwt(),
extra_claims: cfg.extra_claims,
prefix: cfg.prefix,
}))
}
#[must_use]
pub fn router(&self) -> Router<()> {
let p = &self.0.prefix;
Router::new()
.route(&format!("{p}/login"), post(login))
.route(&format!("{p}/refresh"), post(refresh))
.route(&format!("{p}/logout"), post(logout))
.route(&format!("{p}/me"), get(me))
.with_state(self.clone())
}
#[must_use]
pub fn lifecycle(&self) -> &JwtLifecycle {
&self.0.jwt
}
pub async fn verify_for_tenant(
&self,
bearer: &str,
expected_slug: &str,
) -> Result<i64, &'static str> {
let claims = self
.0
.jwt
.verify_access(bearer)
.await
.ok_or("invalid or expired token")?;
let claim_tenant = claims
.custom_value("tenant")
.and_then(|v| v.as_str())
.ok_or("token missing tenant binding")?;
if claim_tenant != expected_slug {
return Err("token issued for different tenant");
}
Ok(claims.sub)
}
}
fn login_claims(
hook: Option<&ClaimsHook>,
ctx: &ClaimsContext<'_>,
) -> serde_json::Map<String, serde_json::Value> {
let mut custom = hook.map_or_else(serde_json::Map::new, |h| h(ctx));
custom.insert(
"tenant".to_owned(),
serde_json::Value::String(ctx.tenant_slug.to_owned()),
);
custom
}
#[derive(Debug, Deserialize)]
pub struct LoginInput {
pub username: String,
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct UserBrief {
pub user_id: i64,
pub username: String,
pub is_superuser: bool,
}
#[derive(Debug, Serialize)]
pub struct LoginOutput {
pub access: String,
pub refresh: String,
pub user: UserBrief,
}
async fn login(
State(auth): State<JwtAuth>,
t: Tenant,
ip: crate::login_throttle::ClientIp,
headers: axum::http::HeaderMap,
Json(body): Json<LoginInput>,
) -> Result<Json<LoginOutput>, Response> {
use crate::core::Column as _;
use crate::login_throttle::LoginRefused;
use crate::signals::auth::{
meta_from_headers, send_user_logged_in, send_user_login_failed, AuthFailureReason,
UserLoggedInContext, UserLoginFailedContext,
};
use crate::sql::FetcherPool as _;
use crate::tenancy::auth::User;
let meta = meta_from_headers(&headers, Some("/auth/login"));
let fire_failed = |reason: AuthFailureReason| -> UserLoginFailedContext {
UserLoginFailedContext {
source: "jwt",
attempted_username: Some(body.username.clone()),
reason,
request: meta.clone(),
}
};
let mut attempt = crate::login_throttle::shared()
.begin(
&crate::login_throttle::LoginScope::Tenant(t.org.slug.clone()),
&ip,
&body.username,
)
.await
.map_err(LoginRefused::into_response)?;
let busy = |e: crate::tenancy::TenancyError| match e {
crate::tenancy::TenancyError::Busy => LoginRefused::Busy.into_response(),
e => err(StatusCode::INTERNAL_SERVER_ERROR, e),
};
let users = User::objects()
.where_(User::username.eq(body.username.clone()))
.fetch(t.pool())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
let Some(user) = users.into_iter().next() else {
crate::tenancy::password::verify_dummy_async(&body.password)
.await
.map_err(busy)?;
attempt.failed().await;
send_user_login_failed(fire_failed(AuthFailureReason::InvalidCredentials)).await;
return Err(err(StatusCode::UNAUTHORIZED, "invalid credentials"));
};
attempt
.resolve(&user.username)
.await
.map_err(LoginRefused::into_response)?;
let uid = user.id.get().copied().unwrap_or(0);
let ok = crate::tenancy::password::verify_async(&body.password, &user.password_hash)
.await
.map_err(busy)?;
if !user.active {
attempt.failed().await;
send_user_login_failed(fire_failed(AuthFailureReason::Inactive)).await;
return Err(err(StatusCode::UNAUTHORIZED, "invalid credentials"));
}
if !ok {
attempt.failed().await;
send_user_login_failed(fire_failed(AuthFailureReason::InvalidCredentials)).await;
return Err(err(StatusCode::UNAUTHORIZED, "invalid credentials"));
}
attempt.succeeded().await;
let user_id = uid;
send_user_logged_in(UserLoggedInContext {
source: "jwt",
user_id,
username: user.username.clone(),
is_superuser: user.is_superuser,
request: meta,
})
.await;
let custom = login_claims(
auth.0.extra_claims.as_ref(),
&ClaimsContext {
user_id,
username: &user.username,
is_superuser: user.is_superuser,
tenant_slug: &t.org.slug,
},
);
let pair = auth
.lifecycle()
.issue_pair_with(user_id, custom)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(LoginOutput {
access: pair.access,
refresh: pair.refresh,
user: UserBrief {
user_id,
username: user.username,
is_superuser: user.is_superuser,
},
}))
}
#[derive(Debug, Deserialize)]
pub struct RefreshInput {
pub refresh: String,
}
#[derive(Debug, Serialize)]
pub struct RefreshOutput {
pub access: String,
pub refresh: String,
}
async fn refresh(
State(auth): State<JwtAuth>,
t: Tenant,
Json(body): Json<RefreshInput>,
) -> Result<Json<RefreshOutput>, Response> {
let jwt = auth.lifecycle();
let claims = jwt
.verify_refresh(&body.refresh)
.await
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "invalid or expired refresh token"))?;
let tenant_ok =
claims.custom_value("tenant").and_then(|v| v.as_str()) == Some(t.org.slug.as_str());
if !tenant_ok {
return Err(err(
StatusCode::UNAUTHORIZED,
"refresh token issued for a different tenant",
));
}
{
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
use crate::tenancy::auth::User;
let users: Vec<User> = User::objects()
.where_(User::id.eq(claims.sub))
.fetch(t.pool())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
let still_active = users.into_iter().next().is_some_and(|u| u.active);
if !still_active {
return Err(err(
StatusCode::UNAUTHORIZED,
"invalid or expired refresh token",
));
}
}
let pair = jwt
.refresh(&body.refresh)
.await
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "invalid or expired refresh token"))?;
Ok(Json(RefreshOutput {
access: pair.access,
refresh: pair.refresh,
}))
}
#[derive(Debug, Deserialize)]
pub struct LogoutInput {
#[serde(default)]
pub refresh: Option<String>,
}
async fn logout(
State(auth): State<JwtAuth>,
t: Tenant,
headers: axum::http::HeaderMap,
bearer: Bearer,
body: Option<Json<LogoutInput>>,
) -> Result<StatusCode, Response> {
let jwt = auth.lifecycle();
use crate::signals::auth::{meta_from_headers, send_user_logged_out, UserLoggedOutContext};
let claims = jwt.verify_access(&bearer.0).await;
if let Some(c) = &claims {
if c.custom_value("tenant").and_then(|v| v.as_str()) != Some(t.org.slug.as_str()) {
return Err(err(
StatusCode::UNAUTHORIZED,
"token issued for a different tenant",
));
}
}
let user_id = claims.map(|c| c.sub);
let meta = meta_from_headers(&headers, Some("/auth/logout"));
jwt.revoke(&bearer.0).await;
if let Some(Json(input)) = body {
if let Some(refresh) = input.refresh.as_deref() {
let ok = match jwt.verify_refresh(refresh).await {
Some(c) => {
c.custom_value("tenant").and_then(|v| v.as_str()) == Some(t.org.slug.as_str())
}
None => true,
};
if ok {
jwt.revoke(refresh).await;
}
}
}
send_user_logged_out(UserLoggedOutContext {
source: "jwt",
user_id,
username: None,
request: meta,
})
.await;
Ok(StatusCode::NO_CONTENT)
}
async fn me(
State(auth): State<JwtAuth>,
t: Tenant,
bearer: Bearer,
) -> Result<Json<UserBrief>, Response> {
use crate::core::Column as _;
use crate::sql::FetcherPool as _;
use crate::tenancy::auth::User;
let user_id = auth
.verify_for_tenant(&bearer.0, &t.org.slug)
.await
.map_err(|msg| err(StatusCode::UNAUTHORIZED, msg))?;
let users = User::objects()
.where_(User::id.eq(user_id))
.fetch(t.pool())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
let user = users
.into_iter()
.next()
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "user not found"))?;
if !user.active {
return Err(err(StatusCode::FORBIDDEN, "account inactive"));
}
Ok(Json(UserBrief {
user_id: user.id.get().copied().unwrap_or(0),
username: user.username,
is_superuser: user.is_superuser,
}))
}
pub async fn require_bearer(
State(auth): State<JwtAuth>,
t: Tenant,
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let Some(token) = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(str::trim)
.filter(|t| !t.is_empty())
else {
return unauthorized("missing Bearer token");
};
let user_id = match auth.verify_for_tenant(token, &t.org.slug).await {
Ok(id) => id,
Err(_) => return unauthorized("invalid or expired token"),
};
let user = match crate::tenancy::auth::User::objects()
.filter("id", user_id)
.fetch(t.pool())
.await
{
Ok(rows) => rows.into_iter().next(),
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, e),
};
let Some(user) = user.filter(|u| u.active) else {
return unauthorized("invalid or expired token");
};
let id = user.id.get().copied().unwrap_or(user_id);
req.extensions_mut()
.insert(crate::tenancy::AuthenticatedUser {
id,
username: user.username.clone(),
is_superuser: user.is_superuser,
});
req.extensions_mut().insert(crate::tenancy::Principal::user(
id,
user.is_superuser,
Some(t.org.slug.clone()),
));
next.run(req).await
}
fn unauthorized(msg: &'static str) -> Response {
err(StatusCode::UNAUTHORIZED, msg)
}
fn err(status: StatusCode, msg: impl std::fmt::Display) -> Response {
crate::api_errors::ApiError::logged(status, "auth_routes", msg).into_response()
}
pub struct Bearer(pub String);
impl<S: Send + Sync> FromRequestParts<S> for Bearer {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|t| Bearer(t.trim().to_owned()))
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "missing Bearer token"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_default_paths_match_documentation() {
let cfg = Config::default();
assert_eq!(cfg.prefix, "/api/auth");
assert_eq!(cfg.access_ttl_secs, 900);
assert_eq!(cfg.refresh_ttl_secs, 7 * 86400);
assert!(cfg.session_secret.is_none());
}
#[test]
fn the_claims_hook_adds_claims_but_cannot_rewrite_tenant() {
let ctx = ClaimsContext {
user_id: 7,
username: "alice",
is_superuser: false,
tenant_slug: "acme",
};
let plain = login_claims(None, &ctx);
assert_eq!(plain.get("tenant").and_then(|v| v.as_str()), Some("acme"));
assert_eq!(plain.len(), 1);
let hook: ClaimsHook = Arc::new(|c: &ClaimsContext<'_>| {
let mut m = serde_json::Map::new();
m.insert("fam".into(), serde_json::Value::String("f-1".into()));
m.insert("su".into(), serde_json::Value::Bool(c.is_superuser));
m.insert(
"who".into(),
serde_json::Value::String(c.username.to_owned()),
);
m
});
let out = login_claims(Some(&hook), &ctx);
assert_eq!(out.get("fam").and_then(|v| v.as_str()), Some("f-1"));
assert_eq!(
out.get("su").and_then(serde_json::Value::as_bool),
Some(false)
);
assert_eq!(out.get("who").and_then(|v| v.as_str()), Some("alice"));
assert_eq!(out.get("tenant").and_then(|v| v.as_str()), Some("acme"));
let evil: ClaimsHook = Arc::new(|_: &ClaimsContext<'_>| {
let mut m = serde_json::Map::new();
m.insert("tenant".into(), serde_json::Value::String("victim".into()));
m
});
let out = login_claims(Some(&evil), &ctx);
assert_eq!(
out.get("tenant").and_then(|v| v.as_str()),
Some("acme"),
"the router's tenant binding must win — it is what stops \
cross-subdomain replay"
);
}
#[tokio::test]
async fn a_custom_jti_store_is_installed() {
use crate::jti_store::{JtiFuture, JtiStore};
#[derive(Default)]
struct MarkerStore(std::sync::atomic::AtomicUsize);
impl JtiStore for MarkerStore {
fn is_used<'a>(&'a self, _jti: &'a str) -> JtiFuture<'a, bool> {
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Box::pin(async { false })
}
fn mark_used<'a>(&'a self, _jti: &'a str, _exp_unix: i64) -> JtiFuture<'a, bool> {
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Box::pin(async { true })
}
}
let store = Arc::new(MarkerStore::default());
let cfg = Config {
session_secret: Some(b"a-test-signing-key-of-32-bytes!!".to_vec()),
jti_store: Some(store.clone()),
..Config::default()
};
let jwt = cfg.build_jwt();
let pair = jwt.issue_pair(1);
jwt.revoke(&pair.access).await;
assert!(
store.0.load(std::sync::atomic::Ordering::Relaxed) > 0,
"the configured JtiStore must be the one the lifecycle uses"
);
}
#[tokio::test]
async fn each_jwt_auth_keeps_its_own_key() {
let auth = |key: &[u8]| {
JwtAuth::new(Config {
session_secret: Some(key.to_vec()),
..Config::default()
})
};
let a = auth(b"key-a-key-a-key-a-key-a-key-a-32");
let b = auth(b"key-b-key-b-key-b-key-b-key-b-32");
let mut custom = serde_json::Map::new();
custom.insert("tenant".into(), "acme".into());
let token = a.lifecycle().issue_pair_with(1, custom).unwrap().access;
assert_eq!(a.verify_for_tenant(&token, "acme").await, Ok(1));
assert!(b.verify_for_tenant(&token, "acme").await.is_err());
}
#[test]
#[should_panic(expected = "JWT signing key")]
fn build_jwt_panics_on_empty_secret() {
let cfg = Config {
session_secret: Some(Vec::new()),
..Default::default()
};
let _ = cfg.build_jwt();
}
#[test]
#[should_panic(expected = "JWT signing key")]
fn build_jwt_panics_on_short_secret() {
let cfg = Config {
session_secret: Some(b"too-short-key".to_vec()),
..Default::default()
};
let _ = cfg.build_jwt();
}
#[tokio::test]
async fn config_uses_explicit_secret_when_set() {
let cfg = Config {
session_secret: Some(b"super-secret-key-for-tests-32b!!".to_vec()),
..Default::default()
};
assert!(cfg.session_secret.as_ref().unwrap().len() >= 32);
let jwt = cfg.build_jwt();
let token = jwt.issue_pair(42);
let claims = jwt
.verify_access(&token.access)
.await
.expect("access valid");
assert_eq!(claims.sub, 42);
}
#[test]
fn router_mounts_all_four_endpoints() {
let cfg = Config {
session_secret: Some(b"router-smoke-test-secret-32-byte".to_vec()),
..Default::default()
};
let r = JwtAuth::new(cfg).router();
let _ = r;
}
#[cfg(feature = "config")]
#[test]
fn with_jwt_settings_overrides_ttls() {
let mut s = crate::config::JwtSettings::default();
s.access_ttl_secs = Some(60); s.refresh_ttl_secs = Some(3600); let cfg = Config::default().with_jwt_settings(&s);
assert_eq!(cfg.access_ttl_secs, 60);
assert_eq!(cfg.refresh_ttl_secs, 3600);
}
#[cfg(feature = "config")]
#[test]
fn with_jwt_settings_unset_preserves_defaults() {
let s = crate::config::JwtSettings::default(); let cfg = Config::default().with_jwt_settings(&s);
assert_eq!(cfg.access_ttl_secs, 900); assert_eq!(cfg.refresh_ttl_secs, 7 * 86400); }
}