identity: refresh-token rotation + cascading revocation (token.sx grant-centric, +9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:26:05 +00:00
parent 27f43dbf10
commit baee67f561
5 changed files with 112 additions and 23 deletions

View File

@@ -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"}

View File

@@ -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 |

View File

@@ -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))

View File

@@ -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!