use axum::body::Body;
use axum::extract::{MatchedPath, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use kanade_shared::feature::Feature;
use kanade_shared::secrets;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::env;
use std::sync::OnceLock;
use tracing::{error, warn};
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Viewer,
Operator,
Admin,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Viewer => "viewer",
Role::Operator => "operator",
Role::Admin => "admin",
}
}
pub fn parse(s: &str) -> Option<Role> {
match s {
"viewer" => Some(Role::Viewer),
"operator" => Some(Role::Operator),
"admin" => Some(Role::Admin),
_ => None,
}
}
pub fn allows(self, required: Role) -> bool {
self >= required
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Claims {
pub sub: String,
pub exp: i64,
#[serde(default)]
pub aud: Option<String>,
#[serde(default)]
pub roles: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_features: Option<Vec<Feature>>,
}
impl Claims {
pub fn role(&self) -> Role {
self.roles
.iter()
.filter_map(|r| Role::parse(r))
.max()
.unwrap_or(Role::Viewer)
}
fn service(sub: &str) -> Self {
Claims {
sub: sub.to_string(),
exp: 4_102_444_800, aud: Some(EXPECTED_AUDIENCE.to_string()),
roles: vec![Role::Admin.as_str().to_string()],
allowed_features: None,
}
}
}
const ENV_DISABLE: &str = "KANADE_AUTH_DISABLE";
const ENV_STATIC_TOKEN: &str = "KANADE_AUTH_STATIC_TOKEN";
const ENV_SECRET: &str = "KANADE_JWT_SECRET";
const REG_SUBKEY: &str = r"SOFTWARE\kanade\backend";
const REG_STATIC_TOKEN: &str = "StaticToken";
const REG_JWT_SECRET: &str = "JwtSecret";
pub const EXPECTED_AUDIENCE: &str = "kanade";
fn resolve_static_token() -> Option<&'static str> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
if let Some(t) = secrets::read_hklm_value(REG_SUBKEY, REG_STATIC_TOKEN) {
return Some(t);
}
match env::var(ENV_STATIC_TOKEN) {
Ok(t) if !t.is_empty() => Some(t),
_ => None,
}
})
.as_deref()
}
fn resolve_jwt_secret() -> Option<String> {
if let Some(s) = secrets::read_hklm_value(REG_SUBKEY, REG_JWT_SECRET) {
return Some(s);
}
match env::var(ENV_SECRET) {
Ok(s) if !s.is_empty() => Some(s),
_ => None,
}
}
pub fn signing_secret() -> &'static str {
static CACHE: OnceLock<String> = OnceLock::new();
CACHE.get_or_init(|| {
resolve_jwt_secret().unwrap_or_else(|| {
warn!(
"no JwtSecret registry value and no $KANADE_JWT_SECRET — using a hard-coded dev fallback (NEVER in production)"
);
"dev-secret-please-override".to_string()
})
})
}
struct UserAuth {
role: Role,
disabled: bool,
allowed_features: Option<Vec<Feature>>,
}
async fn lookup_user(pool: &SqlitePool, username: &str) -> Result<Option<UserAuth>, sqlx::Error> {
let row = sqlx::query_as::<_, (String, i64, Option<String>, Option<String>, Option<String>)>(
"SELECT u.role, u.disabled, u.allowed_features, g.features, u.permission_group \
FROM users u \
LEFT JOIN permission_groups g ON u.permission_group = g.name \
WHERE u.username = ?",
)
.bind(username)
.fetch_optional(pool)
.await?;
Ok(row.and_then(
|(role, disabled, allowed, group_features, permission_group)| {
Role::parse(&role).map(|role| {
let allowed_features = if permission_group.is_some() {
parse_allowed_features(group_features.as_deref())
.or_else(|| parse_allowed_features(allowed.as_deref()))
.or_else(|| Some(Vec::new()))
} else {
parse_allowed_features(allowed.as_deref())
};
UserAuth {
role,
disabled: disabled != 0,
allowed_features,
}
})
},
))
}
fn parse_allowed_features(raw: Option<&str>) -> Option<Vec<Feature>> {
let raw = raw?;
match serde_json::from_str::<Vec<String>>(raw) {
Ok(keys) => Some(keys.iter().filter_map(|k| Feature::parse(k)).collect()),
Err(e) => {
warn!(error = %e, "malformed allowed_features JSON; treating as unrestricted");
None
}
}
}
pub async fn verify(
State(pool): State<SqlitePool>,
req: Request,
next: Next,
) -> Result<Response, Response> {
if env::var(ENV_DISABLE).is_ok() {
let mut req = req;
req.extensions_mut()
.insert(Claims::service("auth-disabled"));
return Ok(next.run(req).await);
}
let path = req.uri().path();
if !path.starts_with("/api/") {
return Ok(next.run(req).await);
}
if path == "/api/auth/login"
|| path == "/api/version"
|| path == "/api/auth/forgot-password"
|| path.starts_with("/api/auth/password-setup/")
{
return Ok(next.run(req).await);
}
let token = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.map(str::trim)
.filter(|t| !t.is_empty());
let Some(token) = token else {
return Err(unauth("missing bearer token"));
};
if let Some(expected) = resolve_static_token()
&& constant_time_eq(token.as_bytes(), expected.as_bytes())
{
let mut req = req;
req.extensions_mut()
.insert(Claims::service("service-token"));
return Ok(next.run(req).await);
}
let secret = signing_secret();
let key = DecodingKey::from_secret(secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
validation.set_audience(&[EXPECTED_AUDIENCE]);
let claims = match decode::<Claims>(token, &key, &validation) {
Ok(data) => data.claims,
Err(e) => {
warn!(error = %e, path, "JWT verify failed");
return Err(unauth(&format!("invalid token: {e}")));
}
};
match lookup_user(&pool, &claims.sub).await {
Ok(Some(user)) => {
if user.disabled {
return Err(unauth("account disabled"));
}
let mut claims = claims;
claims.roles = vec![user.role.as_str().to_string()];
claims.allowed_features = user.allowed_features;
let mut req = req;
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
Ok(None) => Err(unauth("unknown account")),
Err(e) => {
error!(error = %e, sub = %claims.sub, "user lookup failed");
Err(unauth("auth backend unavailable"))
}
}
}
fn gate(req: &Request, required: Role) -> Option<Response> {
let Some(claims) = req.extensions().get::<Claims>().cloned() else {
return Some(forbidden("no authenticated identity"));
};
if claims.role().allows(required) {
None
} else {
Some(forbidden(&format!(
"{} role required (caller is {})",
required.as_str(),
claims.role().as_str()
)))
}
}
pub async fn require_operator(req: Request, next: Next) -> Result<Response, Response> {
if let Some(rejection) = gate(&req, Role::Operator) {
return Err(rejection);
}
Ok(next.run(req).await)
}
pub async fn require_admin(req: Request, next: Next) -> Result<Response, Response> {
if let Some(rejection) = gate(&req, Role::Admin) {
return Err(rejection);
}
Ok(next.run(req).await)
}
pub async fn require_features(req: Request, next: Next) -> Result<Response, Response> {
let denied: Option<&'static str> = {
let ext = req.extensions();
match ext
.get::<Claims>()
.and_then(|c| c.allowed_features.as_ref())
{
None => None,
Some(allowed) => match ext
.get::<MatchedPath>()
.and_then(|m| crate::api::feature_for_path(m.as_str()))
{
None => None,
Some(feature) if allowed.contains(&feature) => None,
Some(feature) => Some(feature.as_str()),
},
}
};
match denied {
None => Ok(next.run(req).await),
Some(want) => Err(forbidden(&format!(
"account not permitted to access this page (requires {want})"
))),
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn unauth(msg: &str) -> Response {
(StatusCode::UNAUTHORIZED, Body::from(msg.to_owned())).into_response()
}
fn forbidden(msg: &str) -> Response {
(StatusCode::FORBIDDEN, Body::from(msg.to_owned())).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn role_hierarchy() {
assert!(Role::Admin.allows(Role::Operator));
assert!(Role::Admin.allows(Role::Viewer));
assert!(Role::Operator.allows(Role::Viewer));
assert!(!Role::Operator.allows(Role::Admin));
assert!(!Role::Viewer.allows(Role::Operator));
assert!(Role::Viewer.allows(Role::Viewer));
}
#[test]
fn role_roundtrip() {
for r in [Role::Viewer, Role::Operator, Role::Admin] {
assert_eq!(Role::parse(r.as_str()), Some(r));
}
assert_eq!(Role::parse("root"), None);
}
#[test]
fn jwt_hs256_roundtrip_does_not_panic() {
use jsonwebtoken::{EncodingKey, Header, encode};
let claims = Claims {
sub: "alice".into(),
exp: 4_102_444_800,
aud: Some(EXPECTED_AUDIENCE.to_string()),
roles: vec![Role::Admin.as_str().to_string()],
allowed_features: None,
};
let key = b"regression-secret";
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(key),
)
.expect("HS256 encode must not panic — pin a jsonwebtoken CryptoProvider feature");
let mut validation = Validation::new(Algorithm::HS256);
validation.set_audience(&[EXPECTED_AUDIENCE]);
let decoded = decode::<Claims>(&token, &DecodingKey::from_secret(key), &validation)
.expect("HS256 decode of our own token");
assert_eq!(decoded.claims.sub, "alice");
assert_eq!(decoded.claims.role(), Role::Admin);
}
#[test]
fn allowed_features_parse() {
assert!(parse_allowed_features(None).is_none());
let got = parse_allowed_features(Some(r#"["compliance","inventory","bogus"]"#)).unwrap();
assert_eq!(got, vec![Feature::Compliance, Feature::Inventory]);
assert_eq!(parse_allowed_features(Some("[]")), Some(vec![]));
assert!(parse_allowed_features(Some("not json")).is_none());
assert!(parse_allowed_features(Some(r#"{"x":1}"#)).is_none());
}
#[tokio::test]
async fn effective_features_group_precedence() {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
sqlx::query(
"INSERT INTO permission_groups (name, features) VALUES ('sec', '[\"compliance\"]')",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO permission_groups (name, features) VALUES ('locked', '[]')")
.execute(&pool)
.await
.unwrap();
let eff = |name: &'static str| {
let pool = pool.clone();
async move {
lookup_user(&pool, name)
.await
.unwrap()
.unwrap()
.allowed_features
}
};
sqlx::query("INSERT INTO users (username, password_hash, role, allowed_features, permission_group) VALUES ('g', 'x', 'viewer', '[\"audit\"]', 'sec')")
.execute(&pool).await.unwrap();
assert_eq!(eff("g").await, Some(vec![Feature::Compliance]));
sqlx::query("INSERT INTO users (username, password_hash, role, permission_group) VALUES ('l', 'x', 'viewer', 'locked')")
.execute(&pool).await.unwrap();
assert_eq!(eff("l").await, Some(vec![]));
sqlx::query("INSERT INTO users (username, password_hash, role, allowed_features) VALUES ('p', 'x', 'viewer', '[\"audit\"]')")
.execute(&pool).await.unwrap();
assert_eq!(eff("p").await, Some(vec![Feature::Audit]));
sqlx::query("INSERT INTO users (username, password_hash, role, allowed_features, permission_group) VALUES ('d', 'x', 'viewer', '[\"logs\"]', 'ghost')")
.execute(&pool).await.unwrap();
assert_eq!(eff("d").await, Some(vec![Feature::Logs]));
sqlx::query("INSERT INTO users (username, password_hash, role, permission_group) VALUES ('x', 'x', 'admin', 'ghost')")
.execute(&pool).await.unwrap();
assert_eq!(eff("x").await, Some(vec![]));
sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ('u', 'x', 'admin')")
.execute(&pool)
.await
.unwrap();
assert_eq!(eff("u").await, None);
}
#[test]
fn claims_role_picks_highest() {
let c = Claims {
sub: "x".into(),
exp: 0,
aud: None,
roles: vec!["viewer".into(), "admin".into()],
allowed_features: None,
};
assert_eq!(c.role(), Role::Admin);
let none = Claims {
sub: "x".into(),
exp: 0,
aud: None,
roles: vec![],
allowed_features: None,
};
assert_eq!(none.role(), Role::Viewer);
}
}