Files
el/ui/vessels/el-auth/src/main.el

202 lines
8.2 KiB
EmacsLisp

// el-auth Built-in authentication and authorization for el-ui.
//
// Engram-aware sessions: server-side invalidation works even with stateless
// JWTs because every session is also a graph node.
//
// Provider trait surface:
// verify(token) -> AuthContext
// issue(user, roles) -> token string
// revoke(token) -> Bool
// Errors
let ERR_INVALID_CREDS: String = "auth.invalid_credentials"
let ERR_TOKEN_EXPIRED: String = "auth.token_expired"
let ERR_TOKEN_INVALID: String = "auth.token_invalid"
let ERR_SESSION_NOT_FOUND: String = "auth.session_not_found"
let ERR_FORBIDDEN: String = "auth.forbidden"
let ERR_CONFIG: String = "auth.config"
// AuthContext + AuthUser
type AuthUser {
id: String
email: String
display_name: String
}
type AuthContext {
user_id: String
email: String
roles: String // JSON array of role names
permissions: String // JSON array of permission strings
session_id: String
issued_at: String
expires_at: String
}
fn auth_context_empty() -> AuthContext {
{ "user_id": "", "email": "", "roles": "[]", "permissions": "[]",
"session_id": "", "issued_at": "", "expires_at": "" }
}
fn auth_context_has_permission(ctx: AuthContext, perm: String) -> Bool {
str_contains(ctx.permissions, "\"" + perm + "\"")
}
fn auth_context_has_role(ctx: AuthContext, role: String) -> Bool {
str_contains(ctx.roles, "\"" + role + "\"")
}
// Roles + permissions
type Permission {
resource: String
action: String
}
fn permission_new(resource: String, action: String) -> Permission {
{ "resource": resource, "action": action }
}
fn permission_string(p: Permission) -> String {
p.resource + ":" + p.action
}
// Role registry maps role name -> JSON array of permission strings.
fn role_registry_grant(registry_path: String, role: String, perm: String) -> Bool {
let raw: String = fs_read(registry_path)
let updated: String = json_array_append(raw, role, "\"" + perm + "\"")
fs_write(registry_path, updated)
}
// JWT (HS256)
type JwtClaims {
sub: String // user id
iss: String // issuer
aud: String // audience
iat: Int // issued at (unix seconds)
exp: Int // expires at (unix seconds)
jti: String // unique token id
}
fn jwt_claims_new(user_id: String, issuer: String, audience: String, ttl_seconds: Int) -> JwtClaims {
let now: Int = time_now_unix()
{ "sub": user_id, "iss": issuer, "aud": audience,
"iat": now, "exp": now + ttl_seconds, "jti": uuid_v4() }
}
fn jwt_encode(claims: JwtClaims, secret: String) -> String {
let header_b64: String = base64url_no_pad("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")
let payload_json: String = json_encode(claims)
let payload_b64: String = base64url_no_pad(payload_json)
let signing_input: String = header_b64 + "." + payload_b64
let sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
signing_input + "." + sig
}
fn jwt_decode(token: String, secret: String) -> AuthContext {
let parts: String = token // [header].[payload].[sig]
let dot1: Int = str_index_of(parts, ".")
if dot1 < 0 { return auth_context_empty() }
let rest: String = str_slice(parts, dot1 + 1, str_len(parts))
let dot2: Int = str_index_of(rest, ".")
if dot2 < 0 { return auth_context_empty() }
let header_b64: String = str_slice(parts, 0, dot1)
let payload_b64: String = str_slice(rest, 0, dot2)
let sig_b64: String = str_slice(rest, dot2 + 1, str_len(rest))
let signing_input: String = header_b64 + "." + payload_b64
let expected_sig: String = base64url_no_pad(hmac_sha256(secret, signing_input))
if !str_eq(expected_sig, sig_b64) { return auth_context_empty() }
let payload_json: String = base64url_decode(payload_b64)
let now: Int = time_now_unix()
let exp: Int = str_to_int(json_get(payload_json, "exp"))
if exp < now { return auth_context_empty() }
{ "user_id": json_get(payload_json, "sub"),
"email": "",
"roles": "[]",
"permissions": "[]",
"session_id": json_get(payload_json, "jti"),
"issued_at": json_get(payload_json, "iat"),
"expires_at": json_get(payload_json, "exp") }
}
// Engram-backed session store
//
// Sessions are nodes of type "Session" connected to User via has_session.
// Revocation = node delete. Verification = node lookup + expiry check.
fn session_store_create(user_id: String, ttl_seconds: Int, ip: String) -> String {
let now: String = time_now_iso()
let exp: String = time_add_seconds(now, ttl_seconds)
let id: String = uuid_v4()
let body: String = "{\"id\":\"" + id + "\",\"user_id\":\"" + user_id
+ "\",\"created_at\":\"" + now + "\",\"expires_at\":\"" + exp
+ "\",\"ip_address\":\"" + ip + "\"}"
let node_id: String = engram_node_create("Session", body)
engram_edge_create(user_id, node_id, "has_session")
node_id
}
fn session_store_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)
}
fn session_store_revoke(session_id: String) -> Bool {
engram_node_delete(session_id)
}
// Middleware
//
// auth_middleware extracts the bearer token, decodes it, and populates the
// AuthContext. Applied automatically by the @authenticate aspect (el-aop).
fn extract_bearer(authorization_header: String) -> String {
if str_starts_with(authorization_header, "Bearer ") {
return str_slice(authorization_header, 7, str_len(authorization_header))
}
""
}
fn auth_middleware(authorization_header: String, jwt_secret: String) -> AuthContext {
let token: String = extract_bearer(authorization_header)
if str_eq(token, "") { return auth_context_empty() }
jwt_decode(token, jwt_secret)
}
fn enforce_permission(ctx: AuthContext, required_perm: String) -> Bool {
if str_eq(ctx.user_id, "") { return false }
auth_context_has_permission(ctx, required_perm)
}
// Provider issue/verify
fn provider_issue(user: AuthUser, jwt_secret: String, issuer: String, audience: String, ttl: Int) -> String {
let claims: JwtClaims = jwt_claims_new(user.id, issuer, audience, ttl)
jwt_encode(claims, jwt_secret)
}
fn provider_verify(token: String, jwt_secret: String) -> AuthContext {
let ctx: AuthContext = jwt_decode(token, jwt_secret)
if str_eq(ctx.user_id, "") { return ctx }
if !session_store_verify(ctx.session_id) { return auth_context_empty() }
ctx
}
fn provider_revoke(session_id: String) -> Bool {
session_store_revoke(session_id)
}
// Entry smoke test
let user: AuthUser = { "id": "u-001", "email": "will@neurontechnologies.ai", "display_name": "Will" }
let token: String = provider_issue(user, "test-secret", "el-ui", "el-app", 3600)
println("[el-auth] issued JWT for " + user.email)