Files
el/ui/vessels/el-identity/src/main.el
T

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 + ")")