identity: client-credentials grant (RFC 6749 §4.4, +9 tests)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 02:22:26 +00:00
parent 9860582b4a
commit 3f3459d129
6 changed files with 139 additions and 25 deletions

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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"}
]
}

View File

@@ -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`.

View File

@@ -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))

View File

@@ -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