Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s
The token registry holds a logical clock (advance/now; the substrate has no wall clock). 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 — short access tokens, long refresh tokens. issue/4 and issue_grant/4 default to infinity so all prior behaviour is unchanged. New tests/expiry.sx. token loop/6. 138/138. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
12 KiB
Markdown
205 lines
12 KiB
Markdown
# 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.sh` → **138/138** (4 phases + ext: scope narrowing, token TTL)
|
|
|
|
## 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
|
|
- [x] `session.sx` — session process, create/lookup/expire
|
|
- [x] `token.sx` — issue/introspect/revoke (opaque, grant-backed)
|
|
- [x] `registry.sx` — route by subject/client
|
|
- [x] `api.sx` + tests + scoreboard + conformance.sh
|
|
|
|
## Phase 2 — OAuth2 flows
|
|
- [x] authorization-code flow as a message protocol
|
|
- [x] refresh + rotation; revocation cascades to issued tokens
|
|
- [x] tests: full code exchange, refresh, revoke-then-use (must fail)
|
|
|
|
## Phase 3 — Silent SSO + membership
|
|
- [x] `prompt=none` cross-app login (one session, many clients)
|
|
- [x] membership state + per-app grant projection
|
|
- [x] grant verification delegated cache (mirror Redis-cache pattern)
|
|
|
|
## Phase 4 — Audit + federation
|
|
- [x] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)`
|
|
- [x] federated identity (peer-asserted subject) — advisory, trust-gated stub
|
|
- [x] 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)
|
|
- [x] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it
|
|
- [x] 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) and device grant (RFC 8628)
|
|
- [ ] 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 — 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.
|