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

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:
2026-06-07 01:53:19 +00:00
parent e951f23f14
commit a43825f25f
6 changed files with 137 additions and 30 deletions

View File

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

View File

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

View File

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

View 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

View File

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