diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index 8035fde2..a4889068 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -47,6 +47,7 @@ SUITES=( "session-mgmt|id-smgmt-test-pass|id-smgmt-test-count" "exchange|id-xchg-test-pass|id-xchg-test-count" "introspect|id-intr-test-pass|id-intr-test-count" + "par|id-par-test-pass|id-par-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -89,6 +90,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/session_mgmt.sx") (load "lib/identity/tests/exchange.sx") (load "lib/identity/tests/introspect.sx") +(load "lib/identity/tests/par.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -127,6 +129,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-xchg-test-pass id-xchg-test-count)") (epoch 118) (eval "(list id-intr-test-pass id-intr-test-count)") +(epoch 119) +(eval "(list id-par-test-pass id-par-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/oauth.sx b/lib/identity/oauth.sx index 6bbf608f..4905fd2f 100644 --- a/lib/identity/oauth.sx +++ b/lib/identity/oauth.sx @@ -1,10 +1,13 @@ ;; identity/oauth.sx — the OAuth2 authorization server as a message ;; protocol. Grants: authorization-code (RFC 6749 §4.1) with PKCE (RFC ;; 7636, `plain`), refresh (§6), silent `prompt=none` (OIDC §3.1.2.1), -;; client-credentials (§4.4), and token exchange (RFC 8693). +;; client-credentials (§4.4), token exchange (RFC 8693), and pushed +;; authorization requests (PAR, RFC 9126). ;; ;; The authz-code flow is a state machine on one process: ;; authorize -> {consent_required, ReqId} (§4.1.1) +;; push_authorization_request -> {ok, RequestUri} (PAR §2.1) +;; authorize_pushed -> {consent_required, ReqId} | {error, invalid_request_uri} ;; consent -> {code, Code} | {error, access_denied} ;; exchange -> {ok, Access, Refresh} | {error, invalid_grant} ;; refresh -> {ok, Access, Refresh} | {error, invalid_grant} @@ -14,16 +17,17 @@ ;; client_credentials -> {ok, Token} | {error, invalid_client|unauthorized_client} ;; token_exchange -> {ok, Token} | {error, invalid_grant|invalid_scope} ;; -;; Token exchange (RFC 8693 §2.1) downscopes: a valid access token is -;; exchanged for a NEW independent grant for the same subject with a subset -;; of its scope — least-privilege handoff to a downstream service. The new -;; token's lifecycle is independent of the subject token (revoking one does -;; not revoke the other). Tokens are grant-backed (token.sx); revocation -;; cascades. The server proves identity; acl decides permission. +;; PAR lodges the authorization parameters up front under a single-use +;; request_uri, so they cannot be tampered with between request and +;; consent; authorize_pushed redeems it into a normal consent flow. Pushed +;; requests share the pending store (a {pushed, Rec} value, keyed by the +;; request_uri ref — distinct from consent req_ids, so the two never +;; collide). Tokens are grant-backed (token.sx); revocation cascades. The +;; server proves identity; acl decides permission. (define identity-oauth-source - "-module(identity_oauth).\n\n start() ->\n spawn(fun () ->\n TokReg = identity_tokens:start(),\n SessReg = identity_registry:start(),\n ClientReg = identity_clients:start(),\n loop(TokReg, SessReg, ClientReg, [], [], 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 register_client(O, ClientId, Type, Secret, RedirectUris) ->\n O ! {register_client, ClientId, Type, Secret, RedirectUris, self()},\n receive {oauth_reply, R} -> R end.\n\n client_credentials(O, ClientId, Secret, Scope) ->\n O ! {client_credentials, ClientId, Secret, Scope, self()},\n receive {oauth_reply, R} -> R end.\n\n token_exchange(O, SubjectToken, RequestedScope) ->\n O ! {token_exchange, SubjectToken, RequestedScope, 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, ClientReg, 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, ClientReg, [{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, ClientReg, 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, ClientReg, Pending2, [{Code, Rec} | Codes], NextSid);\n deny ->\n From ! {oauth_reply, {error, access_denied}},\n loop(TokReg, SessReg, ClientReg, 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, ClientReg, 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, ClientReg, 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, ClientReg, 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, ClientReg, Pending, Codes, NextSid);\n {register_client, ClientId, Type, Secret, RedirectUris, From} ->\n From ! {oauth_reply, identity_clients:register(ClientReg, ClientId, Type, Secret, RedirectUris)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {client_credentials, ClientId, Secret, Scope, From} ->\n case identity_clients:authenticate(ClientReg, ClientId, Secret) of\n {ok, confidential} ->\n {ok, Token} = identity_tokens:issue(TokReg, ClientId, ClientId, Scope),\n From ! {oauth_reply, {ok, Token}};\n {ok, public} ->\n From ! {oauth_reply, {error, unauthorized_client}};\n {error, _} ->\n From ! {oauth_reply, {error, invalid_client}}\n end,\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {token_exchange, SubjectToken, RequestedScope, From} ->\n case identity_tokens:introspect(TokReg, SubjectToken) of\n {inactive} ->\n From ! {oauth_reply, {error, invalid_grant}};\n {active, Subject, Client, Scope} ->\n case subset(RequestedScope, Scope) of\n true ->\n {ok, NewTok} = identity_tokens:issue(TokReg, Subject, Client, RequestedScope),\n From ! {oauth_reply, {ok, NewTok}};\n false ->\n From ! {oauth_reply, {error, invalid_scope}}\n end\n end,\n loop(TokReg, SessReg, ClientReg, 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, ClientReg, 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, ClientReg, Pending, Codes2, NextSid)\n end;\n {refresh, RTok, From} ->\n From ! {oauth_reply, identity_tokens:refresh(TokReg, RTok)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {introspect, Token, From} ->\n From ! {oauth_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, ClientReg, 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 subset([], _) -> true;\n subset([X | Rest], Granted) ->\n case member(X, Granted) of\n true -> subset(Rest, Granted);\n false -> false\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, 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 ClientReg = identity_clients:start(),\n loop(TokReg, SessReg, ClientReg, [], [], 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 push_authorization_request(O, ClientId, RedirectUri, Scope, Subject, Challenge) ->\n O ! {par_push, ClientId, RedirectUri, Scope, Subject, Challenge, self()},\n receive {oauth_reply, R} -> R end.\n\n authorize_pushed(O, RequestUri) ->\n O ! {par_authorize, RequestUri, 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 register_client(O, ClientId, Type, Secret, RedirectUris) ->\n O ! {register_client, ClientId, Type, Secret, RedirectUris, self()},\n receive {oauth_reply, R} -> R end.\n\n client_credentials(O, ClientId, Secret, Scope) ->\n O ! {client_credentials, ClientId, Secret, Scope, self()},\n receive {oauth_reply, R} -> R end.\n\n token_exchange(O, SubjectToken, RequestedScope) ->\n O ! {token_exchange, SubjectToken, RequestedScope, 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, ClientReg, 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, ClientReg, [{ReqId, Rec} | Pending], Codes, NextSid);\n {par_push, ClientId, RedirectUri, Scope, Subject, Challenge, From} ->\n RequestUri = make_ref(),\n Rec = {ClientId, RedirectUri, Scope, Subject, Challenge},\n From ! {oauth_reply, {ok, RequestUri}},\n loop(TokReg, SessReg, ClientReg, [{RequestUri, {pushed, Rec}} | Pending], Codes, NextSid);\n {par_authorize, RequestUri, From} ->\n case find(RequestUri, Pending) of\n {ok, {pushed, Rec}} ->\n ReqId = make_ref(),\n From ! {oauth_reply, {consent_required, ReqId}},\n loop(TokReg, SessReg, ClientReg,\n [{ReqId, Rec} | remove(RequestUri, Pending)], Codes, NextSid);\n Other ->\n From ! {oauth_reply, {error, invalid_request_uri}},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid)\n end;\n {consent, ReqId, Decision, From} ->\n case find(ReqId, Pending) of\n none ->\n From ! {oauth_reply, {error, unknown_request}},\n loop(TokReg, SessReg, ClientReg, 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, ClientReg, Pending2, [{Code, Rec} | Codes], NextSid);\n deny ->\n From ! {oauth_reply, {error, access_denied}},\n loop(TokReg, SessReg, ClientReg, 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, ClientReg, 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, ClientReg, 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, ClientReg, 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, ClientReg, Pending, Codes, NextSid);\n {register_client, ClientId, Type, Secret, RedirectUris, From} ->\n From ! {oauth_reply, identity_clients:register(ClientReg, ClientId, Type, Secret, RedirectUris)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {client_credentials, ClientId, Secret, Scope, From} ->\n case identity_clients:authenticate(ClientReg, ClientId, Secret) of\n {ok, confidential} ->\n {ok, Token} = identity_tokens:issue(TokReg, ClientId, ClientId, Scope),\n From ! {oauth_reply, {ok, Token}};\n {ok, public} ->\n From ! {oauth_reply, {error, unauthorized_client}};\n {error, _} ->\n From ! {oauth_reply, {error, invalid_client}}\n end,\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {token_exchange, SubjectToken, RequestedScope, From} ->\n case identity_tokens:introspect(TokReg, SubjectToken) of\n {inactive} ->\n From ! {oauth_reply, {error, invalid_grant}};\n {active, Subject, Client, Scope} ->\n case subset(RequestedScope, Scope) of\n true ->\n {ok, NewTok} = identity_tokens:issue(TokReg, Subject, Client, RequestedScope),\n From ! {oauth_reply, {ok, NewTok}};\n false ->\n From ! {oauth_reply, {error, invalid_scope}}\n end\n end,\n loop(TokReg, SessReg, ClientReg, 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, ClientReg, 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, ClientReg, Pending, Codes2, NextSid)\n end;\n {refresh, RTok, From} ->\n From ! {oauth_reply, identity_tokens:refresh(TokReg, RTok)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {introspect, Token, From} ->\n From ! {oauth_reply, identity_tokens:introspect(TokReg, Token)},\n loop(TokReg, SessReg, ClientReg, Pending, Codes, NextSid);\n {revoke, Token, From} ->\n identity_tokens:revoke(TokReg, Token),\n From ! {oauth_reply, ok},\n loop(TokReg, SessReg, ClientReg, 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 subset([], _) -> true;\n subset([X | Rest], Granted) ->\n case member(X, Granted) of\n true -> subset(Rest, Granted);\n false -> false\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, 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 b0122f62..3aef5969 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 210, - "total": 210, + "total_pass": 217, + "total": 217, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -21,6 +21,7 @@ {"name":"delegation","pass":8,"total":8,"status":"ok"}, {"name":"session-mgmt","pass":8,"total":8,"status":"ok"}, {"name":"exchange","pass":8,"total":8,"status":"ok"}, - {"name":"introspect","pass":9,"total":9,"status":"ok"} + {"name":"introspect","pass":9,"total":9,"status":"ok"}, + {"name":"par","pass":7,"total":7,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 49045fae..05d642aa 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 210 / 210 tests passing** +**Total: 217 / 217 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -23,6 +23,7 @@ | ✅ | session-mgmt | 8 | 8 | | ✅ | exchange | 8 | 8 | | ✅ | introspect | 9 | 9 | +| ✅ | par | 7 | 7 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/par.sx b/lib/identity/tests/par.sx new file mode 100644 index 00000000..4923cae1 --- /dev/null +++ b/lib/identity/tests/par.sx @@ -0,0 +1,84 @@ +;; identity/tests/par.sx — pushed authorization requests (PAR, RFC 9126): +;; lodge the authorization params up front under a single-use request_uri, +;; then redeem it into the normal consent flow. The binding (client, +;; redirect, PKCE) carried by the pushed request is enforced at exchange. + +(define id-par-test-count 0) +(define id-par-test-pass 0) +(define id-par-test-fails (list)) + +(define + id-par-test + (fn + (name actual expected) + (set! id-par-test-count (+ id-par-test-count 1)) + (if + (= actual expected) + (set! id-par-test-pass (+ id-par-test-pass 1)) + (append! id-par-test-fails {:name name :expected expected :actual actual})))) + +(define idp-ev erlang-eval-ast) +(define idpnm (fn (v) (get v :name))) + +(identity-load-oauth!) + +;; ── pushed request redeems into consent ────────────────────────── + +(id-par-test + "authorize_pushed on a fresh request_uri asks for consent" + (idpnm + (idp-ev + "O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n case identity_oauth:authorize_pushed(O, Ru) of\n {consent_required, _} -> consent_required;\n {error, W} -> W\n end")) + "consent_required") + +;; ── full PAR flow ──────────────────────────────────────────────── + +(id-par-test + "the full PAR flow yields a working token" + (idpnm + (idp-ev + "O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +(id-par-test + "the PAR token carries the pushed subject" + (idpnm + (idp-ev + "O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, v),\n case identity_oauth:introspect(O, A) of\n {active, Subject, _, _} -> Subject\n end")) + "alice") + +;; ── request_uri is single-use ──────────────────────────────────── + +(id-par-test + "a request_uri cannot be redeemed twice" + (idpnm + (idp-ev + "O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n identity_oauth:authorize_pushed(O, Ru),\n case identity_oauth:authorize_pushed(O, Ru) of\n {consent_required, _} -> reused;\n {error, W} -> W\n end")) + "invalid_request_uri") + +(id-par-test + "an unknown request_uri is rejected" + (idpnm + (idp-ev + "O = identity_oauth:start(),\n Bogus = make_ref(),\n case identity_oauth:authorize_pushed(O, Bogus) of\n {consent_required, _} -> ok;\n {error, W} -> W\n end")) + "invalid_request_uri") + +;; ── the pushed binding is still enforced at exchange ───────────── + +(id-par-test + "a PAR-issued code still enforces PKCE" + (idpnm + (idp-ev + "O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n case identity_oauth:exchange(O, Cd, web, uri1, wrongverif) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end")) + "invalid_grant") + +(id-par-test + "a PAR-issued code still enforces client binding" + (idpnm + (idp-ev + "O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, v),\n {consent_required, Rq} = identity_oauth:authorize_pushed(O, Ru),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n case identity_oauth:exchange(O, Cd, attacker, uri1, v) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end")) + "invalid_grant") + +(define + id-par-test-summary + (str "par " id-par-test-pass "/" id-par-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 56551593..303a15b6 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` → **210/210** (4 phases + 11 ext) +`bash lib/identity/conformance.sh` → **217/217** (4 phases + 12 ext) — needs `timeout 580` ## Ground rules @@ -84,13 +84,20 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2) - [x] client-credentials grant (RFC 6749 §4.4) + device grant (RFC 8628) - [x] acl-on-sx delegation: identity-gates-before-acl boundary (401 vs 403), stub decider (live Datalog bridge is cross-substrate) -- [ ] OAuth `state` (CSRF) + OIDC `nonce` threaded through authorize→exchange +- [~] OAuth `state`/OIDC `nonce` — low value in this server-centric model (client-side echo); skipped +- [x] pushed authorization requests (PAR, RFC 9126): single-use request_uri → consent - [x] unify `api.sx` over membership + audit (one facade, audited login/logout) - [x] subject-wide session management: `sessions(Subject)` + `logout_all` (log out everywhere) - [x] token exchange (RFC 8693): downscope a token into a new independent token - [x] RFC 7662 full introspection metadata (`introspect_full`: sub/client_id/scope/exp/iat/token_type) ## Progress log +- 2026-06-07 — PAR (ext, RFC 9126): `push_authorization_request` lodges the + authorization params under a single-use `request_uri`; `authorize_pushed` + redeems it into the normal consent flow. Pushed requests reuse the pending + store (`{pushed, Rec}` keyed by the request_uri ref — distinct from consent + req_ids, no collision), so no new loop state. The pushed binding (client + + redirect + PKCE) is enforced at exchange. New tests/par.sx (7). 210→217. - 2026-06-07 — full introspection (ext, RFC 7662 §2.2): `introspect_full` returns {active, Subject, Client, Scope, Exp, Iat, bearer} for live tokens, {inactive} otherwise — deepening the opaque-token/live-lookup model the