diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index a02693ce..d5221849 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -33,6 +33,7 @@ SUITES=( "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" + "sso|id-sso-test-pass|id-sso-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -54,6 +55,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/registry.sx") (load "lib/identity/tests/api.sx") (load "lib/identity/tests/oauth.sx") +(load "lib/identity/tests/sso.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -64,6 +66,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-api-test-pass id-api-test-count)") (epoch 104) (eval "(list id-oauth-test-pass id-oauth-test-count)") +(epoch 105) +(eval "(list id-sso-test-pass id-sso-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/oauth.sx b/lib/identity/oauth.sx index aee0eac7..672a5413 100644 --- a/lib/identity/oauth.sx +++ b/lib/identity/oauth.sx @@ -1,31 +1,32 @@ ;; identity/oauth.sx — the OAuth2 authorization-code flow as a message -;; protocol (RFC 6749 §4.1), with PKCE (RFC 7636, `plain` method) and the -;; refresh-token grant (RFC 6749 §6). +;; protocol (RFC 6749 §4.1), with PKCE (RFC 7636, `plain`), the refresh +;; grant (§6), and the silent `prompt=none` fast-path (OIDC §3.1.2.1). ;; ;; 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, Access, Refresh} | {error, invalid_grant} (§4.1.3/§5.1) -;; refresh -> {ok, Access, Refresh} | {error, invalid_grant} (§6) +;; authorize -> {consent_required, ReqId} (§4.1.1) +;; consent -> {code, Code} | {error, access_denied} +;; exchange -> {ok, Access, Refresh} | {error, invalid_grant} +;; refresh -> {ok, Access, Refresh} | {error, invalid_grant} +;; establish -> {ok, SessionId} (interactive login = a session) +;; silent_authorize -> {code, Code} | {error, login_required} ;; -;; 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. +;; Silent SSO is the SAME machine on a fast-path, not a second +;; implementation: silent_authorize asks the session registry \"does this +;; subject have a live session?\". If yes it skips consent and mints a code +;; bound to the client + redirect_uri + PKCE challenge, exactly like a +;; consented code, so exchange is unchanged. If no, it returns +;; login_required — a negative state, NOT a redirect to a login page (that +;; is the client's UX problem). One session, many clients: every client +;; that asks silently against the same subject session gets its own code. ;; -;; 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. +;; Tokens are grant-backed (token.sx); revocation cascades; the server +;; proves identity and delegates permission to acl. (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 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.") + "-module(identity_oauth).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n SessReg = identity_registry:start(),\n loop(TokReg, SessReg, [], [], 1)\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 establish(O, Subject, Client) ->\n O ! {establish, Subject, Client, self()},\n receive {oauth_reply, R} -> R end.\n\n silent_authorize(O, ClientId, RedirectUri, Scope, Subject, Challenge) ->\n O ! {silent_authorize, ClientId, RedirectUri, Scope, Subject, Challenge, self()},\n receive {oauth_reply, R} -> R end.\n\n end_session(O, SessionId) ->\n O ! {end_session, SessionId, 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, SessReg, Pending, Codes, NextSid) ->\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, SessReg, [{ReqId, Rec} | Pending], Codes, NextSid);\n {consent, ReqId, Decision, From} ->\n case find(ReqId, Pending) of\n none ->\n From ! {oauth_reply, {error, unknown_request}},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\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, SessReg, Pending2, [{Code, Rec} | Codes], NextSid);\n deny ->\n From ! {oauth_reply, {error, access_denied}},\n loop(TokReg, SessReg, Pending2, Codes, NextSid)\n end\n end;\n {establish, Subject, Client, From} ->\n Sid = NextSid,\n Self = self(),\n S = identity_session:start(Sid, Subject, Client, Self, infinity),\n identity_registry:register(SessReg, Sid, Subject, Client, S),\n From ! {oauth_reply, {ok, Sid}},\n loop(TokReg, SessReg, Pending, Codes, NextSid + 1);\n {silent_authorize, ClientId, RedirectUri, Scope, Subject, Challenge, From} ->\n case subject_active(SessReg, Subject) of\n false ->\n From ! {oauth_reply, {error, login_required}},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\n true ->\n Code = make_ref(),\n Rec = {ClientId, RedirectUri, Scope, Subject, Challenge},\n From ! {oauth_reply, {code, Code}},\n loop(TokReg, SessReg, Pending, [{Code, Rec} | Codes], NextSid)\n end;\n {end_session, Sid, From} ->\n case identity_registry:whereis_session(SessReg, Sid) of\n {ok, Pid} -> identity_session:revoke(Pid);\n {error, _} -> ok\n end,\n identity_registry:deregister(SessReg, Sid),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\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, SessReg, Pending, Codes, NextSid);\n {ok, Rec} ->\n Codes2 = remove(Code, Codes),\n From ! {oauth_reply, redeem(TokReg, Rec, ClientId, RedirectUri, Verifier)},\n loop(TokReg, SessReg, Pending, Codes2, NextSid)\n end;\n {refresh, RTok, From} ->\n From ! {oauth_reply, identity_tokens:refresh(TokReg, RTok)},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\n {introspect, Token, From} ->\n From ! {oauth_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, Pending, Codes, NextSid);\n {session_expired, _Sid} ->\n loop(TokReg, SessReg, Pending, Codes, NextSid)\n end.\n\n subject_active(SessReg, Subject) ->\n case identity_registry:sessions_for(SessReg, Subject) of\n {ok, Ids} -> any_active(SessReg, Ids)\n end.\n\n any_active(_, []) -> false;\n any_active(SessReg, [Id | Rest]) ->\n case identity_registry:whereis_session(SessReg, Id) of\n {ok, Pid} ->\n case identity_session:lookup(Pid) of\n {ok, _} -> true;\n {error, _} -> any_active(SessReg, Rest)\n end;\n {error, _} -> any_active(SessReg, Rest)\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 b5f2f020..b1a4783b 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,12 +1,13 @@ { "language": "identity", - "total_pass": 65, - "total": 65, + "total_pass": 75, + "total": 75, "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":17,"total":17,"status":"ok"} + {"name":"oauth","pass":17,"total":17,"status":"ok"}, + {"name":"sso","pass":10,"total":10,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 3803b034..8cbd8a33 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 65 / 65 tests passing** +**Total: 75 / 75 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -9,6 +9,7 @@ | ✅ | registry | 9 | 9 | | ✅ | api | 10 | 10 | | ✅ | oauth | 17 | 17 | +| ✅ | sso | 10 | 10 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/sso.sx b/lib/identity/tests/sso.sx new file mode 100644 index 00000000..0f8d3a61 --- /dev/null +++ b/lib/identity/tests/sso.sx @@ -0,0 +1,115 @@ +;; identity/tests/sso.sx — silent SSO (prompt=none, OIDC §3.1.2.1) as a +;; fast-path through the authorization-code machine. One subject session, +;; many client apps; no session → login_required (a negative state, not a +;; redirect). Silently-issued codes carry the same client/redirect/PKCE +;; binding as consented codes. + +(define id-sso-test-count 0) +(define id-sso-test-pass 0) +(define id-sso-test-fails (list)) + +(define + id-sso-test + (fn + (name actual expected) + (set! id-sso-test-count (+ id-sso-test-count 1)) + (if + (= actual expected) + (set! id-sso-test-pass (+ id-sso-test-pass 1)) + (append! id-sso-test-fails {:name name :expected expected :actual actual})))) + +(define ids-ev erlang-eval-ast) +(define idsnm (fn (v) (get v :name))) + +(identity-load-token!) +(identity-load-session!) +(identity-load-registry!) +(identity-load-oauth!) + +;; ── no session → login_required ────────────────────────────────── + +(id-sso-test + "silent authorize without a session is login_required" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end")) + "login_required") + +;; ── established session → silent code ──────────────────────────── + +(id-sso-test + "silent authorize for the same client returns a code" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, web, uri1, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end")) + "got_code") + +;; ── one session, many clients ──────────────────────────────────── + +(id-sso-test + "a different client gets a silent code off the same session" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end")) + "got_code") + +(id-sso-test + "many clients all silently authorize off one session" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, _C1} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {code, _C2} = identity_oauth:silent_authorize(O, mobile, uri3, read, alice, vv),\n case identity_oauth:silent_authorize(O, billing, uri4, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end")) + "got_code") + +;; ── full SSO → token ───────────────────────────────────────────── + +(id-sso-test + "silent code exchanges to a working token" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {ok, A, _R} = identity_oauth:exchange(O, C, dashboard, uri2, vv),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +(id-sso-test + "SSO token carries the subject" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n {ok, A, _R} = identity_oauth:exchange(O, C, dashboard, uri2, vv),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end")) + "alice") + +;; ── silent codes keep the full binding ─────────────────────────── + +(id-sso-test + "silent code still enforces PKCE at exchange" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n case identity_oauth:exchange(O, C, dashboard, uri2, wrongverif) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")) + "invalid_grant") + +(id-sso-test + "silent code still enforces client binding at exchange" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n {code, C} = identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv),\n case identity_oauth:exchange(O, C, attacker, uri2, vv) of\n {ok, _, _} -> ok;\n {error, Why} -> Why\n end")) + "invalid_grant") + +;; ── subject scoping: SSO is per subject ────────────────────────── + +(id-sso-test + "another subject is still login_required" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, _Sid} = identity_oauth:establish(O, alice, web),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, bob, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end")) + "login_required") + +;; ── ending the session closes the SSO fast-path ────────────────── + +(id-sso-test + "after end_session, silent authorize is login_required" + (idsnm + (ids-ev + "O = identity_oauth:start(),\n {ok, Sid} = identity_oauth:establish(O, alice, web),\n identity_oauth:end_session(O, Sid),\n case identity_oauth:silent_authorize(O, dashboard, uri2, read, alice, vv) of\n {code, _} -> got_code;\n {error, Why} -> Why\n end")) + "login_required") + +(define + id-sso-test-summary + (str "sso " id-sso-test-pass "/" id-sso-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 6f34fb29..76a93595 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` → **65/65** (Phases 1–2 complete) +`bash lib/identity/conformance.sh` → **75/75** (Phases 1–2 + silent SSO) ## Ground rules @@ -68,7 +68,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [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) +- [x] `prompt=none` cross-app login (one session, many clients) - [ ] membership state + per-app grant projection - [ ] grant verification delegated cache (mirror Redis-cache pattern) @@ -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 — silent SSO (`prompt=none`, OIDC §3.1.2.1): `oauth.sx` now owns + a session registry; `establish` creates a subject session, `silent_authorize` + asks "does this subject have a live session?" → mints a code (skipping + consent) bound to client+redirect+PKCE, else `login_required`. Same machine, + fast-path — one session, many clients; `end_session` closes the path. + New `tests/sso.sx` (10). +10 → 75/75. - 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: