Files
rose-ash/plans/identity-on-sx.md
giles 3f3459d129
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
identity: client-credentials grant (RFC 6749 §4.4, +9 tests)
oauth.sx now owns a client registry (loop/6) with register_client and the
client_credentials grant. A confidential client authenticates and gets a
token acting on its own behalf (subject = the client), no refresh token
(§4.4.3). A public client is unauthorized_client; any auth failure (unknown
client or wrong secret) is invalid_client — no client-existence oracle
(§5.2). identity-load-oauth! now pulls its deps. New tests/grants.sx.
158/158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 02:22:26 +00:00

14 KiB

identity-on-sx: OAuth2, sessions & membership on Erlang

DRAFT outline. The identity core acl-on-sx assumes already exists. acl answers "may X do Y"; identity answers "who is X, and how did they prove it." Depends on persist-on-sx (grant/audit ledger). Pairs with acl-on-sx.

rose-ash's account domain is the OAuth2 authorization server every other app is a client of: silent SSO, per-app first-party cookies, grant verification, membership. Sessions and grants are long-lived, concurrent, individually addressable, and expire on their own — that is the actor model. Erlang's processes + mailboxes map cleanly: a session is a process, token issue/refresh/ revoke are messages, expiry is a process timeout, and SSO is one process answering many apps.

End-state: an Erlang-on-SX layer with the OAuth2 authorization-code + silent (prompt=none) flows as message protocols, a session/grant registry, token lifecycle (issue/refresh/revoke/introspect), and membership state — all auditable through the event log, all authorization questions delegated to acl-on-sx.

Status (rolling)

bash lib/identity/conformance.sh158/158 (4 phases + ext: scope, TTL, clients, client-creds)

Ground rules

  • Scope: only lib/identity/** and plans/identity-on-sx.md. May import from lib/erlang/, and (once they exist) lib/persist/ + lib/acl/. Do not edit substrates.
  • Architecture: a session/grant is a process holding its own state; the registry routes messages by subject/client id. Tokens are opaque + introspected, not self-validating (revocation must be real). Authorization decisions are NOT made here — identity proves identity, acl decides permission.
  • Security: revocation is immediate (kill the process / tombstone the grant); no decision relies on a token that outlived its grant. Negative answers are explicit, never "absence of a yes."
  • Commits: one feature per commit. Progress log + tick boxes.

Architecture sketch

Auth request                            Token / session
  (authorize client scope subject)        {:access :refresh :expires :grant}
        │                                       ▲
        ▼                                       │
lib/identity/oauth.sx                   lib/identity/token.sx
  — authz-code + prompt=none flows        — issue / refresh / revoke / introspect
  — as Erlang message protocols           — opaque tokens, grant-backed
        │                                       ▲
        ▼                                       │
lib/identity/session.sx                 lib/identity/registry.sx
  — session = process, expiry=timeout     — route by subject/client; SSO fan-out
        │                                       │
        ▼                                       ▼
lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) ──┐
        │                                                                      │
        └──────── grant + audit events → persist ;  permission? → acl ──────────┘

Phase 1 — Sessions + tokens

  • session.sx — session process, create/lookup/expire
  • token.sx — issue/introspect/revoke (opaque, grant-backed)
  • registry.sx — route by subject/client
  • api.sx + tests + scoreboard + conformance.sh

Phase 2 — OAuth2 flows

  • authorization-code flow as a message protocol
  • refresh + rotation; revocation cascades to issued tokens
  • tests: full code exchange, refresh, revoke-then-use (must fail)

Phase 3 — Silent SSO + membership

  • prompt=none cross-app login (one session, many clients)
  • membership state + per-app grant projection
  • grant verification delegated cache (mirror Redis-cache pattern)

Phase 4 — Audit + federation

  • every issue/refresh/revoke is a persist event; (identity/audit subject)
  • federated identity (peer-asserted subject) — advisory, trust-gated stub
  • tests: audit completeness, cross-instance subject mapping

Extensions (base roadmap complete; deepen the engine)

  • [~] PKCE S256 method (RFC 7636 §4.2) — BLOCKED on erlang substrate (see Blockers)
  • access-token TTL / expires_in — logical-clock expiry, introspect honours it
  • scope as a set + scope narrowing on refresh (RFC 6749 §6)
  • client registry: public vs confidential clients, client authentication (RFC 6749 §2)
  • [~] client-credentials grant (RFC 6749 §4.4) DONE; device grant (RFC 8628) pending
  • acl-on-sx delegation: wire verify/membership projection → an acl decision, integration test
  • OAuth state (CSRF) + OIDC nonce threaded through authorize→exchange
  • unify api.sx over oauth + membership + audit (one facade, audited login/consent)

Progress log

  • 2026-06-07 — client-credentials grant (ext, RFC 6749 §4.4): oauth.sx now owns a client registry (loop/6); register_client + client_credentials. A confidential client authenticates and gets a token acting on its own behalf (subject = the client), no refresh token (§4.4.3). A public client is unauthorized_client; any auth failure (unknown client OR wrong secret) is invalid_client — no client-existence oracle (§5.2). identity-load-oauth! now pulls its deps (token/session/registry/clients). New tests/grants.sx (9). 149→158.
  • 2026-06-07 — clients.sx (ext): OAuth client registry (RFC 6749 §2). public vs confidential clients; confidential clients MUST present the right secret (wrong → invalid_client), public clients are identified but not authenticated; redirect_uris are allow-listed with exact-match valid_redirect (§3.1.2.2 + Security BCP). Standalone module (no oauth wiring yet — that's a follow-up). New tests/clients.sx (11). 138→149.
  • 2026-06-07 — access-token expiry (ext): logical clock in the token registry (advance/now; no wall clock in substrate). Grants carry a Ttl; each access token carries an Expires (Now-at-issue + Ttl, or infinity); introspect returns inactive once Now reaches it. Refresh mints a fresh short-lived access token (new Expires) — short access tokens, long refresh tokens. issue/4
    • issue_grant/4 default to infinity, so all prior tests unchanged. New tests/expiry.sx (8). token loop/6. 130→138.
  • 2026-06-07 — scope narrowing (ext): each access token now carries its own EFFECTIVE scope (<= the grant's max). refresh/3 requests a narrower scope; the request must be a subset of the grant scope (RFC 6749 §6) else {error, invalid_scope} and the refresh token is NOT consumed (client may retry, §5.2). refresh/2 keeps full scope; scope stays opaque (atom or list) for issue, so all prior atom-scope tests pass unchanged. token 18→24, 130/130. Also filed Blocker: PKCE S256 needs SHA256+binary compare, both broken in the erlang substrate (binary =:= always true; crypto:hash ignores binary content) — deferred, plain method stays.
  • 2026-06-07 — federation.sx: trust-gated, advisory federated identity. A peer assertion is accepted only from an explicitly trusted peer (else {error, untrusted}) and is flagged {peer_asserted, Peer}, never promoted to local authority — acl decides what it may do. Cross-instance subject mapping namespaces remote subjects by peer ({federated, Peer, Remote}) so two peers' "alice" never collide, with optional explicit aliasing. Added an audit-completeness test (mixed transition stream → no event dropped). New tests/federation.sx (12). Phase 4 complete — all four phases done. +13 → 124/124.
  • 2026-06-07 — audit.sx: append-only grant audit ledger (an Erlang process). token.sx gains start/1(Audit) and emits issue/refresh/revoke events (incl. reuse-triggered revoke); start/0 stays unaudited (no regression — token.sx has no compile-time dep on the audit module, just sends to a pid). Ledger queryable per subject — audit/actions/count/ all, chronological. In-memory event stream (persist-backing is a future Erlang↔persist bridge, out of scope per loop allowance). New tests/audit.sx (10). +10 → 111/111.
  • 2026-06-07 — cache.sx: delegated grant-verification cache (Redis-cache pattern) wrapping the token registry. introspect memoised; generation invalidation keeps revocation real — any revoke/refresh bumps a generation counter so every cached positive instantly becomes a miss and re-validates against the live registry. A revoked token never reads valid from cache. stats() exposes hits/misses. New tests/cache.sx (9). Phase 3 complete. +9 → 101/101.
  • 2026-06-07 — membership.sx: coop membership as a guarded state machine (none→pending→active→lapsed⇄active, any→revoked terminal); invalid transitions are explicit {error, CurrentStatus}. project(Subject, App) renders the one canonical state into a per-app claim ({member,Tier,App}/{pending,App}/{lapsed,App}/{denied,App}/{non_member,App}) — identity reports what; acl decides whether. New tests/membership.sx (17). +17 → 92/92.
  • 2026-06-07 — silent SSO (prompt=none, OIDC §3.1.2.1): oauth.sx now owns a session registry; establish creates a subject session, silent_authorize asks "does this subject have a live session?" → mints a code (skipping consent) bound to client+redirect+PKCE, else login_required. Same machine, fast-path — one session, many clients; end_session closes the path. New tests/sso.sx (10). +10 → 75/75.
  • 2026-06-07 — oauth.sx refresh wiring + e2e: exchange now issues an access+refresh pair (RFC 6749 §4.1.4/§5.1) via token.sx issue_grant; added the refresh grant (§6) delegating to token rotation. End-to-end tests: code-exchange→refresh→introspect, refresh-reuse rejected, and revoke-then-refresh blocked by cascade. Phase 2 complete. +3 → oauth 17, 65/65.
  • 2026-06-07 — token.sx grant-centric rewrite: refresh-token rotation (RFC 6749 §6) + cascading revocation. The grant {Subject,Client,Scope, Status} is the cascade unit; access + refresh tokens reference it. issue_grant → {ok, Access, Refresh}; refresh supersedes the old refresh + mints a new pair; reusing a superseded refresh token revokes the whole family (RFC 6819 §5.2.2.3), killing the live descendant. revoke of ANY token (access or refresh) cascades to the grant. All prior issue/introspect/revoke behaviour preserved. +9 → token 18, 62/62.
  • 2026-06-07 — oauth.sx: OAuth2 authorization-code flow as a message protocol (RFC 6749 §4.1) + PKCE (RFC 7636, plain). State machine on one authz-server process: authorize → {consent_required} → consent → {code} → exchange → {ok, Token}. Exchange enforces single-use codes (§10.5; removed on first attempt, replay → invalid_grant), client_id + redirect_uri binding (§4.1.3), and PKCE verifier match. Issued tokens are grant-backed so revocation stays real. +14 → 53/53.
  • 2026-06-06 — api.sx: service facade. identity:start() spawns one coordinator owning the token table + session registry; exposes login/verify/revoke/logout/session_status. Coordinator is the sessions' owner, so an expired session deregisters itself (timeout-driven, no sweep). verify answers IDENTITY only ({active, Subject, Client, Scope}); permission is acl's job — explicit delegation boundary. Phase 1 complete. +10 → 39/39.
  • 2026-06-06 — registry.sx: directory process routing sessions by id and by (subject, client). Answers the SSO probe lookup(Subject, Client) and the fan-out sessions_for(Subject) (one subject, many clients). Routes only — holds no grant state. Integration-tested end-to-end: register a live session, route to it, confirm it answers active. +9 → 29/29.
  • 2026-06-06 — token.sx: opaque grant-backed tokens. Token = make_ref (carries no info); the token table is a process; introspect is a live lookup every time so revocation is real (RFC 7009) — a revoked token reads {inactive} on the next introspection, no validity window. Reply shapes follow RFC 7662 §2.2 ({active,...} / {inactive}, never says why). +9 → 20/20.
  • 2026-06-06 — session.sx: session-as-Erlang-process. create/lookup/touch/ explicit-expire/revoke as messages; idle-timeout self-expiry via receive ... after Ttl notifying the owner then tombstoning. Tombstones answer lookups with {error, expired|revoked} — never a silent dead mailbox. Established the conformance harness (conformance.sh, scoreboard, tests/session.sx). 11/11.

Blockers

  • 2026-06-07 — PKCE S256 blocked: erlang binary bugs. Two substrate bugs in lib/erlang make a correct/secure S256 impossible (S256 needs BASE64URL(SHA256(verifier)) compared against the stored challenge):
    1. Binary =:= always true. <<"v1">> =:= <<"v2">>true; <<"abc">> =:= <<"abd">>true. So a hash comparison can't reject a wrong verifier.
    2. crypto:hash ignores binary-literal content. crypto:hash(sha256, <<"v1">>) and crypto:hash(sha256, <<"v2">>) return the identical 32-byte digest (6e 34 0b 9c …), which is also ≠ the correct SX-level (crypto-sha256 "abc") (ba 78 16 bf …). The binary payload isn't reaching the hash. (Atom input → badarg→nil, separate issue.) Minimal repro (epoch protocol, after loading lib/erlang/runtime.sx): (erlang-eval-ast "case <<\"a\">> =:= <<\"b\">> of true -> bug; false -> ok end")bug. Not in scope to fix (lib/erlang is a substrate). PKCE plain remains correct and in use; S256 deferred until the binary path is fixed.