From ded71705404c3105689daa39e209a6a40d2b51be Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 03:31:14 +0000 Subject: [PATCH] =?UTF-8?q?identity:=20token=20exchange=20=E2=80=94=20down?= =?UTF-8?q?scope=20into=20an=20independent=20token=20(RFC=208693,=20+8=20t?= =?UTF-8?q?ests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oauth.sx gains token_exchange(SubjectToken, RequestedScope): a valid access token is downscoped into a NEW independent grant for the same subject (subset only, else invalid_scope; inactive subject token → invalid_grant). The exchanged token's lifecycle is independent of the subject token (revoking either leaves the other active); exchanges chain. Least-privilege handoff to downstream services. New tests/exchange.sx. 201/201. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/conformance.sh | 4 ++ lib/identity/oauth.sx | 21 +++---- lib/identity/scoreboard.json | 7 ++- lib/identity/scoreboard.md | 3 +- lib/identity/tests/exchange.sx | 110 +++++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 10 ++- 6 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 lib/identity/tests/exchange.sx diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index 806ce8a5..ab6d7fc5 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -45,6 +45,7 @@ SUITES=( "facade|id-facade-test-pass|id-facade-test-count" "delegation|id-deleg-test-pass|id-deleg-test-count" "session-mgmt|id-smgmt-test-pass|id-smgmt-test-count" + "exchange|id-xchg-test-pass|id-xchg-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -85,6 +86,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/facade.sx") (load "lib/identity/tests/delegation.sx") (load "lib/identity/tests/session_mgmt.sx") +(load "lib/identity/tests/exchange.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -119,6 +121,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-deleg-test-pass id-deleg-test-count)") (epoch 116) (eval "(list id-smgmt-test-pass id-smgmt-test-count)") +(epoch 117) +(eval "(list id-xchg-test-pass id-xchg-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/oauth.sx b/lib/identity/oauth.sx index 08744343..6bbf608f 100644 --- a/lib/identity/oauth.sx +++ b/lib/identity/oauth.sx @@ -1,7 +1,7 @@ ;; identity/oauth.sx — the OAuth2 authorization server as a message ;; protocol. Grants: authorization-code (RFC 6749 §4.1) with PKCE (RFC -;; 7636, `plain`), refresh (§6), silent `prompt=none` (OIDC §3.1.2.1), and -;; client-credentials (§4.4). +;; 7636, `plain`), refresh (§6), silent `prompt=none` (OIDC §3.1.2.1), +;; client-credentials (§4.4), and token exchange (RFC 8693). ;; ;; The authz-code flow is a state machine on one process: ;; authorize -> {consent_required, ReqId} (§4.1.1) @@ -12,19 +12,18 @@ ;; silent_authorize -> {code, Code} | {error, login_required} ;; register_client -> ok | {error, exists} ;; client_credentials -> {ok, Token} | {error, invalid_client|unauthorized_client} +;; token_exchange -> {ok, Token} | {error, invalid_grant|invalid_scope} ;; -;; Exchange enforces single-use codes (§10.5), client+redirect binding -;; (§4.1.3), PKCE. Silent SSO reuses the same machine. Tokens are -;; grant-backed (token.sx); revocation cascades. Client-credentials -;; authenticates a CONFIDENTIAL client (clients.sx) acting on its own -;; behalf — no end-user, no refresh token (§4.4.3); a public client is -;; unauthorized_client, and any auth failure (unknown client or wrong -;; secret) is invalid_client — never a client-existence oracle (§5.2). The -;; server proves identity; acl decides permission. +;; Token exchange (RFC 8693 §2.1) downscopes: a valid access token is +;; exchanged for a NEW independent grant for the same subject with a subset +;; of its scope — least-privilege handoff to a downstream service. The new +;; token's lifecycle is independent of the subject token (revoking one does +;; not revoke the other). Tokens are grant-backed (token.sx); revocation +;; cascades. The server proves identity; acl decides permission. (define identity-oauth-source - "-module(identity_oauth).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n SessReg = identity_registry:start(),\n ClientReg = identity_clients:start(),\n loop(TokReg, SessReg, ClientReg, [], [], 1)\n end).\n\n authorize(O, ClientId, RedirectUri, Scope, Subject, Challenge) ->\n O ! {authorize, ClientId, RedirectUri, Scope, Subject, Challenge, self()},\n receive {oauth_reply, R} -> R end.\n\n consent(O, ReqId, Decision) ->\n O ! {consent, ReqId, Decision, self()},\n receive {oauth_reply, R} -> R end.\n\n exchange(O, Code, ClientId, RedirectUri, Verifier) ->\n O ! {exchange, Code, ClientId, RedirectUri, Verifier, self()},\n receive {oauth_reply, R} -> R end.\n\n refresh(O, RefreshTok) ->\n O ! {refresh, RefreshTok, self()},\n receive {oauth_reply, R} -> R end.\n\n establish(O, Subject, Client) ->\n O ! {establish, Subject, Client, self()},\n receive {oauth_reply, R} -> R end.\n\n silent_authorize(O, ClientId, RedirectUri, Scope, Subject, Challenge) ->\n O ! {silent_authorize, ClientId, RedirectUri, Scope, Subject, Challenge, self()},\n receive {oauth_reply, R} -> R end.\n\n end_session(O, SessionId) ->\n O ! {end_session, SessionId, self()},\n receive {oauth_reply, R} -> R end.\n\n register_client(O, ClientId, Type, Secret, RedirectUris) ->\n O ! {register_client, ClientId, Type, Secret, RedirectUris, self()},\n receive {oauth_reply, R} -> R end.\n\n client_credentials(O, ClientId, Secret, Scope) ->\n O ! {client_credentials, ClientId, Secret, Scope, self()},\n receive {oauth_reply, R} -> R end.\n\n introspect(O, Token) ->\n O ! {introspect, Token, self()},\n receive {oauth_reply, R} -> R end.\n\n revoke(O, Token) ->\n O ! {revoke, Token, self()},\n receive {oauth_reply, R} -> R end.\n\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid) ->\n receive\n {authorize, ClientId, RedirectUri, Scope, Subject, Challenge, From} ->\n ReqId = make_ref(),\n Rec = {ClientId, RedirectUri, Scope, Subject, Challenge},\n From ! {oauth_reply, {consent_required, ReqId}},\n loop(TokReg, SessReg, ClientReg, [{ReqId, Rec} | Pending], Codes, NextSid);\n {consent, ReqId, Decision, From} ->\n case find(ReqId, Pending) of\n none ->\n From ! {oauth_reply, {error, unknown_request}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {ok, Rec} ->\n Pending2 = remove(ReqId, Pending),\n case Decision of\n allow ->\n Code = make_ref(),\n From ! {oauth_reply, {code, Code}},\n loop(TokReg, SessReg, ClientReg, Pending2, [{Code, Rec} | Codes], NextSid);\n deny ->\n From ! {oauth_reply, {error, access_denied}},\n loop(TokReg, SessReg, ClientReg, Pending2, Codes, NextSid)\n end\n end;\n {establish, Subject, Client, From} ->\n Sid = NextSid,\n Self = self(),\n S = identity_session:start(Sid, Subject, Client, Self, infinity),\n identity_registry:register(SessReg, Sid, Subject, Client, S),\n From ! {oauth_reply, {ok, Sid}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid + 1);\n {silent_authorize, ClientId, RedirectUri, Scope, Subject, Challenge, From} ->\n case subject_active(SessReg, Subject) of\n false ->\n From ! {oauth_reply, {error, login_required}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n true ->\n Code = make_ref(),\n Rec = {ClientId, RedirectUri, Scope, Subject, Challenge},\n From ! {oauth_reply, {code, Code}},\n loop(TokReg, SessReg, ClientReg, Pending, [{Code, Rec} | Codes], NextSid)\n end;\n {end_session, Sid, From} ->\n case identity_registry:whereis_session(SessReg, Sid) of\n {ok, Pid} -> identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, Sid),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {register_client, ClientId, Type, Secret, RedirectUris, From} ->\n From ! {oauth_reply, identity_clients:register(ClientReg, ClientId, Type, Secret, RedirectUris)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {client_credentials, ClientId, Secret, Scope, From} ->\n case identity_clients:authenticate(ClientReg, ClientId, Secret) of\n {ok, confidential} ->\n {ok, Token} = identity_tokens:issue(TokReg, ClientId, ClientId, Scope),\n From ! {oauth_reply, {ok, Token}};\n {ok, public} ->\n From ! {oauth_reply, {error, unauthorized_client}};\n {error, _} ->\n From ! {oauth_reply, {error, invalid_client}}\n end,\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {exchange, Code, ClientId, RedirectUri, Verifier, From} ->\n case find(Code, Codes) of\n none ->\n From ! {oauth_reply, {error, invalid_grant}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {ok, Rec} ->\n Codes2 = remove(Code, Codes),\n From ! {oauth_reply, redeem(TokReg, Rec, ClientId, RedirectUri, Verifier)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes2, NextSid)\n end;\n {refresh, RTok, From} ->\n From ! {oauth_reply, identity_tokens:refresh(TokReg, RTok)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {introspect, Token, From} ->\n From ! {oauth_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid)\n end.\n\n subject_active(SessReg, Subject) ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> any_active(SessReg, Ids)\n end.\n\n any_active(_, []) -> false;\n any_active(SessReg, [Id | Rest]) ->\n case identity_registry:whereis_session(SessReg, Id) of\n {ok, Pid} ->\n case identity_session:lookup(Pid) of\n {ok, _} -> true;\n {error, _} -> any_active(SessReg, Rest)\n end;\n {error, _} -> any_active(SessReg, Rest)\n end.\n\n redeem(TokReg, {CCid, CRedir, Scope, Subject, Challenge}, ClientId, RedirectUri, Verifier) ->\n case CCid =:= ClientId of\n false -> {error, invalid_grant};\n true ->\n case CRedir =:= RedirectUri of\n false -> {error, invalid_grant};\n true ->\n case Challenge =:= Verifier of\n false -> {error, invalid_grant};\n true -> identity_tokens:issue_grant(TokReg, Subject, ClientId, Scope)\n end\n end\n end.\n\n find(_, []) -> none;\n find(Key, [{K, Rec} | Rest]) ->\n case K =:= Key of\n true -> {ok, Rec};\n false -> find(Key, Rest)\n end.\n\n remove(_, []) -> [];\n remove(Key, [{K, Rec} | Rest]) ->\n case K =:= Key of\n true -> remove(Key, Rest);\n false -> [{K, Rec} | remove(Key, Rest)]\n end.") + "-module(identity_oauth).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n SessReg = identity_registry:start(),\n ClientReg = identity_clients:start(),\n loop(TokReg, SessReg, ClientReg, [], [], 1)\n end).\n\n authorize(O, ClientId, RedirectUri, Scope, Subject, Challenge) ->\n O ! {authorize, ClientId, RedirectUri, Scope, Subject, Challenge, self()},\n receive {oauth_reply, R} -> R end.\n\n consent(O, ReqId, Decision) ->\n O ! {consent, ReqId, Decision, self()},\n receive {oauth_reply, R} -> R end.\n\n exchange(O, Code, ClientId, RedirectUri, Verifier) ->\n O ! {exchange, Code, ClientId, RedirectUri, Verifier, self()},\n receive {oauth_reply, R} -> R end.\n\n refresh(O, RefreshTok) ->\n O ! {refresh, RefreshTok, self()},\n receive {oauth_reply, R} -> R end.\n\n establish(O, Subject, Client) ->\n O ! {establish, Subject, Client, self()},\n receive {oauth_reply, R} -> R end.\n\n silent_authorize(O, ClientId, RedirectUri, Scope, Subject, Challenge) ->\n O ! {silent_authorize, ClientId, RedirectUri, Scope, Subject, Challenge, self()},\n receive {oauth_reply, R} -> R end.\n\n end_session(O, SessionId) ->\n O ! {end_session, SessionId, self()},\n receive {oauth_reply, R} -> R end.\n\n register_client(O, ClientId, Type, Secret, RedirectUris) ->\n O ! {register_client, ClientId, Type, Secret, RedirectUris, self()},\n receive {oauth_reply, R} -> R end.\n\n client_credentials(O, ClientId, Secret, Scope) ->\n O ! {client_credentials, ClientId, Secret, Scope, self()},\n receive {oauth_reply, R} -> R end.\n\n token_exchange(O, SubjectToken, RequestedScope) ->\n O ! {token_exchange, SubjectToken, RequestedScope, self()},\n receive {oauth_reply, R} -> R end.\n\n introspect(O, Token) ->\n O ! {introspect, Token, self()},\n receive {oauth_reply, R} -> R end.\n\n revoke(O, Token) ->\n O ! {revoke, Token, self()},\n receive {oauth_reply, R} -> R end.\n\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid) ->\n receive\n {authorize, ClientId, RedirectUri, Scope, Subject, Challenge, From} ->\n ReqId = make_ref(),\n Rec = {ClientId, RedirectUri, Scope, Subject, Challenge},\n From ! {oauth_reply, {consent_required, ReqId}},\n loop(TokReg, SessReg, ClientReg, [{ReqId, Rec} | Pending], Codes, NextSid);\n {consent, ReqId, Decision, From} ->\n case find(ReqId, Pending) of\n none ->\n From ! {oauth_reply, {error, unknown_request}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {ok, Rec} ->\n Pending2 = remove(ReqId, Pending),\n case Decision of\n allow ->\n Code = make_ref(),\n From ! {oauth_reply, {code, Code}},\n loop(TokReg, SessReg, ClientReg, Pending2, [{Code, Rec} | Codes], NextSid);\n deny ->\n From ! {oauth_reply, {error, access_denied}},\n loop(TokReg, SessReg, ClientReg, Pending2, Codes, NextSid)\n end\n end;\n {establish, Subject, Client, From} ->\n Sid = NextSid,\n Self = self(),\n S = identity_session:start(Sid, Subject, Client, Self, infinity),\n identity_registry:register(SessReg, Sid, Subject, Client, S),\n From ! {oauth_reply, {ok, Sid}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid + 1);\n {silent_authorize, ClientId, RedirectUri, Scope, Subject, Challenge, From} ->\n case subject_active(SessReg, Subject) of\n false ->\n From ! {oauth_reply, {error, login_required}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n true ->\n Code = make_ref(),\n Rec = {ClientId, RedirectUri, Scope, Subject, Challenge},\n From ! {oauth_reply, {code, Code}},\n loop(TokReg, SessReg, ClientReg, Pending, [{Code, Rec} | Codes], NextSid)\n end;\n {end_session, Sid, From} ->\n case identity_registry:whereis_session(SessReg, Sid) of\n {ok, Pid} -> identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, Sid),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {register_client, ClientId, Type, Secret, RedirectUris, From} ->\n From ! {oauth_reply, identity_clients:register(ClientReg, ClientId, Type, Secret, RedirectUris)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {client_credentials, ClientId, Secret, Scope, From} ->\n case identity_clients:authenticate(ClientReg, ClientId, Secret) of\n {ok, confidential} ->\n {ok, Token} = identity_tokens:issue(TokReg, ClientId, ClientId, Scope),\n From ! {oauth_reply, {ok, Token}};\n {ok, public} ->\n From ! {oauth_reply, {error, unauthorized_client}};\n {error, _} ->\n From ! {oauth_reply, {error, invalid_client}}\n end,\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {token_exchange, SubjectToken, RequestedScope, From} ->\n case identity_tokens:introspect(TokReg, SubjectToken) of\n {inactive} ->\n From ! {oauth_reply, {error, invalid_grant}};\n {active, Subject, Client, Scope} ->\n case subset(RequestedScope, Scope) of\n true ->\n {ok, NewTok} = identity_tokens:issue(TokReg, Subject, Client, RequestedScope),\n From ! {oauth_reply, {ok, NewTok}};\n false ->\n From ! {oauth_reply, {error, invalid_scope}}\n end\n end,\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {exchange, Code, ClientId, RedirectUri, Verifier, From} ->\n case find(Code, Codes) of\n none ->\n From ! {oauth_reply, {error, invalid_grant}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {ok, Rec} ->\n Codes2 = remove(Code, Codes),\n From ! {oauth_reply, redeem(TokReg, Rec, ClientId, RedirectUri, Verifier)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes2, NextSid)\n end;\n {refresh, RTok, From} ->\n From ! {oauth_reply, identity_tokens:refresh(TokReg, RTok)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {introspect, Token, From} ->\n From ! {oauth_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid)\n end.\n\n subject_active(SessReg, Subject) ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> any_active(SessReg, Ids)\n end.\n\n any_active(_, []) -> false;\n any_active(SessReg, [Id | Rest]) ->\n case identity_registry:whereis_session(SessReg, Id) of\n {ok, Pid} ->\n case identity_session:lookup(Pid) of\n {ok, _} -> true;\n {error, _} -> any_active(SessReg, Rest)\n end;\n {error, _} -> any_active(SessReg, Rest)\n end.\n\n redeem(TokReg, {CCid, CRedir, Scope, Subject, Challenge}, ClientId, RedirectUri, Verifier) ->\n case CCid =:= ClientId of\n false -> {error, invalid_grant};\n true ->\n case CRedir =:= RedirectUri of\n false -> {error, invalid_grant};\n true ->\n case Challenge =:= Verifier of\n false -> {error, invalid_grant};\n true -> identity_tokens:issue_grant(TokReg, Subject, ClientId, Scope)\n end\n end\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, Rec} | Rest]) ->\n case K =:= Key of\n true -> {ok, Rec};\n false -> find(Key, Rest)\n end.\n\n remove(_, []) -> [];\n remove(Key, [{K, Rec} | Rest]) ->\n case K =:= Key of\n true -> remove(Key, Rest);\n false -> [{K, Rec} | remove(Key, Rest)]\n end.") (define identity-load-oauth! diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 0e52bebe..ab9ec2c4 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 193, - "total": 193, + "total_pass": 201, + "total": 201, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -19,6 +19,7 @@ {"name":"device","pass":10,"total":10,"status":"ok"}, {"name":"facade","pass":9,"total":9,"status":"ok"}, {"name":"delegation","pass":8,"total":8,"status":"ok"}, - {"name":"session-mgmt","pass":8,"total":8,"status":"ok"} + {"name":"session-mgmt","pass":8,"total":8,"status":"ok"}, + {"name":"exchange","pass":8,"total":8,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index b2eba5aa..b2791e71 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 193 / 193 tests passing** +**Total: 201 / 201 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -21,6 +21,7 @@ | ✅ | facade | 9 | 9 | | ✅ | delegation | 8 | 8 | | ✅ | session-mgmt | 8 | 8 | +| ✅ | exchange | 8 | 8 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/exchange.sx b/lib/identity/tests/exchange.sx new file mode 100644 index 00000000..28faee37 --- /dev/null +++ b/lib/identity/tests/exchange.sx @@ -0,0 +1,110 @@ +;; identity/tests/exchange.sx — token exchange (RFC 8693 §2.1): downscope a +;; valid access token into a new independent token for a downstream service. + +(define id-xchg-test-count 0) +(define id-xchg-test-pass 0) +(define id-xchg-test-fails (list)) + +(define + id-xchg-test + (fn + (name actual expected) + (set! id-xchg-test-count (+ id-xchg-test-count 1)) + (if + (= actual expected) + (set! id-xchg-test-pass (+ id-xchg-test-pass 1)) + (append! id-xchg-test-fails {:name name :expected expected :actual actual})))) + +(define idx-ev erlang-eval-ast) +(define idxnm (fn (v) (get v :name))) + +(identity-load-oauth!) + +;; Shared prelude: an access token A for alice with scope [read, write]. +(define + idx-token + "O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, [read, write], alice, v),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v)") + +;; ── downscoping ────────────────────────────────────────────────── + +(id-xchg-test + "exchange downscopes to a subset" + (idxnm + (idx-ev + (str + idx-token + ", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n case identity_oauth:introspect(O, X) of\n {active, _, _, [read]} -> downscoped;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))) + "downscoped") + +(id-xchg-test + "the exchanged token keeps the subject" + (idxnm + (idx-ev + (str + idx-token + ", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n case identity_oauth:introspect(O, X) of\n {active, Subject, _, _} -> Subject\n end"))) + "alice") + +(id-xchg-test + "exchange to the same scope is allowed" + (idxnm + (idx-ev + (str + idx-token + ", {ok, X} = identity_oauth:token_exchange(O, A, [read, write]),\n case identity_oauth:introspect(O, X) of\n {active, _, _, [read, write]} -> full;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))) + "full") + +;; ── scope cannot be widened ────────────────────────────────────── + +(id-xchg-test + "exchange cannot widen beyond the subject token's scope" + (idxnm + (idx-ev + "O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, [read], alice, v),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:token_exchange(O, A, [read, write]) of\n {ok, _} -> widened;\n {error, W} -> W\n end")) + "invalid_scope") + +;; ── inactive subject token cannot be exchanged ─────────────────── + +(id-xchg-test + "exchanging a revoked subject token is invalid_grant" + (idxnm + (idx-ev + (str + idx-token + ", identity_oauth:revoke(O, A),\n case identity_oauth:token_exchange(O, A, [read]) of\n {ok, _} -> issued;\n {error, W} -> W\n end"))) + "invalid_grant") + +;; ── independent lifecycles ─────────────────────────────────────── + +(id-xchg-test + "revoking the subject token does not revoke the exchanged token" + (idxnm + (idx-ev + (str + idx-token + ", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n identity_oauth:revoke(O, A),\n case identity_oauth:introspect(O, X) of\n {active, _, _, _} -> still_active;\n {inactive} -> inactive\n end"))) + "still_active") + +(id-xchg-test + "revoking the exchanged token does not revoke the subject token" + (idxnm + (idx-ev + (str + idx-token + ", {ok, X} = identity_oauth:token_exchange(O, A, [read]),\n identity_oauth:revoke(O, X),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> still_active;\n {inactive} -> inactive\n end"))) + "still_active") + +;; ── chained downscoping ────────────────────────────────────────── + +(id-xchg-test + "an exchanged token can itself be exchanged (chain)" + (idxnm + (idx-ev + (str + idx-token + ", {ok, X1} = identity_oauth:token_exchange(O, A, [read, write]),\n {ok, X2} = identity_oauth:token_exchange(O, X1, [read]),\n case identity_oauth:introspect(O, X2) of\n {active, _, _, [read]} -> chained;\n {active, _, _, _} -> other;\n {inactive} -> inactive\n end"))) + "chained") + +(define + id-xchg-test-summary + (str "exchange " id-xchg-test-pass "/" id-xchg-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 2b5c7d52..a16b2e93 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` → **193/193** (4 phases + 9 ext) +`bash lib/identity/conformance.sh` → **201/201** (4 phases + 10 ext) ## Ground rules @@ -87,8 +87,16 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [ ] OAuth `state` (CSRF) + OIDC `nonce` threaded through authorize→exchange - [x] unify `api.sx` over membership + audit (one facade, audited login/logout) - [x] subject-wide session management: `sessions(Subject)` + `logout_all` (log out everywhere) +- [x] token exchange (RFC 8693): downscope a token into a new independent token ## Progress log +- 2026-06-07 — token exchange (ext, RFC 8693 §2.1): `oauth.sx` gains + `token_exchange(SubjectToken, RequestedScope)` — a valid access token is + downscoped into a NEW independent grant for the same subject (subset only, + else invalid_scope; inactive subject token → invalid_grant). The new token's + lifecycle is independent (revoking either leaves the other active); + exchanges chain. Least-privilege handoff to downstream services. New + tests/exchange.sx (8). 193→201. - 2026-06-07 — subject-wide session management (ext): `api.sx` gains `sessions(Subject)` (enumerate) and `logout_all(Subject)` ("log out everywhere") — revokes + deregisters every session a subject holds,