identity: access-token TTL via logical clock — expires_in (RFC 6749 §4.2.2, +8 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 22s
The token registry holds a logical clock (advance/now; the substrate has no wall clock). Grants carry a Ttl; each access token carries an Expires (Now-at-issue + Ttl, or infinity); introspect returns inactive once Now reaches it. Refresh mints a fresh short-lived access token — short access tokens, long refresh tokens. issue/4 and issue_grant/4 default to infinity so all prior behaviour is unchanged. New tests/expiry.sx. token loop/6. 138/138. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ SUITES=(
|
||||
"cache|id-cache-test-pass|id-cache-test-count"
|
||||
"audit|id-audit-test-pass|id-audit-test-count"
|
||||
"federation|id-fed-test-pass|id-fed-test-count"
|
||||
"expiry|id-expiry-test-pass|id-expiry-test-count"
|
||||
)
|
||||
|
||||
cat > "$TMPFILE" << 'EPOCHS'
|
||||
@@ -68,6 +69,7 @@ cat > "$TMPFILE" << 'EPOCHS'
|
||||
(load "lib/identity/tests/cache.sx")
|
||||
(load "lib/identity/tests/audit.sx")
|
||||
(load "lib/identity/tests/federation.sx")
|
||||
(load "lib/identity/tests/expiry.sx")
|
||||
(epoch 100)
|
||||
(eval "(list id-session-test-pass id-session-test-count)")
|
||||
(epoch 101)
|
||||
@@ -88,6 +90,8 @@ cat > "$TMPFILE" << 'EPOCHS'
|
||||
(eval "(list id-audit-test-pass id-audit-test-count)")
|
||||
(epoch 109)
|
||||
(eval "(list id-fed-test-pass id-fed-test-count)")
|
||||
(epoch 110)
|
||||
(eval "(list id-expiry-test-pass id-expiry-test-count)")
|
||||
EPOCHS
|
||||
|
||||
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"language": "identity",
|
||||
"total_pass": 130,
|
||||
"total": 130,
|
||||
"total_pass": 138,
|
||||
"total": 138,
|
||||
"suites": [
|
||||
{"name":"session","pass":11,"total":11,"status":"ok"},
|
||||
{"name":"token","pass":24,"total":24,"status":"ok"},
|
||||
@@ -12,6 +12,7 @@
|
||||
{"name":"membership","pass":17,"total":17,"status":"ok"},
|
||||
{"name":"cache","pass":9,"total":9,"status":"ok"},
|
||||
{"name":"audit","pass":11,"total":11,"status":"ok"},
|
||||
{"name":"federation","pass":12,"total":12,"status":"ok"}
|
||||
{"name":"federation","pass":12,"total":12,"status":"ok"},
|
||||
{"name":"expiry","pass":8,"total":8,"status":"ok"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# identity-on-sx Scoreboard
|
||||
|
||||
**Total: 130 / 130 tests passing**
|
||||
**Total: 138 / 138 tests passing**
|
||||
|
||||
| | Suite | Pass | Total |
|
||||
|---|---|---|---|
|
||||
@@ -14,6 +14,7 @@
|
||||
| ✅ | cache | 9 | 9 |
|
||||
| ✅ | audit | 11 | 11 |
|
||||
| ✅ | federation | 12 | 12 |
|
||||
| ✅ | expiry | 8 | 8 |
|
||||
|
||||
|
||||
Generated by `lib/identity/conformance.sh`.
|
||||
|
||||
92
lib/identity/tests/expiry.sx
Normal file
92
lib/identity/tests/expiry.sx
Normal file
@@ -0,0 +1,92 @@
|
||||
;; identity/tests/expiry.sx — access-token expiry on a logical clock
|
||||
;; (RFC 6749 §4.2.2 expires_in). `advance` stands in for time passing;
|
||||
;; introspect returns inactive once the clock reaches a token's expiry.
|
||||
;; Refresh mints a fresh short-lived access token — the point of refresh.
|
||||
|
||||
(define id-expiry-test-count 0)
|
||||
(define id-expiry-test-pass 0)
|
||||
(define id-expiry-test-fails (list))
|
||||
|
||||
(define
|
||||
id-expiry-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(set! id-expiry-test-count (+ id-expiry-test-count 1))
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! id-expiry-test-pass (+ id-expiry-test-pass 1))
|
||||
(append! id-expiry-test-fails {:name name :expected expected :actual actual}))))
|
||||
|
||||
(define ide-ev erlang-eval-ast)
|
||||
(define idenm (fn (v) (get v :name)))
|
||||
|
||||
(identity-load-token!)
|
||||
|
||||
;; ── within TTL is active; past TTL is inactive ───────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"a token within its TTL is active"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 50),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
(id-expiry-test
|
||||
"a token at its TTL boundary is expired"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(id-expiry-test
|
||||
"a token just before its TTL is still active"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 99),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── no TTL (infinity) never expires ──────────────────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"a token issued without a TTL never expires"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read),\n identity_tokens:advance(R, 100000),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"active")
|
||||
|
||||
;; ── refresh mints a fresh short-lived token ──────────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"refresh renews access after the old token expired"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, A, Rt} = identity_tokens:issue_grant(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n inactive = case identity_tokens:introspect(R, A) of\n {active, _, _, _} -> active; {inactive} -> inactive end,\n {ok, A2, _R2} = identity_tokens:refresh(R, Rt),\n case identity_tokens:introspect(R, A2) of\n {active, _, _, _} -> renewed;\n {inactive} -> inactive\n end"))
|
||||
"renewed")
|
||||
|
||||
(id-expiry-test
|
||||
"the renewed token also expires after its own TTL"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, _A, Rt} = identity_tokens:issue_grant(R, alice, web, read, 100),\n identity_tokens:advance(R, 100),\n {ok, A2, _R2} = identity_tokens:refresh(R, Rt),\n identity_tokens:advance(R, 100),\n case identity_tokens:introspect(R, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
;; ── the logical clock ────────────────────────────────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"the clock starts at zero and advances"
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n identity_tokens:advance(R, 7),\n identity_tokens:advance(R, 35),\n identity_tokens:now(R)")
|
||||
42)
|
||||
|
||||
;; ── expiry composes with revocation ──────────────────────────────
|
||||
|
||||
(id-expiry-test
|
||||
"an expired token is also inactive after revoke (no contradiction)"
|
||||
(idenm
|
||||
(ide-ev
|
||||
"R = identity_tokens:start(),\n {ok, T} = identity_tokens:issue(R, alice, web, read, 100),\n identity_tokens:advance(R, 200),\n identity_tokens:revoke(R, T),\n case identity_tokens:introspect(R, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))
|
||||
"inactive")
|
||||
|
||||
(define
|
||||
id-expiry-test-summary
|
||||
(str "expiry " id-expiry-test-pass "/" id-expiry-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` → **130/130** (4 phases + ext: scope narrowing)
|
||||
`bash lib/identity/conformance.sh` → **138/138** (4 phases + ext: scope narrowing, token TTL)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -79,7 +79,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke)
|
||||
|
||||
## Extensions (base roadmap complete; deepen the engine)
|
||||
- [~] 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
|
||||
- [x] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it
|
||||
- [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)
|
||||
@@ -88,6 +88,13 @@ 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 — access-token expiry (ext): logical clock in the token registry
|
||||
(`advance`/`now`; no wall clock in substrate). Grants carry a Ttl; each
|
||||
access token carries an Expires (Now-at-issue + Ttl, or infinity); introspect
|
||||
returns inactive once `Now` reaches it. Refresh mints a fresh short-lived
|
||||
access token (new Expires) — short access tokens, long refresh tokens. issue/4
|
||||
+ issue_grant/4 default to infinity, so all prior tests unchanged. New
|
||||
tests/expiry.sx (8). token loop/6. 130→138.
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user