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
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:
@@ -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"},
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`.
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/identity/conformance.sh` → **124/124** (all four phases complete)
|
||||
`bash lib/identity/conformance.sh` → **130/130** (4 phases + ext: scope narrowing)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -78,9 +78,9 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
||||
- [x] tests: audit completeness, cross-instance subject mapping
|
||||
|
||||
## Extensions (base roadmap complete; deepen the engine)
|
||||
- [ ] PKCE S256 method (RFC 7636 §4.2) — SHA256 challenge derivation, not just `plain`
|
||||
- [~] PKCE S256 method (RFC 7636 §4.2) — BLOCKED on erlang substrate (see Blockers)
|
||||
- [ ] access-token TTL / `expires_in` — tokens expire as a grant timeout, introspect honours it
|
||||
- [ ] scope as a set + scope narrowing on refresh (RFC 6749 §6)
|
||||
- [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
|
||||
@@ -88,6 +88,15 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
||||
- [ ] unify `api.sx` over oauth + membership + audit (one facade, audited login/consent)
|
||||
|
||||
## Progress log
|
||||
- 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
|
||||
@@ -171,4 +180,18 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
||||
`tests/session.sx`). 11/11.
|
||||
|
||||
## Blockers
|
||||
(loop fills this in)
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user