Archived
feat: rename crates/ → vessels/ + add El ports per sub-vessel
Belated rename commit for foundation/el-ui — was missed in the workspace-wide crates→vessels pass earlier today. Same structural intent as the rename in the other repos: 'crates' is the Rust word, 'vessel' is El's, and the directory rename is the marker that this slot holds an El buildable unit even if its current contents are still Rust pending port. Plus the El ports themselves — manifest.el + src/main.el per sub- vessel (el-aop, el-auth, el-config, el-i18n, el-identity, el-layout, el-platform, el-publish, el-secrets, el-services, el-style, el-ui- compiler). The ui-compiler is a stub: elc only emits C right now; generating browser-target JS/Wasm is the biggest open language gap and gets its own project. Until then, el-ui-compiler emits a JS module that throws elc.backend_missing so callers fail loudly. Cross-repo path dependencies in Cargo.toml updated to vessels/.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "el-auth"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui built-in authentication and authorization — native to the framework"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_auth"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
el-identity = { path = "../el-identity" }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,22 @@
|
||||
// el-auth — Built-in authentication and authorization for el-ui.
|
||||
//
|
||||
// Not a library you add. Native to the framework. Sits on top of el-identity:
|
||||
// el-identity owns the graph (users, sessions, OAuth tokens); el-auth owns the
|
||||
// request-time enforcement (verify token, check permission, enforce roles).
|
||||
|
||||
vessel "el-auth" {
|
||||
version "0.1.0"
|
||||
description "Built-in auth/authz: JWT, sessions, RBAC, middleware"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
el-identity "0.1"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -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 `manifest.el`).
|
||||
//! 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