use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuthContext {
pub user_id: String,
pub session_id: String,
pub roles: Vec<String>,
pub metadata: Value,
}
impl AuthContext {
pub fn new(user_id: String, session_id: String, roles: Vec<String>, metadata: Value) -> Self {
Self {
user_id,
session_id,
roles,
metadata,
}
}
pub fn anonymous() -> Self {
Self {
user_id: "anonymous".to_string(),
session_id: String::new(),
roles: vec![],
metadata: Value::Null,
}
}
pub fn is_authenticated(&self) -> bool {
self.user_id != "anonymous" && !self.session_id.is_empty()
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
pub fn get_metadata_string(&self, key: &str) -> Option<String> {
self.metadata
.get(key)
.and_then(|v| v.as_str())
.map(String::from)
}
pub fn tenant(&self) -> Option<String> {
self.get_metadata_string("tenant_id")
.or_else(|| self.get_metadata_string("realm"))
}
pub(crate) fn derive_callee_context(
caller_ctx: &AuthContext,
derivation: &crate::forward::ForwardDerivation,
_immediate_caller_stamp: &crate::principal::Principal,
) -> AuthContext {
let (user_id, session_id) = if derivation.keep_verified_user {
(caller_ctx.user_id.clone(), caller_ctx.session_id.clone())
} else {
("anonymous".to_string(), String::new())
};
let roles = if derivation.keep_roles {
caller_ctx.roles.clone()
} else {
Vec::new()
};
let metadata = if derivation.keep_metadata {
caller_ctx.metadata.clone()
} else {
Value::Null
};
AuthContext {
user_id,
session_id,
roles,
metadata,
}
}
pub fn with_callee_context<F, R>(
&self,
derivation: &crate::forward::ForwardDerivation,
immediate_caller_stamp: &crate::principal::Principal,
f: F,
) -> R
where
F: FnOnce(AuthContext) -> R,
{
f(Self::derive_callee_context(self, derivation, immediate_caller_stamp))
}
}
#[async_trait]
pub trait SessionValidator: Send + Sync + 'static {
async fn validate(&self, cookie_value: &str) -> Option<AuthContext>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_context_creation() {
let ctx = AuthContext::new(
"user-123".to_string(),
"sess-456".to_string(),
vec!["admin".to_string()],
serde_json::json!({"tenant_id": "acme"}),
);
assert_eq!(ctx.user_id, "user-123");
assert_eq!(ctx.session_id, "sess-456");
assert!(ctx.has_role("admin"));
assert!(!ctx.has_role("user"));
assert_eq!(ctx.tenant(), Some("acme".to_string()));
assert!(ctx.is_authenticated());
}
#[test]
fn test_auth_context_clone() {
let ctx = AuthContext::new(
"alice".to_string(),
"sess-1".to_string(),
vec!["admin".to_string()],
serde_json::json!({"org": "acme"}),
);
let cloned = ctx.clone();
assert_eq!(ctx.user_id, cloned.user_id);
assert_eq!(ctx.session_id, cloned.session_id);
assert_eq!(ctx.roles, cloned.roles);
}
#[test]
fn test_anonymous_context() {
let ctx = AuthContext::anonymous();
assert_eq!(ctx.user_id, "anonymous");
assert!(!ctx.is_authenticated());
assert!(ctx.roles.is_empty());
}
#[test]
fn test_role_checking() {
let ctx = AuthContext::new(
"user-1".to_string(),
"sess-1".to_string(),
vec!["user".to_string(), "editor".to_string()],
Value::Null,
);
assert!(ctx.has_role("user"));
assert!(ctx.has_role("editor"));
assert!(!ctx.has_role("admin"));
}
#[test]
fn test_metadata_access() {
let ctx = AuthContext::new(
"user-1".to_string(),
"sess-1".to_string(),
vec![],
serde_json::json!({
"tenant_id": "org-123",
"realm": "production",
"email": "user@example.com"
}),
);
assert_eq!(
ctx.get_metadata_string("tenant_id"),
Some("org-123".to_string())
);
assert_eq!(
ctx.get_metadata_string("realm"),
Some("production".to_string())
);
assert_eq!(
ctx.get_metadata_string("email"),
Some("user@example.com".to_string())
);
assert_eq!(ctx.get_metadata_string("nonexistent"), None);
}
#[test]
fn derive_callee_context_identity_only_strips_roles_and_metadata() {
use crate::forward::ForwardDerivation;
use crate::principal::Principal;
let caller = AuthContext::new(
"alice".to_string(),
"sess-1".to_string(),
vec!["admin".to_string(), "editor".to_string()],
serde_json::json!({"tenant_id": "acme"}),
);
let stamp = Principal::anonymous_sealed();
let callee = AuthContext::derive_callee_context(
&caller,
&ForwardDerivation::IDENTITY_ONLY,
&stamp,
);
assert_eq!(callee.user_id, "alice");
assert_eq!(callee.session_id, "sess-1");
assert!(callee.roles.is_empty());
assert_eq!(callee.metadata, Value::Null);
}
#[test]
fn derive_callee_context_pass_through_retains_all_fields() {
use crate::forward::ForwardDerivation;
use crate::principal::Principal;
let caller = AuthContext::new(
"alice".to_string(),
"sess-1".to_string(),
vec!["admin".to_string()],
serde_json::json!({"tenant_id": "acme", "k": "v"}),
);
let stamp = Principal::anonymous_sealed();
let callee = AuthContext::derive_callee_context(
&caller,
&ForwardDerivation::PASS_THROUGH,
&stamp,
);
assert_eq!(callee.user_id, caller.user_id);
assert_eq!(callee.session_id, caller.session_id);
assert_eq!(callee.roles, caller.roles);
assert_eq!(callee.metadata, caller.metadata);
}
#[test]
fn derive_callee_context_anonymous_drops_everything() {
use crate::forward::ForwardDerivation;
use crate::principal::Principal;
let caller = AuthContext::new(
"alice".to_string(),
"sess-1".to_string(),
vec!["admin".to_string()],
serde_json::json!({"tenant_id": "acme"}),
);
let stamp = Principal::anonymous_sealed();
let callee = AuthContext::derive_callee_context(
&caller,
&ForwardDerivation::ANONYMOUS,
&stamp,
);
assert_eq!(callee.user_id, "anonymous");
assert_eq!(callee.session_id, "");
assert!(callee.roles.is_empty());
assert_eq!(callee.metadata, Value::Null);
assert!(!callee.is_authenticated());
}
#[test]
fn with_callee_context_invokes_closure_with_derived_callee() {
use crate::forward::ForwardDerivation;
use crate::principal::Principal;
let caller = AuthContext::new(
"alice".to_string(),
"sess-1".to_string(),
vec!["admin".to_string()],
serde_json::json!({"tenant_id": "org-1"}),
);
let stamp = Principal::anonymous_sealed();
let observed_user_id =
caller.with_callee_context(&ForwardDerivation::IDENTITY_ONLY, &stamp, |callee| {
assert!(callee.roles.is_empty());
assert_eq!(callee.metadata, Value::Null);
callee.user_id
});
assert_eq!(observed_user_id, "alice");
}
#[test]
fn with_callee_context_returns_closure_value() {
use crate::forward::ForwardDerivation;
use crate::principal::Principal;
let caller = AuthContext::anonymous();
let stamp = Principal::anonymous_sealed();
let answer =
caller.with_callee_context(&ForwardDerivation::PASS_THROUGH, &stamp, |_| 42_u32);
assert_eq!(answer, 42);
}
#[test]
fn derive_callee_context_never_grows_context() {
use crate::forward::ForwardDerivation;
use crate::principal::Principal;
let caller = AuthContext::anonymous();
let stamp = Principal::anonymous_sealed();
let callee = AuthContext::derive_callee_context(
&caller,
&ForwardDerivation::PASS_THROUGH,
&stamp,
);
assert_eq!(callee.user_id, "anonymous");
assert!(callee.roles.is_empty());
assert_eq!(callee.metadata, Value::Null);
assert!(!callee.is_authenticated());
}
#[test]
fn test_tenant_from_metadata() {
let ctx1 = AuthContext::new(
"user-1".to_string(),
"sess-1".to_string(),
vec![],
serde_json::json!({"tenant_id": "org-123", "realm": "prod"}),
);
assert_eq!(ctx1.tenant(), Some("org-123".to_string()));
let ctx2 = AuthContext::new(
"user-1".to_string(),
"sess-1".to_string(),
vec![],
serde_json::json!({"realm": "prod"}),
);
assert_eq!(ctx2.tenant(), Some("prod".to_string()));
let ctx3 = AuthContext::new(
"user-1".to_string(),
"sess-1".to_string(),
vec![],
Value::Null,
);
assert_eq!(ctx3.tenant(), None);
}
}