diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index fa2857d1..3a5328f1 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -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 diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index d206d258..97e4e5a4 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -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"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 7488a197..48af5476 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -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`. diff --git a/lib/identity/tests/expiry.sx b/lib/identity/tests/expiry.sx new file mode 100644 index 00000000..8f16de04 --- /dev/null +++ b/lib/identity/tests/expiry.sx @@ -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)) diff --git a/lib/identity/token.sx b/lib/identity/token.sx index 199515eb..24d62373 100644 --- a/lib/identity/token.sx +++ b/lib/identity/token.sx @@ -1,44 +1,46 @@ ;; identity/token.sx — opaque, grant-backed tokens with refresh-token -;; rotation (RFC 6749 §6, RFC 6819 §5.2.2.3), cascading revocation, and -;; scope narrowing on refresh (RFC 6749 §6 / §3.3). +;; rotation (RFC 6749 §6, RFC 6819 §5.2.2.3), cascading revocation, scope +;; narrowing (RFC 6749 §6 / §3.3), and access-token expiry (§4.2.2 / +;; §5.1 expires_in). ;; ;; 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. +;; Client, Scope, Status, Ttl}. Tokens are opaque handles (make_ref); every +;; introspection is a live lookup against the grant AND the access token's +;; own expiry, so revocation is real (RFC 7009) and an expired token reads +;; inactive. Revoking ANY token of a grant cascades to the whole grant. +;; +;; Expiry uses a LOGICAL clock — the substrate has no wall clock. The +;; registry holds `Now`; `advance(Reg, N)` moves it forward (this stands in +;; for time passing). Each access token carries `Expires` (Now-at-issue + +;; grant Ttl, or `infinity`); introspect returns inactive once Now reaches +;; it. Refresh mints a FRESH short-lived access token (a new Expires from +;; the current Now) — the whole point of refresh: short access tokens, long +;; refresh tokens. ;; ;; 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. +;; mints a fresh pair; re-presenting a superseded refresh token is theft +;; (RFC 6819 §5.2.2.3) and revokes the whole grant. ;; -;; Scope: a grant records the maximum scope granted. Each access token -;; carries its own EFFECTIVE scope (<= the grant's). refresh/2 keeps the -;; grant scope; 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 (the client may retry). Scope is -;; treated opaquely for issue/refresh-2 (atom or list); narrowing in -;; refresh/3 treats it as a set (list of scope atoms). +;; Scope: a grant records the max scope; each access token its effective +;; scope (<= the grant's). refresh/3 narrows (subset, else invalid_scope, +;; token not consumed). Scope is opaque (atom or list) for issue/refresh-2. ;; -;; Auditing: start/1 takes an audit ledger; every grant transition -;; (issue, refresh, revoke) appends an event. start/0 audits nothing. +;; Auditing: start/1 takes an audit ledger; issue/refresh/revoke append. ;; ;; introspect reply shapes (RFC 7662 §2.2): ;; {active, Subject, Client, Scope} | {inactive} ;; -;; State threaded through loop/5: -;; Grants : [{Gid, {Subject, Client, Scope, active|revoked}}] -;; Access : [{AccessTok, {Gid, EffectiveScope}}] +;; State threaded through loop/6: +;; Grants : [{Gid, {Subject, Client, Scope, active|revoked, Ttl}}] +;; Access : [{AccessTok, {Gid, EffectiveScope, Expires}}] ;; Refresh : [{RefreshTok, {Gid, current|superseded}}] +;; Now : logical clock (integer) ;; Audit : an identity_audit pid, or the atom none (define identity-token-source - "-module(identity_tokens).\n\n start() ->\n start(none).\n\n start(Audit) ->\n spawn(fun () -> loop([], [], [], 1, Audit) 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 refresh(Reg, RefreshTok, Scope) ->\n Reg ! {refresh_scoped, RefreshTok, 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(Grants, Access, Refresh, NextGid, Audit) ->\n receive\n {issue, Subject, Client, Scope, From} ->\n Gid = NextGid,\n Tok = make_ref(),\n From ! {token_reply, {ok, Tok}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active}} | Grants],\n [{Tok, {Gid, Scope}} | Access], Refresh, NextGid + 1, Audit);\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 audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active}} | Grants],\n [{A, {Gid, Scope}} | Access],\n [{R, {Gid, current}} | Refresh],\n NextGid + 1, Audit);\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, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Audit);\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, Audit);\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 audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, {Gid, Sc}} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Audit)\n end\n end;\n {refresh_scoped, RTok, Requested, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Audit);\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, Audit);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n case subset(Requested, Sc) of\n false ->\n From ! {token_reply, {error, invalid_scope}},\n loop(Grants, Access, Refresh, NextGid, Audit);\n true ->\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, {Gid, Requested}} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Audit)\n end\n end\n end;\n {introspect, Tok, From} ->\n From ! {token_reply, introspect_access(Tok, Access, Grants)},\n loop(Grants, Access, Refresh, NextGid, Audit);\n {revoke, Tok, From} ->\n From ! {token_reply, ok},\n case find_gid(Tok, Access, Refresh) of\n none -> loop(Grants, Access, Refresh, NextGid, Audit);\n {ok, Gid} ->\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Audit)\n end;\n {stop, From} ->\n From ! {token_reply, ok}\n end.\n\n audit_event(none, _, _) -> ok;\n audit_event(Audit, Subject, Action) ->\n Audit ! {event, Subject, Action},\n ok.\n\n audit_grant(none, _, _, _) -> ok;\n audit_grant(Audit, Gid, Grants, Action) ->\n {Su, _, _} = grant_info(Gid, Grants),\n Audit ! {event, Su, Action},\n ok.\n\n introspect_access(Tok, Access, Grants) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, {Gid, Scope}} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, _, active}} -> {active, Su, Cl, Scope};\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 subset([], _) -> true;\n subset([X | Rest], Granted) ->\n case member(X, Granted) of\n true -> subset(Rest, Granted);\n false -> false\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, 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.") + "-module(identity_tokens).\n\n start() ->\n start(none).\n\n start(Audit) ->\n spawn(fun () -> loop([], [], [], 1, 0, Audit) end).\n\n issue(Reg, Subject, Client, Scope) ->\n issue(Reg, Subject, Client, Scope, infinity).\n\n issue(Reg, Subject, Client, Scope, Ttl) ->\n Reg ! {issue, Subject, Client, Scope, Ttl, self()},\n receive {token_reply, R} -> R end.\n\n issue_grant(Reg, Subject, Client, Scope) ->\n issue_grant(Reg, Subject, Client, Scope, infinity).\n\n issue_grant(Reg, Subject, Client, Scope, Ttl) ->\n Reg ! {issue_grant, Subject, Client, Scope, Ttl, 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 refresh(Reg, RefreshTok, Scope) ->\n Reg ! {refresh_scoped, RefreshTok, 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 advance(Reg, N) ->\n Reg ! {advance, N, self()},\n receive {token_reply, R} -> R end.\n\n now(Reg) ->\n Reg ! {now, 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, Now, Audit) ->\n receive\n {issue, Subject, Client, Scope, Ttl, From} ->\n Gid = NextGid,\n Tok = make_ref(),\n From ! {token_reply, {ok, Tok}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active, Ttl}} | Grants],\n [{Tok, {Gid, Scope, exp(Now, Ttl)}} | Access],\n Refresh, NextGid + 1, Now, Audit);\n {issue_grant, Subject, Client, Scope, Ttl, From} ->\n Gid = NextGid,\n A = make_ref(),\n R = make_ref(),\n From ! {token_reply, {ok, A, R}},\n audit_event(Audit, Subject, issue),\n loop([{Gid, {Subject, Client, Scope, active, Ttl}} | Grants],\n [{A, {Gid, Scope, exp(Now, Ttl)}} | Access],\n [{R, {Gid, current}} | Refresh],\n NextGid + 1, Now, Audit);\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, Now, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit);\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, Now, Audit);\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 audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, {Gid, Sc, exp(Now, grant_ttl(Gid, Grants))}} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Now, Audit)\n end\n end;\n {refresh_scoped, RTok, Requested, From} ->\n case find(RTok, Refresh) of\n none ->\n From ! {token_reply, {error, invalid_grant}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {ok, {Gid, superseded}} ->\n From ! {token_reply, {error, invalid_grant}},\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit);\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, Now, Audit);\n true ->\n {Su, Cl, Sc} = grant_info(Gid, Grants),\n case subset(Requested, Sc) of\n false ->\n From ! {token_reply, {error, invalid_scope}},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n true ->\n A2 = make_ref(),\n R2 = make_ref(),\n From ! {token_reply, {ok, A2, R2}},\n audit_event(Audit, Su, refresh),\n loop(Grants,\n [{A2, {Gid, Requested, exp(Now, grant_ttl(Gid, Grants))}} | Access],\n [{R2, {Gid, current}} | supersede(RTok, Refresh)],\n NextGid, Now, Audit)\n end\n end\n end;\n {introspect, Tok, From} ->\n From ! {token_reply, introspect_access(Tok, Access, Grants, Now)},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {revoke, Tok, From} ->\n From ! {token_reply, ok},\n case find_gid(Tok, Access, Refresh) of\n none -> loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {ok, Gid} ->\n audit_grant(Audit, Gid, Grants, revoke),\n loop(set_status(Gid, revoked, Grants), Access, Refresh, NextGid, Now, Audit)\n end;\n {advance, N, From} ->\n From ! {token_reply, ok},\n loop(Grants, Access, Refresh, NextGid, Now + N, Audit);\n {now, From} ->\n From ! {token_reply, Now},\n loop(Grants, Access, Refresh, NextGid, Now, Audit);\n {stop, From} ->\n From ! {token_reply, ok}\n end.\n\n exp(_, infinity) -> infinity;\n exp(Now, Ttl) -> Now + Ttl.\n\n not_expired(_, infinity) -> true;\n not_expired(Now, Expires) -> Now < Expires.\n\n audit_event(none, _, _) -> ok;\n audit_event(Audit, Subject, Action) ->\n Audit ! {event, Subject, Action},\n ok.\n\n audit_grant(none, _, _, _) -> ok;\n audit_grant(Audit, Gid, Grants, Action) ->\n {Su, _, _} = grant_info(Gid, Grants),\n Audit ! {event, Su, Action},\n ok.\n\n introspect_access(Tok, Access, Grants, Now) ->\n case find(Tok, Access) of\n none -> {inactive};\n {ok, {Gid, Scope, Expires}} ->\n case find(Gid, Grants) of\n none -> {inactive};\n {ok, {Su, Cl, _, active, _}} ->\n case not_expired(Now, Expires) of\n true -> {active, Su, Cl, Scope};\n false -> {inactive}\n end;\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 grant_ttl(Gid, Grants) ->\n case find(Gid, Grants) of\n {ok, {_, _, _, _, Ttl}} -> Ttl\n end.\n\n set_status(_, _, []) -> [];\n set_status(Gid, St, [{G, {Su, Cl, Sc, Old, Ttl}} | Rest]) ->\n case G =:= Gid of\n true -> [{G, {Su, Cl, Sc, St, Ttl}} | Rest];\n false -> [{G, {Su, Cl, Sc, Old, Ttl}} | 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 subset([], _) -> true;\n subset([X | Rest], Granted) ->\n case member(X, Granted) of\n true -> subset(Rest, Granted);\n false -> false\n end.\n\n member(_, []) -> false;\n member(X, [Y | Rest]) ->\n case X =:= Y of\n true -> true;\n false -> member(X, 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! diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 2a3f13b6..b576a6eb 100644 --- a/plans/identity-on-sx.md +++ b/plans/identity-on-sx.md @@ -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