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 →