feat: port el-ui vessels — rename crates→vessels, add El source + manifests
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
//! Auth context — the current authenticated user and their roles/permissions.
|
||||
|
||||
/// The authenticated user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
email: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
email: email.into(),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The auth context — populated by `AuthMiddleware` and available to all
|
||||
/// components and services downstream in the request.
|
||||
///
|
||||
/// Passed as `ctx.metadata["user_id"]`, `ctx.metadata["roles"]` in the AOP
|
||||
/// layer (see `el-aop`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthContext {
|
||||
pub user: Option<AuthUser>,
|
||||
pub roles: Vec<String>,
|
||||
pub permissions: Vec<String>,
|
||||
/// The raw token/session ID that was verified.
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
pub fn anonymous() -> Self {
|
||||
Self {
|
||||
user: None,
|
||||
roles: Vec::new(),
|
||||
permissions: Vec::new(),
|
||||
token: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authenticated(user: AuthUser, roles: Vec<String>, token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
user: Some(user),
|
||||
roles,
|
||||
permissions: Vec::new(),
|
||||
token: token.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_permissions(mut self, perms: Vec<String>) -> Self {
|
||||
self.permissions = perms;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.user.is_some()
|
||||
}
|
||||
|
||||
pub fn has_role(&self, role: &str) -> bool {
|
||||
self.roles.iter().any(|r| r == role)
|
||||
}
|
||||
|
||||
pub fn has_permission(&self, permission: &str) -> bool {
|
||||
self.permissions.iter().any(|p| p == permission)
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> Option<&str> {
|
||||
self.user.as_ref().map(|u| u.id.as_str())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! EngramSessionStore — a `SessionProvider`-compatible store backed by the Engram graph.
|
||||
//!
|
||||
//! Sessions are Engram graph nodes (via el-identity's `SessionManager`).
|
||||
//! This allows server-side invalidation even for stateless JWT workflows:
|
||||
//! on every request, the JWT's `session_id` claim is used to look up the
|
||||
//! Session node in Engram. If the node is missing or expired, the request
|
||||
//! is rejected regardless of JWT validity.
|
||||
//!
|
||||
//! Dependency: takes `Arc<dyn EngramClient>` — soft dependency, no tight coupling
|
||||
//! to the Engram crate.
|
||||
|
||||
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
|
||||
use el_identity::{
|
||||
engram::EngramClient,
|
||||
session::SessionManager,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Session provider backed by the Engram identity graph.
|
||||
///
|
||||
/// Sessions issued by this provider are stored as Engram Session nodes.
|
||||
/// The session ID returned is the Engram node's UUID, which is also embedded
|
||||
/// in JWTs via `JwtClaims::session_id`.
|
||||
pub struct EngramSessionStore {
|
||||
session_manager: Arc<SessionManager>,
|
||||
client: Arc<dyn EngramClient>,
|
||||
}
|
||||
|
||||
impl EngramSessionStore {
|
||||
/// Create a new `EngramSessionStore`.
|
||||
///
|
||||
/// `client` is the Engram graph client. In production, pass your real
|
||||
/// Engram client. In tests, use `el_identity::engram::MockEngramClient`.
|
||||
pub fn new(client: Arc<dyn EngramClient>) -> Self {
|
||||
let sm = Arc::new(SessionManager::new(client.clone()));
|
||||
Self { session_manager: sm, client }
|
||||
}
|
||||
|
||||
/// Create with a custom session TTL (seconds).
|
||||
pub fn with_ttl(client: Arc<dyn EngramClient>, ttl_seconds: i64) -> Self {
|
||||
let sm = Arc::new(SessionManager::new(client.clone()).with_ttl(ttl_seconds));
|
||||
Self { session_manager: sm, client }
|
||||
}
|
||||
|
||||
/// Expose the underlying SessionManager for advanced use (e.g., listing sessions).
|
||||
pub fn session_manager(&self) -> &Arc<SessionManager> {
|
||||
&self.session_manager
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthProvider for EngramSessionStore {
|
||||
fn name(&self) -> &'static str {
|
||||
"engram_session"
|
||||
}
|
||||
|
||||
/// Verify a session by its ID (Engram Session node UUID).
|
||||
///
|
||||
/// Validates expiry via graph lookup. Returns `AuthContext` populated from
|
||||
/// the Session and User nodes.
|
||||
fn verify(&self, session_id: &str) -> AuthResult<AuthContext> {
|
||||
// Validate session node (checks expiry, lazy-deletes expired)
|
||||
let session = self
|
||||
.session_manager
|
||||
.validate(session_id)
|
||||
.map_err(|e| match e {
|
||||
el_identity::IdentityError::SessionNotFound => AuthError::SessionNotFound,
|
||||
el_identity::IdentityError::SessionExpired => AuthError::SessionNotFound,
|
||||
other => AuthError::Config(other.to_string()),
|
||||
})?;
|
||||
|
||||
// Load user node
|
||||
let user_id_str = session.user_id.to_string();
|
||||
let user_node = self
|
||||
.client
|
||||
.get_node(&user_id_str)
|
||||
.map_err(|e| AuthError::Config(e.to_string()))?
|
||||
.ok_or(AuthError::InvalidCredentials)?;
|
||||
|
||||
let identity_user = el_identity::User::from_value(&user_node)
|
||||
.ok_or_else(|| AuthError::Config("user node parse failed".into()))?;
|
||||
|
||||
let auth_user = AuthUser::new(
|
||||
identity_user.id.to_string(),
|
||||
&identity_user.email,
|
||||
&identity_user.display_name,
|
||||
);
|
||||
|
||||
// Load roles via has_role edges
|
||||
let role_nodes = self
|
||||
.client
|
||||
.find_connected(&user_id_str, el_identity::nodes::EDGE_HAS_ROLE)
|
||||
.map_err(|e| AuthError::Config(e.to_string()))?;
|
||||
|
||||
let role_names: Vec<String> = role_nodes
|
||||
.iter()
|
||||
.filter_map(el_identity::Role::from_value)
|
||||
.map(|r| r.name)
|
||||
.collect();
|
||||
|
||||
Ok(AuthContext::authenticated(auth_user, role_names, session_id))
|
||||
}
|
||||
|
||||
/// Issue a new Engram-backed session for the given user.
|
||||
///
|
||||
/// The user must already exist as a User node in Engram. Returns the
|
||||
/// session ID (UUID string) which should be embedded in the JWT's
|
||||
/// `session_id` claim.
|
||||
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
|
||||
// Parse user ID as UUID
|
||||
let user_uuid = uuid::Uuid::parse_str(&user.id)
|
||||
.map_err(|_| AuthError::Config(format!("invalid user ID UUID: {}", user.id)))?;
|
||||
|
||||
let session = self
|
||||
.session_manager
|
||||
.create(user_uuid, None)
|
||||
.map_err(|e| AuthError::Config(e.to_string()))?;
|
||||
|
||||
Ok(session.id.to_string())
|
||||
}
|
||||
|
||||
/// Revoke a session by deleting the Session node from the graph.
|
||||
fn revoke(&self, session_id: &str) -> AuthResult<()> {
|
||||
self.session_manager
|
||||
.invalidate(session_id)
|
||||
.map_err(|e| AuthError::Config(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! JWT provider — sign and verify JSON Web Tokens.
|
||||
//!
|
||||
//! Uses HMAC-SHA256 (HS256) for signing. Does NOT use the `jsonwebtoken` crate
|
||||
//! to keep dependencies minimal; implements the JWT spec directly.
|
||||
//!
|
||||
//! Format: base64url(header).base64url(payload).base64url(signature)
|
||||
|
||||
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// JWT claims payload.
|
||||
///
|
||||
/// The `session_id` field is included so the Engram session node can be
|
||||
/// validated on every request, enabling server-side session invalidation
|
||||
/// even for stateless JWTs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String, // user ID
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub roles: Vec<String>,
|
||||
/// The Engram Session node ID. Used by `EngramSessionStore` to validate
|
||||
/// the session graph node on every request, enabling server-side logout.
|
||||
pub session_id: Option<String>,
|
||||
pub iat: u64, // issued-at (unix seconds)
|
||||
pub exp: u64, // expiry (unix seconds)
|
||||
}
|
||||
|
||||
impl JwtClaims {
|
||||
pub fn new(user: &AuthUser, roles: Vec<String>, ttl_seconds: u64) -> Self {
|
||||
let now = unix_now();
|
||||
Self {
|
||||
sub: user.id.clone(),
|
||||
email: user.email.clone(),
|
||||
name: user.name.clone(),
|
||||
roles,
|
||||
session_id: None,
|
||||
iat: now,
|
||||
exp: now + ttl_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create claims with an Engram session ID embedded.
|
||||
pub fn new_with_session(
|
||||
user: &AuthUser,
|
||||
roles: Vec<String>,
|
||||
session_id: impl Into<String>,
|
||||
ttl_seconds: u64,
|
||||
) -> Self {
|
||||
let mut claims = Self::new(user, roles, ttl_seconds);
|
||||
claims.session_id = Some(session_id.into());
|
||||
claims
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
unix_now() > self.exp
|
||||
}
|
||||
|
||||
/// Serialize claims to JSON (manual, no serde dependency).
|
||||
pub fn to_json(&self) -> String {
|
||||
let roles_json = self
|
||||
.roles
|
||||
.iter()
|
||||
.map(|r| format!("\"{}\"", r))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
// Build the JSON manually, inserting session_id only when present.
|
||||
let mut json = format!(
|
||||
"{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}]",
|
||||
self.sub, self.email, self.name, roles_json
|
||||
);
|
||||
if let Some(sid) = &self.session_id {
|
||||
json.push_str(&format!(",\"session_id\":\"{}\"", sid));
|
||||
}
|
||||
json.push_str(&format!(",\"iat\":{},\"exp\":{}}}", self.iat, self.exp));
|
||||
json
|
||||
}
|
||||
|
||||
/// Deserialize claims from JSON (manual parser).
|
||||
pub fn from_json(json: &str) -> Option<Self> {
|
||||
let sub = extract_str(json, "sub")?;
|
||||
let email = extract_str(json, "email").unwrap_or_default();
|
||||
let name = extract_str(json, "name").unwrap_or_default();
|
||||
let iat = extract_u64(json, "iat").unwrap_or(0);
|
||||
let exp = extract_u64(json, "exp").unwrap_or(0);
|
||||
let roles = extract_str_array(json, "roles");
|
||||
let session_id = extract_str(json, "session_id");
|
||||
Some(Self { sub, email, name, roles, session_id, iat, exp })
|
||||
}
|
||||
}
|
||||
|
||||
/// JWT provider — issues and verifies HS256 JWTs.
|
||||
pub struct JwtProvider {
|
||||
secret: Vec<u8>,
|
||||
/// Token TTL in seconds (default: 3600 = 1 hour).
|
||||
pub ttl_seconds: u64,
|
||||
}
|
||||
|
||||
impl JwtProvider {
|
||||
pub fn new(secret: impl Into<Vec<u8>>) -> Self {
|
||||
Self { secret: secret.into(), ttl_seconds: 3600 }
|
||||
}
|
||||
|
||||
pub fn from_env(env_var: &str) -> AuthResult<Self> {
|
||||
let secret = std::env::var(env_var).map_err(|_| {
|
||||
AuthError::Config(format!("env var {} not set", env_var))
|
||||
})?;
|
||||
Ok(Self::new(secret.into_bytes()))
|
||||
}
|
||||
|
||||
pub fn with_ttl(mut self, seconds: u64) -> Self {
|
||||
self.ttl_seconds = seconds;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sign a token with HMAC-SHA256.
|
||||
fn sign(&self, header_payload: &str) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(&self.secret)
|
||||
.expect("HMAC can take key of any size");
|
||||
mac.update(header_payload.as_bytes());
|
||||
let result = mac.finalize();
|
||||
base64url_encode(&result.into_bytes())
|
||||
}
|
||||
|
||||
/// Encode a JWT token from claims.
|
||||
pub fn encode(&self, claims: &JwtClaims) -> String {
|
||||
let header = base64url_encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
|
||||
let payload = base64url_encode(claims.to_json().as_bytes());
|
||||
let header_payload = format!("{}.{}", header, payload);
|
||||
let signature = self.sign(&header_payload);
|
||||
format!("{}.{}", header_payload, signature)
|
||||
}
|
||||
|
||||
/// Decode and verify a JWT token.
|
||||
pub fn decode(&self, token: &str) -> AuthResult<JwtClaims> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(AuthError::TokenInvalid("not a valid JWT".into()));
|
||||
}
|
||||
|
||||
let header_payload = format!("{}.{}", parts[0], parts[1]);
|
||||
let expected_sig = self.sign(&header_payload);
|
||||
if !constant_time_eq(parts[2], &expected_sig) {
|
||||
return Err(AuthError::TokenInvalid("signature mismatch".into()));
|
||||
}
|
||||
|
||||
let payload_bytes = base64url_decode(parts[1])
|
||||
.ok_or_else(|| AuthError::TokenInvalid("payload decode failed".into()))?;
|
||||
let payload_str = String::from_utf8(payload_bytes)
|
||||
.map_err(|_| AuthError::TokenInvalid("payload not utf8".into()))?;
|
||||
|
||||
let claims = JwtClaims::from_json(&payload_str)
|
||||
.ok_or_else(|| AuthError::TokenInvalid("claims parse failed".into()))?;
|
||||
|
||||
if claims.is_expired() {
|
||||
return Err(AuthError::TokenExpired);
|
||||
}
|
||||
|
||||
Ok(claims)
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthProvider for JwtProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"jwt"
|
||||
}
|
||||
|
||||
fn verify(&self, token: &str) -> AuthResult<AuthContext> {
|
||||
let claims = self.decode(token)?;
|
||||
let user = AuthUser::new(&claims.sub, &claims.email, &claims.name);
|
||||
Ok(AuthContext::authenticated(user, claims.roles, token))
|
||||
}
|
||||
|
||||
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
|
||||
let claims = JwtClaims::new(&user, Vec::new(), self.ttl_seconds);
|
||||
Ok(self.encode(&claims))
|
||||
}
|
||||
|
||||
fn revoke(&self, _token: &str) -> AuthResult<()> {
|
||||
// JWTs are stateless — revocation requires a blocklist.
|
||||
// TODO: maintain a revocation list (in-memory or Redis).
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crypto helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn base64url_encode(input: &[u8]) -> String {
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let mut out = String::new();
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
|
||||
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(CHARS[((n >> 18) & 63) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 63) as usize] as char);
|
||||
if chunk.len() > 1 {
|
||||
out.push(CHARS[((n >> 6) & 63) as usize] as char);
|
||||
}
|
||||
if chunk.len() > 2 {
|
||||
out.push(CHARS[(n & 63) as usize] as char);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base64url_decode(input: &str) -> Option<Vec<u8>> {
|
||||
// Pad if needed
|
||||
let mut s = input.replace('-', "+").replace('_', "/");
|
||||
while s.len() % 4 != 0 {
|
||||
s.push('=');
|
||||
}
|
||||
base64_decode_standard(&s)
|
||||
}
|
||||
|
||||
fn base64_decode_standard(input: &str) -> Option<Vec<u8>> {
|
||||
const TABLE: [u8; 128] = {
|
||||
let mut t = [255u8; 128];
|
||||
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
t[chars[i] as usize] = i as u8;
|
||||
i += 1;
|
||||
}
|
||||
t
|
||||
};
|
||||
|
||||
let input = input.trim_end_matches('=');
|
||||
let mut out = Vec::new();
|
||||
let bytes = input.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 3 < bytes.len() {
|
||||
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
|
||||
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
|
||||
let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?;
|
||||
let d = TABLE.get(bytes[i+3] as usize).copied().filter(|&v| v != 255)?;
|
||||
let n = ((a as u32) << 18) | ((b as u32) << 12) | ((c as u32) << 6) | (d as u32);
|
||||
out.push((n >> 16) as u8);
|
||||
out.push((n >> 8) as u8);
|
||||
out.push(n as u8);
|
||||
i += 4;
|
||||
}
|
||||
// Handle remaining bytes
|
||||
if i + 2 == bytes.len() {
|
||||
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
|
||||
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
|
||||
out.push(((a as u32) << 2 | (b as u32) >> 4) as u8);
|
||||
} else if i + 3 == bytes.len() {
|
||||
let a = TABLE.get(bytes[i] as usize).copied().filter(|&v| v != 255)?;
|
||||
let b = TABLE.get(bytes[i+1] as usize).copied().filter(|&v| v != 255)?;
|
||||
let c = TABLE.get(bytes[i+2] as usize).copied().filter(|&v| v != 255)?;
|
||||
let n = ((a as u32) << 10) | ((b as u32) << 4) | ((c as u32) >> 2);
|
||||
out.push((n >> 8) as u8);
|
||||
out.push(n as u8);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.bytes()
|
||||
.zip(b.bytes())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ── Minimal JSON field extractors ─────────────────────────────────────────────
|
||||
|
||||
fn extract_str(json: &str, key: &str) -> Option<String> {
|
||||
let pattern = format!("\"{}\":\"", key);
|
||||
let start = json.find(&pattern)? + pattern.len();
|
||||
let rest = &json[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
fn extract_u64(json: &str, key: &str) -> Option<u64> {
|
||||
let pattern = format!("\"{}\":", key);
|
||||
let start = json.find(&pattern)? + pattern.len();
|
||||
let rest = &json[start..];
|
||||
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
|
||||
rest[..end].parse().ok()
|
||||
}
|
||||
|
||||
fn extract_str_array(json: &str, key: &str) -> Vec<String> {
|
||||
let pattern = format!("\"{}\":[", key);
|
||||
let start = match json.find(&pattern) {
|
||||
None => return Vec::new(),
|
||||
Some(s) => s + pattern.len(),
|
||||
};
|
||||
let rest = &json[start..];
|
||||
let end = rest.find(']').unwrap_or(rest.len());
|
||||
let content = &rest[..end];
|
||||
content
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let s = s.trim().trim_matches('"');
|
||||
if s.is_empty() { None } else { Some(s.to_string()) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! el-auth — Built-in authentication and authorization for el-ui.
|
||||
//!
|
||||
//! Not a library you add. Native to the framework.
|
||||
//!
|
||||
//! ```toml
|
||||
//! [auth]
|
||||
//! provider = "jwt"
|
||||
//! jwt_secret_env = "JWT_SECRET"
|
||||
//! session_store = "memory" # or "engram"
|
||||
//! ```
|
||||
//!
|
||||
//! ## Engram-native sessions
|
||||
//!
|
||||
//! Use `EngramSessionStore` (in `engram_session`) for sessions backed by the
|
||||
//! Engram identity graph. Sessions are graph nodes — server-side invalidation
|
||||
//! works even with stateless JWTs.
|
||||
|
||||
pub mod context;
|
||||
pub mod engram_session;
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod roles;
|
||||
pub mod session;
|
||||
|
||||
pub use context::{AuthContext, AuthUser};
|
||||
pub use engram_session::EngramSessionStore;
|
||||
pub use jwt::{JwtClaims, JwtProvider};
|
||||
pub use middleware::AuthMiddleware;
|
||||
pub use roles::{Permission, Role, RoleRegistry};
|
||||
pub use session::SessionProvider;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("token expired")]
|
||||
TokenExpired,
|
||||
#[error("token invalid: {0}")]
|
||||
TokenInvalid(String),
|
||||
#[error("session not found")]
|
||||
SessionNotFound,
|
||||
#[error("forbidden: requires permission '{0}'")]
|
||||
Forbidden(String),
|
||||
#[error("auth configuration error: {0}")]
|
||||
Config(String),
|
||||
}
|
||||
|
||||
pub type AuthResult<T> = Result<T, AuthError>;
|
||||
|
||||
/// The AuthProvider trait — implemented by JWT, Session, OAuth providers.
|
||||
pub trait AuthProvider: Send + Sync {
|
||||
/// The provider name (e.g., "jwt", "session").
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Verify a token/session string and return the auth context.
|
||||
fn verify(&self, token: &str) -> AuthResult<AuthContext>;
|
||||
|
||||
/// Issue a new token/session for an authenticated user.
|
||||
fn issue(&self, user: AuthUser, role_registry: &RoleRegistry) -> AuthResult<String>;
|
||||
|
||||
/// Revoke a token/session (for logout).
|
||||
fn revoke(&self, token: &str) -> AuthResult<()>;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// el-auth — Built-in authentication and authorization for el-ui.
|
||||
//
|
||||
// Engram-aware sessions: server-side invalidation works even with stateless
|
||||
// JWTs because every session is also a graph node.
|
||||
//
|
||||
// Provider trait surface:
|
||||
// verify(token) -> AuthContext
|
||||
// issue(user, roles) -> token string
|
||||
// revoke(token) -> Bool
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let ERR_INVALID_CREDS: String = "auth.invalid_credentials"
|
||||
let ERR_TOKEN_EXPIRED: String = "auth.token_expired"
|
||||
let ERR_TOKEN_INVALID: String = "auth.token_invalid"
|
||||
let ERR_SESSION_NOT_FOUND: String = "auth.session_not_found"
|
||||
let ERR_FORBIDDEN: String = "auth.forbidden"
|
||||
let ERR_CONFIG: String = "auth.config"
|
||||
|
||||
// ── AuthContext + AuthUser ────────────────────────────────────────────────────
|
||||
|
||||
type AuthUser {
|
||||
id: String
|
||||
email: String
|
||||
display_name: String
|
||||
}
|
||||
|
||||
type AuthContext {
|
||||
user_id: String
|
||||
email: String
|
||||
roles: String // JSON array of role names
|
||||
permissions: String // JSON array of permission strings
|
||||
session_id: String
|
||||
issued_at: String
|
||||
expires_at: String
|
||||
}
|
||||
|
||||
fn auth_context_empty() -> AuthContext {
|
||||
{ "user_id": "", "email": "", "roles": "[]", "permissions": "[]",
|
||||
"session_id": "", "issued_at": "", "expires_at": "" }
|
||||
}
|
||||
|
||||
fn auth_context_has_permission(ctx: AuthContext, perm: String) -> Bool {
|
||||
str_contains(ctx.permissions, "\"" + perm + "\"")
|
||||
}
|
||||
|
||||
fn auth_context_has_role(ctx: AuthContext, role: String) -> Bool {
|
||||
str_contains(ctx.roles, "\"" + role + "\"")
|
||||
}
|
||||
|
||||
// ── Roles + permissions ───────────────────────────────────────────────────────
|
||||
|
||||
type Permission {
|
||||
resource: String
|
||||
action: String
|
||||
}
|
||||
|
||||
fn permission_new(resource: String, action: String) -> Permission {
|
||||
{ "resource": resource, "action": action }
|
||||
}
|
||||
|
||||
fn permission_string(p: Permission) -> String {
|
||||
p.resource + ":" + p.action
|
||||
}
|
||||
|
||||
// Role registry — maps role name -> JSON array of permission strings.
|
||||
fn role_registry_grant(registry_path: String, role: String, perm: String) -> Bool {
|
||||
let raw: String = fs_read(registry_path)
|
||||
let updated: String = json_array_append(raw, role, "\"" + perm + "\"")
|
||||
fs_write(registry_path, updated)
|
||||
}
|
||||
|
||||
// ── JWT (HS256) ──────────────────────────────────────────────────────────────
|
||||
|
||||
type JwtClaims {
|
||||
sub: String // user id
|
||||
iss: String // issuer
|
||||
aud: String // audience
|
||||
iat: Int // issued at (unix seconds)
|
||||
exp: Int // expires at (unix seconds)
|
||||
jti: String // unique token id
|
||||
}
|
||||
|
||||
fn jwt_claims_new(user_id: String, issuer: String, audience: String, ttl_seconds: Int) -> JwtClaims {
|
||||
let now: Int = time_now_unix()
|
||||
{ "sub": user_id, "iss": issuer, "aud": audience,
|
||||
"iat": now, "exp": now + ttl_seconds, "jti": uuid_v4() }
|
||||
}
|
||||
|
||||
fn jwt_encode(claims: JwtClaims, secret: String) -> String {
|
||||
let header_b64: String = base64url_no_pad("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")
|
||||
let payload_json: String = json_encode(claims)
|
||||
let payload_b64: String = base64url_no_pad(payload_json)
|
||||
let signing_input: String = header_b64 + "." + payload_b64
|
||||
let sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
|
||||
signing_input + "." + sig
|
||||
}
|
||||
|
||||
fn jwt_decode(token: String, secret: String) -> AuthContext {
|
||||
let parts: String = token // [header].[payload].[sig]
|
||||
let dot1: Int = str_index_of(parts, ".")
|
||||
if dot1 < 0 { return auth_context_empty() }
|
||||
let rest: String = str_slice(parts, dot1 + 1, str_len(parts))
|
||||
let dot2: Int = str_index_of(rest, ".")
|
||||
if dot2 < 0 { return auth_context_empty() }
|
||||
let header_b64: String = str_slice(parts, 0, dot1)
|
||||
let payload_b64: String = str_slice(rest, 0, dot2)
|
||||
let sig_b64: String = str_slice(rest, dot2 + 1, str_len(rest))
|
||||
|
||||
let signing_input: String = header_b64 + "." + payload_b64
|
||||
let expected_sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
|
||||
if !str_eq(expected_sig, sig_b64) { return auth_context_empty() }
|
||||
|
||||
let payload_json: String = base64url_decode(payload_b64)
|
||||
let now: Int = time_now_unix()
|
||||
let exp: Int = str_to_int(json_get(payload_json, "exp"))
|
||||
if exp < now { return auth_context_empty() }
|
||||
|
||||
{ "user_id": json_get(payload_json, "sub"),
|
||||
"email": "",
|
||||
"roles": "[]",
|
||||
"permissions": "[]",
|
||||
"session_id": json_get(payload_json, "jti"),
|
||||
"issued_at": json_get(payload_json, "iat"),
|
||||
"expires_at": json_get(payload_json, "exp") }
|
||||
}
|
||||
|
||||
// ── Engram-backed session store ──────────────────────────────────────────────
|
||||
//
|
||||
// Sessions are nodes of type "Session" connected to User via has_session.
|
||||
// Revocation = node delete. Verification = node lookup + expiry check.
|
||||
|
||||
fn session_store_create(user_id: String, ttl_seconds: Int, ip: String) -> String {
|
||||
let now: String = time_now_iso()
|
||||
let exp: String = time_add_seconds(now, ttl_seconds)
|
||||
let id: String = uuid_v4()
|
||||
let body: String = "{\"id\":\"" + id + "\",\"user_id\":\"" + user_id
|
||||
+ "\",\"created_at\":\"" + now + "\",\"expires_at\":\"" + exp
|
||||
+ "\",\"ip_address\":\"" + ip + "\"}"
|
||||
let node_id: String = engram_node_create("Session", body)
|
||||
engram_edge_create(user_id, node_id, "has_session")
|
||||
node_id
|
||||
}
|
||||
|
||||
fn session_store_verify(session_id: String) -> Bool {
|
||||
let raw: String = engram_node_get(session_id)
|
||||
if str_eq(raw, "") { return false }
|
||||
let exp: String = json_get(raw, "expires_at")
|
||||
!time_after(time_now_iso(), exp)
|
||||
}
|
||||
|
||||
fn session_store_revoke(session_id: String) -> Bool {
|
||||
engram_node_delete(session_id)
|
||||
}
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// auth_middleware extracts the bearer token, decodes it, and populates the
|
||||
// AuthContext. Applied automatically by the @authenticate aspect (el-aop).
|
||||
|
||||
fn extract_bearer(authorization_header: String) -> String {
|
||||
if str_starts_with(authorization_header, "Bearer ") {
|
||||
return str_slice(authorization_header, 7, str_len(authorization_header))
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
fn auth_middleware(authorization_header: String, jwt_secret: String) -> AuthContext {
|
||||
let token: String = extract_bearer(authorization_header)
|
||||
if str_eq(token, "") { return auth_context_empty() }
|
||||
jwt_decode(token, jwt_secret)
|
||||
}
|
||||
|
||||
fn enforce_permission(ctx: AuthContext, required_perm: String) -> Bool {
|
||||
if str_eq(ctx.user_id, "") { return false }
|
||||
auth_context_has_permission(ctx, required_perm)
|
||||
}
|
||||
|
||||
// ── Provider issue/verify ────────────────────────────────────────────────────
|
||||
|
||||
fn provider_issue(user: AuthUser, jwt_secret: String, issuer: String, audience: String, ttl: Int) -> String {
|
||||
let claims: JwtClaims = jwt_claims_new(user.id, issuer, audience, ttl)
|
||||
jwt_encode(claims, jwt_secret)
|
||||
}
|
||||
|
||||
fn provider_verify(token: String, jwt_secret: String) -> AuthContext {
|
||||
let ctx: AuthContext = jwt_decode(token, jwt_secret)
|
||||
if str_eq(ctx.user_id, "") { return ctx }
|
||||
if !session_store_verify(ctx.session_id) { return auth_context_empty() }
|
||||
ctx
|
||||
}
|
||||
|
||||
fn provider_revoke(session_id: String) -> Bool {
|
||||
session_store_revoke(session_id)
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ───────────────────────────────────────────────────────
|
||||
|
||||
let user: AuthUser = { "id": "u-001", "email": "will@neurontechnologies.ai", "display_name": "Will" }
|
||||
let token: String = provider_issue(user, "test-secret", "el-ui", "el-app", 3600)
|
||||
println("[el-auth] issued JWT for " + user.email)
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Auth middleware — extracts and verifies auth tokens from requests.
|
||||
//!
|
||||
//! In an axum application:
|
||||
//! ```text
|
||||
//! let app = Router::new()
|
||||
//! .route("/api/users", get(list_users))
|
||||
//! .layer(AuthMiddleware::new(jwt_provider));
|
||||
//! ```
|
||||
//!
|
||||
//! The middleware populates `AuthContext` from the `Authorization` header.
|
||||
|
||||
use crate::{AuthContext, AuthProvider, AuthResult};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Auth middleware — wraps an auth provider to extract context from HTTP headers.
|
||||
pub struct AuthMiddleware {
|
||||
provider: Arc<dyn AuthProvider>,
|
||||
}
|
||||
|
||||
impl AuthMiddleware {
|
||||
pub fn new(provider: Arc<dyn AuthProvider>) -> Self {
|
||||
Self { provider }
|
||||
}
|
||||
|
||||
/// Extract and verify the auth token from an Authorization header value.
|
||||
///
|
||||
/// Supported formats:
|
||||
/// - `Bearer <token>` — JWT or opaque token
|
||||
/// - `Session <session_id>` — server-side session
|
||||
pub fn authenticate_from_header(&self, authorization: Option<&str>) -> AuthResult<AuthContext> {
|
||||
match authorization {
|
||||
None => Ok(AuthContext::anonymous()),
|
||||
Some(header) => {
|
||||
let token = if let Some(t) = header.strip_prefix("Bearer ") {
|
||||
t.trim()
|
||||
} else if let Some(t) = header.strip_prefix("Session ") {
|
||||
t.trim()
|
||||
} else {
|
||||
header.trim()
|
||||
};
|
||||
self.provider.verify(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate from a query parameter (for WebSocket upgrades where
|
||||
/// Authorization headers can't be set from JS).
|
||||
pub fn authenticate_from_query_param(&self, token: Option<&str>) -> AuthResult<AuthContext> {
|
||||
match token {
|
||||
None => Ok(AuthContext::anonymous()),
|
||||
Some(t) => self.provider.verify(t),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the underlying provider name.
|
||||
pub fn provider_name(&self) -> &'static str {
|
||||
self.provider.name()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Role and permission model.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A fine-grained permission (e.g., "read", "write", "delete").
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Permission(pub String);
|
||||
|
||||
impl Permission {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Permission {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// A role that grants a set of permissions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Role {
|
||||
pub name: String,
|
||||
pub permissions: Vec<Permission>,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
permissions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
|
||||
self.permissions.push(Permission::new(perm));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_permissions(mut self, perms: Vec<impl Into<String>>) -> Self {
|
||||
self.permissions.extend(perms.into_iter().map(Permission::new));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn has_permission(&self, perm: &str) -> bool {
|
||||
self.permissions.iter().any(|p| p.0 == perm)
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry of all roles in the application.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RoleRegistry {
|
||||
roles: HashMap<String, Role>,
|
||||
}
|
||||
|
||||
impl RoleRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a role.
|
||||
pub fn register(&mut self, role: Role) {
|
||||
self.roles.insert(role.name.clone(), role);
|
||||
}
|
||||
|
||||
/// Get a role by name.
|
||||
pub fn get(&self, name: &str) -> Option<&Role> {
|
||||
self.roles.get(name)
|
||||
}
|
||||
|
||||
/// Check if the given role names grant the given permission.
|
||||
pub fn has_permission(&self, role_names: &[String], permission: &str) -> bool {
|
||||
role_names.iter().any(|role_name| {
|
||||
self.roles
|
||||
.get(role_name)
|
||||
.map(|r| r.has_permission(permission))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// List all registered role names.
|
||||
pub fn role_names(&self) -> Vec<&str> {
|
||||
self.roles.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Session provider — server-side sessions stored in memory.
|
||||
//!
|
||||
//! In production, sessions are stored in Redis or Engram (configured via
|
||||
//! `session_store = "redis"` or `session_store = "engram"` in `el.toml`).
|
||||
//! This implementation uses in-memory storage for simplicity and testing.
|
||||
|
||||
use crate::{AuthContext, AuthError, AuthProvider, AuthResult, AuthUser, RoleRegistry};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
struct SessionEntry {
|
||||
context: AuthContext,
|
||||
created_at: Instant,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl SessionEntry {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.created_at.elapsed() > self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory session store.
|
||||
pub struct SessionProvider {
|
||||
sessions: Mutex<HashMap<String, SessionEntry>>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
impl SessionProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
ttl: Duration::from_secs(3600),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_ttl(mut self, seconds: u64) -> Self {
|
||||
self.ttl = Duration::from_secs(seconds);
|
||||
self
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
format!("sess-{:016x}", nanos as u64 ^ 0x7b5e3f1a2c4d6890)
|
||||
}
|
||||
|
||||
/// Count active (non-expired) sessions.
|
||||
pub fn active_session_count(&self) -> usize {
|
||||
let sessions = self.sessions.lock().expect("session lock poisoned");
|
||||
sessions.values().filter(|s| !s.is_expired()).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthProvider for SessionProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"session"
|
||||
}
|
||||
|
||||
fn verify(&self, session_id: &str) -> AuthResult<AuthContext> {
|
||||
let mut sessions = self.sessions.lock().expect("session lock poisoned");
|
||||
// Clean expired sessions
|
||||
sessions.retain(|_, v| !v.is_expired());
|
||||
sessions
|
||||
.get(session_id)
|
||||
.filter(|s| !s.is_expired())
|
||||
.map(|s| s.context.clone())
|
||||
.ok_or(AuthError::SessionNotFound)
|
||||
}
|
||||
|
||||
fn issue(&self, user: AuthUser, _role_registry: &RoleRegistry) -> AuthResult<String> {
|
||||
let session_id = Self::generate_session_id();
|
||||
let ctx = AuthContext::authenticated(user, Vec::new(), &session_id);
|
||||
let entry = SessionEntry {
|
||||
context: ctx,
|
||||
created_at: Instant::now(),
|
||||
ttl: self.ttl,
|
||||
};
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("session lock poisoned")
|
||||
.insert(session_id.clone(), entry);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
fn revoke(&self, session_id: &str) -> AuthResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("session lock poisoned")
|
||||
.remove(session_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Tests for el-auth.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
context::{AuthContext, AuthUser},
|
||||
jwt::{JwtClaims, JwtProvider},
|
||||
middleware::AuthMiddleware,
|
||||
roles::{Permission, Role, RoleRegistry},
|
||||
session::SessionProvider,
|
||||
AuthError, AuthProvider,
|
||||
};
|
||||
|
||||
fn test_user() -> AuthUser {
|
||||
AuthUser::new("user-1", "alice@example.com", "Alice")
|
||||
}
|
||||
|
||||
fn test_provider() -> JwtProvider {
|
||||
JwtProvider::new(b"super-secret-key-for-testing-only".to_vec())
|
||||
.with_ttl(3600)
|
||||
}
|
||||
|
||||
fn registry() -> RoleRegistry {
|
||||
let mut r = RoleRegistry::new();
|
||||
r.register(
|
||||
Role::new("admin")
|
||||
.with_permissions(vec!["read", "write", "delete"]),
|
||||
);
|
||||
r.register(
|
||||
Role::new("user")
|
||||
.with_permissions(vec!["read"]),
|
||||
);
|
||||
r
|
||||
}
|
||||
|
||||
// ── Test 1: JWT round-trip — sign and verify ──────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_sign_and_verify() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let decoded = provider.decode(&token).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
assert_eq!(decoded.email, "alice@example.com");
|
||||
assert_eq!(decoded.roles, vec!["user"]);
|
||||
}
|
||||
|
||||
// ── Test 2: JWT invalid signature is rejected ─────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_invalid_signature() {
|
||||
let provider = test_provider();
|
||||
let other_provider = JwtProvider::new(b"different-secret".to_vec());
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec![], 3600);
|
||||
let token = other_provider.encode(&claims);
|
||||
let result = provider.decode(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
// ── Test 3: JWT expired token is rejected ────────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_expired_token() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
// TTL of 0 — expires immediately
|
||||
let claims = JwtClaims::new(&user, vec![], 0);
|
||||
let token = provider.encode(&claims);
|
||||
// Wait a moment (in tests, just check the claims are_expired)
|
||||
assert!(claims.is_expired() || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
true
|
||||
});
|
||||
let result = provider.decode(&token);
|
||||
assert!(matches!(result, Err(AuthError::TokenExpired) | Err(AuthError::TokenInvalid(_))));
|
||||
}
|
||||
|
||||
// ── Test 4: JWT verify returns correct AuthContext ───────────────────────
|
||||
#[test]
|
||||
fn test_jwt_verify_returns_auth_context() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let reg = registry();
|
||||
let token = provider.issue(user, ®).unwrap();
|
||||
let ctx = provider.verify(&token).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
assert_eq!(ctx.user_id().unwrap(), "user-1");
|
||||
}
|
||||
|
||||
// ── Test 5: AuthContext::has_role works ──────────────────────────────────
|
||||
#[test]
|
||||
fn test_auth_context_has_role() {
|
||||
let ctx = AuthContext::authenticated(
|
||||
test_user(),
|
||||
vec!["admin".into(), "user".into()],
|
||||
"tok",
|
||||
);
|
||||
assert!(ctx.has_role("admin"));
|
||||
assert!(ctx.has_role("user"));
|
||||
assert!(!ctx.has_role("superadmin"));
|
||||
}
|
||||
|
||||
// ── Test 6: AuthContext::has_permission works ─────────────────────────────
|
||||
#[test]
|
||||
fn test_auth_context_has_permission() {
|
||||
let ctx = AuthContext::authenticated(test_user(), vec!["admin".into()], "tok")
|
||||
.with_permissions(vec!["read".into(), "write".into(), "delete".into()]);
|
||||
assert!(ctx.has_permission("read"));
|
||||
assert!(ctx.has_permission("delete"));
|
||||
assert!(!ctx.has_permission("sudo"));
|
||||
}
|
||||
|
||||
// ── Test 7: AuthContext::anonymous is not authenticated ───────────────────
|
||||
#[test]
|
||||
fn test_anonymous_context() {
|
||||
let ctx = AuthContext::anonymous();
|
||||
assert!(!ctx.is_authenticated());
|
||||
assert!(ctx.user_id().is_none());
|
||||
}
|
||||
|
||||
// ── Test 8: Role has_permission ──────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_role_has_permission() {
|
||||
let role = Role::new("editor")
|
||||
.with_permissions(vec!["read", "write"]);
|
||||
assert!(role.has_permission("read"));
|
||||
assert!(role.has_permission("write"));
|
||||
assert!(!role.has_permission("delete"));
|
||||
}
|
||||
|
||||
// ── Test 9: RoleRegistry::has_permission checks across roles ─────────────
|
||||
#[test]
|
||||
fn test_role_registry_permission_check() {
|
||||
let reg = registry();
|
||||
let roles = vec!["user".to_string()];
|
||||
assert!(reg.has_permission(&roles, "read"));
|
||||
assert!(!reg.has_permission(&roles, "delete"));
|
||||
|
||||
let admin_roles = vec!["admin".to_string()];
|
||||
assert!(reg.has_permission(&admin_roles, "delete"));
|
||||
}
|
||||
|
||||
// ── Test 10: SessionProvider issue and verify ─────────────────────────────
|
||||
#[test]
|
||||
fn test_session_issue_and_verify() {
|
||||
let provider = SessionProvider::new();
|
||||
let reg = registry();
|
||||
let session_id = provider.issue(test_user(), ®).unwrap();
|
||||
let ctx = provider.verify(&session_id).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
assert_eq!(ctx.user_id().unwrap(), "user-1");
|
||||
}
|
||||
|
||||
// ── Test 11: SessionProvider revoke removes session ───────────────────────
|
||||
#[test]
|
||||
fn test_session_revoke() {
|
||||
let provider = SessionProvider::new();
|
||||
let reg = registry();
|
||||
let session_id = provider.issue(test_user(), ®).unwrap();
|
||||
provider.revoke(&session_id).unwrap();
|
||||
let result = provider.verify(&session_id);
|
||||
assert!(matches!(result, Err(AuthError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// ── Test 12: SessionProvider unknown session returns error ────────────────
|
||||
#[test]
|
||||
fn test_session_unknown() {
|
||||
let provider = SessionProvider::new();
|
||||
let result = provider.verify("nonexistent-session-id");
|
||||
assert!(matches!(result, Err(AuthError::SessionNotFound)));
|
||||
}
|
||||
|
||||
// ── Test 13: AuthMiddleware extracts Bearer token ─────────────────────────
|
||||
#[test]
|
||||
fn test_middleware_extracts_bearer_token() {
|
||||
let provider = Arc::new(test_provider());
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let middleware = AuthMiddleware::new(provider);
|
||||
let header = format!("Bearer {}", token);
|
||||
let ctx = middleware.authenticate_from_header(Some(&header)).unwrap();
|
||||
assert!(ctx.is_authenticated());
|
||||
}
|
||||
|
||||
// ── Test 14: AuthMiddleware with no header returns anonymous ──────────────
|
||||
#[test]
|
||||
fn test_middleware_no_header_anonymous() {
|
||||
let provider = Arc::new(test_provider());
|
||||
let middleware = AuthMiddleware::new(provider);
|
||||
let ctx = middleware.authenticate_from_header(None).unwrap();
|
||||
assert!(!ctx.is_authenticated());
|
||||
}
|
||||
|
||||
// ── Test 15: Permission Display ───────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_permission_display() {
|
||||
let perm = Permission::new("write");
|
||||
assert_eq!(perm.to_string(), "write");
|
||||
assert_eq!(perm.as_str(), "write");
|
||||
}
|
||||
|
||||
// ── Test 16: JwtClaims::to_json and from_json round-trip ─────────────────
|
||||
#[test]
|
||||
fn test_jwt_claims_json_round_trip() {
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
|
||||
let json = claims.to_json();
|
||||
let decoded = JwtClaims::from_json(&json).unwrap();
|
||||
assert_eq!(decoded.sub, "user-1");
|
||||
assert_eq!(decoded.email, "alice@example.com");
|
||||
assert_eq!(decoded.roles, vec!["admin", "user"]);
|
||||
}
|
||||
|
||||
// ── Test 17: JWT with multiple roles ─────────────────────────────────────
|
||||
#[test]
|
||||
fn test_jwt_multiple_roles() {
|
||||
let provider = test_provider();
|
||||
let user = test_user();
|
||||
let claims = JwtClaims::new(&user, vec!["admin".into(), "user".into()], 3600);
|
||||
let token = provider.encode(&claims);
|
||||
let decoded = provider.decode(&token).unwrap();
|
||||
assert_eq!(decoded.roles.len(), 2);
|
||||
assert!(decoded.roles.contains(&"admin".to_string()));
|
||||
}
|
||||
|
||||
// ── Test 18: RoleRegistry::role_names lists all roles ────────────────────
|
||||
#[test]
|
||||
fn test_role_registry_names() {
|
||||
let reg = registry();
|
||||
let mut names = reg.role_names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["admin", "user"]);
|
||||
}
|
||||
|
||||
// ── Test 19: SessionProvider TTL configuration ────────────────────────────
|
||||
#[test]
|
||||
fn test_session_ttl_config() {
|
||||
let provider = SessionProvider::new().with_ttl(7200);
|
||||
assert_eq!(provider.ttl.as_secs(), 7200);
|
||||
}
|
||||
|
||||
// ── Test 20: AuthMiddleware provider_name returns correct name ────────────
|
||||
#[test]
|
||||
fn test_middleware_provider_name() {
|
||||
let jwt_provider = Arc::new(test_provider());
|
||||
let middleware = AuthMiddleware::new(jwt_provider);
|
||||
assert_eq!(middleware.provider_name(), "jwt");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user