From 27f43dbf10d3735fb79f76b0ba76b20596c30d3c Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 00:11:18 +0000 Subject: [PATCH] identity: OAuth2 authorization-code flow as message protocol + PKCE (14 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oauth.sx — RFC 6749 §4.1 as a state machine on one authz-server process: authorize → {consent_required} → consent(allow|deny) → {code} → exchange → {ok, Token}. Exchange enforces single-use codes (§10.5, replay → invalid_grant), client_id + redirect_uri binding (§4.1.3), and PKCE (RFC 7636 plain) verifier match. Issued tokens are grant-backed via token.sx so revocation stays real. 53/53. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/identity/conformance.sh | 5 ++ lib/identity/oauth.sx | 29 +++++++ lib/identity/scoreboard.json | 7 +- lib/identity/scoreboard.md | 3 +- lib/identity/tests/oauth.sx | 162 +++++++++++++++++++++++++++++++++++ plans/identity-on-sx.md | 11 ++- 6 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 lib/identity/oauth.sx create mode 100644 lib/identity/tests/oauth.sx diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index 71df9186..a02693ce 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -32,6 +32,7 @@ SUITES=( "token|id-token-test-pass|id-token-test-count" "registry|id-registry-test-pass|id-registry-test-count" "api|id-api-test-pass|id-api-test-count" + "oauth|id-oauth-test-pass|id-oauth-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -47,10 +48,12 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/token.sx") (load "lib/identity/registry.sx") (load "lib/identity/api.sx") +(load "lib/identity/oauth.sx") (load "lib/identity/tests/session.sx") (load "lib/identity/tests/token.sx") (load "lib/identity/tests/registry.sx") (load "lib/identity/tests/api.sx") +(load "lib/identity/tests/oauth.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -59,6 +62,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-registry-test-pass id-registry-test-count)") (epoch 103) (eval "(list id-api-test-pass id-api-test-count)") +(epoch 104) +(eval "(list id-oauth-test-pass id-oauth-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/oauth.sx b/lib/identity/oauth.sx new file mode 100644 index 00000000..8c2008f0 --- /dev/null +++ b/lib/identity/oauth.sx @@ -0,0 +1,29 @@ +;; identity/oauth.sx — the OAuth2 authorization-code flow as a message +;; protocol (RFC 6749 §4.1), with PKCE (RFC 7636, `plain` method). +;; +;; The flow is a state machine threaded through one authorization-server +;; process, never a single function: +;; +;; authorize -> {consent_required, ReqId} (§4.1.1 request stored) +;; consent -> {code, Code} | {error, access_denied} (§4.1.2 grant/deny) +;; exchange -> {ok, Token} | {error, invalid_grant} (§4.1.3 / §5.2) +;; +;; Security invariants enforced at exchange (§4.1.3, §10.5): +;; - the code is single-use: it is removed on the FIRST exchange attempt, +;; so replay yields invalid_grant; +;; - the code is bound to its client_id and redirect_uri; a mismatch is +;; invalid_grant; +;; - PKCE: the presented verifier must match the stored challenge +;; (plain method: challenge == verifier), else invalid_grant. +;; +;; On success a token is issued into a grant-backed table (token.sx), so +;; revocation stays real. The server proves identity; it does not decide +;; permission — that is acl's job, keyed off the issued grant. + +(define + identity-oauth-source + "-module(identity_oauth).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n loop(TokReg, [], [])\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 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, Pending, Codes) ->\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, [{ReqId, Rec} | Pending], Codes);\n {consent, ReqId, Decision, From} ->\n case find(ReqId, Pending) of\n none ->\n From ! {oauth_reply, {error, unknown_request}},\n loop(TokReg, Pending, Codes);\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, Pending2, [{Code, Rec} | Codes]);\n deny ->\n From ! {oauth_reply, {error, access_denied}},\n loop(TokReg, Pending2, Codes)\n end\n end;\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, Pending, Codes);\n {ok, Rec} ->\n Codes2 = remove(Code, Codes),\n From ! {oauth_reply, redeem(TokReg, Rec, ClientId, RedirectUri, Verifier)},\n loop(TokReg, Pending, Codes2)\n end;\n {introspect, Token, From} ->\n From ! {oauth_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, Pending, Codes);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {oauth_reply, ok},\n loop(TokReg, Pending, Codes)\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 ->\n {ok, Token} = identity_tokens:issue(TokReg, Subject, ClientId, Scope),\n {ok, Token}\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))) diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 81e54f3d..0b9850cf 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,11 +1,12 @@ { "language": "identity", - "total_pass": 39, - "total": 39, + "total_pass": 53, + "total": 53, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":9,"total":9,"status":"ok"}, {"name":"registry","pass":9,"total":9,"status":"ok"}, - {"name":"api","pass":10,"total":10,"status":"ok"} + {"name":"api","pass":10,"total":10,"status":"ok"}, + {"name":"oauth","pass":14,"total":14,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 7891a135..bee1db94 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 39 / 39 tests passing** +**Total: 53 / 53 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -8,6 +8,7 @@ | ✅ | token | 9 | 9 | | ✅ | registry | 9 | 9 | | ✅ | api | 10 | 10 | +| ✅ | oauth | 14 | 14 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/oauth.sx b/lib/identity/tests/oauth.sx new file mode 100644 index 00000000..69f02867 --- /dev/null +++ b/lib/identity/tests/oauth.sx @@ -0,0 +1,162 @@ +;; identity/tests/oauth.sx — OAuth2 authorization-code flow (RFC 6749 +;; §4.1) + PKCE (RFC 7636). Covers the full happy path and every +;; rejection: denied consent, single-use codes, client/redirect binding, +;; PKCE verifier mismatch, unknown code/request, and real revocation of +;; an exchanged token. + +(define id-oauth-test-count 0) +(define id-oauth-test-pass 0) +(define id-oauth-test-fails (list)) + +(define + id-oauth-test + (fn + (name actual expected) + (set! id-oauth-test-count (+ id-oauth-test-count 1)) + (if + (= actual expected) + (set! id-oauth-test-pass (+ id-oauth-test-pass 1)) + (append! id-oauth-test-fails {:name name :expected expected :actual actual})))) + +(define ido-ev erlang-eval-ast) +(define idonm (fn (v) (get v :name))) + +(identity-load-token!) +(identity-load-oauth!) + +;; Shared prelude: authorize + consent(allow) leaving Code bound. +(define + ido-granted + "O = identity_oauth:start(),\n {consent_required, ReqId} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, verif1),\n {code, Code} = identity_oauth:consent(O, ReqId, allow)") + +;; ── full happy path ────────────────────────────────────────────── + +(id-oauth-test + "authorize asks for consent" + (idonm + (ido-ev + "O = identity_oauth:start(),\n case identity_oauth:authorize(O, webapp, uri1, read, alice, verif1) of\n {consent_required, _} -> consent_required;\n Other -> Other\n end")) + "consent_required") + +(id-oauth-test + "consent(allow) returns a code" + (idonm (ido-ev (str ido-granted ", case Code of _ -> issued end"))) + "issued") + +(id-oauth-test + "exchanged token introspects active" + (idonm + (ido-ev + (str + ido-granted + ", {ok, Tok} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))) + "active") + +(id-oauth-test + "exchanged token carries the authorized subject" + (idonm + (ido-ev + (str + ido-granted + ", {ok, Tok} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, Subject, _, _} -> Subject\n end"))) + "alice") + +(id-oauth-test + "exchanged token carries the authorized scope" + (idonm + (ido-ev + (str + ido-granted + ", {ok, Tok} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, Scope} -> Scope\n end"))) + "read") + +;; ── consent denied (§4.1.2.1) ──────────────────────────────────── + +(id-oauth-test + "denied consent yields access_denied" + (idonm + (ido-ev + "O = identity_oauth:start(),\n {consent_required, ReqId} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, verif1),\n case identity_oauth:consent(O, ReqId, deny) of\n {error, Why} -> Why;\n {code, _} -> issued\n end")) + "access_denied") + +;; ── single-use codes (§10.5) ───────────────────────────────────── + +(id-oauth-test + "code cannot be exchanged twice" + (idonm + (ido-ev + (str + ido-granted + ", identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:exchange(O, Code, webapp, uri1, verif1) of\n {ok, _} -> replayed;\n {error, Why} -> Why\n end"))) + "invalid_grant") + +;; ── code binding to client + redirect_uri (§4.1.3) ─────────────── + +(id-oauth-test + "exchange with wrong client is invalid_grant" + (idonm + (ido-ev + (str + ido-granted + ", case identity_oauth:exchange(O, Code, attacker, uri1, verif1) of\n {ok, _} -> ok;\n {error, Why} -> Why\n end"))) + "invalid_grant") + +(id-oauth-test + "exchange with wrong redirect_uri is invalid_grant" + (idonm + (ido-ev + (str + ido-granted + ", case identity_oauth:exchange(O, Code, webapp, evil_uri, verif1) of\n {ok, _} -> ok;\n {error, Why} -> Why\n end"))) + "invalid_grant") + +;; ── PKCE verifier mismatch (RFC 7636) ──────────────────────────── + +(id-oauth-test + "exchange with wrong PKCE verifier is invalid_grant" + (idonm + (ido-ev + (str + ido-granted + ", case identity_oauth:exchange(O, Code, webapp, uri1, badverif) of\n {ok, _} -> ok;\n {error, Why} -> Why\n end"))) + "invalid_grant") + +;; ── unknown code / request ─────────────────────────────────────── + +(id-oauth-test + "exchanging an unknown code is invalid_grant" + (idonm + (ido-ev + "O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:exchange(O, Bogus, webapp, uri1, verif1) of\n {ok, _} -> ok;\n {error, Why} -> Why\n end")) + "invalid_grant") + +(id-oauth-test + "consent on an unknown request is unknown_request" + (idonm + (ido-ev + "O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:consent(O, Bogus, allow) of\n {code, _} -> issued;\n {error, Why} -> Why\n end")) + "unknown_request") + +;; ── revocation is real on an exchanged token (RFC 7009) ────────── + +(id-oauth-test + "revoked exchanged token introspects inactive" + (idonm + (ido-ev + (str + ido-granted + ", {ok, Tok} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n identity_oauth:revoke(O, Tok),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, _} -> still_valid;\n {inactive} -> inactive\n end"))) + "inactive") + +;; ── independence: two concurrent authorizations don't collide ──── + +(id-oauth-test + "two authorizations issue independent grants" + (idonm + (ido-ev + "O = identity_oauth:start(),\n {consent_required, R1} =\n identity_oauth:authorize(O, webapp, uri1, read, alice, va),\n {consent_required, R2} =\n identity_oauth:authorize(O, cli, uri2, write, bob, vb),\n {code, C1} = identity_oauth:consent(O, R1, allow),\n {code, C2} = identity_oauth:consent(O, R2, allow),\n {ok, _T1} = identity_oauth:exchange(O, C1, webapp, uri1, va),\n {ok, T2} = identity_oauth:exchange(O, C2, cli, uri2, vb),\n case identity_oauth:introspect(O, T2) of\n {active, Subject, _, _} -> Subject\n end")) + "bob") + +(define + id-oauth-test-summary + (str "oauth " id-oauth-test-pass "/" id-oauth-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 253ed76a..aaef9c2a 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` → **39/39** (Phase 1 complete: session, token, registry, api) +`bash lib/identity/conformance.sh` → **53/53** (Phase 1 + authz-code flow) ## Ground rules @@ -63,7 +63,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] `api.sx` + tests + scoreboard + conformance.sh ## Phase 2 — OAuth2 flows -- [ ] authorization-code flow as a message protocol +- [x] authorization-code flow as a message protocol - [ ] refresh + rotation; revocation cascades to issued tokens - [ ] tests: full code exchange, refresh, revoke-then-use (must fail) @@ -78,6 +78,13 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [ ] tests: audit completeness, cross-instance subject mapping ## Progress log +- 2026-06-07 — `oauth.sx`: OAuth2 authorization-code flow as a message + protocol (RFC 6749 §4.1) + PKCE (RFC 7636, plain). State machine on one + authz-server process: authorize → {consent_required} → consent → + {code} → exchange → {ok, Token}. Exchange enforces single-use codes + (§10.5; removed on first attempt, replay → invalid_grant), client_id + + redirect_uri binding (§4.1.3), and PKCE verifier match. Issued tokens are + grant-backed so revocation stays real. +14 → 53/53. - 2026-06-06 — `api.sx`: service facade. `identity:start()` spawns one coordinator owning the token table + session registry; exposes login/verify/revoke/logout/session_status. Coordinator is the sessions'