add el-identity: Engram-native identity, OAuth, @authenticate by default
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user