272 lines
11 KiB
EmacsLisp
272 lines
11 KiB
EmacsLisp
// 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 + ")")
|