diff --git a/lib/identity/conformance.sh b/lib/identity/conformance.sh index f247cf76..9a4e9ef6 100755 --- a/lib/identity/conformance.sh +++ b/lib/identity/conformance.sh @@ -50,6 +50,7 @@ SUITES=( "par|id-par-test-pass|id-par-test-count" "dynreg|id-dyn-test-pass|id-dyn-test-count" "account|id-acct-test-pass|id-acct-test-count" + "pkce|id-pkce-test-pass|id-pkce-test-count" ) cat > "$TMPFILE" << 'EPOCHS' @@ -95,6 +96,7 @@ cat > "$TMPFILE" << 'EPOCHS' (load "lib/identity/tests/par.sx") (load "lib/identity/tests/dynreg.sx") (load "lib/identity/tests/account.sx") +(load "lib/identity/tests/pkce.sx") (epoch 100) (eval "(list id-session-test-pass id-session-test-count)") (epoch 101) @@ -139,6 +141,8 @@ cat > "$TMPFILE" << 'EPOCHS' (eval "(list id-dyn-test-pass id-dyn-test-count)") (epoch 121) (eval "(list id-acct-test-pass id-acct-test-count)") +(epoch 122) +(eval "(list id-pkce-test-pass id-pkce-test-count)") EPOCHS timeout 1200 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1 diff --git a/lib/identity/oauth.sx b/lib/identity/oauth.sx index 8ae4c487..632e70bf 100644 --- a/lib/identity/oauth.sx +++ b/lib/identity/oauth.sx @@ -24,7 +24,7 @@ (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 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.") + "-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 pkce_ok(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 pkce_ok({s256, H}, Verifier) ->\n crypto:hash(sha256, Verifier) =:= H;\n pkce_ok(Challenge, Verifier) ->\n Challenge =:= Verifier.\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/tests/pkce.sx b/lib/identity/tests/pkce.sx new file mode 100644 index 00000000..c89fe55d --- /dev/null +++ b/lib/identity/tests/pkce.sx @@ -0,0 +1,92 @@ +;; identity/tests/pkce.sx — PKCE S256 (RFC 7636 §4.2). The challenge is +;; sha256(verifier) (compared as raw digests — base64url is wire encoding, +;; omitted); the verifier is presented at exchange and re-hashed. An S256 +;; challenge is carried as a tagged {s256, Hash}; bare challenges remain +;; `plain` (RFC 7636 §4.1), so both methods coexist. Requires the fixed +;; erlang binary substrate (binary =:= + crypto:hash on binary literals). + +(define id-pkce-test-count 0) +(define id-pkce-test-pass 0) +(define id-pkce-test-fails (list)) + +(define + id-pkce-test + (fn + (name actual expected) + (set! id-pkce-test-count (+ id-pkce-test-count 1)) + (if + (= actual expected) + (set! id-pkce-test-pass (+ id-pkce-test-pass 1)) + (append! id-pkce-test-fails {:name name :expected expected :actual actual})))) + +(define idp-ev erlang-eval-ast) +(define idpnm (fn (v) (get v :name))) + +(identity-load-oauth!) + +;; Shared S256 prelude: verifier V, challenge = sha256(V), consented code. +(define + idp-s256 + "V = <<\"verifier-abc-123\">>,\n Ch = crypto:hash(sha256, V),\n O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, read, alice, {s256, Ch}),\n {code, Cd} = identity_oauth:consent(O, Rq, allow)") + +;; ── S256 happy path ────────────────────────────────────────────── + +(id-pkce-test + "S256: exchange with the matching verifier yields an active token" + (idpnm + (idp-ev + (str + idp-s256 + ", {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-pkce-test + "S256: the token carries the authorized subject" + (idpnm + (idp-ev + (str + idp-s256 + ", {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") + +;; ── S256 rejects a wrong verifier ──────────────────────────────── + +(id-pkce-test + "S256: a wrong verifier is invalid_grant" + (idpnm + (idp-ev + (str + idp-s256 + ", case identity_oauth:exchange(O, Cd, web, uri1, <<\"wrong-verifier\">>) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end"))) + "invalid_grant") + +(id-pkce-test + "S256: the bare challenge hash is not accepted as the verifier" + (idpnm + (idp-ev + (str + idp-s256 + ", case identity_oauth:exchange(O, Cd, web, uri1, Ch) of\n {ok, _, _} -> ok;\n {error, W} -> W\n end"))) + "invalid_grant") + +;; ── plain PKCE still works (coexistence) ───────────────────────── + +(id-pkce-test + "plain: a bare challenge still matches its verifier" + (idpnm + (idp-ev + "O = identity_oauth:start(),\n {consent_required, Rq} = identity_oauth:authorize(O, web, uri1, read, alice, plainverif),\n {code, Cd} = identity_oauth:consent(O, Rq, allow),\n {ok, A, _R} = identity_oauth:exchange(O, Cd, web, uri1, plainverif),\n case identity_oauth:introspect(O, A) of\n {active, _, _, _} -> active;\n {inactive} -> inactive\n end")) + "active") + +;; ── S256 threads through PAR ────────────────────────────────────── + +(id-pkce-test + "S256 works through a pushed authorization request" + (idpnm + (idp-ev + "V = <<\"par-verifier\">>,\n Ch = crypto:hash(sha256, V),\n O = identity_oauth:start(),\n {ok, Ru} = identity_oauth:push_authorization_request(O, web, uri1, read, alice, {s256, Ch}),\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") + +(define + id-pkce-test-summary + (str "pkce " id-pkce-test-pass "/" id-pkce-test-count)) diff --git a/plans/identity-on-sx.md b/plans/identity-on-sx.md index fe6da4e6..7f6fddc2 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` → **233/233** (4 phases + 15 ext) — slow (~10min, run in background; internal timeout 1200) +`bash lib/identity/conformance.sh` → **239/239** (4 phases + 16 ext) — slow (~10min, run in background; internal timeout 1200) ## Ground rules @@ -78,7 +78,7 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] tests: audit completeness, cross-instance subject mapping ## Extensions (base roadmap complete; deepen the engine) -- [~] PKCE S256 method (RFC 7636 §4.2) — BLOCKED on erlang substrate (see Blockers) +- [x] PKCE S256 method (RFC 7636 §4.2) — substrate fixed; `{s256, sha256(verifier)}` challenge - [x] access-token TTL / `expires_in` — logical-clock expiry, introspect honours it - [x] scope as a set + scope narrowing on refresh (RFC 6749 §6) - [x] client registry: public vs confidential clients, client authentication (RFC 6749 §2) @@ -95,6 +95,21 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) - [x] RFC 7662 full introspection metadata (`introspect_full`: sub/client_id/scope/exp/iat/token_type) ## Progress log +- 2026-06-07 — PKCE S256 UNBLOCKED + implemented (RFC 7636 §4.2). Root-caused + the substrate blocker to ONE bug in `lib/erlang/transpile.sx` + `er-eval-binary-segment`: a string literal in a binary (`<<"abc">>`) was + evaluated as a single integer segment, emitting one `0` byte — so every + binary literal became `{:bytes (0)}` (hence binary `=:=` "always equal" and + crypto:hash input-independent). Fix (user-authorized cross-scope edit on + architecture; loops/erlang should adopt as owner): the integer branch now + expands a string value to per-character bytes (`<<"abc">>` ≡ `<<97,98,99>>`). + Verified: `byte_size(<<"abc">>)`=3, binary `=:=` correct, sha256 distinct. + Then `oauth.sx`: PKCE check routed through `pkce_ok` — `{s256, H}` challenge + compares `crypto:hash(sha256, Verifier) =:= H`; bare challenge stays `plain` + (RFC §4.1), so both coexist with zero change to existing flows. New + tests/pkce.sx (6, incl. S256-through-PAR). +6 → 239. Done directly on + architecture (where fixed-erlang + merged-identity coexist); loops/identity + trails until refreshed. - 2026-06-07 — "disconnect app" (ext): `identity_tokens:revoke_app(Subject, Client)` revokes every grant a subject holds for one client at once (audited one revoke per grant), exposed at the facade as `identity:revoke_app`. The @@ -276,6 +291,12 @@ lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) `tests/session.sx`). 11/11. ## Blockers +- 2026-06-07 — **RESOLVED.** Both symptoms below were ONE bug — + `er-eval-binary-segment` in `lib/erlang/transpile.sx` emitting a single `0` + byte for a string literal in a binary, so every `<<"...">>` was `{:bytes (0)}` + (equal to each other, and hashing the same). Fixed (string → per-character + bytes); binary `=:=` and `crypto:hash` now correct, and PKCE S256 is + implemented. See Progress log 2026-06-07. (Historical detail below.) - 2026-06-07 — **PKCE S256 blocked: erlang binary bugs.** Two substrate bugs in `lib/erlang` make a correct/secure S256 impossible (S256 needs `BASE64URL(SHA256(verifier))` compared against the stored challenge):