Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 53s
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: code-exchange → refresh → introspect (active), refresh-token reuse rejected (invalid_grant), and revoke-then-refresh blocked by grant cascade. oauth 17/17, 65/65. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
33 lines
5.1 KiB
Plaintext
33 lines
5.1 KiB
Plaintext
;; 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).
|
|
;;
|
|
;; 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)
|
|
;;
|
|
;; 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.
|
|
;;
|
|
;; 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.
|
|
|
|
(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.")
|
|
|
|
(define
|
|
identity-load-oauth!
|
|
(fn () (erlang-load-module identity-oauth-source)))
|