identity_tokens:revoke_app(Subject, Client) revokes every grant a subject holds for one client at once (audited one revoke per grant), exposed at the facade as identity:revoke_app. The action counterpart to the grants view — completing the account-security view+action pairs (sessions/logout_all, grants/revoke_app, history). Other subjects' same-client grants are untouched. account 11/11, 233/233. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
identity-on-sx: OAuth2, sessions & membership on Erlang
DRAFT outline. The identity core
acl-on-sxassumes already exists.aclanswers "may X do Y"; identity answers "who is X, and how did they prove it." Depends onpersist-on-sx(grant/audit ledger). Pairs withacl-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.sh → 233/233 (4 phases + 15 ext) — slow (~10min, run in background; internal timeout 1200)
Ground rules
- Scope: only
lib/identity/**andplans/identity-on-sx.md. May import fromlib/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 —
identityproves identity,acldecides 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/expiretoken.sx— issue/introspect/revoke (opaque, grant-backed)registry.sx— route by subject/clientapi.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=nonecross-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
persistevent;(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) + device grant (RFC 8628)
- acl-on-sx delegation: identity-gates-before-acl boundary (401 vs 403), stub decider (live Datalog bridge is cross-substrate)
- [~] OAuth
state/OIDCnonce— low value in this server-centric model (client-side echo); skipped - pushed authorization requests (PAR, RFC 9126): single-use request_uri → consent
- dynamic client registration (RFC 7591): server-generated client_id + secret
- "apps with access":
grants_for(Subject)/identity:grants(per-subject active grants) - "disconnect app":
revoke_app(Subject, Client)— revoke all of a subject's grants for a client - unify
api.sxover membership + audit (one facade, audited login/logout) - subject-wide session management:
sessions(Subject)+logout_all(log out everywhere) - token exchange (RFC 8693): downscope a token into a new independent token
- RFC 7662 full introspection metadata (
introspect_full: sub/client_id/scope/exp/iat/token_type)
Progress log
- 2026-06-07 — "disconnect app" (ext):
identity_tokens:revoke_app(Subject, Client)revokes every grant a subject holds for one client at once (audited one revoke per grant), exposed at the facade asidentity:revoke_app. The action counterpart to thegrantsview — completes the account-security view+action pairs: sessions/logout_all, grants/revoke_app, history. Other subjects' same-client grants are untouched. +4 → account 11, 233/233. - 2026-06-07 — "apps with access" (ext):
identity_tokens:grants_for(Subject)lists a subject's ACTIVE grants as[{Client, Scope}](revoked excluded), exposed through the facade asidentity:grants(Subject). Completes the per-subject account-security trio: sessions (where), grants (which apps), history (what happened). New tests/account.sx (7). 222→229. NOTE: conformance is now slow (~10 min, 22 suites); run it in the background — internal sx_server timeout raised to 1200s. The suite is at its monolithic-runtime ceiling; further test growth should consider splitting the harness. - 2026-06-07 — dynamic client registration (ext, RFC 7591):
register_dynamicgenerates a client_id + secret server-side (make_ref each) and registers the client, returning {ok, ClientId, Secret} — self-service onboarding distinct from the manual register_client. A dynamic confidential client can then use client_credentials; a dynamic public client stays unauthorized_client. New tests/dynreg.sx (5). 217→222. - 2026-06-07 — PAR (ext, RFC 9126):
push_authorization_requestlodges the authorization params under a single-userequest_uri;authorize_pushedredeems it into the normal consent flow. Pushed requests reuse the pending store ({pushed, Rec}keyed by the request_uri ref — distinct from consent req_ids, no collision), so no new loop state. The pushed binding (client + redirect + PKCE) is enforced at exchange. New tests/par.sx (7). 210→217. - 2026-06-07 — full introspection (ext, RFC 7662 §2.2):
introspect_fullreturns {active, Subject, Client, Scope, Exp, Iat, bearer} for live tokens, {inactive} otherwise — deepening the opaque-token/live-lookup model the whole design rests on. Access tokens now carryIat(clock-at-issue); exp = iat + ttl. Simpleintrospectunchanged. New tests/introspect.sx (9). 201→210. NOTE: conformance now needs an explicit long timeout (>120s, 19 suites) — run withtimeout 580. - 2026-06-07 — token exchange (ext, RFC 8693 §2.1):
oauth.sxgainstoken_exchange(SubjectToken, RequestedScope)— a valid access token is downscoped into a NEW independent grant for the same subject (subset only, else invalid_scope; inactive subject token → invalid_grant). The new token's lifecycle is independent (revoking either leaves the other active); exchanges chain. Least-privilege handoff to downstream services. New tests/exchange.sx (8). 193→201. - 2026-06-07 — subject-wide session management (ext):
api.sxgainssessions(Subject)(enumerate) andlogout_all(Subject)("log out everywhere") — revokes + deregisters every session a subject holds, auditing a logout per session, leaving other subjects untouched. Builds on registry.sessions_for. New tests/session_mgmt.sx (8). 185→193. - 2026-06-07 —
delegation.sx(ext): the identity→acl boundary made concrete.checkintrospects the token first: inactive →{error, unauthenticated}(401, acl never consulted); active → constructs {Subject, Scope, Action, Resource} and hands off to acl, which returns permit/deny (forbidden= 403). 401 strictly precedes 403 (a revoked token with no scope is still unauthenticated). acl-on-sx (Datalog) is a different SX guest language — wired at the integration layer — so the decider here is a labelled stub (permits when Action ∈ Scope); swap the pid, boundary unchanged. New tests/delegation.sx (8). 177→185. Extensions backlog clear. - 2026-06-07 — unified facade (ext):
api.sxcoordinator now owns an audit ledger + a membership registry alongside its token table (started with the ledger) and session registry. login/logout are audited; new opshistory/enroll/member_status/member_projectexpose the audit + membership axes through the oneidentitydoor. identity proves who + reports membership; acl still decides permission. Existing api behaviour unchanged (10/10). New tests/facade.sx (9). 168→177. - 2026-06-07 —
device.sx(ext, RFC 8628): device authorization grant for input-constrained devices. authorize → {device_code, user_code}; the human approve/deny out-of-band by user_code; the device polls by device_code through the §3.5 status machine (authorization_pending → access_denied / {ok,Token}). Device code is single-use once a token issues; guarded transitions (approve-after-deny rejected). Tokens grant-backed. Device-code expiry + slow_down deferred (no wall clock). New tests/device.sx (10). 158→168. - 2026-06-07 — client-credentials grant (ext, RFC 6749 §4.4):
oauth.sxnow 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 isunauthorized_client; any auth failure (unknown client OR wrong secret) isinvalid_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-matchvalid_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 onceNowreaches 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/3requests 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/2keeps 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.sxgainsstart/1(Audit)and emits issue/refresh/revoke events (incl. reuse-triggered revoke);start/0stays 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.sxnow owns a session registry;establishcreates a subject session,silent_authorizeasks "does this subject have a live session?" → mints a code (skipping consent) bound to client+redirect+PKCE, elselogin_required. Same machine, fast-path — one session, many clients;end_sessioncloses the path. Newtests/sso.sx(10). +10 → 75/75. - 2026-06-07 —
oauth.sxrefresh 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.sxgrant-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};refreshsupersedes 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.revokeof 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).verifyanswers 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 probelookup(Subject, Client)and the fan-outsessions_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;introspectis 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 viareceive ... after Ttlnotifying 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/erlangmake a correct/secure S256 impossible (S256 needsBASE64URL(SHA256(verifier))compared against the stored challenge):- Binary
=:=always true.<<"v1">> =:= <<"v2">>→true;<<"abc">> =:= <<"abd">>→true. So a hash comparison can't reject a wrong verifier. crypto:hashignores binary-literal content.crypto:hash(sha256, <<"v1">>)andcrypto: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). PKCEplainremains correct and in use; S256 deferred until the binary path is fixed.
- Binary