identity: OAuth client registry — public/confidential clients + redirect allow-list (11 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
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) <noreply@anthropic.com>
This commit is contained in:
28
lib/identity/clients.sx
Normal file
28
lib/identity/clients.sx
Normal file
@@ -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)))
|
||||||
@@ -39,6 +39,7 @@ SUITES=(
|
|||||||
"audit|id-audit-test-pass|id-audit-test-count"
|
"audit|id-audit-test-pass|id-audit-test-count"
|
||||||
"federation|id-fed-test-pass|id-fed-test-count"
|
"federation|id-fed-test-pass|id-fed-test-count"
|
||||||
"expiry|id-expiry-test-pass|id-expiry-test-count"
|
"expiry|id-expiry-test-pass|id-expiry-test-count"
|
||||||
|
"clients|id-clients-test-pass|id-clients-test-count"
|
||||||
)
|
)
|
||||||
|
|
||||||
cat > "$TMPFILE" << 'EPOCHS'
|
cat > "$TMPFILE" << 'EPOCHS'
|
||||||
@@ -59,6 +60,7 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(load "lib/identity/cache.sx")
|
(load "lib/identity/cache.sx")
|
||||||
(load "lib/identity/audit.sx")
|
(load "lib/identity/audit.sx")
|
||||||
(load "lib/identity/federation.sx")
|
(load "lib/identity/federation.sx")
|
||||||
|
(load "lib/identity/clients.sx")
|
||||||
(load "lib/identity/tests/session.sx")
|
(load "lib/identity/tests/session.sx")
|
||||||
(load "lib/identity/tests/token.sx")
|
(load "lib/identity/tests/token.sx")
|
||||||
(load "lib/identity/tests/registry.sx")
|
(load "lib/identity/tests/registry.sx")
|
||||||
@@ -70,6 +72,7 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(load "lib/identity/tests/audit.sx")
|
(load "lib/identity/tests/audit.sx")
|
||||||
(load "lib/identity/tests/federation.sx")
|
(load "lib/identity/tests/federation.sx")
|
||||||
(load "lib/identity/tests/expiry.sx")
|
(load "lib/identity/tests/expiry.sx")
|
||||||
|
(load "lib/identity/tests/clients.sx")
|
||||||
(epoch 100)
|
(epoch 100)
|
||||||
(eval "(list id-session-test-pass id-session-test-count)")
|
(eval "(list id-session-test-pass id-session-test-count)")
|
||||||
(epoch 101)
|
(epoch 101)
|
||||||
@@ -92,6 +95,8 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(eval "(list id-fed-test-pass id-fed-test-count)")
|
(eval "(list id-fed-test-pass id-fed-test-count)")
|
||||||
(epoch 110)
|
(epoch 110)
|
||||||
(eval "(list id-expiry-test-pass id-expiry-test-count)")
|
(eval "(list id-expiry-test-pass id-expiry-test-count)")
|
||||||
|
(epoch 111)
|
||||||
|
(eval "(list id-clients-test-pass id-clients-test-count)")
|
||||||
EPOCHS
|
EPOCHS
|
||||||
|
|
||||||
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"language": "identity",
|
"language": "identity",
|
||||||
"total_pass": 138,
|
"total_pass": 149,
|
||||||
"total": 138,
|
"total": 149,
|
||||||
"suites": [
|
"suites": [
|
||||||
{"name":"session","pass":11,"total":11,"status":"ok"},
|
{"name":"session","pass":11,"total":11,"status":"ok"},
|
||||||
{"name":"token","pass":24,"total":24,"status":"ok"},
|
{"name":"token","pass":24,"total":24,"status":"ok"},
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
{"name":"cache","pass":9,"total":9,"status":"ok"},
|
{"name":"cache","pass":9,"total":9,"status":"ok"},
|
||||||
{"name":"audit","pass":11,"total":11,"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"}
|
{"name":"expiry","pass":8,"total":8,"status":"ok"},
|
||||||
|
{"name":"clients","pass":11,"total":11,"status":"ok"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# identity-on-sx Scoreboard
|
# identity-on-sx Scoreboard
|
||||||
|
|
||||||
**Total: 138 / 138 tests passing**
|
**Total: 149 / 149 tests passing**
|
||||||
|
|
||||||
| | Suite | Pass | Total |
|
| | Suite | Pass | Total |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
| ✅ | audit | 11 | 11 |
|
| ✅ | audit | 11 | 11 |
|
||||||
| ✅ | federation | 12 | 12 |
|
| ✅ | federation | 12 | 12 |
|
||||||
| ✅ | expiry | 8 | 8 |
|
| ✅ | expiry | 8 | 8 |
|
||||||
|
| ✅ | clients | 11 | 11 |
|
||||||
|
|
||||||
|
|
||||||
Generated by `lib/identity/conformance.sh`.
|
Generated by `lib/identity/conformance.sh`.
|
||||||
|
|||||||
108
lib/identity/tests/clients.sx
Normal file
108
lib/identity/tests/clients.sx
Normal file
@@ -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))
|
||||||
@@ -19,7 +19,7 @@ through the event log, all authorization questions delegated to `acl-on-sx`.
|
|||||||
|
|
||||||
## Status (rolling)
|
## 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
|
## 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)
|
- [~] 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] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it
|
||||||
- [x] scope as a set + scope narrowing on refresh (RFC 6749 §6)
|
- [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)
|
- [ ] 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
|
- [ ] acl-on-sx delegation: wire `verify`/membership projection → an acl decision, integration test
|
||||||
- [ ] OAuth `state` (CSRF) + OIDC `nonce` threaded through authorize→exchange
|
- [ ] OAuth `state` (CSRF) + OIDC `nonce` threaded through authorize→exchange
|
||||||
- [ ] unify `api.sx` over oauth + membership + audit (one facade, audited login/consent)
|
- [ ] unify `api.sx` over oauth + membership + audit (one facade, audited login/consent)
|
||||||
|
|
||||||
## Progress log
|
## 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
|
- 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
|
(`advance`/`now`; no wall clock in substrate). Grants carry a Ttl; each
|
||||||
access token carries an Expires (Now-at-issue + Ttl, or infinity); introspect
|
access token carries an Expires (Now-at-issue + Ttl, or infinity); introspect
|
||||||
|
|||||||
Reference in New Issue
Block a user