Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s
oauth.sx now owns a session registry. establish creates a subject session; silent_authorize (OIDC prompt=none §3.1.2.1) asks "does this subject have a live session?" — if yes it mints a code skipping consent, bound to client + redirect_uri + PKCE exactly like a consented code; if no it returns login_required (a negative state, not a login redirect). One session serves many clients; end_session closes the fast-path. New tests/sso.sx. 75/75. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
34 lines
7.7 KiB
Plaintext
34 lines
7.7 KiB
Plaintext
;; identity/oauth.sx — the OAuth2 authorization-code flow as a message
|
|
;; 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)
|
|
;; 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}
|
|
;;
|
|
;; 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); 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 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!
|
|
(fn () (erlang-load-module identity-oauth-source)))
|