From baee67f5610869a1c3de1dcf31372aadf2f89688 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 00:26:05 +0000 Subject: [PATCH] identity: refresh-token rotation + cascading revocation (token.sx grant-centric, +9 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grant {Subject,Client,Scope,Status} becomes the unit of authorization and cascade; access + refresh tokens reference it. issue_grant returns an access+refresh pair; refresh (RFC 6749 §6) supersedes the presented refresh token and mints a fresh pair; reusing a superseded refresh token is treated as theft (RFC 6819 §5.2.2.3) and revokes the whole family, killing the live descendant. revoke of any token cascades to the grant. All prior token behaviour preserved. token 18/18, 62/62. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/scoreboard.json | 6 +-- lib/identity/scoreboard.md | 4 +- lib/identity/tests/token.sx | 78 ++++++++++++++++++++++++++++++++++-- lib/identity/token.sx | 35 ++++++++++------ plans/identity-on-sx.md | 12 +++++- 5 files changed, 112 insertions(+), 23 deletions(-) diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 0b9850cf..5d763cf7 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,10 +1,10 @@ { "language": "identity", - "total_pass": 53, - "total": 53, + "total_pass": 62, + "total": 62, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, - {"name":"token","pass":9,"total":9,"status":"ok"}, + {"name":"token","pass":18,"total":18,"status":"ok"}, {"name":"registry","pass":9,"total":9,"status":"ok"}, {"name":"api","pass":10,"total":10,"status":"ok"}, {"name":"oauth","pass":14,"total":14,"status":"ok"} diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index bee1db94..e03b1c5d 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,11 +1,11 @@ # identity-on-sx Scoreboard -**Total: 53 / 53 tests passing** +**Total: 62 / 62 tests passing** | | Suite | Pass | Total | |---|---|---|---| | ✅ | session | 11 | 11 | -| ✅ | token | 9 | 9 | +| ✅ | token | 18 | 18 | | ✅ | registry | 9 | 9 | | ✅ | api | 10 | 10 | | ✅ | oauth | 14 | 14 | diff --git a/lib/identity/tests/token.sx b/lib/identity/tests/token.sx index b6acb94e..7c9d1edc 100644 --- a/lib/identity/tests/token.sx +++ b/lib/identity/tests/token.sx @@ -1,6 +1,7 @@ -;; identity/tests/token.sx — opaque tokens, grant-backed lookup, and -;; real revocation. The revoke-then-introspect path is the security -;; centrepiece: a revoked token must read inactive immediately. +;; identity/tests/token.sx — opaque tokens, grant-backed lookup, real +;; revocation, refresh-token rotation, and cascading revocation. The +;; revoke-then-introspect and refresh-reuse paths are the security +;; centrepieces. (define id-token-test-count 0) (define id-token-test-pass 0) @@ -94,6 +95,77 @@ "Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, alice, cli, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) "active") +;; ── issue_grant: access + refresh pair (RFC 6749 §4.1.4 / §5.1) ─── + +(id-token-test + "issue_grant access token introspects active" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +;; ── refresh rotation (RFC 6749 §6) ─────────────────────────────── + +(id-token-test + "refresh mints a working new access token" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +(id-token-test + "rotated token keeps the grant's subject" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, Subject, _, _} -> Subject\n end")) + "alice") + +(id-token-test + "refresh chains across rotations" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, R2} = identity_tokens:refresh(Reg, R),\n {ok, A3, _R3} = identity_tokens:refresh(Reg, R2),\n case identity_tokens:introspect(Reg, A3) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +(id-token-test + "refreshing an unknown token is invalid_grant" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:refresh(Reg, Bogus) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end")) + "invalid_grant") + +;; ── refresh-token reuse = theft → revoke the family (RFC 6819) ──── + +(id-token-test + "reusing a superseded refresh token is invalid_grant" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end")) + "invalid_grant") + +(id-token-test + "refresh reuse revokes the live descendant too" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")) + "inactive") + +;; ── cascading revocation: revoke any token, the grant dies ─────── + +(id-token-test + "revoking the access token blocks refresh" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end")) + "invalid_grant") + +(id-token-test + "revoking the refresh token deactivates the access token" + (idtnm + (idt-ev + "Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, R),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "inactive") + (define id-token-test-summary (str "token " id-token-test-pass "/" id-token-test-count)) diff --git a/lib/identity/token.sx b/lib/identity/token.sx index 952e6ef8..9c4cb58c 100644 --- a/lib/identity/token.sx +++ b/lib/identity/token.sx @@ -1,22 +1,31 @@ -;; identity/token.sx — opaque, grant-backed tokens (RFC 7662 / 7009). +;; identity/token.sx — opaque, grant-backed tokens with refresh-token +;; rotation (RFC 6749 §6, RFC 6819 §5.2.2.3) and cascading revocation. ;; -;; The token table is a process; the token itself is an opaque handle -;; (make_ref) that carries NO information. introspect(Token) is a live -;; lookup against the table every time — the token is never decoded. -;; Because every introspection consults the live table, revocation is -;; real: a revoked token reads inactive on the very next introspection, -;; with no window where it still validates (RFC 7009 §2). +;; The grant is the unit of authorization and the unit of cascade: an +;; access token and a refresh token both reference a grant {Subject, +;; Client, Scope, Status}. Tokens are opaque handles (make_ref) carrying +;; no information; every introspection is a live lookup against the grant, +;; so revocation is real (RFC 7009): once a grant is revoked, every token +;; ever issued under it — access AND refresh, including rotated +;; descendants — reads inactive on the next call. Revoking ANY token of a +;; grant (access or refresh) cascades to the whole grant. ;; -;; introspect replies model RFC 7662 §2.2: -;; {active, Subject, Client, Scope} — token is currently valid -;; {inactive} — unknown OR revoked; never says why +;; Refresh rotation: refreshing supersedes the presented refresh token and +;; mints a fresh access+refresh pair under the same grant. Re-presenting a +;; superseded refresh token is treated as token theft (RFC 6819 §5.2.2.3): +;; the entire grant is revoked, killing the legitimate descendant too. ;; -;; Authorization is NOT decided here. {active, ...} states WHO and WHAT -;; was granted; whether that subject may do a thing is acl's question. +;; introspect reply shapes (RFC 7662 §2.2): +;; {active, Subject, Client, Scope} | {inactive} +;; +;; State threaded through loop/4: +;; Grants : [{Gid, {Subject, Client, Scope, active|revoked}}] +;; Access : [{AccessTok, Gid}] +;; Refresh : [{RefreshTok, {Gid, current|superseded}}] (define identity-token-source - "-module(identity_tokens).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n issue(Reg, Subject, Client, Scope) ->\n Reg ! {issue, Subject, Client, Scope, self()},\n receive {token_reply, R} -> R end.\n\n introspect(Reg, Token) ->\n Reg ! {introspect, Token, self()},\n receive {token_reply, R} -> R end.\n\n revoke(Reg, Token) ->\n Reg ! {revoke, Token, self()},\n receive {token_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {token_reply, R} -> R end.\n\n loop(Tokens) ->\n receive\n {issue, Subject, Client, Scope, From} ->\n Token = make_ref(),\n From ! {token_reply, {ok, Token}},\n loop([{Token, {Subject, Client, Scope, active}} | Tokens]);\n {introspect, Token, From} ->\n From ! {token_reply, find(Token, Tokens)},\n loop(Tokens);\n {revoke, Token, From} ->\n From ! {token_reply, ok},\n loop(revoke_token(Token, Tokens));\n {stop, From} ->\n From ! {token_reply, ok}\n end.\n\n find(_, []) -> {inactive};\n find(Token, [{T, {Subject, Client, Scope, active}} | Rest]) ->\n case T =:= Token of\n true -> {active, Subject, Client, Scope};\n false -> find(Token, Rest)\n end;\n find(Token, [{T, {_, _, _, revoked}} | Rest]) ->\n case T =:= Token of\n true -> {inactive};\n false -> find(Token, Rest)\n end.\n\n revoke_token(_, []) -> [];\n revoke_token(Token, [{T, {Su, Cl, Sc, St}} | Rest]) ->\n case T =:= Token of\n true -> [{T, {Su, Cl, Sc, revoked}} | Rest];\n false -> [{T, {Su, Cl, Sc, St}} | revoke_token(Token, Rest)]\n end.") + "-module(identity_tokens).\n\n start() ->\n spawn(fun () -> loop([], [], [], 1) end).\n\n issue(Reg, Subject, Client, Scope) ->\n Reg ! {issue, Subject, Client, Scope, self()},\n receive {token_reply, R} -> R end.\n\n issue_grant(Reg, Subject, Client, Scope) ->\n Reg ! {issue_grant, Subject, Client, Scope, self()},\n receive {token_reply, R} -> R end.\n\n refresh(Reg, RefreshTok) ->\n Reg ! {refresh, RefreshTok, self()},\n receive {token_reply, R} -> R end.\n\n introspect(Reg, Token) ->\n Reg ! {introspect, Token, self()},\n receive {token_reply, R} -> R end.\n\n revoke(Reg, Token) ->\n Reg ! {revoke, Token, self()},\n receive {token_reply, R} -> R end.\n\n stop(Reg) ->\n Reg ! {stop, self()},\n receive {token_reply, R} -> R end.\n\n loop(Grants, Access, Refresh, NextGid) ->\n receive\n {issue, Subject, Client, Scope, From} ->\n Gid = NextGid,\n Tok = make_ref(),\n From ! {token_reply, {ok, Tok}},\n loop([{Gid, {Subject, Client, Scope, active}} | Grants],\n [{Tok, Gid} | Access], Refresh, NextGid + 1);\n {issue_grant, Subject, Client, Scope, From} ->\n Gid = NextGid,\n A = make_ref(),\n R = make_ref(),\n From ! {token_reply, {ok, A, R}},\n loop([{Gid, {Subject, Client, Scope, active}} | Grants],\n [{A, Gid} | Access],\n [{R, {Gid, current}} | Refresh],\n NextGid + 1);\n {refresh, RTok, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid);\n {ok, {Gid, current}} ->\n case grant_active(Gid, Grants) of\n false ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n loop(Grants,\n [{A2, Gid} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid)\n end\n end;\n {introspect, Tok, From} ->\n From ! {token_reply, introspect_access(Tok, Access, Grants)},\n loop(Grants, Access, Refresh, NextGid);\n {revoke, Tok, From} ->\n From ! {token_reply, ok},\n case find_gid(Tok, Access, Refresh) of\n none -> loop(Grants, Access, Refresh, NextGid);\n {ok, Gid} -> loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid)\n end;\n {stop, From} ->\n From ! {token_reply, ok}\n end.\n\n introspect_access(Tok, Access, Grants) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, Gid} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, Sc, active}} -> {active, Su, Cl, Sc};\n {ok, {_, _, _, revoked}} -> {inactive}\n end\n end.\n\n find_gid(Tok, Access, Refresh) ->\n case find(Tok, Access) of\n {ok, Gid} -> {ok, Gid};\n none ->\n case find(Tok, Refresh) of\n {ok, {Gid, _}} -> {ok, Gid};\n none -> none\n end\n end.\n\n grant_active(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {_, _, _, active}} -> true;\n Other -> false\n end.\n\n grant_info(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {Su, Cl, Sc, _}} -> {Su, Cl, Sc}\n end.\n\n set_status(_, _, []) -> [];\n set_status(Gid, St, [{G, {Su, Cl, Sc, Old}} | Rest]) ->\n case G =:= Gid of\n true -> [{G, {Su, Cl, Sc, St}} | Rest];\n false -> [{G, {Su, Cl, Sc, Old}} | set_status(Gid, St, Rest)]\n end.\n\n supersede(_, []) -> [];\n supersede(RTok, [{T, {Gid, St}} | Rest]) ->\n case T =:= RTok of\n true -> [{T, {Gid, superseded}} | Rest];\n false -> [{T, {Gid, St}} | supersede(RTok, Rest)]\n end.\n\n find(_, []) -> none;\n find(Key, [{K, V} | Rest]) ->\n case K =:= Key of\n true -> {ok, V};\n false -> find(Key, Rest)\n end.") (define identity-load-token! diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index aaef9c2a..4384e697 100644 --- a/plans/identity-on-sx.md +++ b/plans/identity-on-sx.md @@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`. ## Status (rolling) -`bash lib/identity/conformance.sh` → **53/53** (Phase 1 + authz-code flow) +`bash lib/identity/conformance.sh` → **62/62** (Phase 1 + authz-code + refresh/rotation/cascade) ## Ground rules @@ -64,7 +64,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) ## Phase 2 — OAuth2 flows - [x] authorization-code flow as a message protocol -- [ ] refresh + rotation; revocation cascades to issued tokens +- [x] refresh + rotation; revocation cascades to issued tokens - [ ] tests: full code exchange, refresh, revoke-then-use (must fail) ## Phase 3 — Silent SSO + membership @@ -78,6 +78,14 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [ ] tests: audit completeness, cross-instance subject mapping ## Progress log +- 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 →