From 3f3459d1294e8a6edf6db3b6bf52f15139eee85c Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 02:22:26 +0000 Subject: [PATCH] =?UTF-8?q?identity:=20client-credentials=20grant=20(RFC?= =?UTF-8?q?=206749=20=C2=A74.4,=20+9=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oauth.sx now owns a client registry (loop/6) with register_client and the client_credentials grant. A confidential client authenticates and gets a token acting on its own behalf (subject = the client), no refresh token (§4.4.3). A public client is unauthorized_client; any auth failure (unknown client or wrong secret) is invalid_client — no client-existence oracle (§5.2). identity-load-oauth! now pulls its deps. New tests/grants.sx. 158/158. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/conformance.sh | 4 ++ lib/identity/oauth.sx | 42 +++++++++------- lib/identity/scoreboard.json | 7 +-- lib/identity/scoreboard.md | 3 +- lib/identity/tests/grants.sx | 96 ++++++++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 12 ++++- 6 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 lib/identity/tests/grants.sx diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index bf71703a..99705a64 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -40,6 +40,7 @@ SUITES=( "federation|id-fed-test-pass|id-fed-test-count" "expiry|id-expiry-test-pass|id-expiry-test-count" "clients|id-clients-test-pass|id-clients-test-count" + "grants|id-grants-test-pass|id-grants-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -73,6 +74,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/federation.sx") (load "lib/identity/tests/expiry.sx") (load "lib/identity/tests/clients.sx") +(load "lib/identity/tests/grants.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -97,6 +99,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-expiry-test-pass id-expiry-test-count)") (epoch 111) (eval "(list id-clients-test-pass id-clients-test-count)") +(epoch 112) +(eval "(list id-grants-test-pass id-grants-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/oauth.sx b/lib/identity/oauth.sx index 672a5413..08744343 100644 --- a/lib/identity/oauth.sx +++ b/lib/identity/oauth.sx @@ -1,33 +1,37 @@ -;; identity/oauth.sx — the OAuth2 authorization-code flow as a message -;; protocol (RFC 6749 §4.1), with PKCE (RFC 7636, `plain`), the refresh -;; grant (§6), and the silent `prompt=none` fast-path (OIDC §3.1.2.1). -;; -;; The flow is a state machine threaded through one authorization-server -;; process, never a single function: +;; 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). ;; +;; The authz-code flow is a state machine on one process: ;; authorize -> {consent_required, ReqId} (§4.1.1) ;; consent -> {code, Code} | {error, access_denied} ;; exchange -> {ok, Access, Refresh} | {error, invalid_grant} ;; refresh -> {ok, Access, Refresh} | {error, invalid_grant} ;; establish -> {ok, SessionId} (interactive login = a session) ;; silent_authorize -> {code, Code} | {error, login_required} +;; register_client -> ok | {error, exists} +;; client_credentials -> {ok, Token} | {error, invalid_client|unauthorized_client} ;; -;; Silent SSO is the SAME machine on a fast-path, not a second -;; implementation: silent_authorize asks the session registry \"does this -;; subject have a live session?\". If yes it skips consent and mints a code -;; bound to the client + redirect_uri + PKCE challenge, exactly like a -;; consented code, so exchange is unchanged. If no, it returns -;; login_required — a negative state, NOT a redirect to a login page (that -;; is the client's UX problem). One session, many clients: every client -;; that asks silently against the same subject session gets its own code. -;; -;; Tokens are grant-backed (token.sx); revocation cascades; the server -;; proves identity and delegates permission to acl. +;; 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. (define identity-oauth-source - "-module(identity_oauth).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n SessReg = identity_registry:start(),\n loop(TokReg, SessReg, [], [], 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 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, 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, [{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, 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, Pending2, [{Code, Rec} | Codes], NextSid);\n deny ->\n From ! {oauth_reply, {error, access_denied}},\n loop(TokReg, SessReg, 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, 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, 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, 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, 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, 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, Pending, Codes2, NextSid)\n end;\n {refresh, RTok, From} ->\n From ! {oauth_reply, identity_tokens:refresh(TokReg, RTok)},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\n {introspect, Token, From} ->\n From ! {oauth_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\n {session_expired, _Sid} ->\n loop(TokReg, SessReg, 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 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.") (define identity-load-oauth! - (fn () (erlang-load-module identity-oauth-source))) + (fn + () + (identity-load-token!) + (identity-load-session!) + (identity-load-registry!) + (identity-load-clients!) + (erlang-load-module identity-oauth-source))) diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 7676bf1b..894e7a32 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 149, - "total": 149, + "total_pass": 158, + "total": 158, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -14,6 +14,7 @@ {"name":"audit","pass":11,"total":11,"status":"ok"}, {"name":"federation","pass":12,"total":12,"status":"ok"}, {"name":"expiry","pass":8,"total":8,"status":"ok"}, - {"name":"clients","pass":11,"total":11,"status":"ok"} + {"name":"clients","pass":11,"total":11,"status":"ok"}, + {"name":"grants","pass":9,"total":9,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 0c4d98e4..dc6fe7a7 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 149 / 149 tests passing** +**Total: 158 / 158 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -16,6 +16,7 @@ | ✅ | federation | 12 | 12 | | ✅ | expiry | 8 | 8 | | ✅ | clients | 11 | 11 | +| ✅ | grants | 9 | 9 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/grants.sx b/lib/identity/tests/grants.sx new file mode 100644 index 00000000..ff64afd1 --- /dev/null +++ b/lib/identity/tests/grants.sx @@ -0,0 +1,96 @@ +;; identity/tests/grants.sx — the client-credentials grant (RFC 6749 +;; §4.4): a confidential client authenticates and gets a token acting on +;; its own behalf — no end-user, no refresh token (§4.4.3). Public clients +;; cannot use it. + +(define id-grants-test-count 0) +(define id-grants-test-pass 0) +(define id-grants-test-fails (list)) + +(define + id-grants-test + (fn + (name actual expected) + (set! id-grants-test-count (+ id-grants-test-count 1)) + (if + (= actual expected) + (set! id-grants-test-pass (+ id-grants-test-pass 1)) + (append! id-grants-test-fails {:name name :expected expected :actual actual})))) + +(define idg-ev erlang-eval-ast) +(define idgnm (fn (v) (get v :name))) + +(identity-load-oauth!) + +;; ── confidential client-credentials happy path ─────────────────── + +(id-grants-test + "a confidential client obtains a working token" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +(id-grants-test + "the client-credentials token's subject is the client itself" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n case identity_oauth:introspect(O, T) of\n {active, Subject, _, _} -> Subject\n end")) + "svc") + +(id-grants-test + "the client-credentials token carries the requested scope" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, reports),\n case identity_oauth:introspect(O, T) of\n {active, _, _, Scope} -> Scope\n end")) + "reports") + +(id-grants-test + "client-credentials issues no refresh token (single value)" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n case identity_oauth:client_credentials(O, svc, sk, batch) of\n {ok, _, _} -> pair;\n {ok, _} -> single;\n {error, W} -> W\n end")) + "single") + +;; ── authentication failures ────────────────────────────────────── + +(id-grants-test + "a wrong client secret is invalid_client" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n case identity_oauth:client_credentials(O, svc, wrong, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end")) + "invalid_client") + +(id-grants-test + "a public client cannot use client-credentials" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n identity_oauth:register_client(O, spa, public, none, [uri1]),\n case identity_oauth:client_credentials(O, spa, none, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end")) + "unauthorized_client") + +(id-grants-test + "an unregistered client cannot use client-credentials" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n case identity_oauth:client_credentials(O, ghost, x, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end")) + "invalid_client") + +;; ── independence + real revocation for client tokens ───────────── + +(id-grants-test + "two confidential clients get independent tokens" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n identity_oauth:register_client(O, svc1, confidential, k1, [uri1]),\n identity_oauth:register_client(O, svc2, confidential, k2, [uri1]),\n {ok, _T1} = identity_oauth:client_credentials(O, svc1, k1, batch),\n {ok, T2} = identity_oauth:client_credentials(O, svc2, k2, batch),\n case identity_oauth:introspect(O, T2) of\n {active, Subject, _, _} -> Subject\n end")) + "svc2") + +(id-grants-test + "a client-credentials token can be revoked" + (idgnm + (idg-ev + "O = identity_oauth:start(),\n identity_oauth:register_client(O, svc, confidential, sk, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, svc, sk, batch),\n identity_oauth:revoke(O, T),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end")) + "inactive") + +(define + id-grants-test-summary + (str "grants " id-grants-test-pass "/" id-grants-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 86d6a9c7..43f73023 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` → **149/149** (4 phases + ext: scope, TTL, client registry) +`bash lib/identity/conformance.sh` → **158/158** (4 phases + ext: scope, TTL, clients, client-creds) ## Ground rules @@ -82,12 +82,20 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it - [x] scope as a set + scope narrowing on refresh (RFC 6749 §6) - [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2) -- [ ] client-credentials grant (RFC 6749 §4.4) and device grant (RFC 8628) +- [~] client-credentials grant (RFC 6749 §4.4) DONE; device grant (RFC 8628) pending - [ ] acl-on-sx delegation: wire `verify`/membership projection → an acl decision, integration test - [ ] OAuth `state` (CSRF) + OIDC `nonce` threaded through authorize→exchange - [ ] unify `api.sx` over oauth + membership + audit (one facade, audited login/consent) ## Progress log +- 2026-06-07 — client-credentials grant (ext, RFC 6749 §4.4): `oauth.sx` now + owns a client registry (loop/6); `register_client` + `client_credentials`. + A confidential client authenticates and gets a token acting on its own + behalf (subject = the client), no refresh token (§4.4.3). A public client is + `unauthorized_client`; any auth failure (unknown client OR wrong secret) is + `invalid_client` — no client-existence oracle (§5.2). `identity-load-oauth!` + now pulls its deps (token/session/registry/clients). New tests/grants.sx (9). + 149→158. - 2026-06-07 — `clients.sx` (ext): OAuth client registry (RFC 6749 §2). public vs confidential clients; confidential clients MUST present the right secret (wrong → invalid_client), public clients are identified but not