;; identity/tests/token.sx — opaque tokens, grant-backed lookup, real ;; 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) (define id-token-test-fails (list)) (define id-token-test (fn (name actual expected) (set! id-token-test-count (+ id-token-test-count 1)) (if (= actual expected) (set! id-token-test-pass (+ id-token-test-pass 1)) (append! id-token-test-fails {:name name :expected expected :actual actual})))) (define idt-ev erlang-eval-ast) (define idtnm (fn (v) (get v :name))) (identity-load-token!) ;; ── issue + introspect (happy path) ────────────────────────────── (id-token-test "fresh token introspects active" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) "active") (id-token-test "introspect returns the granted subject" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, Tok) of\n {active, Subject, _, _} -> Subject\n end")) "alice") (id-token-test "introspect returns the granted scope" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, write),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, Scope} -> Scope\n end")) "write") ;; ── opacity: distinct tokens, no cross-talk ────────────────────── (id-token-test "two issues yield independent grants" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, bob, cli, write),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, Subject, _, _} -> Subject;\n {inactive} -> inactive\n end")) "bob") ;; ── revocation is real (RFC 7009) ──────────────────────────────── (id-token-test "revoked token introspects inactive immediately" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n active = case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> active end,\n identity_tokens:revoke(Reg, Tok),\n case identity_tokens:introspect(Reg, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")) "inactive") (id-token-test "revoke is idempotent" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, Tok} = identity_tokens:issue(Reg, alice, web, read),\n identity_tokens:revoke(Reg, Tok),\n identity_tokens:revoke(Reg, Tok)")) "ok") ;; ── unknown tokens are inactive, never an error/crash ──────────── (id-token-test "introspecting an unknown token is inactive" (idtnm (idt-ev "Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:introspect(Reg, Bogus) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) "inactive") (id-token-test "revoking an unknown token is ok, not a crash" (idtnm (idt-ev "Reg = identity_tokens:start(),\n Bogus = make_ref(),\n identity_tokens:revoke(Reg, Bogus)")) "ok") ;; ── one revocation does not affect a sibling token ─────────────── (id-token-test "revoking one token leaves the other active" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, A} = identity_tokens:issue(Reg, alice, web, read),\n {ok, B} = identity_tokens:issue(Reg, alice, cli, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:introspect(Reg, B) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) "active") ;; ── issue_grant: access + refresh pair (RFC 6749 §4.1.4 / §5.1) ─── (id-token-test "issue_grant access token introspects active" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, A, _R} = identity_tokens:issue_grant(Reg, alice, web, read),\n case identity_tokens:introspect(Reg, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) "active") ;; ── refresh rotation (RFC 6749 §6) ─────────────────────────────── (id-token-test "refresh mints a working new access token" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) "active") (id-token-test "rotated token keeps the grant's subject" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, Subject, _, _} -> Subject\n end")) "alice") (id-token-test "refresh chains across rotations" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, R2} = identity_tokens:refresh(Reg, R),\n {ok, A3, _R3} = identity_tokens:refresh(Reg, R2),\n case identity_tokens:introspect(Reg, A3) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) "active") (id-token-test "refreshing an unknown token is invalid_grant" (idtnm (idt-ev "Reg = identity_tokens:start(),\n Bogus = make_ref(),\n case identity_tokens:refresh(Reg, Bogus) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end")) "invalid_grant") ;; ── refresh-token reuse = theft → revoke the family (RFC 6819) ──── (id-token-test "reusing a superseded refresh token is invalid_grant" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, _A2, _R2} = identity_tokens:refresh(Reg, R),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end")) "invalid_grant") (id-token-test "refresh reuse revokes the live descendant too" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, _A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n {ok, A2, _R2} = identity_tokens:refresh(Reg, R),\n identity_tokens:refresh(Reg, R),\n case identity_tokens:introspect(Reg, A2) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")) "inactive") ;; ── cascading revocation: revoke any token, the grant dies ─────── (id-token-test "revoking the access token blocks refresh" (idtnm (idt-ev "Reg = identity_tokens:start(),\n {ok, A, R} = identity_tokens:issue_grant(Reg, alice, web, read),\n identity_tokens:revoke(Reg, A),\n case identity_tokens:refresh(Reg, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end")) "invalid_grant") (id-token-test "revoking the refresh token deactivates the access token" (idtnm (idt-ev "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))