202 lines
8.2 KiB
EmacsLisp
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)
|