Remove all .rs bootstrap files from el-ui vessels
El SDK CI - dev / build-and-test (pull_request) Successful in 3m33s

el-ui vessels are El. The Rust bootstrap implementations were added as
a stopgap but don't belong here — everything should be El source.
Each vessel's src/main.el and manifest.el are the source of truth.
This commit is contained in:
2026-05-06 23:12:25 -05:00
parent e8b01583d8
commit 5d9299a472
98 changed files with 0 additions and 17426 deletions
-96
View File
@@ -1,96 +0,0 @@
//! IdentityContext — the resolved identity of the current caller.
//!
//! Populated by `AuthGuard` during request processing. Contains the User node,
//! their roles (graph-resolved), their scopes (role→scope edges), and the active
//! session node.
//!
//! Everything downstream in the request receives an `IdentityContext` — not raw
//! tokens, not string maps.
use crate::nodes::{Role, Scope, Session, User};
/// The fully-resolved identity context for an authenticated request.
///
/// Created by `AuthGuard::authenticate()` after:
/// 1. Validating the session node (expiry check)
/// 2. Loading the User node from graph
/// 3. Traversing `User ──has_role──▶ Role` edges
/// 4. Traversing `Role ──grants──▶ Scope` edges
#[derive(Debug, Clone)]
pub struct IdentityContext {
/// The authenticated user.
pub user: User,
/// The active session node that produced this context.
pub session: Session,
/// All roles held by the user (via has_role edges).
pub roles: Vec<Role>,
/// All scopes granted across all roles (via grants edges).
pub scopes: Vec<Scope>,
}
impl IdentityContext {
pub fn new(user: User, session: Session, roles: Vec<Role>, scopes: Vec<Scope>) -> Self {
Self { user, session, roles, scopes }
}
/// Check whether the user has a specific role by name.
pub fn has_role(&self, role_name: &str) -> bool {
self.roles.iter().any(|r| r.name == role_name)
}
/// Check whether the user has a specific scope by name.
pub fn has_scope(&self, scope_name: &str) -> bool {
self.scopes.iter().any(|s| s.name == scope_name)
}
/// Check whether the user has a specific permission (via any role).
pub fn has_permission(&self, permission: &str) -> bool {
self.roles.iter().any(|r| r.has_permission(permission))
}
/// The user's ID as a string (convenience accessor).
pub fn user_id(&self) -> &str {
// UUID's Display impl gives the hyphenated string form
// We lazily format it; callers cache as needed.
// To avoid allocation on every call, store a pre-formatted string.
// For simplicity we use the node's UUID directly via format — this is
// framework infrastructure code called once per request boundary.
let _ = ();
// Returned as a borrowed string from the session (which stores user_id as UUID).
// We work around the lifetime by returning user.id formatted on the fly.
// In a real app this would be &str from a pre-computed field.
self._user_id_buf()
}
fn _user_id_buf(&self) -> &str {
// This is a limitation of returning &str from a UUID without allocation.
// The idiomatic approach is to expose the Uuid directly.
// We provide user_uuid() as the primary accessor.
""
}
/// The user's UUID.
pub fn user_uuid(&self) -> uuid::Uuid {
self.user.id
}
/// The user's email.
pub fn email(&self) -> &str {
&self.user.email
}
/// The user's display name.
pub fn display_name(&self) -> &str {
&self.user.display_name
}
/// All role names as strings (for passing to AOP metadata).
pub fn role_names(&self) -> Vec<String> {
self.roles.iter().map(|r| r.name.clone()).collect()
}
/// All scope names as strings.
pub fn scope_names(&self) -> Vec<String> {
self.scopes.iter().map(|s| s.name.clone()).collect()
}
}
-214
View File
@@ -1,214 +0,0 @@
//! EngramClient trait — thin abstraction over the Engram graph engine.
//!
//! el-identity does not depend on the Engram crate directly. Instead, it
//! defines this trait and accepts any implementor. In production, the host
//! application wires in a real Engram client. In tests, `MockEngramClient`
//! provides an in-memory HashMap-backed implementation.
use crate::error::IdentityError;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Minimal graph operations required by el-identity.
pub trait EngramClient: Send + Sync {
/// Fetch a node by its ID. Returns `None` if the node does not exist.
fn get_node(&self, id: &str) -> Result<Option<serde_json::Value>, IdentityError>;
/// Create a new node of the given type with the given data.
/// Returns the new node's ID.
fn create_node(
&self,
node_type: &str,
data: serde_json::Value,
) -> Result<String, IdentityError>;
/// Create a directed edge between two nodes.
fn create_edge(
&self,
from: &str,
to: &str,
edge_type: &str,
) -> Result<(), IdentityError>;
/// Find nodes of the given type matching the query (field equality).
fn find_nodes(
&self,
node_type: &str,
query: serde_json::Value,
) -> Result<Vec<serde_json::Value>, IdentityError>;
/// Delete a node by ID. Edges referencing it are also removed.
fn delete_node(&self, id: &str) -> Result<(), IdentityError>;
/// Find all nodes reachable from `from_id` via `edge_type`.
fn find_connected(
&self,
from_id: &str,
edge_type: &str,
) -> Result<Vec<serde_json::Value>, IdentityError>;
}
// ── MockEngramClient ──────────────────────────────────────────────────────────
#[derive(Debug)]
struct NodeEntry {
node_type: String,
data: serde_json::Value,
}
#[derive(Debug, Clone)]
struct Edge {
from: String,
to: String,
edge_type: String,
}
/// In-memory Engram client for unit tests.
///
/// Stores nodes in a `HashMap<id, NodeEntry>` and edges in a `Vec<Edge>`.
/// Thread-safe via `RwLock`.
#[derive(Debug, Default)]
pub struct MockEngramClient {
nodes: RwLock<HashMap<String, NodeEntry>>,
edges: RwLock<Vec<Edge>>,
}
impl MockEngramClient {
pub fn new() -> Self {
Self::default()
}
/// Count nodes of a specific type (useful in tests).
pub fn count_nodes(&self, node_type: &str) -> usize {
self.nodes
.read()
.expect("nodes lock poisoned")
.values()
.filter(|n| n.node_type == node_type)
.count()
}
/// Count edges of a specific type.
pub fn count_edges(&self, edge_type: &str) -> usize {
self.edges
.read()
.expect("edges lock poisoned")
.iter()
.filter(|e| e.edge_type == edge_type)
.count()
}
}
impl EngramClient for MockEngramClient {
fn get_node(&self, id: &str) -> Result<Option<serde_json::Value>, IdentityError> {
let nodes = self.nodes.read().expect("nodes lock poisoned");
Ok(nodes.get(id).map(|n| n.data.clone()))
}
fn create_node(
&self,
node_type: &str,
data: serde_json::Value,
) -> Result<String, IdentityError> {
// Extract the node's own "id" field if present, otherwise generate one.
let id = data
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
self.nodes
.write()
.expect("nodes lock poisoned")
.insert(
id.clone(),
NodeEntry {
node_type: node_type.to_string(),
data,
},
);
Ok(id)
}
fn create_edge(
&self,
from: &str,
to: &str,
edge_type: &str,
) -> Result<(), IdentityError> {
self.edges.write().expect("edges lock poisoned").push(Edge {
from: from.to_string(),
to: to.to_string(),
edge_type: edge_type.to_string(),
});
Ok(())
}
fn find_nodes(
&self,
node_type: &str,
query: serde_json::Value,
) -> Result<Vec<serde_json::Value>, IdentityError> {
let nodes = self.nodes.read().expect("nodes lock poisoned");
let results = nodes
.values()
.filter(|n| n.node_type == node_type)
.filter(|n| matches_query(&n.data, &query))
.map(|n| n.data.clone())
.collect();
Ok(results)
}
fn delete_node(&self, id: &str) -> Result<(), IdentityError> {
self.nodes
.write()
.expect("nodes lock poisoned")
.remove(id);
// Remove any edges referencing this node.
self.edges
.write()
.expect("edges lock poisoned")
.retain(|e| e.from != id && e.to != id);
Ok(())
}
fn find_connected(
&self,
from_id: &str,
edge_type: &str,
) -> Result<Vec<serde_json::Value>, IdentityError> {
let edges = self.edges.read().expect("edges lock poisoned");
let to_ids: Vec<String> = edges
.iter()
.filter(|e| e.from == from_id && e.edge_type == edge_type)
.map(|e| e.to.clone())
.collect();
drop(edges);
let nodes = self.nodes.read().expect("nodes lock poisoned");
let results = to_ids
.iter()
.filter_map(|id| nodes.get(id).map(|n| n.data.clone()))
.collect();
Ok(results)
}
}
/// Check whether a node's data matches all key-value pairs in the query.
fn matches_query(data: &serde_json::Value, query: &serde_json::Value) -> bool {
if let serde_json::Value::Object(q_map) = query {
if q_map.is_empty() {
return true;
}
if let serde_json::Value::Object(data_map) = data {
return q_map.iter().all(|(k, v)| data_map.get(k) == Some(v));
}
return false;
}
true // empty or non-object query matches everything
}
/// Convenience: wrap a MockEngramClient in Arc for trait object use.
pub fn mock_client() -> Arc<dyn EngramClient> {
Arc::new(MockEngramClient::new())
}
-56
View File
@@ -1,56 +0,0 @@
//! IdentityError — all errors from the el-identity system.
use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum IdentityError {
#[error("user not found: {0}")]
UserNotFound(String),
#[error("session not found or expired")]
SessionNotFound,
#[error("session expired")]
SessionExpired,
#[error("OAuth error: {0}")]
OAuthError(String),
#[error("OAuth provider not configured: {0}")]
ProviderNotConfigured(String),
#[error("token exchange failed: {status} {body}")]
TokenExchangeFailed { status: u16, body: String },
#[error("token refresh failed: {0}")]
TokenRefreshFailed(String),
#[error("PKCE verification failed")]
PkceVerificationFailed,
#[error("graph error: {0}")]
GraphError(String),
#[error("serialization error: {0}")]
SerializationError(String),
#[error("authentication required")]
Unauthenticated,
#[error("forbidden: requires role '{0}'")]
Forbidden(String),
#[error("forbidden: requires scope '{0}'")]
ScopeForbidden(String),
#[error("invalid credentials")]
InvalidCredentials,
#[error("role not found: {0}")]
RoleNotFound(String),
#[error("node not found: {0}")]
NodeNotFound(String),
}
pub type IdentityResult<T> = Result<T, IdentityError>;
-152
View File
@@ -1,152 +0,0 @@
//! AuthGuard — the mechanism behind `@authenticate`.
//!
//! `AuthGuard` is the bridge between a raw session/JWT token string (extracted
//! from the request) and a fully-resolved `IdentityContext`.
//!
//! Execution:
//! 1. Extract session ID from the token (JWT decode or opaque lookup)
//! 2. Validate the Session node in Engram (expiry check)
//! 3. Load the User node
//! 4. Traverse `User ──has_role──▶ Role` edges
//! 5. Traverse `Role ──grants──▶ Scope` edges
//! 6. Return `IdentityContext` — fully resolved, ready for downstream use
//!
//! `@public` bypasses this guard entirely (see `el-aop::PublicMarker`).
use crate::{
context::IdentityContext,
engram::EngramClient,
error::{IdentityError, IdentityResult},
nodes::{Role, Scope, User, EDGE_GRANTS, EDGE_HAS_ROLE, NODE_ROLE, NODE_SCOPE, NODE_USER},
session::SessionManager,
};
use std::sync::Arc;
/// AuthGuard resolves a session/token string into a full `IdentityContext`.
///
/// Configured once at application startup and shared across requests.
pub struct AuthGuard {
client: Arc<dyn EngramClient>,
session_manager: Arc<SessionManager>,
}
impl AuthGuard {
pub fn new(client: Arc<dyn EngramClient>, session_manager: Arc<SessionManager>) -> Self {
Self { client, session_manager }
}
/// Authenticate a request given its session ID.
///
/// This is called by `@authenticate` in the AOP chain. For every protected
/// endpoint, this runs before the handler. If it returns `Err`, the request
/// is rejected.
pub fn authenticate(&self, session_id: &str) -> IdentityResult<IdentityContext> {
// 1. Validate session (checks expiry, lazy-deletes expired)
let session = self.session_manager.validate(session_id)?;
// 2. 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| IdentityError::GraphError(e.to_string()))?
.ok_or_else(|| IdentityError::UserNotFound(user_id_str.clone()))?;
let user = User::from_value(&user_node)
.ok_or_else(|| IdentityError::GraphError("user node parse failed".into()))?;
// 3. Load roles via has_role edges
let role_nodes = self
.client
.find_connected(&user_id_str, EDGE_HAS_ROLE)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
let roles: Vec<Role> = role_nodes
.iter()
.filter_map(Role::from_value)
.collect();
// 4. Load scopes via grants edges from each role
let mut scopes: Vec<Scope> = Vec::new();
for role in &roles {
let scope_nodes = self
.client
.find_connected(&role.id.to_string(), EDGE_GRANTS)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
scopes.extend(scope_nodes.iter().filter_map(Scope::from_value));
}
// Deduplicate scopes by name
scopes.dedup_by(|a, b| a.name == b.name);
Ok(IdentityContext::new(user, session, roles, scopes))
}
/// Require a specific role — returns Err::Forbidden if missing.
pub fn require_role(
&self,
ctx: &IdentityContext,
role: &str,
) -> IdentityResult<()> {
if ctx.has_role(role) {
Ok(())
} else {
Err(IdentityError::Forbidden(role.to_string()))
}
}
/// Require a specific scope — returns Err::ScopeForbidden if missing.
pub fn require_scope(
&self,
ctx: &IdentityContext,
scope: &str,
) -> IdentityResult<()> {
if ctx.has_scope(scope) {
Ok(())
} else {
Err(IdentityError::ScopeForbidden(scope.to_string()))
}
}
/// Register a user in the Engram graph.
///
/// Creates the User node. Call this after first OAuth login or on signup.
pub fn register_user(&self, user: &User) -> IdentityResult<()> {
self.client
.create_node(NODE_USER, user.to_value())
.map(|_| ())
.map_err(|e| IdentityError::GraphError(e.to_string()))
}
/// Assign a role to a user by creating a `has_role` edge.
pub fn assign_role(&self, user_id: &str, role: &Role) -> IdentityResult<()> {
// Ensure role node exists
if self.client.get_node(&role.id.to_string())
.map_err(|e| IdentityError::GraphError(e.to_string()))?
.is_none()
{
self.client
.create_node(NODE_ROLE, role.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
}
// Edge: User → Role
self.client
.create_edge(user_id, &role.id.to_string(), EDGE_HAS_ROLE)
.map_err(|e| IdentityError::GraphError(e.to_string()))
}
/// Register a scope and link it to a role via a `grants` edge.
pub fn assign_scope_to_role(&self, role: &Role, scope: &Scope) -> IdentityResult<()> {
if self.client.get_node(&scope.id.to_string())
.map_err(|e| IdentityError::GraphError(e.to_string()))?
.is_none()
{
self.client
.create_node(NODE_SCOPE, scope.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
}
self.client
.create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS)
.map_err(|e| IdentityError::GraphError(e.to_string()))
}
}
-37
View File
@@ -1,37 +0,0 @@
//! el-identity — Engram-native identity for el-ui.
//!
//! Identity is not a bolt-on — it is activation spreading through the graph.
//! Users, roles, scopes, sessions, and OAuth tokens are first-class Engram nodes
//! connected by typed edges.
//!
//! ```text
//! User ──has_role──▶ Role ──grants──▶ Scope
//! │
//! └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
//! ```
//!
//! Security-by-default: `@authenticate` is applied to every endpoint.
//! `@public` is the explicit opt-out.
#![deny(warnings)]
pub mod context;
pub mod engram;
pub mod error;
pub mod guard;
pub mod nodes;
pub mod oauth;
pub mod provider;
pub mod session;
pub use context::IdentityContext;
pub use engram::{EngramClient, MockEngramClient};
pub use error::{IdentityError, IdentityResult};
pub use guard::AuthGuard;
pub use nodes::{OAuthToken, Role, Scope, Session, User};
pub use oauth::{OAuthFlow, PkceChallenge};
pub use provider::{AppleOAuth, GithubOAuth, GoogleOAuth, OAuthProvider};
pub use session::SessionManager;
#[cfg(test)]
mod tests;
-244
View File
@@ -1,244 +0,0 @@
//! Identity graph nodes — User, Role, Scope, OAuthToken, Session as
//! strongly-typed Engram node structs.
//!
//! Each node maps to an Engram graph node. The identity graph looks like:
//!
//! ```text
//! User ──has_role──▶ Role ──grants──▶ Scope
//! │
//! └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
//! ```
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ── User ──────────────────────────────────────────────────────────────────────
/// A user identity node in the Engram graph.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct User {
/// Stable UUID for this user.
pub id: Uuid,
/// Email address — unique identifier for the user.
pub email: String,
/// Display name (may differ from email).
pub display_name: String,
/// When this user node was created.
pub created_at: DateTime<Utc>,
}
impl User {
/// Create a new user with a fresh UUID.
pub fn new(email: impl Into<String>, display_name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
email: email.into(),
display_name: display_name.into(),
created_at: Utc::now(),
}
}
/// Deserialize from a serde_json::Value (as stored in Engram).
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
/// Serialize to serde_json::Value for storage in Engram.
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("User is always serializable")
}
}
// ── Role ──────────────────────────────────────────────────────────────────────
/// A role node — grants a set of named permissions to connected User nodes.
///
/// Edge: `User ──has_role──▶ Role`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Role {
pub id: Uuid,
pub name: String,
/// Flat list of permission strings (e.g., `"orders:read"`, `"users:write"`).
pub permissions: Vec<String>,
}
impl Role {
pub fn new(name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
permissions: Vec::new(),
}
}
pub fn with_permission(mut self, perm: impl Into<String>) -> Self {
self.permissions.push(perm.into());
self
}
pub fn with_permissions(mut self, perms: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.permissions.extend(perms.into_iter().map(|p| p.into()));
self
}
pub fn has_permission(&self, perm: &str) -> bool {
self.permissions.iter().any(|p| p == perm)
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("Role is always serializable")
}
}
// ── Scope ─────────────────────────────────────────────────────────────────────
/// An OAuth scope node.
///
/// Edge: `Role ──grants──▶ Scope`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Scope {
pub id: Uuid,
/// The OAuth scope string (e.g., `"openid"`, `"email"`, `"profile"`).
pub name: String,
pub description: String,
}
impl Scope {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
description: description.into(),
}
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("Scope is always serializable")
}
}
// ── OAuthToken ────────────────────────────────────────────────────────────────
/// An OAuth token node — stores hashed tokens so the graph is breach-safe.
///
/// Edge: `Session ──authenticated_via──▶ OAuthToken`
///
/// Tokens are hashed with SHA-256 before storage. The raw token is never
/// persisted — only the hash. Refresh tokens use the same scheme.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OAuthToken {
pub id: Uuid,
pub provider: String,
/// SHA-256 hex hash of the access token.
pub access_token_hash: String,
/// SHA-256 hex hash of the refresh token, if present.
pub refresh_token_hash: Option<String>,
pub expires_at: DateTime<Utc>,
/// Scopes granted by this token.
pub scopes: Vec<String>,
}
impl OAuthToken {
pub fn new(
provider: impl Into<String>,
access_token_hash: impl Into<String>,
refresh_token_hash: Option<String>,
expires_at: DateTime<Utc>,
scopes: Vec<String>,
) -> Self {
Self {
id: Uuid::new_v4(),
provider: provider.into(),
access_token_hash: access_token_hash.into(),
refresh_token_hash,
expires_at,
scopes,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.expires_at
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope)
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("OAuthToken is always serializable")
}
}
// ── Session ───────────────────────────────────────────────────────────────────
/// A session node — represents an active authenticated session.
///
/// Edge: `User ──has_session──▶ Session`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Session {
pub id: Uuid,
/// The user this session belongs to.
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
/// The IP address that created this session.
pub ip_address: Option<String>,
}
impl Session {
pub fn new(user_id: Uuid, ttl_seconds: i64, ip_address: Option<String>) -> Self {
let now = Utc::now();
let expires_at = now + chrono::Duration::seconds(ttl_seconds);
Self {
id: Uuid::new_v4(),
user_id,
created_at: now,
expires_at,
ip_address,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.expires_at
}
pub fn from_value(value: &serde_json::Value) -> Option<Self> {
serde_json::from_value(value.clone()).ok()
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::to_value(self).expect("Session is always serializable")
}
}
// ── Edge type constants ───────────────────────────────────────────────────────
/// Edge type: User → Role
pub const EDGE_HAS_ROLE: &str = "has_role";
/// Edge type: User → Session
pub const EDGE_HAS_SESSION: &str = "has_session";
/// Edge type: Session → OAuthToken
pub const EDGE_AUTHENTICATED_VIA: &str = "authenticated_via";
/// Edge type: Role → Scope
pub const EDGE_GRANTS: &str = "grants";
// ── Node type constants ───────────────────────────────────────────────────────
pub const NODE_USER: &str = "User";
pub const NODE_ROLE: &str = "Role";
pub const NODE_SCOPE: &str = "Scope";
pub const NODE_OAUTH_TOKEN: &str = "OAuthToken";
pub const NODE_SESSION: &str = "Session";
-275
View File
@@ -1,275 +0,0 @@
//! OAuth 2.0 flows implemented as Engram graph operations.
//!
//! Implements PKCE (RFC 7636) and Authorization Code flow without external
//! OAuth crates. Token exchange uses `reqwest` (already in the workspace).
//! All tokens are hashed before graph storage — the raw token never persists.
//!
//! Flow:
//! 1. `begin_auth_flow()` → PKCE verifier + challenge, redirect URL
//! 2. Provider redirects back with `code`
//! 3. `exchange_code()` → calls provider token endpoint, stores OAuthToken node
//! 4. `refresh_token()` → find OAuthToken node, call refresh endpoint, update node
use crate::{
engram::EngramClient,
error::{IdentityError, IdentityResult},
nodes::{OAuthToken, User, EDGE_AUTHENTICATED_VIA, NODE_OAUTH_TOKEN},
provider::OAuthProvider,
session::SessionManager,
};
use chrono::{Duration, Utc};
use sha2::{Digest, Sha256};
use std::sync::Arc;
// ── PKCE helpers ──────────────────────────────────────────────────────────────
/// A PKCE verifier/challenge pair.
#[derive(Debug, Clone)]
pub struct PkceChallenge {
/// The raw verifier — sent to the token endpoint.
pub verifier: String,
/// The challenge (BASE64URL(SHA256(verifier))) — sent in the auth request.
pub challenge: String,
/// Always "S256".
pub method: &'static str,
}
impl PkceChallenge {
/// Generate a new PKCE verifier and compute the S256 challenge.
///
/// The verifier is a 43-character URL-safe random string derived from
/// entropy collected from the system clock and a UUID.
pub fn generate() -> Self {
let verifier = generate_pkce_verifier();
let challenge = pkce_s256_challenge(&verifier);
Self {
verifier,
challenge,
method: "S256",
}
}
/// Verify that a verifier matches this challenge (used in tests and server-side).
pub fn verify(&self, verifier: &str) -> bool {
pkce_s256_challenge(verifier) == self.challenge
}
}
fn generate_pkce_verifier() -> String {
// 32 random bytes → base64url (43 chars, no padding)
// We derive entropy from UUID (random in v4) + timestamp nanos.
let id = uuid::Uuid::new_v4();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let mut raw = [0u8; 32];
let id_bytes = id.as_bytes();
for i in 0..16 {
raw[i] = id_bytes[i];
}
let ts_bytes = ts.to_le_bytes();
for i in 0..4 {
raw[16 + i] = ts_bytes[i];
}
// Fill remaining with XOR mix
for i in 20..32 {
raw[i] = id_bytes[i - 16] ^ ts_bytes[i % 4];
}
base64url_encode_no_pad(&raw)
}
fn pkce_s256_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
base64url_encode_no_pad(&hasher.finalize())
}
fn base64url_encode_no_pad(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
}
/// Hash a token with SHA-256 for safe graph storage.
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode_lower_sha256(hasher.finalize().as_ref())
}
mod hex {
pub fn encode_lower_sha256(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
}
// ── Authorization Code Flow ───────────────────────────────────────────────────
/// Parameters for starting an OAuth authorization code flow.
#[derive(Debug, Clone)]
pub struct AuthFlowParams {
/// The PKCE challenge (save the verifier for use at exchange time).
pub pkce: PkceChallenge,
/// The full redirect URL to send the user to.
pub redirect_url: String,
/// An opaque state value for CSRF protection.
pub state: String,
}
/// The result of a successful token exchange.
#[derive(Debug, Clone)]
pub struct TokenSet {
pub access_token: String,
pub refresh_token: Option<String>,
/// Expiry in seconds from now.
pub expires_in: u64,
pub scopes: Vec<String>,
}
/// OAuth flow coordinator — executes auth code + PKCE flows and writes
/// the resulting tokens to the Engram graph.
pub struct OAuthFlow {
client: Arc<dyn EngramClient>,
/// Kept for future use (e.g., session creation during OAuth callback).
_session_manager: Arc<SessionManager>,
}
impl OAuthFlow {
pub fn new(client: Arc<dyn EngramClient>, session_manager: Arc<SessionManager>) -> Self {
Self { client, _session_manager: session_manager }
}
/// Step 1: Generate the redirect URL and PKCE parameters.
///
/// The caller should:
/// 1. Save `params.pkce.verifier` in the user's browser session (cookie/localStorage).
/// 2. Redirect the user to `params.redirect_url`.
pub fn begin_auth_flow(
&self,
provider: &dyn OAuthProvider,
redirect_uri: &str,
extra_scopes: &[&str],
) -> IdentityResult<AuthFlowParams> {
let pkce = PkceChallenge::generate();
let state = generate_pkce_verifier(); // reuse the verifier generator for state
let url = provider.authorization_url(
redirect_uri,
&pkce.challenge,
&state,
extra_scopes,
);
Ok(AuthFlowParams { pkce, redirect_url: url, state })
}
/// Step 2: Exchange the authorization code for tokens.
///
/// - `user` — the authenticated user to link the token to
/// - `code` — the authorization code from the provider callback
/// - `pkce_verifier` — the verifier saved in step 1
/// - `session_id` — the session to attach the OAuthToken to
///
/// Returns the session ID that now has an OAuthToken attached via graph edge.
pub fn exchange_code(
&self,
provider: &dyn OAuthProvider,
_user: &User,
code: &str,
pkce_verifier: &str,
redirect_uri: &str,
session_id: &str,
) -> IdentityResult<OAuthToken> {
// Exchange code with provider (HTTP call inside OAuthProvider::exchange_code)
let token_set = provider.exchange_code(code, pkce_verifier, redirect_uri)?;
// Hash tokens before storing
let access_hash = hash_token(&token_set.access_token);
let refresh_hash = token_set.refresh_token.as_deref().map(hash_token);
let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64);
let oauth_token = OAuthToken::new(
provider.name(),
access_hash,
refresh_hash,
expires_at,
token_set.scopes,
);
// Store OAuthToken node in graph
let token_id = self
.client
.create_node(NODE_OAUTH_TOKEN, oauth_token.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
// Edge: Session → OAuthToken (authenticated_via)
self.client
.create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
Ok(oauth_token)
}
/// Step 3: Refresh an expired access token.
///
/// Finds the OAuthToken node for the given session, calls the provider's
/// refresh endpoint, and updates the node in-place (delete old, create new).
pub fn refresh_token(
&self,
provider: &dyn OAuthProvider,
session_id: &str,
refresh_token: &str,
) -> IdentityResult<OAuthToken> {
// Exchange refresh token with provider
let token_set = provider.refresh_token(refresh_token)?;
// Find and delete old OAuthToken nodes attached to this session
let old_tokens = self
.client
.find_connected(session_id, EDGE_AUTHENTICATED_VIA)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
for old in &old_tokens {
if let Some(id) = old.get("id").and_then(|v| v.as_str()) {
let _ = self.client.delete_node(id);
}
}
// Store new token
let access_hash = hash_token(&token_set.access_token);
let refresh_hash = token_set.refresh_token.as_deref().map(hash_token);
let expires_at = Utc::now() + Duration::seconds(token_set.expires_in as i64);
let new_token = OAuthToken::new(
provider.name(),
access_hash,
refresh_hash,
expires_at,
token_set.scopes,
);
let token_id = self
.client
.create_node(NODE_OAUTH_TOKEN, new_token.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
self.client
.create_edge(session_id, &token_id, EDGE_AUTHENTICATED_VIA)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
Ok(new_token)
}
}
-372
View File
@@ -1,372 +0,0 @@
//! OAuthProvider trait and built-in provider implementations.
//!
//! Each provider knows its own OAuth endpoints and default scopes.
//! Token exchange is done over HTTP using `reqwest` in async or
//! in the sync shim below. For simplicity in a framework context
//! we use blocking HTTP (same pattern as the rest of el-ui).
//!
//! Implementations: `GoogleOAuth`, `AppleOAuth`, `GithubOAuth`.
use crate::{error::{IdentityError, IdentityResult}, oauth::TokenSet};
use std::collections::HashMap;
// ── OAuthProvider trait ───────────────────────────────────────────────────────
/// Implemented by each OAuth provider.
pub trait OAuthProvider: Send + Sync {
/// The provider identifier (e.g., `"google"`, `"github"`, `"apple"`).
fn name(&self) -> &'static str;
/// Build the authorization URL the user is redirected to.
fn authorization_url(
&self,
redirect_uri: &str,
pkce_challenge: &str,
state: &str,
extra_scopes: &[&str],
) -> String;
/// Exchange an authorization code for tokens (HTTP POST to token endpoint).
fn exchange_code(
&self,
code: &str,
pkce_verifier: &str,
redirect_uri: &str,
) -> IdentityResult<TokenSet>;
/// Refresh an access token using a refresh token.
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet>;
/// Default scopes requested by this provider.
fn default_scopes(&self) -> Vec<&'static str>;
}
// ── Shared HTTP helper ────────────────────────────────────────────────────────
/// Perform a URL-encoded POST and parse the JSON response.
///
/// In production this would use an async client. Here we use the blocking
/// reqwest API to keep el-identity sync-friendly (same pattern as el-auth JWT).
/// The actual HTTP call is behind a feature-flag stub so tests never need a
/// running server.
fn post_token_request(
endpoint: &str,
params: &HashMap<&str, &str>,
) -> IdentityResult<serde_json::Value> {
// Attempt real HTTP — fall through to error if reqwest isn't available at
// compile time. Since we don't add reqwest as a dep (no_std compat), we
// return a clear error. The caller (OAuthFlow) is the integration point.
let _ = (endpoint, params);
Err(IdentityError::OAuthError(
"HTTP client not configured: wire in a reqwest::blocking::Client or use the async variant".into(),
))
}
/// Parse token endpoint JSON response → TokenSet.
fn parse_token_response(json: &serde_json::Value) -> IdentityResult<TokenSet> {
let access_token = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| IdentityError::OAuthError("missing access_token in response".into()))?
.to_string();
let refresh_token = json
.get("refresh_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let expires_in = json
.get("expires_in")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
let scope_str = json
.get("scope")
.and_then(|v| v.as_str())
.unwrap_or("");
let scopes = scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
Ok(TokenSet {
access_token,
refresh_token,
expires_in,
scopes,
})
}
// ── GoogleOAuth ───────────────────────────────────────────────────────────────
/// Google OAuth 2.0 provider.
///
/// Endpoints:
/// - Auth: `https://accounts.google.com/o/oauth2/v2/auth`
/// - Token: `https://oauth2.googleapis.com/token`
#[derive(Debug, Clone)]
pub struct GoogleOAuth {
pub client_id: String,
pub client_secret: String,
}
impl GoogleOAuth {
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
client_secret: client_secret.into(),
}
}
const AUTH_URL: &'static str = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL: &'static str = "https://oauth2.googleapis.com/token";
}
impl OAuthProvider for GoogleOAuth {
fn name(&self) -> &'static str {
"google"
}
fn authorization_url(
&self,
redirect_uri: &str,
pkce_challenge: &str,
state: &str,
extra_scopes: &[&str],
) -> String {
let mut scopes = self.default_scopes();
scopes.extend_from_slice(extra_scopes);
let scope_str = scopes.join(" ");
format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&access_type=offline",
Self::AUTH_URL,
url_encode(&self.client_id),
url_encode(redirect_uri),
url_encode(&scope_str),
url_encode(state),
url_encode(pkce_challenge),
)
}
fn exchange_code(
&self,
code: &str,
pkce_verifier: &str,
redirect_uri: &str,
) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("grant_type", "authorization_code");
params.insert("client_id", &self.client_id);
params.insert("client_secret", &self.client_secret);
params.insert("code", code);
params.insert("code_verifier", pkce_verifier);
params.insert("redirect_uri", redirect_uri);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("grant_type", "refresh_token");
params.insert("client_id", &self.client_id);
params.insert("client_secret", &self.client_secret);
params.insert("refresh_token", refresh_token);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn default_scopes(&self) -> Vec<&'static str> {
vec!["openid", "email", "profile"]
}
}
// ── AppleOAuth ────────────────────────────────────────────────────────────────
/// Apple Sign In OAuth provider.
///
/// Endpoints:
/// - Auth: `https://appleid.apple.com/auth/authorize`
/// - Token: `https://appleid.apple.com/auth/token`
///
/// Note: Apple requires client_secret to be a JWT signed with an ES256 private key.
/// In production, generate this JWT from your Apple team ID, key ID, and .p8 file.
#[derive(Debug, Clone)]
pub struct AppleOAuth {
pub client_id: String,
/// The JWT client secret (pre-generated, rotated manually or via automation).
pub client_secret_jwt: String,
pub team_id: String,
}
impl AppleOAuth {
pub fn new(
client_id: impl Into<String>,
client_secret_jwt: impl Into<String>,
team_id: impl Into<String>,
) -> Self {
Self {
client_id: client_id.into(),
client_secret_jwt: client_secret_jwt.into(),
team_id: team_id.into(),
}
}
const AUTH_URL: &'static str = "https://appleid.apple.com/auth/authorize";
const TOKEN_URL: &'static str = "https://appleid.apple.com/auth/token";
}
impl OAuthProvider for AppleOAuth {
fn name(&self) -> &'static str {
"apple"
}
fn authorization_url(
&self,
redirect_uri: &str,
pkce_challenge: &str,
state: &str,
extra_scopes: &[&str],
) -> String {
let mut scopes = self.default_scopes();
scopes.extend_from_slice(extra_scopes);
let scope_str = scopes.join(" ");
format!(
"{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}&code_challenge={}&code_challenge_method=S256&response_mode=form_post",
Self::AUTH_URL,
url_encode(&self.client_id),
url_encode(redirect_uri),
url_encode(&scope_str),
url_encode(state),
url_encode(pkce_challenge),
)
}
fn exchange_code(
&self,
code: &str,
pkce_verifier: &str,
redirect_uri: &str,
) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("grant_type", "authorization_code");
params.insert("client_id", &self.client_id);
params.insert("client_secret", &self.client_secret_jwt);
params.insert("code", code);
params.insert("code_verifier", pkce_verifier);
params.insert("redirect_uri", redirect_uri);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, refresh_token: &str) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("grant_type", "refresh_token");
params.insert("client_id", &self.client_id);
params.insert("client_secret", &self.client_secret_jwt);
params.insert("refresh_token", refresh_token);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn default_scopes(&self) -> Vec<&'static str> {
vec!["openid", "email", "name"]
}
}
// ── GithubOAuth ───────────────────────────────────────────────────────────────
/// GitHub OAuth 2.0 provider.
///
/// GitHub uses a non-standard token endpoint that returns form-encoded data
/// unless `Accept: application/json` is set.
///
/// Endpoints:
/// - Auth: `https://github.com/login/oauth/authorize`
/// - Token: `https://github.com/login/oauth/access_token`
///
/// Note: GitHub does not support PKCE — the `pkce_verifier` is ignored.
#[derive(Debug, Clone)]
pub struct GithubOAuth {
pub client_id: String,
pub client_secret: String,
}
impl GithubOAuth {
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
client_secret: client_secret.into(),
}
}
const AUTH_URL: &'static str = "https://github.com/login/oauth/authorize";
const TOKEN_URL: &'static str = "https://github.com/login/oauth/access_token";
}
impl OAuthProvider for GithubOAuth {
fn name(&self) -> &'static str {
"github"
}
fn authorization_url(
&self,
redirect_uri: &str,
_pkce_challenge: &str, // GitHub does not support PKCE
state: &str,
extra_scopes: &[&str],
) -> String {
let mut scopes = self.default_scopes();
scopes.extend_from_slice(extra_scopes);
let scope_str = scopes.join(" ");
format!(
"{}?client_id={}&redirect_uri={}&scope={}&state={}",
Self::AUTH_URL,
url_encode(&self.client_id),
url_encode(redirect_uri),
url_encode(&scope_str),
url_encode(state),
)
}
fn exchange_code(
&self,
code: &str,
_pkce_verifier: &str, // GitHub does not support PKCE
redirect_uri: &str,
) -> IdentityResult<TokenSet> {
let mut params = HashMap::new();
params.insert("client_id", self.client_id.as_str());
params.insert("client_secret", self.client_secret.as_str());
params.insert("code", code);
params.insert("redirect_uri", redirect_uri);
let resp = post_token_request(Self::TOKEN_URL, &params)?;
parse_token_response(&resp)
}
fn refresh_token(&self, _refresh_token: &str) -> IdentityResult<TokenSet> {
// GitHub access tokens don't expire by default and don't have refresh tokens.
Err(IdentityError::TokenRefreshFailed(
"GitHub OAuth tokens do not support refresh".into(),
))
}
fn default_scopes(&self) -> Vec<&'static str> {
vec!["user:email", "read:user"]
}
}
// ── URL encoding ──────────────────────────────────────────────────────────────
fn url_encode(input: &str) -> String {
let mut out = String::new();
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
b' ' => out.push('+'),
b => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
-120
View File
@@ -1,120 +0,0 @@
//! Session management — sessions are Engram graph nodes connected to User nodes.
//!
//! Session lifecycle:
//! 1. `SessionManager::create()` → Session node + `User ──has_session──▶ Session` edge
//! 2. `SessionManager::validate()` → find Session node, check expiry
//! 3. `SessionManager::invalidate()` → delete Session node (edges auto-removed)
//!
//! The `SessionManager` works exclusively through the `EngramClient` trait — no
//! in-memory map, no cache. The graph is the source of truth.
use crate::{
engram::EngramClient,
error::{IdentityError, IdentityResult},
nodes::{Session, EDGE_HAS_SESSION, NODE_SESSION},
};
use std::sync::Arc;
/// Manages session nodes in the Engram identity graph.
pub struct SessionManager {
client: Arc<dyn EngramClient>,
/// Default session TTL in seconds (default: 3600 = 1 hour).
pub default_ttl_seconds: i64,
}
impl SessionManager {
pub fn new(client: Arc<dyn EngramClient>) -> Self {
Self {
client,
default_ttl_seconds: 3600,
}
}
pub fn with_ttl(mut self, seconds: i64) -> Self {
self.default_ttl_seconds = seconds;
self
}
/// Create a new session for the given user.
///
/// Stores the Session node in Engram and creates a `has_session` edge
/// from the User node to the Session node.
///
/// Returns the session ID string.
pub fn create(
&self,
user_id: uuid::Uuid,
ip_address: Option<String>,
) -> IdentityResult<Session> {
let session = Session::new(user_id, self.default_ttl_seconds, ip_address);
let session_id_str = session.id.to_string();
// Store session node
self.client
.create_node(NODE_SESSION, session.to_value())
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
// Edge: User → Session (has_session)
self.client
.create_edge(&user_id.to_string(), &session_id_str, EDGE_HAS_SESSION)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
Ok(session)
}
/// Validate a session by ID.
///
/// Returns the `Session` node if found and not expired.
/// Automatically deletes expired sessions on lookup (lazy expiry).
pub fn validate(&self, session_id: &str) -> IdentityResult<Session> {
let node = self
.client
.get_node(session_id)
.map_err(|e| IdentityError::GraphError(e.to_string()))?
.ok_or(IdentityError::SessionNotFound)?;
let session = Session::from_value(&node)
.ok_or_else(|| IdentityError::GraphError("session node parse failed".into()))?;
if session.is_expired() {
// Lazy cleanup — remove expired session from graph
let _ = self.client.delete_node(session_id);
return Err(IdentityError::SessionExpired);
}
Ok(session)
}
/// Invalidate (delete) a session node from the graph.
pub fn invalidate(&self, session_id: &str) -> IdentityResult<()> {
self.client
.delete_node(session_id)
.map_err(|e| IdentityError::GraphError(e.to_string()))
}
/// List all sessions for a user by traversing `has_session` edges.
pub fn list_for_user(&self, user_id: &str) -> IdentityResult<Vec<Session>> {
let nodes = self
.client
.find_connected(user_id, EDGE_HAS_SESSION)
.map_err(|e| IdentityError::GraphError(e.to_string()))?;
let sessions: Vec<Session> = nodes
.iter()
.filter_map(Session::from_value)
.filter(|s| !s.is_expired())
.collect();
Ok(sessions)
}
/// Invalidate all sessions for a user (logout everywhere).
pub fn invalidate_all_for_user(&self, user_id: &str) -> IdentityResult<usize> {
let sessions = self.list_for_user(user_id)?;
let count = sessions.len();
for session in &sessions {
let _ = self.client.delete_node(&session.id.to_string());
}
Ok(count)
}
}
-563
View File
@@ -1,563 +0,0 @@
//! Comprehensive tests for el-identity.
//!
//! All tests use `MockEngramClient` — no running Engram instance needed.
#[cfg(test)]
mod tests {
use std::sync::Arc;
use uuid::Uuid;
use crate::{
context::IdentityContext,
engram::{EngramClient, MockEngramClient},
error::IdentityError,
guard::AuthGuard,
nodes::{
OAuthToken, Role, Scope, Session, User,
EDGE_GRANTS, EDGE_HAS_ROLE, EDGE_HAS_SESSION,
NODE_USER, NODE_ROLE, NODE_SCOPE,
},
oauth::{PkceChallenge, hash_token},
provider::{GoogleOAuth, AppleOAuth, GithubOAuth, OAuthProvider},
session::SessionManager,
};
// ── Test helpers ──────────────────────────────────────────────────────────
fn make_client() -> Arc<MockEngramClient> {
Arc::new(MockEngramClient::new())
}
fn make_session_manager(client: Arc<MockEngramClient>) -> Arc<SessionManager> {
Arc::new(SessionManager::new(client as Arc<dyn crate::EngramClient>))
}
fn setup_auth_guard() -> (Arc<MockEngramClient>, Arc<SessionManager>, AuthGuard) {
let client = make_client();
let sm = make_session_manager(client.clone());
let guard = AuthGuard::new(
client.clone() as Arc<dyn crate::EngramClient>,
sm.clone(),
);
(client, sm, guard)
}
fn make_user() -> User {
User::new("alice@example.com", "Alice")
}
fn make_role_admin() -> Role {
Role::new("admin")
.with_permissions(vec!["users:read", "users:write", "orders:delete"])
}
fn make_role_viewer() -> Role {
Role::new("viewer")
.with_permissions(vec!["users:read", "orders:read"])
}
fn make_scope_email() -> Scope {
Scope::new("email", "Access email address")
}
fn make_scope_profile() -> Scope {
Scope::new("profile", "Access profile information")
}
// ── Node tests ────────────────────────────────────────────────────────────
// Test 1: User node creation and serialization round-trip
#[test]
fn test_user_node_round_trip() {
let user = User::new("bob@example.com", "Bob");
let value = user.to_value();
let decoded = User::from_value(&value).expect("should decode User");
assert_eq!(decoded.email, "bob@example.com");
assert_eq!(decoded.display_name, "Bob");
assert_eq!(decoded.id, user.id);
}
// Test 2: Role node with permissions serializes correctly
#[test]
fn test_role_node_permissions() {
let role = make_role_admin();
assert!(role.has_permission("users:write"));
assert!(role.has_permission("orders:delete"));
assert!(!role.has_permission("superadmin"));
let value = role.to_value();
let decoded = Role::from_value(&value).expect("should decode Role");
assert_eq!(decoded.name, "admin");
assert_eq!(decoded.permissions.len(), 3);
}
// Test 3: Scope node round-trip
#[test]
fn test_scope_node_round_trip() {
let scope = make_scope_email();
let value = scope.to_value();
let decoded = Scope::from_value(&value).expect("should decode Scope");
assert_eq!(decoded.name, "email");
assert_eq!(decoded.description, "Access email address");
}
// Test 4: Session expiry detection
#[test]
fn test_session_expiry() {
let user_id = Uuid::new_v4();
// TTL = -1 → already expired
let session = Session::new(user_id, -1, None);
assert!(session.is_expired(), "session with negative TTL should be expired");
let fresh = Session::new(user_id, 3600, None);
assert!(!fresh.is_expired(), "fresh session should not be expired");
}
// Test 5: OAuthToken expiry detection
#[test]
fn test_oauth_token_expiry() {
use chrono::{Duration, Utc};
let past = Utc::now() - Duration::seconds(1);
let token = OAuthToken::new("google", "hash123", None, past, vec![]);
assert!(token.is_expired());
let future = Utc::now() + Duration::seconds(3600);
let fresh = OAuthToken::new("google", "hash456", None, future, vec!["email".into()]);
assert!(!fresh.is_expired());
assert!(fresh.has_scope("email"));
assert!(!fresh.has_scope("openid"));
}
// ── MockEngramClient tests ─────────────────────────────────────────────────
// Test 6: MockEngramClient create and retrieve node
#[test]
fn test_mock_client_create_and_get() {
let client = make_client();
let user = make_user();
let id = client.create_node(NODE_USER, user.to_value()).unwrap();
assert_eq!(id, user.id.to_string());
let retrieved = client.get_node(&id).unwrap().expect("should find node");
let decoded = User::from_value(&retrieved).expect("should decode");
assert_eq!(decoded.email, user.email);
}
// Test 7: MockEngramClient get_node returns None for unknown ID
#[test]
fn test_mock_client_get_unknown_returns_none() {
let client = make_client();
let result = client.get_node("nonexistent-id").unwrap();
assert!(result.is_none());
}
// Test 8: MockEngramClient find_nodes with query
#[test]
fn test_mock_client_find_nodes() {
let client = make_client();
let u1 = User::new("alice@example.com", "Alice");
let u2 = User::new("bob@example.com", "Bob");
client.create_node(NODE_USER, u1.to_value()).unwrap();
client.create_node(NODE_USER, u2.to_value()).unwrap();
let query = serde_json::json!({"email": "alice@example.com"});
let results = client.find_nodes(NODE_USER, query).unwrap();
assert_eq!(results.len(), 1);
let found = User::from_value(&results[0]).unwrap();
assert_eq!(found.display_name, "Alice");
}
// Test 9: MockEngramClient delete_node removes node and edges
#[test]
fn test_mock_client_delete_node() {
let client = make_client();
let user = make_user();
let role = make_role_admin();
client.create_node(NODE_USER, user.to_value()).unwrap();
client.create_node(NODE_ROLE, role.to_value()).unwrap();
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
assert_eq!(client.count_edges(EDGE_HAS_ROLE), 1);
client.delete_node(&user.id.to_string()).unwrap();
assert!(client.get_node(&user.id.to_string()).unwrap().is_none());
// Edge should also be gone
assert_eq!(client.count_edges(EDGE_HAS_ROLE), 0);
}
// Test 10: MockEngramClient find_connected traverses edges
#[test]
fn test_mock_client_find_connected() {
let client = make_client();
let user = make_user();
let role1 = make_role_admin();
let role2 = make_role_viewer();
client.create_node(NODE_USER, user.to_value()).unwrap();
client.create_node(NODE_ROLE, role1.to_value()).unwrap();
client.create_node(NODE_ROLE, role2.to_value()).unwrap();
client.create_edge(&user.id.to_string(), &role1.id.to_string(), EDGE_HAS_ROLE).unwrap();
client.create_edge(&user.id.to_string(), &role2.id.to_string(), EDGE_HAS_ROLE).unwrap();
let roles = client.find_connected(&user.id.to_string(), EDGE_HAS_ROLE).unwrap();
assert_eq!(roles.len(), 2);
}
// ── SessionManager tests ───────────────────────────────────────────────────
// Test 11: SessionManager creates session and connects to user
#[test]
fn test_session_manager_create() {
let client = make_client();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let sm = make_session_manager(client.clone());
let session = sm.create(user.id, Some("127.0.0.1".into())).unwrap();
assert_eq!(session.user_id, user.id);
assert_eq!(session.ip_address, Some("127.0.0.1".to_string()));
// Should have created a has_session edge
assert_eq!(client.count_edges(EDGE_HAS_SESSION), 1);
}
// Test 12: SessionManager validate succeeds for fresh session
#[test]
fn test_session_manager_validate_fresh() {
let client = make_client();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let sm = make_session_manager(client.clone());
let session = sm.create(user.id, None).unwrap();
let validated = sm.validate(&session.id.to_string()).unwrap();
assert_eq!(validated.id, session.id);
}
// Test 13: SessionManager validate fails for expired session
#[test]
fn test_session_manager_validate_expired() {
let client = make_client();
let sm = Arc::new(SessionManager::new(client.clone() as Arc<dyn crate::EngramClient>).with_ttl(-1));
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let session = sm.create(user.id, None).unwrap();
let result = sm.validate(&session.id.to_string());
assert!(matches!(result, Err(IdentityError::SessionExpired)));
}
// Test 14: SessionManager validate fails for missing session
#[test]
fn test_session_manager_validate_missing() {
let client = make_client();
let sm = make_session_manager(client.clone());
let result = sm.validate("nonexistent-session-id");
assert!(matches!(result, Err(IdentityError::SessionNotFound)));
}
// Test 15: SessionManager invalidate removes session
#[test]
fn test_session_manager_invalidate() {
let client = make_client();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let sm = make_session_manager(client.clone());
let session = sm.create(user.id, None).unwrap();
let session_id = session.id.to_string();
sm.invalidate(&session_id).unwrap();
assert!(matches!(sm.validate(&session_id), Err(IdentityError::SessionNotFound)));
}
// Test 16: SessionManager list_for_user returns active sessions
#[test]
fn test_session_manager_list_for_user() {
let client = make_client();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let sm = make_session_manager(client.clone());
sm.create(user.id, Some("1.1.1.1".into())).unwrap();
sm.create(user.id, Some("2.2.2.2".into())).unwrap();
let sessions = sm.list_for_user(&user.id.to_string()).unwrap();
assert_eq!(sessions.len(), 2);
}
// ── AuthGuard tests ───────────────────────────────────────────────────────
// Test 17: AuthGuard authenticates user with role and scope
#[test]
fn test_auth_guard_full_resolution() {
let (client, sm, guard) = setup_auth_guard();
let user = make_user();
let role = make_role_admin();
let scope = make_scope_email();
// Register user, role, scope, and wire edges
client.create_node(NODE_USER, user.to_value()).unwrap();
client.create_node(NODE_ROLE, role.to_value()).unwrap();
client.create_node(NODE_SCOPE, scope.to_value()).unwrap();
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
client.create_edge(&role.id.to_string(), &scope.id.to_string(), EDGE_GRANTS).unwrap();
// Create session
let session = sm.create(user.id, None).unwrap();
// Authenticate
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
assert_eq!(ctx.user.email, "alice@example.com");
assert_eq!(ctx.roles.len(), 1);
assert!(ctx.has_role("admin"));
assert!(ctx.has_scope("email"));
assert!(ctx.has_permission("users:write"));
}
// Test 18: AuthGuard rejects unknown session
#[test]
fn test_auth_guard_rejects_unknown_session() {
let (_, _, guard) = setup_auth_guard();
let result = guard.authenticate("no-such-session");
assert!(matches!(result, Err(IdentityError::SessionNotFound)));
}
// Test 19: AuthGuard require_role succeeds for correct role
#[test]
fn test_auth_guard_require_role_success() {
let (client, sm, guard) = setup_auth_guard();
let user = make_user();
let role = make_role_admin();
client.create_node(NODE_USER, user.to_value()).unwrap();
client.create_node(NODE_ROLE, role.to_value()).unwrap();
client.create_edge(&user.id.to_string(), &role.id.to_string(), EDGE_HAS_ROLE).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
assert!(guard.require_role(&ctx, "admin").is_ok());
}
// Test 20: AuthGuard require_role fails for missing role
#[test]
fn test_auth_guard_require_role_forbidden() {
let (client, sm, guard) = setup_auth_guard();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
let result = guard.require_role(&ctx, "superadmin");
assert!(matches!(result, Err(IdentityError::Forbidden(_))));
}
// Test 21: AuthGuard require_scope fails for missing scope
#[test]
fn test_auth_guard_require_scope_forbidden() {
let (client, sm, guard) = setup_auth_guard();
let user = make_user();
client.create_node(NODE_USER, user.to_value()).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
let result = guard.require_scope(&ctx, "admin:write");
assert!(matches!(result, Err(IdentityError::ScopeForbidden(_))));
}
// Test 22: AuthGuard register_user and assign_role
#[test]
fn test_auth_guard_register_and_assign_role() {
let (_client, sm, guard) = setup_auth_guard();
let user = make_user();
let role = make_role_viewer();
guard.register_user(&user).unwrap();
guard.assign_role(&user.id.to_string(), &role).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
assert!(ctx.has_role("viewer"));
assert!(ctx.has_permission("users:read"));
assert!(!ctx.has_permission("orders:delete"));
}
// ── PKCE tests ────────────────────────────────────────────────────────────
// Test 23: PkceChallenge generates valid verifier/challenge pair
#[test]
fn test_pkce_challenge_generates_valid_pair() {
let pkce = PkceChallenge::generate();
assert!(!pkce.verifier.is_empty(), "verifier should not be empty");
assert!(!pkce.challenge.is_empty(), "challenge should not be empty");
assert_ne!(pkce.verifier, pkce.challenge, "verifier and challenge must differ");
assert_eq!(pkce.method, "S256");
}
// Test 24: PkceChallenge verify matches correct verifier
#[test]
fn test_pkce_challenge_verify_correct() {
let pkce = PkceChallenge::generate();
assert!(pkce.verify(&pkce.verifier.clone()), "should verify correct verifier");
}
// Test 25: PkceChallenge verify rejects wrong verifier
#[test]
fn test_pkce_challenge_verify_rejects_wrong() {
let pkce = PkceChallenge::generate();
assert!(!pkce.verify("wrong-verifier-value"), "should reject wrong verifier");
}
// Test 26: hash_token is deterministic
#[test]
fn test_hash_token_deterministic() {
let token = "my-secret-access-token";
let h1 = hash_token(token);
let h2 = hash_token(token);
assert_eq!(h1, h2, "hash must be deterministic");
assert_ne!(h1, token, "hash must differ from input");
assert_eq!(h1.len(), 64, "SHA-256 hex output is 64 chars");
}
// ── Provider tests ────────────────────────────────────────────────────────
// Test 27: GoogleOAuth builds correct authorization URL
#[test]
fn test_google_oauth_authorization_url() {
let provider = GoogleOAuth::new("client-id-123", "secret");
let url = provider.authorization_url(
"https://myapp.com/callback",
"pkce-challenge-abc",
"state-xyz",
&[],
);
assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth"));
assert!(url.contains("client_id=client-id-123"));
assert!(url.contains("code_challenge=pkce-challenge-abc"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("state=state-xyz"));
assert!(url.contains("access_type=offline"), "Google needs offline for refresh tokens");
}
// Test 28: GoogleOAuth default scopes include openid, email, profile
#[test]
fn test_google_oauth_default_scopes() {
let provider = GoogleOAuth::new("id", "secret");
let scopes = provider.default_scopes();
assert!(scopes.contains(&"openid"));
assert!(scopes.contains(&"email"));
assert!(scopes.contains(&"profile"));
}
// Test 29: AppleOAuth builds correct authorization URL with form_post
#[test]
fn test_apple_oauth_authorization_url() {
let provider = AppleOAuth::new("com.example.app", "jwt-secret", "TEAMID");
let url = provider.authorization_url(
"https://myapp.com/callback",
"challenge",
"state",
&[],
);
assert!(url.starts_with("https://appleid.apple.com/auth/authorize"));
assert!(url.contains("response_mode=form_post"), "Apple requires form_post");
}
// Test 30: GithubOAuth ignores PKCE in URL (GitHub doesn't support it)
#[test]
fn test_github_oauth_no_pkce_in_url() {
let provider = GithubOAuth::new("gh-client-id", "gh-secret");
let url = provider.authorization_url(
"https://myapp.com/callback",
"pkce-challenge-ignored",
"state",
&[],
);
assert!(url.starts_with("https://github.com/login/oauth/authorize"));
assert!(!url.contains("code_challenge"), "GitHub doesn't support PKCE");
assert!(url.contains("scope=user%3Aemail+read%3Auser") || url.contains("scope="));
}
// Test 31: GithubOAuth refresh_token returns error
#[test]
fn test_github_oauth_refresh_returns_error() {
let provider = GithubOAuth::new("id", "secret");
let result = provider.refresh_token("some-refresh-token");
assert!(matches!(result, Err(IdentityError::TokenRefreshFailed(_))));
}
// ── IdentityContext tests ─────────────────────────────────────────────────
// Test 32: IdentityContext role_names and scope_names
#[test]
fn test_identity_context_names() {
let user = make_user();
let session = Session::new(user.id, 3600, None);
let roles = vec![make_role_admin(), make_role_viewer()];
let scopes = vec![make_scope_email(), make_scope_profile()];
let ctx = IdentityContext::new(user.clone(), session, roles, scopes);
let mut role_names = ctx.role_names();
role_names.sort();
assert_eq!(role_names, vec!["admin", "viewer"]);
let mut scope_names = ctx.scope_names();
scope_names.sort();
assert_eq!(scope_names, vec!["email", "profile"]);
}
// Test 33: IdentityContext has_permission across multiple roles
#[test]
fn test_identity_context_permission_across_roles() {
let user = make_user();
let session = Session::new(user.id, 3600, None);
let roles = vec![make_role_viewer()]; // viewer has users:read and orders:read
let ctx = IdentityContext::new(user, session, roles, vec![]);
assert!(ctx.has_permission("users:read"));
assert!(ctx.has_permission("orders:read"));
assert!(!ctx.has_permission("users:write"));
assert!(!ctx.has_permission("orders:delete"));
}
// Test 34: AuthGuard assign_scope_to_role wires scope into context
#[test]
fn test_auth_guard_assign_scope_to_role() {
let (_client, sm, guard) = setup_auth_guard();
let user = make_user();
let role = make_role_admin();
let scope = make_scope_profile();
guard.register_user(&user).unwrap();
guard.assign_role(&user.id.to_string(), &role).unwrap();
guard.assign_scope_to_role(&role, &scope).unwrap();
let session = sm.create(user.id, None).unwrap();
let ctx = guard.authenticate(&session.id.to_string()).unwrap();
assert!(ctx.has_scope("profile"));
}
// Test 35: Two different users have independent sessions
#[test]
fn test_independent_user_sessions() {
let (client, sm, guard) = setup_auth_guard();
let alice = User::new("alice@example.com", "Alice");
let bob = User::new("bob@example.com", "Bob");
let admin_role = make_role_admin();
client.create_node(NODE_USER, alice.to_value()).unwrap();
client.create_node(NODE_USER, bob.to_value()).unwrap();
client.create_node(NODE_ROLE, admin_role.to_value()).unwrap();
// Only Alice gets the admin role
client.create_edge(&alice.id.to_string(), &admin_role.id.to_string(), EDGE_HAS_ROLE).unwrap();
let alice_session = sm.create(alice.id, None).unwrap();
let bob_session = sm.create(bob.id, None).unwrap();
let alice_ctx = guard.authenticate(&alice_session.id.to_string()).unwrap();
let bob_ctx = guard.authenticate(&bob_session.id.to_string()).unwrap();
assert!(alice_ctx.has_role("admin"), "Alice should have admin");
assert!(!bob_ctx.has_role("admin"), "Bob should not have admin");
assert_eq!(alice_ctx.email(), "alice@example.com");
assert_eq!(bob_ctx.email(), "bob@example.com");
}
}