diff --git a/lib/identity/oauth.sx b/lib/identity/oauth.sx index 8c2008f0..aee0eac7 100644 --- a/lib/identity/oauth.sx +++ b/lib/identity/oauth.sx @@ -1,12 +1,14 @@ ;; identity/oauth.sx — the OAuth2 authorization-code flow as a message -;; protocol (RFC 6749 §4.1), with PKCE (RFC 7636, `plain` method). +;; protocol (RFC 6749 §4.1), with PKCE (RFC 7636, `plain` method) and the +;; refresh-token grant (RFC 6749 §6). ;; ;; 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) +;; exchange -> {ok, Access, Refresh} | {error, invalid_grant} (§4.1.3/§5.1) +;; refresh -> {ok, Access, Refresh} | {error, invalid_grant} (§6) ;; ;; Security invariants enforced at exchange (§4.1.3, §10.5): ;; - the code is single-use: it is removed on the FIRST exchange attempt, @@ -16,13 +18,14 @@ ;; - 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 +;; Tokens are grant-backed (token.sx): exchange issues an access+refresh +;; pair, refresh rotates it, and revoking any token cascades to the grant — +;; so revocation is 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.") + "-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 refresh(O, RefreshTok) ->\n O ! {refresh, RefreshTok, 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 {refresh, RTok, From} ->\n From ! {oauth_reply, identity_tokens:refresh(TokReg, RTok)},\n loop(TokReg, Pending, Codes);\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 -> 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! diff --git a/lib/identity/scoreboard.json b/lib/identity/scoreboard.json index 5d763cf7..b5f2f020 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,12 +1,12 @@ { "language": "identity", - "total_pass": 62, - "total": 62, + "total_pass": 65, + "total": 65, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":18,"total":18,"status":"ok"}, {"name":"registry","pass":9,"total":9,"status":"ok"}, {"name":"api","pass":10,"total":10,"status":"ok"}, - {"name":"oauth","pass":14,"total":14,"status":"ok"} + {"name":"oauth","pass":17,"total":17,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index e03b1c5d..3803b034 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 62 / 62 tests passing** +**Total: 65 / 65 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -8,7 +8,7 @@ | ✅ | token | 18 | 18 | | ✅ | registry | 9 | 9 | | ✅ | api | 10 | 10 | -| ✅ | oauth | 14 | 14 | +| ✅ | oauth | 17 | 17 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/oauth.sx b/lib/identity/tests/oauth.sx index 69f02867..6160331e 100644 --- a/lib/identity/tests/oauth.sx +++ b/lib/identity/tests/oauth.sx @@ -1,8 +1,9 @@ ;; 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. +;; §4.1) + PKCE (RFC 7636) + refresh grant (§6). Covers the full happy +;; path end-to-end (code exchange → access+refresh → refresh rotation) and +;; every rejection: denied consent, single-use codes, client/redirect +;; binding, PKCE mismatch, unknown code/request, refresh-token reuse, and +;; revoke-then-use (which must fail). (define id-oauth-test-count 0) (define id-oauth-test-pass 0) @@ -44,12 +45,12 @@ "issued") (id-oauth-test - "exchanged token introspects active" + "exchanged access 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"))) + ", {ok, Tok, _R} = 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 @@ -58,7 +59,7 @@ (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"))) + ", {ok, Tok, _R} = 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 @@ -67,9 +68,29 @@ (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"))) + ", {ok, Tok, _R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n case identity_oauth:introspect(O, Tok) of\n {active, _, _, Scope} -> Scope\n end"))) "read") +;; ── refresh grant (RFC 6749 §6) end-to-end ─────────────────────── + +(id-oauth-test + "refresh after exchange yields a working access token" + (idonm + (ido-ev + (str + ido-granted + ", {ok, _A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n {ok, A2, _R2} = identity_oauth:refresh(O, R),\n case identity_oauth:introspect(O, A2) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end"))) + "active") + +(id-oauth-test + "reusing a rotated refresh token is invalid_grant" + (idonm + (ido-ev + (str + ido-granted + ", {ok, _A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n {ok, _A2, _R2} = identity_oauth:refresh(O, R),\n case identity_oauth:refresh(O, R) of\n {ok, _, _} -> rotated;\n {error, Why} -> Why\n end"))) + "invalid_grant") + ;; ── consent denied (§4.1.2.1) ──────────────────────────────────── (id-oauth-test @@ -87,7 +108,7 @@ (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"))) + ", 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) ─────────────── @@ -98,7 +119,7 @@ (ido-ev (str ido-granted - ", case identity_oauth:exchange(O, Code, attacker, uri1, verif1) of\n {ok, _} -> ok;\n {error, Why} -> Why\n end"))) + ", case identity_oauth:exchange(O, Code, attacker, uri1, verif1) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))) "invalid_grant") (id-oauth-test @@ -107,7 +128,7 @@ (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"))) + ", 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) ──────────────────────────── @@ -118,7 +139,7 @@ (ido-ev (str ido-granted - ", case identity_oauth:exchange(O, Code, webapp, uri1, badverif) of\n {ok, _} -> ok;\n {error, Why} -> Why\n end"))) + ", case identity_oauth:exchange(O, Code, webapp, uri1, badverif) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end"))) "invalid_grant") ;; ── unknown code / request ─────────────────────────────────────── @@ -127,7 +148,7 @@ "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")) + "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 @@ -137,7 +158,7 @@ "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) ────────── +;; ── revoke-then-use must fail (RFC 7009) ───────────────────────── (id-oauth-test "revoked exchanged token introspects inactive" @@ -145,16 +166,25 @@ (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"))) + ", {ok, Tok, _R} = 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") +(id-oauth-test + "revoking the access token blocks a later refresh (cascade)" + (idonm + (ido-ev + (str + ido-granted + ", {ok, A, R} = identity_oauth:exchange(O, Code, webapp, uri1, verif1),\n identity_oauth:revoke(O, A),\n case identity_oauth:refresh(O, R) of\n {ok, _, _} -> refreshed;\n {error, Why} -> Why\n end"))) + "invalid_grant") + ;; ── 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")) + "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, _A1, _RR1} = identity_oauth:exchange(O, C1, webapp, uri1, va),\n {ok, A2, _RR2} = identity_oauth:exchange(O, C2, cli, uri2, vb),\n case identity_oauth:introspect(O, A2) of\n {active, Subject, _, _} -> Subject\n end")) "bob") (define diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 4384e697..6f34fb29 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` → **62/62** (Phase 1 + authz-code + refresh/rotation/cascade) +`bash lib/identity/conformance.sh` → **65/65** (Phases 1–2 complete) ## Ground rules @@ -65,7 +65,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) ## Phase 2 — OAuth2 flows - [x] authorization-code flow as a message protocol - [x] refresh + rotation; revocation cascades to issued tokens -- [ ] tests: full code exchange, refresh, revoke-then-use (must fail) +- [x] tests: full code exchange, refresh, revoke-then-use (must fail) ## Phase 3 — Silent SSO + membership - [ ] `prompt=none` cross-app login (one session, many clients) @@ -78,6 +78,12 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [ ] tests: audit completeness, cross-instance subject mapping ## Progress log +- 2026-06-07 — `oauth.sx` refresh wiring + e2e: exchange now issues an + access+refresh pair (RFC 6749 §4.1.4/§5.1) via token.sx issue_grant; added + the refresh grant (§6) delegating to token rotation. End-to-end tests: + code-exchange→refresh→introspect, refresh-reuse rejected, and + revoke-then-refresh blocked by cascade. **Phase 2 complete.** +3 → oauth 17, + 65/65. - 2026-06-07 — `token.sx` grant-centric rewrite: refresh-token rotation (RFC 6749 §6) + cascading revocation. The grant {Subject,Client,Scope, Status} is the cascade unit; access + refresh tokens reference it.