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,22 @@
|
||||
[package]
|
||||
name = "el-identity"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "el-ui Engram-native identity — users, roles, scopes, OAuth, and sessions as graph nodes"
|
||||
license = "MIT"
|
||||
|
||||
[lib]
|
||||
name = "el_identity"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
thiserror = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,24 @@
|
||||
// el-identity — Engram-native identity for el-ui.
|
||||
//
|
||||
// Identity is not a bolt-on. Users, roles, scopes, sessions, and OAuth tokens
|
||||
// are first-class Engram nodes connected by typed edges:
|
||||
//
|
||||
// User ──has_role──▶ Role ──grants──▶ Scope
|
||||
// │
|
||||
// └──has_session──▶ Session ──authenticated_via──▶ OAuthToken
|
||||
|
||||
vessel "el-identity" {
|
||||
version "0.1.0"
|
||||
description "Engram-native identity graph: users, roles, sessions, OAuth"
|
||||
authors ["Will Anderson <will@neurontechnologies.ai>"]
|
||||
edition "2026"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
el-platform "1.0"
|
||||
}
|
||||
|
||||
build {
|
||||
entry "src/main.el"
|
||||
output "dist/"
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! 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())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 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>;
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 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()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,271 @@
|
||||
// el-identity — Engram-native identity for el-ui.
|
||||
//
|
||||
// Identity in el-ui is a graph, not a table. Every entity is a node; every
|
||||
// relationship is an edge. Authentication is spreading activation from a
|
||||
// session token through the identity subgraph until it touches a User node.
|
||||
//
|
||||
// Edges:
|
||||
// User ──has_role──▶ Role ──grants──▶ Scope
|
||||
// User ──has_session──▶ Session ──authenticated_via──▶ OAuthToken
|
||||
|
||||
// ── Edge type constants ──────────────────────────────────────────────────────
|
||||
|
||||
let EDGE_HAS_ROLE: String = "has_role"
|
||||
let EDGE_HAS_SESSION: String = "has_session"
|
||||
let EDGE_AUTHENTICATED_VIA: String = "authenticated_via"
|
||||
let EDGE_GRANTS: String = "grants"
|
||||
|
||||
// ── Node type constants ──────────────────────────────────────────────────────
|
||||
|
||||
let NODE_USER: String = "User"
|
||||
let NODE_ROLE: String = "Role"
|
||||
let NODE_SCOPE: String = "Scope"
|
||||
let NODE_OAUTH_TOKEN: String = "OAuthToken"
|
||||
let NODE_SESSION: String = "Session"
|
||||
|
||||
// ── User ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type User {
|
||||
id: String
|
||||
email: String
|
||||
display_name: String
|
||||
created_at: String
|
||||
}
|
||||
|
||||
fn user_new(email: String, display_name: String) -> User {
|
||||
let now: String = time_now_iso()
|
||||
let id: String = uuid_v4()
|
||||
{ "id": id, "email": email, "display_name": display_name, "created_at": now }
|
||||
}
|
||||
|
||||
// ── Role / Scope ─────────────────────────────────────────────────────────────
|
||||
|
||||
type Role {
|
||||
id: String
|
||||
name: String
|
||||
permissions: String // JSON-encoded array; struct fields are flat in El today
|
||||
}
|
||||
|
||||
type Scope {
|
||||
id: String
|
||||
name: String
|
||||
description: String
|
||||
}
|
||||
|
||||
fn role_new(name: String) -> Role {
|
||||
{ "id": uuid_v4(), "name": name, "permissions": "[]" }
|
||||
}
|
||||
|
||||
fn role_has_permission(role: Role, perm: String) -> Bool {
|
||||
str_contains(role.permissions, "\"" + perm + "\"")
|
||||
}
|
||||
|
||||
fn scope_new(name: String, description: String) -> Scope {
|
||||
{ "id": uuid_v4(), "name": name, "description": description }
|
||||
}
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Session {
|
||||
id: String
|
||||
user_id: String
|
||||
created_at: String
|
||||
expires_at: String
|
||||
ip_address: String
|
||||
}
|
||||
|
||||
fn session_new(user_id: String, ttl_seconds: Int, ip_address: String) -> Session {
|
||||
let now: String = time_now_iso()
|
||||
let exp: String = time_add_seconds(now, ttl_seconds)
|
||||
{ "id": uuid_v4(), "user_id": user_id, "created_at": now, "expires_at": exp, "ip_address": ip_address }
|
||||
}
|
||||
|
||||
fn session_is_expired(session: Session) -> Bool {
|
||||
time_after(time_now_iso(), session.expires_at)
|
||||
}
|
||||
|
||||
// ── OAuthToken ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Tokens are SHA-256 hashed before storage — the raw token never persists.
|
||||
|
||||
type OAuthToken {
|
||||
id: String
|
||||
provider: String
|
||||
access_token_hash: String
|
||||
refresh_token_hash: String
|
||||
expires_at: String
|
||||
scopes: String // JSON-encoded array
|
||||
}
|
||||
|
||||
fn token_hash(raw: String) -> String {
|
||||
sha256_hex(raw)
|
||||
}
|
||||
|
||||
fn oauth_token_new(provider: String, access_raw: String, refresh_raw: String, expires_at: String, scopes: String) -> OAuthToken {
|
||||
let access_h: String = token_hash(access_raw)
|
||||
let refresh_h: String = ""
|
||||
if !str_eq(refresh_raw, "") { let refresh_h = token_hash(refresh_raw) }
|
||||
{ "id": uuid_v4(), "provider": provider, "access_token_hash": access_h,
|
||||
"refresh_token_hash": refresh_h, "expires_at": expires_at, "scopes": scopes }
|
||||
}
|
||||
|
||||
fn oauth_token_is_expired(t: OAuthToken) -> Bool {
|
||||
time_after(time_now_iso(), t.expires_at)
|
||||
}
|
||||
|
||||
// ── PKCE (RFC 7636) ──────────────────────────────────────────────────────────
|
||||
|
||||
type PkceChallenge {
|
||||
verifier: String
|
||||
challenge: String
|
||||
method: String // always "S256"
|
||||
}
|
||||
|
||||
fn pkce_generate() -> PkceChallenge {
|
||||
let verifier: String = base64url_no_pad(random_bytes(32))
|
||||
let chal: String = base64url_no_pad(sha256_bytes(verifier))
|
||||
{ "verifier": verifier, "challenge": chal, "method": "S256" }
|
||||
}
|
||||
|
||||
fn pkce_verify(challenge: String, verifier: String) -> Bool {
|
||||
let computed: String = base64url_no_pad(sha256_bytes(verifier))
|
||||
str_eq(computed, challenge)
|
||||
}
|
||||
|
||||
// ── OAuth providers (Google / GitHub / Apple) ────────────────────────────────
|
||||
//
|
||||
// Stubbed: shape-only. `provider_authorization_url` produces the redirect URL;
|
||||
// `provider_exchange_code` POSTs to the token endpoint via http_post.
|
||||
|
||||
type OAuthProviderCfg {
|
||||
name: String
|
||||
client_id: String
|
||||
client_secret: String
|
||||
auth_url: String
|
||||
token_url: String
|
||||
default_scopes: String
|
||||
}
|
||||
|
||||
fn google_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "google", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"default_scopes": "[\"openid\",\"email\",\"profile\"]" }
|
||||
}
|
||||
|
||||
fn github_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "github", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://github.com/login/oauth/authorize",
|
||||
"token_url": "https://github.com/login/oauth/access_token",
|
||||
"default_scopes": "[\"read:user\",\"user:email\"]" }
|
||||
}
|
||||
|
||||
fn apple_provider(client_id: String, client_secret: String) -> OAuthProviderCfg {
|
||||
{ "name": "apple", "client_id": client_id, "client_secret": client_secret,
|
||||
"auth_url": "https://appleid.apple.com/auth/authorize",
|
||||
"token_url": "https://appleid.apple.com/auth/token",
|
||||
"default_scopes": "[\"name\",\"email\"]" }
|
||||
}
|
||||
|
||||
fn provider_authorization_url(p: OAuthProviderCfg, redirect_uri: String, code_challenge: String, state: String) -> String {
|
||||
p.auth_url + "?response_type=code"
|
||||
+ "&client_id=" + url_encode(p.client_id)
|
||||
+ "&redirect_uri=" + url_encode(redirect_uri)
|
||||
+ "&scope=" + url_encode(json_array_to_space_list(p.default_scopes))
|
||||
+ "&state=" + url_encode(state)
|
||||
+ "&code_challenge=" + url_encode(code_challenge)
|
||||
+ "&code_challenge_method=S256"
|
||||
}
|
||||
|
||||
// ── Engram client interface (graph CRUD) ─────────────────────────────────────
|
||||
//
|
||||
// Identity persists to the local Engram graph. These wrap engram_* runtime
|
||||
// calls (planned). Until that lands, the server-side stub uses a JSON file.
|
||||
|
||||
fn engram_create_node(node_type: String, value_json: String) -> String {
|
||||
engram_node_create(node_type, value_json)
|
||||
}
|
||||
|
||||
fn engram_create_edge(from_id: String, to_id: String, edge_type: String) -> Bool {
|
||||
engram_edge_create(from_id, to_id, edge_type)
|
||||
}
|
||||
|
||||
fn engram_find_connected(node_id: String, edge_type: String) -> String {
|
||||
engram_edge_traverse(node_id, edge_type)
|
||||
}
|
||||
|
||||
// ── OAuth flow coordinator ───────────────────────────────────────────────────
|
||||
|
||||
type AuthFlowParams {
|
||||
pkce_verifier: String
|
||||
pkce_challenge: String
|
||||
redirect_url: String
|
||||
state: String
|
||||
}
|
||||
|
||||
fn begin_auth_flow(provider: OAuthProviderCfg, redirect_uri: String) -> AuthFlowParams {
|
||||
let pkce: PkceChallenge = pkce_generate()
|
||||
let state: String = base64url_no_pad(random_bytes(16))
|
||||
let url: String = provider_authorization_url(provider, redirect_uri, pkce.challenge, state)
|
||||
{ "pkce_verifier": pkce.verifier, "pkce_challenge": pkce.challenge, "redirect_url": url, "state": state }
|
||||
}
|
||||
|
||||
fn exchange_code(provider: OAuthProviderCfg, code: String, pkce_verifier: String, redirect_uri: String, session_id: String) -> OAuthToken {
|
||||
let body: String = "grant_type=authorization_code"
|
||||
+ "&code=" + url_encode(code)
|
||||
+ "&redirect_uri=" + url_encode(redirect_uri)
|
||||
+ "&client_id=" + url_encode(provider.client_id)
|
||||
+ "&client_secret=" + url_encode(provider.client_secret)
|
||||
+ "&code_verifier=" + url_encode(pkce_verifier)
|
||||
let resp: String = http_post(provider.token_url, body)
|
||||
let access: String = json_get(resp, "access_token")
|
||||
let refresh: String = json_get(resp, "refresh_token")
|
||||
let expires_in: Int = str_to_int(json_get(resp, "expires_in"))
|
||||
let exp_at: String = time_add_seconds(time_now_iso(), expires_in)
|
||||
let token: OAuthToken = oauth_token_new(provider.name, access, refresh, exp_at, "[]")
|
||||
let token_id: String = engram_create_node(NODE_OAUTH_TOKEN, json_encode(token))
|
||||
engram_create_edge(session_id, token_id, EDGE_AUTHENTICATED_VIA)
|
||||
token
|
||||
}
|
||||
|
||||
// ── Session manager ──────────────────────────────────────────────────────────
|
||||
|
||||
fn session_create(user_id: String, ttl: Int, ip: String) -> Session {
|
||||
let s: Session = session_new(user_id, ttl, ip)
|
||||
let session_id: String = engram_create_node(NODE_SESSION, json_encode(s))
|
||||
engram_create_edge(user_id, session_id, EDGE_HAS_SESSION)
|
||||
s
|
||||
}
|
||||
|
||||
fn session_revoke(session_id: String) -> Bool {
|
||||
engram_node_delete(session_id)
|
||||
}
|
||||
|
||||
// ── AuthGuard — applied via @authenticate decorator ─────────────────────────
|
||||
|
||||
fn auth_guard_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)
|
||||
}
|
||||
|
||||
// ── Identity context (passed through the request lifecycle) ─────────────────
|
||||
|
||||
type IdentityContext {
|
||||
user_id: String
|
||||
session_id: String
|
||||
roles_json: String
|
||||
}
|
||||
|
||||
fn identity_load(session_id: String) -> IdentityContext {
|
||||
let session_raw: String = engram_node_get(session_id)
|
||||
let user_id: String = json_get(session_raw, "user_id")
|
||||
let roles_raw: String = engram_find_connected(user_id, EDGE_HAS_ROLE)
|
||||
{ "user_id": user_id, "session_id": session_id, "roles_json": roles_raw }
|
||||
}
|
||||
|
||||
// ── Entry — smoke test ──────────────────────────────────────────────────────
|
||||
|
||||
let user: User = user_new("will@neurontechnologies.ai", "Will Anderson")
|
||||
println("[el-identity] user " + user.email + " (" + user.id + ")")
|
||||
@@ -0,0 +1,244 @@
|
||||
//! 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";
|
||||
@@ -0,0 +1,275 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! 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, ¶ms)?;
|
||||
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, ¶ms)?;
|
||||
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, ¶ms)?;
|
||||
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, ¶ms)?;
|
||||
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, ¶ms)?;
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user