use std::sync::Arc;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum_extra::TypedHeader;
use axum_extra::headers::Authorization;
use axum_extra::headers::authorization::Bearer;
use tracing::warn;
use crate::acl::{ActScope, Role, act_scope_for};
use crate::auth::jwt::JwtKeys;
use crate::auth::session::{SessionState, get_session};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
pub trait AuthState: Clone + Send + Sync + 'static {
fn jwt_keys(&self) -> Option<&Arc<JwtKeys>>;
fn sessions_ks(&self) -> &KeyspaceHandle;
}
#[derive(Debug, Default, Clone)]
pub struct AuthClaims {
pub did: String,
pub role: Role,
pub allowed_contexts: Vec<String>,
pub session_id: String,
pub access_expires_at: u64,
pub amr: Vec<String>,
pub acr: String,
}
pub const ADMIN_SESSION_COOKIE: &str = "vtc_admin_session";
impl<S: AuthState> FromRequestParts<S> for AuthClaims {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let bearer_token = TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
.await
.ok()
.map(|TypedHeader(auth)| auth.token().to_string());
let token: String = match bearer_token {
Some(t) => t,
None => match cookie_token(parts, ADMIN_SESSION_COOKIE) {
Some(t) => t,
None => {
warn!(
"auth rejected: no Authorization header and no {ADMIN_SESSION_COOKIE} cookie"
);
return Err(AppError::Unauthorized(
"missing or invalid Authorization header".into(),
));
}
},
};
let token = token.as_str();
let jwt_keys = state
.jwt_keys()
.ok_or_else(|| AppError::Unauthorized("auth not configured".into()))?;
let claims = jwt_keys.decode(token)?;
let session = get_session(state.sessions_ks(), &claims.session_id)
.await?
.ok_or_else(|| {
warn!(session_id = %claims.session_id, "auth rejected: session not found");
AppError::Unauthorized("session not found".into())
})?;
if session.state != SessionState::Authenticated {
warn!(session_id = %claims.session_id, "auth rejected: session not in authenticated state");
return Err(AppError::Unauthorized("session not authenticated".into()));
}
if let Some(ref pinned) = session.token_id
&& claims.jti != *pinned
{
warn!(session_id = %claims.session_id, "auth rejected: token superseded (jti mismatch)");
return Err(AppError::Unauthorized("token superseded".into()));
}
let role = Role::parse(&claims.role)?;
Ok(AuthClaims {
did: claims.sub,
role,
allowed_contexts: claims.contexts,
session_id: claims.session_id,
access_expires_at: claims.exp,
amr: claims.amr,
acr: claims.acr,
})
}
}
impl AuthClaims {
#[cfg(feature = "cli-synthesis")]
pub fn unsafe_local_cli_super_admin(channel: &str) -> Self {
Self {
did: format!("cli:{channel}"),
role: Role::Admin,
allowed_contexts: Vec::new(),
session_id: format!("cli:{channel}"),
access_expires_at: 0,
amr: vec!["cli".to_string()],
acr: String::new(),
}
}
pub fn act_scope(&self) -> ActScope {
act_scope_for(&self.role, &self.allowed_contexts)
}
pub fn is_super_admin(&self) -> bool {
self.role == Role::Admin && self.act_scope().is_unrestricted()
}
pub fn has_context_access(&self, context_id: &str) -> bool {
self.act_scope().covers(context_id)
}
pub fn with_delegated_contexts(&self, extra: &[String]) -> Self {
let mut claims = self.clone();
if extra.is_empty() || claims.is_super_admin() {
return claims;
}
for ctx in extra {
if !claims.allowed_contexts.iter().any(|c| c == ctx) {
claims.allowed_contexts.push(ctx.clone());
}
}
claims
}
pub fn with_delegated_authority(&self, extra: &[String]) -> Self {
let mut claims = self.clone();
if extra.is_empty() || claims.is_super_admin() {
return claims;
}
claims.role = Role::Admin;
for ctx in extra {
if !claims.allowed_contexts.iter().any(|c| c == ctx) {
claims.allowed_contexts.push(ctx.clone());
}
}
claims
}
pub fn require_context(&self, context_id: &str) -> Result<(), AppError> {
if self.has_context_access(context_id) {
return Ok(());
}
Err(AppError::Forbidden(format!(
"no access to context: {context_id}"
)))
}
pub fn default_context(&self) -> Option<&str> {
if self.allowed_contexts.len() == 1 {
Some(&self.allowed_contexts[0])
} else {
None
}
}
pub fn require_read(&self) -> Result<(), AppError> {
if self.role == Role::Monitor {
return Err(AppError::Forbidden("reader role or higher required".into()));
}
Ok(())
}
pub fn require_write(&self) -> Result<(), AppError> {
if matches!(self.role, Role::Admin | Role::Initiator | Role::Application) {
return Ok(());
}
Err(AppError::Forbidden(
"application role or higher required".into(),
))
}
pub fn require_admin(&self) -> Result<(), AppError> {
if self.role == Role::Admin {
return Ok(());
}
Err(AppError::Forbidden("admin role required".into()))
}
pub fn require_manage(&self) -> Result<(), AppError> {
if self.role == Role::Admin || self.role == Role::Initiator {
return Ok(());
}
Err(AppError::Forbidden(
"admin or initiator role required".into(),
))
}
pub fn require_super_admin(&self) -> Result<(), AppError> {
if self.is_super_admin() {
return Ok(());
}
Err(AppError::Forbidden("super admin required".into()))
}
}
#[derive(Debug, Clone)]
pub struct ManageAuth(pub AuthClaims);
impl<S: AuthState> FromRequestParts<S> for ManageAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let claims = AuthClaims::from_request_parts(parts, state).await?;
match claims.role {
Role::Admin | Role::Initiator => Ok(ManageAuth(claims)),
_ => {
warn!(did = %claims.did, role = %claims.role, "auth rejected: admin or initiator role required");
Err(AppError::Forbidden(
"admin or initiator role required".into(),
))
}
}
}
}
#[derive(Debug, Clone)]
pub struct AdminAuth(pub AuthClaims);
impl<S: AuthState> FromRequestParts<S> for AdminAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let claims = AuthClaims::from_request_parts(parts, state).await?;
match claims.role {
Role::Admin => Ok(AdminAuth(claims)),
_ => {
warn!(did = %claims.did, role = %claims.role, "auth rejected: admin role required");
Err(AppError::Forbidden("admin role required".into()))
}
}
}
}
#[derive(Debug, Clone)]
pub struct StepUpAuth(pub AuthClaims);
impl<S: AuthState> FromRequestParts<S> for StepUpAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let claims = AuthClaims::from_request_parts(parts, state).await?;
if claims.acr == "aal2" {
Ok(StepUpAuth(claims))
} else {
warn!(
did = %claims.did,
acr = %claims.acr,
"auth rejected: step-up (aal2) required",
);
Err(AppError::StepUpRequired(
"operation requires a stepped-up (aal2) session".into(),
))
}
}
}
#[derive(Debug, Clone)]
pub struct SuperAdminAuth(pub AuthClaims);
impl<S: AuthState> FromRequestParts<S> for SuperAdminAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let claims = AuthClaims::from_request_parts(parts, state).await?;
if !claims.is_super_admin() {
warn!(did = %claims.did, "auth rejected: super admin required");
return Err(AppError::Forbidden("super admin required".into()));
}
Ok(SuperAdminAuth(claims))
}
}
#[derive(Debug, Clone)]
pub struct WriteAuth(pub AuthClaims);
impl<S: AuthState> FromRequestParts<S> for WriteAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let claims = AuthClaims::from_request_parts(parts, state).await?;
match claims.role {
Role::Admin | Role::Initiator | Role::Application => Ok(WriteAuth(claims)),
_ => {
warn!(did = %claims.did, role = %claims.role, "auth rejected: application role or higher required");
Err(AppError::Forbidden(
"application role or higher required".into(),
))
}
}
}
}
fn cookie_token(parts: &Parts, name: &str) -> Option<String> {
parts
.headers
.get_all(axum::http::header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(';'))
.map(|s| s.trim())
.find_map(|kv| {
let (k, v) = kv.split_once('=')?;
(k == name).then(|| v.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_context_access_grants_the_subtree_to_a_parent_admin() {
let claims = AuthClaims {
role: Role::Admin,
allowed_contexts: vec!["acme/eng".into()],
..Default::default()
};
assert!(!claims.is_super_admin());
assert!(claims.has_context_access("acme/eng"));
assert!(claims.has_context_access("acme/eng/team-a"));
assert!(claims.has_context_access("acme/eng/team-a/squad-1"));
assert!(!claims.has_context_access("acme"));
assert!(!claims.has_context_access("acme/ops"));
assert!(!claims.has_context_access("acme/engineering"));
assert!(claims.require_context("acme/eng/team-a").is_ok());
assert!(claims.require_context("acme/ops").is_err());
}
#[test]
fn with_delegated_contexts_widens_a_scoped_admin_for_one_call() {
let base = AuthClaims {
role: Role::Admin,
allowed_contexts: vec!["ctx-a".into()],
..Default::default()
};
assert!(base.require_context("openvtc").is_err());
let widened = base.with_delegated_contexts(&["openvtc".into()]);
assert!(widened.require_context("openvtc").is_ok());
assert!(
widened.require_context("ctx-a").is_ok(),
"keeps its own context"
);
assert!(base.require_context("openvtc").is_err());
}
#[test]
fn with_delegated_contexts_is_a_noop_for_empty_or_super_admin() {
let scoped = AuthClaims {
role: Role::Admin,
allowed_contexts: vec!["ctx-a".into()],
..Default::default()
};
assert_eq!(
scoped.with_delegated_contexts(&[]).allowed_contexts,
scoped.allowed_contexts
);
let sa = AuthClaims {
role: Role::Admin,
..Default::default()
};
assert!(sa.is_super_admin());
let after = sa.with_delegated_contexts(&["openvtc".into()]);
assert!(after.is_super_admin(), "super-admin stays unrestricted");
assert!(after.allowed_contexts.is_empty());
}
#[test]
fn with_delegated_authority_lifts_a_non_admin_for_one_dispatch() {
let reader = AuthClaims {
role: Role::Reader,
allowed_contexts: vec![],
..Default::default()
};
assert!(reader.require_admin().is_err());
assert!(!reader.has_context_access("openvtc"));
let widened = reader.with_delegated_authority(&["openvtc".into()]);
assert!(widened.require_admin().is_ok(), "grant confers admin");
assert!(
widened.has_context_access("openvtc"),
"grant confers the context"
);
assert!(reader.require_admin().is_err());
assert!(!reader.has_context_access("openvtc"));
}
#[test]
fn with_delegated_authority_is_a_noop_for_empty_or_super_admin() {
let reader = AuthClaims {
role: Role::Reader,
allowed_contexts: vec![],
..Default::default()
};
let after = reader.with_delegated_authority(&[]);
assert_eq!(after.role, Role::Reader);
assert!(after.allowed_contexts.is_empty());
let sa = AuthClaims {
role: Role::Admin,
..Default::default()
};
assert!(sa.is_super_admin());
let after = sa.with_delegated_authority(&["openvtc".into()]);
assert!(after.is_super_admin(), "super-admin stays unrestricted");
}
#[test]
fn with_delegated_contexts_dedups() {
let base = AuthClaims {
role: Role::Admin,
allowed_contexts: vec!["ctx-a".into()],
..Default::default()
};
let widened = base.with_delegated_contexts(&["ctx-a".into(), "openvtc".into()]);
assert_eq!(widened.allowed_contexts, vec!["ctx-a", "openvtc"]);
}
#[test]
fn flat_context_grant_is_exact_match_only() {
let claims = AuthClaims {
role: Role::Reader,
allowed_contexts: vec!["prod-mediator".into()],
..Default::default()
};
assert!(claims.has_context_access("prod-mediator"));
assert!(!claims.has_context_access("prod-mediator-2"));
assert!(!claims.has_context_access("other"));
}
#[cfg(feature = "cli-synthesis")]
#[test]
fn local_cli_synthesizes_super_admin_with_channel_sentinel() {
let claims = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
assert_eq!(claims.did, "cli:provision-integration");
assert_eq!(claims.role, Role::Admin);
assert!(claims.allowed_contexts.is_empty());
assert!(claims.is_super_admin());
}
#[cfg(feature = "cli-synthesis")]
#[test]
fn local_cli_grants_any_context_access() {
let claims = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
assert!(claims.has_context_access("any-context"));
assert!(claims.has_context_access("another"));
claims
.require_context("prod-mediator")
.expect("super-admin passes require_context");
}
#[cfg(feature = "cli-synthesis")]
#[test]
fn local_cli_did_sentinel_cannot_be_confused_with_real_did() {
let claims = AuthClaims::unsafe_local_cli_super_admin("context-reprovision");
assert!(!claims.did.starts_with("did:"));
assert!(claims.did.starts_with("cli:"));
}
#[cfg(feature = "cli-synthesis")]
#[test]
fn local_cli_channel_embedded_in_did() {
let a = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
let b = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
assert_ne!(a.did, b.did);
}
}