diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index a4889068..a814e6a0 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -48,6 +48,7 @@ SUITES=( "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" + "dynreg|id-dyn-test-pass|id-dyn-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -91,6 +92,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/exchange.sx") (load "lib/identity/tests/introspect.sx") (load "lib/identity/tests/par.sx") +(load "lib/identity/tests/dynreg.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -131,6 +133,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-intr-test-pass id-intr-test-count)") (epoch 119) (eval "(list id-par-test-pass id-par-test-count)") +(epoch 120) +(eval "(list id-dyn-test-pass id-dyn-test-count)") EPOCHS timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/oauth.sx b/lib/identity/oauth.sx index 4905fd2f..8ae4c487 100644 --- a/lib/identity/oauth.sx +++ b/lib/identity/oauth.sx @@ -2,7 +2,8 @@ ;; 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), token exchange (RFC 8693), and pushed -;; authorization requests (PAR, RFC 9126). +;; authorization requests (PAR, RFC 9126). Clients may be registered +;; manually or self-service (dynamic client registration, RFC 7591). ;; ;; The authz-code flow is a state machine on one process: ;; authorize -> {consent_required, ReqId} (§4.1.1) @@ -13,21 +14,17 @@ ;; refresh -> {ok, Access, Refresh} | {error, invalid_grant} ;; establish -> {ok, SessionId} (interactive login = a session) ;; silent_authorize -> {code, Code} | {error, login_required} -;; register_client -> ok | {error, exists} +;; register_client -> ok | {error, exists} (manual) +;; register_dynamic -> {ok, ClientId, Secret} (RFC 7591) ;; client_credentials -> {ok, Token} | {error, invalid_client|unauthorized_client} ;; token_exchange -> {ok, Token} | {error, invalid_grant|invalid_scope} ;; -;; 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. +;; 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 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.") + "-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 register_dynamic(O, Type, RedirectUris) ->\n O ! {register_dynamic, Type, 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 {register_dynamic, Type, RedirectUris, From} ->\n ClientId = make_ref(),\n Secret = make_ref(),\n identity_clients:register(ClientReg, ClientId, Type, Secret, RedirectUris),\n From ! {oauth_reply, {ok, ClientId, Secret}},\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 3aef5969..3f3692d8 100644 --- a/lib/identity/scoreboard.json +++ b/lib/identity/scoreboard.json @@ -1,7 +1,7 @@ { "language": "identity", - "total_pass": 217, - "total": 217, + "total_pass": 222, + "total": 222, "suites": [ {"name":"session","pass":11,"total":11,"status":"ok"}, {"name":"token","pass":24,"total":24,"status":"ok"}, @@ -22,6 +22,7 @@ {"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":"par","pass":7,"total":7,"status":"ok"} + {"name":"par","pass":7,"total":7,"status":"ok"}, + {"name":"dynreg","pass":5,"total":5,"status":"ok"} ] } diff --git a/lib/identity/scoreboard.md b/lib/identity/scoreboard.md index 05d642aa..7a36485d 100644 --- a/lib/identity/scoreboard.md +++ b/lib/identity/scoreboard.md @@ -1,6 +1,6 @@ # identity-on-sx Scoreboard -**Total: 217 / 217 tests passing** +**Total: 222 / 222 tests passing** | | Suite | Pass | Total | |---|---|---|---| @@ -24,6 +24,7 @@ | ✅ | exchange | 8 | 8 | | ✅ | introspect | 9 | 9 | | ✅ | par | 7 | 7 | +| ✅ | dynreg | 5 | 5 | Generated by `lib/identity/conformance.sh`. diff --git a/lib/identity/tests/dynreg.sx b/lib/identity/tests/dynreg.sx new file mode 100644 index 00000000..065efcf7 --- /dev/null +++ b/lib/identity/tests/dynreg.sx @@ -0,0 +1,68 @@ +;; identity/tests/dynreg.sx — dynamic client registration (RFC 7591): the +;; server generates the client_id + secret for self-service onboarding. + +(define id-dyn-test-count 0) +(define id-dyn-test-pass 0) +(define id-dyn-test-fails (list)) + +(define + id-dyn-test + (fn + (name actual expected) + (set! id-dyn-test-count (+ id-dyn-test-count 1)) + (if + (= actual expected) + (set! id-dyn-test-pass (+ id-dyn-test-pass 1)) + (append! id-dyn-test-fails {:name name :expected expected :actual actual})))) + +(define idd-ev erlang-eval-ast) +(define iddnm (fn (v) (get v :name))) + +(identity-load-oauth!) + +;; ── self-service registration yields usable credentials ────────── + +(id-dyn-test + "a dynamically registered confidential client can get a token" + (iddnm + (idd-ev + "O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, Cid, Sec, batch),\n case identity_oauth:introspect(O, T) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +(id-dyn-test + "the token's subject is the generated client id" + (iddnm + (idd-ev + "O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, T} = identity_oauth:client_credentials(O, Cid, Sec, batch),\n case identity_oauth:introspect(O, T) of\n {active, Sub, _, _} ->\n case Sub =:= Cid of true -> matches; false -> mismatch end;\n {inactive} -> inactive\n end")) + "matches") + +;; ── the generated secret is required ───────────────────────────── + +(id-dyn-test + "a wrong secret for a dynamic client is invalid_client" + (iddnm + (idd-ev + "O = identity_oauth:start(),\n {ok, Cid, _Sec} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n case identity_oauth:client_credentials(O, Cid, wrongsecret, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end")) + "invalid_client") + +;; ── uniqueness ─────────────────────────────────────────────────── + +(id-dyn-test + "two registrations yield distinct client ids" + (iddnm + (idd-ev + "O = identity_oauth:start(),\n {ok, C1, _} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n {ok, C2, _} = identity_oauth:register_dynamic(O, confidential, [uri1]),\n case C1 =:= C2 of true -> collision; false -> distinct end")) + "distinct") + +;; ── a dynamic public client still cannot use client-credentials ── + +(id-dyn-test + "a dynamic public client is unauthorized for client-credentials" + (iddnm + (idd-ev + "O = identity_oauth:start(),\n {ok, Cid, Sec} = identity_oauth:register_dynamic(O, public, [uri1]),\n case identity_oauth:client_credentials(O, Cid, Sec, batch) of\n {ok, _} -> issued;\n {error, W} -> W\n end")) + "unauthorized_client") + +(define + id-dyn-test-summary + (str "dynreg " id-dyn-test-pass "/" id-dyn-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index 303a15b6..ee4259a9 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` → **217/217** (4 phases + 12 ext) — needs `timeout 580` +`bash lib/identity/conformance.sh` → **222/222** (4 phases + 13 ext) — needs `timeout 580` ## Ground rules @@ -86,12 +86,19 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] acl-on-sx delegation: identity-gates-before-acl boundary (401 vs 403), stub decider (live Datalog bridge is cross-substrate) - [~] 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] dynamic client registration (RFC 7591): server-generated client_id + secret - [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 — dynamic client registration (ext, RFC 7591): `register_dynamic` + generates a client_id + secret server-side (make_ref each) and registers the + client, returning {ok, ClientId, Secret} — self-service onboarding distinct + from the manual register_client. A dynamic confidential client can then use + client_credentials; a dynamic public client stays unauthorized_client. New + tests/dynreg.sx (5). 217→222. - 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