identity: scope-as-set + scope narrowing on refresh (RFC 6749 §6, +6 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s

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, 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 are unchanged.
Also files a Blocker: PKCE S256 is blocked on erlang substrate bugs (binary
=:= always true; crypto:hash ignores binary content). token 24/24, 130/130.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 01:43:16 +00:00
parent 21673b6731
commit e951f23f14
5 changed files with 93 additions and 19 deletions

View File

@@ -1,10 +1,10 @@
{
"language": "identity",
"total_pass": 124,
"total": 124,
"total_pass": 130,
"total": 130,
"suites": [
{"name":"session","pass":11,"total":11,"status":"ok"},
{"name":"token","pass":18,"total":18,"status":"ok"},
{"name":"token","pass":24,"total":24,"status":"ok"},
{"name":"registry","pass":9,"total":9,"status":"ok"},
{"name":"api","pass":10,"total":10,"status":"ok"},
{"name":"oauth","pass":17,"total":17,"status":"ok"},

View File

@@ -1,11 +1,11 @@
# identity-on-sx Scoreboard
**Total: 124 / 124 tests passing**
**Total: 130 / 130 tests passing**
| | Suite | Pass | Total |
|---|---|---|---|
| ✅ | session | 11 | 11 |
| ✅ | token | 18 | 18 |
| ✅ | token | 24 | 24 |
| ✅ | registry | 9 | 9 |
| ✅ | api | 10 | 10 |
| ✅ | oauth | 17 | 17 |

View File

@@ -1,7 +1,7 @@
;; 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.
;; revocation, refresh-token rotation, cascading revocation, and scope
;; narrowing on refresh. The revoke-then-introspect and refresh-reuse
;; paths are the security centrepieces.
(define id-token-test-count 0)
(define id-token-test-pass 0)
@@ -166,6 +166,50 @@
"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")
;; ── scope as a set + narrowing on refresh (RFC 6749 §6 / §3.3) ───
(id-token-test
"a list scope round-trips through introspect"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, [read, write]} -> matched;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
"matched")
(id-token-test
"refresh can narrow the scope to a subset"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R, [read]),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, [read]} -> narrowed;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
"narrowed")
(id-token-test
"refresh cannot widen scope beyond the grant"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read]),\n case identity_tokens:refresh(Reg, R, [read, write]) of\n {ok, _, _} -> widened;\n {error, Why} -> Why\n end"))
"invalid_scope")
(id-token-test
"an invalid_scope refresh does not consume the refresh token"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n identity_tokens:refresh(Reg, R, [admin]),\n case identity_tokens:refresh(Reg, R, [read]) of\n {ok, _, _} -> still_usable;\n {error, Why} -> Why\n end"))
"still_usable")
(id-token-test
"plain refresh keeps the full grant scope"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, [read, write]} -> full;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))
"full")
(id-token-test
"a narrowed token still cascades on revoke"
(idtnm
(idt-ev
"Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, [read, write]),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R, [read]),\n identity_tokens:revoke(Reg, A2),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))
"inactive")
(define
id-token-test-summary
(str "token " id-token-test-pass "/" id-token-test-count))

File diff suppressed because one or more lines are too long