add el-identity: Engram-native identity, OAuth, @authenticate by default

This commit is contained in:
Will Anderson
2026-04-27 20:04:52 -05:00
parent 69d1085d2d
commit a1159eec65
21 changed files with 3124 additions and 8 deletions
+128
View File
@@ -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()))
}
}
+32 -5
View File
@@ -12,12 +12,19 @@ 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)
}
@@ -30,11 +37,24 @@ impl JwtClaims {
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
}
@@ -47,10 +67,16 @@ impl JwtClaims {
.map(|r| format!("\"{}\"", r))
.collect::<Vec<_>>()
.join(",");
format!(
"{{\"sub\":\"{}\",\"email\":\"{}\",\"name\":\"{}\",\"roles\":[{}],\"iat\":{},\"exp\":{}}}",
self.sub, self.email, self.name, roles_json, self.iat, self.exp
)
// 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).
@@ -61,7 +87,8 @@ impl JwtClaims {
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");
Some(Self { sub, email, name, roles, iat, exp })
let session_id = extract_str(json, "session_id");
Some(Self { sub, email, name, roles, session_id, iat, exp })
}
}
+9 -1
View File
@@ -6,16 +6,24 @@
//! [auth]
//! provider = "jwt"
//! jwt_secret_env = "JWT_SECRET"
//! session_store = "memory"
//! 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};