From 9860582b4ab4e87491c6ed47b702c5a5a01fdde2 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 02:03:44 +0000 Subject: [PATCH] =?UTF-8?q?identity:=20OAuth=20client=20registry=20?= =?UTF-8?q?=E2=80=94=20public/confidential=20clients=20+=20redirect=20allo?= =?UTF-8?q?w-list=20(11=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clients.sx (RFC 6749 §2) — confidential clients must present the correct secret at the token endpoint (wrong → invalid_client); public clients are identified but not authenticated; redirect_uris are pre-registered and checked by exact-match valid_redirect (§3.1.2.2 + Security BCP). Standalone module for now; wiring confidential-client auth into oauth exchange is a follow-up. New tests/clients.sx. 149/149. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/clients.sx | 28 +++++++++ lib/identity/conformance.sh | 5 ++ lib/identity/scoreboard.json | 7 ++- lib/identity/scoreboard.md | 3 +- lib/identity/tests/clients.sx | 108 ++++++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 10 +++- 6 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 lib/identity/clients.sx create mode 100644 lib/identity/tests/clients.sx diff --git a/lib/identity/clients.sx b/lib/identity/clients.sx new file mode 100644 index 00000000..5bf41408 --- /dev/null +++ b/lib/identity/clients.sx @@ -0,0 +1,28 @@ +;; identity/clients.sx — the OAuth client registry (RFC 6749 §2). +;; +;; A client is registered with a type, a secret, and its allow-listed +;; redirect_uris: +;; +;; public — cannot keep a secret (SPAs, native apps, §2.1); +;; identified but not authenticated. +;; confidential — can authenticate; MUST present its secret at the token +;; endpoint (§3.2.1, §4.1.3). A wrong secret is +;; invalid_client — never a soft pass. +;; +;; Redirect URIs must be pre-registered (§3.1.2.2 + OAuth Security BCP): +;; valid_redirect/3 is the exact-match check the authorize/exchange steps +;; consult so an attacker cannot redirect the code to an unregistered URI. +;; +;; register(C, ClientId, Type, Secret, RedirectUris) -> ok | {error, exists} +;; lookup(C, ClientId) -> {ok, Type, RedirectUris} | {error, unknown_client} +;; authenticate(C, ClientId, Sec) -> {ok, public} | {ok, confidential} +;; | {error, invalid_client} | {error, unknown_client} +;; valid_redirect(C, ClientId, U) -> true | false + +(define + identity-clients-source + "-module(identity_clients).\n\n start() ->\n spawn(fun () -> loop([]) end).\n\n register(C, ClientId, Type, Secret, RedirectUris) ->\n C ! {register, ClientId, Type, Secret, RedirectUris, self()},\n receive {client_reply, R} -> R end.\n\n lookup(C, ClientId) ->\n C ! {lookup, ClientId, self()},\n receive {client_reply, R} -> R end.\n\n authenticate(C, ClientId, Secret) ->\n C ! {authenticate, ClientId, Secret, self()},\n receive {client_reply, R} -> R end.\n\n valid_redirect(C, ClientId, Uri) ->\n C ! {valid_redirect, ClientId, Uri, self()},\n receive {client_reply, R} -> R end.\n\n loop(Clients) ->\n receive\n {register, ClientId, Type, Secret, RedirectUris, From} ->\n case find(ClientId, Clients) of\n {ok, _} ->\n From ! {client_reply, {error, exists}},\n loop(Clients);\n none ->\n From ! {client_reply, ok},\n loop([{ClientId, {Type, Secret, RedirectUris}} | Clients])\n end;\n {lookup, ClientId, From} ->\n case find(ClientId, Clients) of\n none -> From ! {client_reply, {error, unknown_client}};\n {ok, {Type, _, Uris}} -> From ! {client_reply, {ok, Type, Uris}}\n end,\n loop(Clients);\n {authenticate, ClientId, Secret, From} ->\n case find(ClientId, Clients) of\n none ->\n From ! {client_reply, {error, unknown_client}};\n {ok, {public, _, _}} ->\n From ! {client_reply, {ok, public}};\n {ok, {confidential, S, _}} ->\n case S =:= Secret of\n true -> From ! {client_reply, {ok, confidential}};\n false -> From ! {client_reply, {error, invalid_client}}\n end\n end,\n loop(Clients);\n {valid_redirect, ClientId, Uri, From} ->\n case find(ClientId, Clients) of\n none -> From ! {client_reply, false};\n {ok, {_, _, Uris}} -> From ! {client_reply, member(Uri, Uris)}\n end,\n loop(Clients);\n {stop, From} ->\n From ! {client_reply, ok}\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-clients! + (fn () (erlang-load-module identity-clients-source))) diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index 3a5328f1..bf71703a 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -39,6 +39,7 @@ SUITES=( "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" + "clients|id-clients-test-pass|id-clients-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -59,6 +60,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/cache.sx") (load "lib/identity/audit.sx") (load "lib/identity/federation.sx") +(load "lib/identity/clients.sx") (load "lib/identity/tests/session.sx") (load "lib/identity/tests/token.sx") (load "lib/identity/tests/registry.sx") @@ -70,6 +72,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/audit.sx") (load "lib/identity/tests/federation.sx") (load "lib/identity/tests/expiry.sx") +(load "lib/identity/tests/clients.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -92,6 +95,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-fed-test-pass id-fed-test-count)") (epoch 110) (eval "(list id-expiry-test-pass id-expiry-test-count)") +(epoch 111) +(eval "(list id-clients-test-pass id-clients-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 97e4e5a4..7676bf1b 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 138, - "total": 138, + "total_pass": 149, + "total": 149, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -13,6 +13,7 @@ {"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":"expiry","pass":8,"total":8,"status":"ok"} + {"name":"expiry","pass":8,"total":8,"status":"ok"}, + {"name":"clients","pass":11,"total":11,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 48af5476..0c4d98e4 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 138 / 138 tests passing** +**Total: 149 / 149 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -15,6 +15,7 @@ | ✅ | audit | 11 | 11 | | ✅ | federation | 12 | 12 | | ✅ | expiry | 8 | 8 | +| ✅ | clients | 11 | 11 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/clients.sx b/lib/identity/tests/clients.sx new file mode 100644 index 00000000..e071446d --- /dev/null +++ b/lib/identity/tests/clients.sx @@ -0,0 +1,108 @@ +;; identity/tests/clients.sx — OAuth client registry: registration, +;; public vs confidential authentication, and redirect_uri allow-listing. + +(define id-clients-test-count 0) +(define id-clients-test-pass 0) +(define id-clients-test-fails (list)) + +(define + id-clients-test + (fn + (name actual expected) + (set! id-clients-test-count (+ id-clients-test-count 1)) + (if + (= actual expected) + (set! id-clients-test-pass (+ id-clients-test-pass 1)) + (append! id-clients-test-fails {:name name :expected expected :actual actual})))) + +(define idc-ev erlang-eval-ast) +(define idcnm (fn (v) (get v :name))) + +(identity-load-clients!) + +;; ── registration + lookup ──────────────────────────────────────── + +(id-clients-test + "a registered client looks up its type" + (idcnm + (idc-ev + "C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:lookup(C, app1) of\n {ok, Type, _} -> Type;\n {error, W} -> W\n end")) + "confidential") + +(id-clients-test + "registering the same client twice is an error" + (idcnm + (idc-ev + "C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:register(C, app1, public, none, [uri1]) of\n ok -> ok;\n {error, W} -> W\n end")) + "exists") + +(id-clients-test + "looking up an unregistered client is unknown_client" + (idcnm + (idc-ev + "C = identity_clients:start(),\n case identity_clients:lookup(C, ghost) of\n {ok, _, _} -> found;\n {error, W} -> W\n end")) + "unknown_client") + +;; ── confidential client authentication ─────────────────────────── + +(id-clients-test + "a confidential client authenticates with the right secret" + (idcnm + (idc-ev + "C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:authenticate(C, app1, s3cret) of\n {ok, Kind} -> Kind;\n {error, W} -> W\n end")) + "confidential") + +(id-clients-test + "a confidential client with the wrong secret is invalid_client" + (idcnm + (idc-ev + "C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:authenticate(C, app1, wrongsecret) of\n {ok, _} -> accepted;\n {error, W} -> W\n end")) + "invalid_client") + +(id-clients-test + "a public client needs no secret to authenticate" + (idcnm + (idc-ev + "C = identity_clients:start(),\n identity_clients:register(C, spa, public, none, [uri1]),\n case identity_clients:authenticate(C, spa, anything) of\n {ok, Kind} -> Kind;\n {error, W} -> W\n end")) + "public") + +(id-clients-test + "authenticating an unknown client is unknown_client" + (idcnm + (idc-ev + "C = identity_clients:start(),\n case identity_clients:authenticate(C, ghost, x) of\n {ok, _} -> accepted;\n {error, W} -> W\n end")) + "unknown_client") + +;; ── redirect_uri allow-listing ─────────────────────────────────── + +(id-clients-test + "a registered redirect_uri is valid" + (idcnm + (idc-ev + "C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1, uri2]),\n case identity_clients:valid_redirect(C, app1, uri1) of\n true -> yes;\n false -> no\n end")) + "yes") + +(id-clients-test + "a second registered redirect_uri is also valid" + (idcnm + (idc-ev + "C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1, uri2]),\n case identity_clients:valid_redirect(C, app1, uri2) of\n true -> yes;\n false -> no\n end")) + "yes") + +(id-clients-test + "an unregistered redirect_uri is rejected" + (idcnm + (idc-ev + "C = identity_clients:start(),\n identity_clients:register(C, app1, confidential, s3cret, [uri1]),\n case identity_clients:valid_redirect(C, app1, evil_uri) of\n true -> yes;\n false -> no\n end")) + "no") + +(id-clients-test + "redirect validation for an unknown client is rejected" + (idcnm + (idc-ev + "C = identity_clients:start(),\n case identity_clients:valid_redirect(C, ghost, uri1) of\n true -> yes;\n false -> no\n end")) + "no") + +(define + id-clients-test-summary + (str "clients " id-clients-test-pass "/" id-clients-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index b576a6eb..86d6a9c7 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` → **138/138** (4 phases + ext: scope narrowing, token TTL) +`bash lib/identity/conformance.sh` → **149/149** (4 phases + ext: scope, TTL, client registry) ## Ground rules @@ -81,13 +81,19 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [~] PKCE S256 method (RFC 7636 §4.2) — BLOCKED on erlang substrate (see Blockers) - [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) +- [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2) - [ ] client-credentials grant (RFC 6749 §4.4) and device grant (RFC 8628) - [ ] 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 — `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 + authenticated; redirect_uris are allow-listed with exact-match + `valid_redirect` (§3.1.2.2 + Security BCP). Standalone module (no oauth + wiring yet — that's a follow-up). New tests/clients.sx (11). 138→149. - 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