OAuth 2.0 (RFC 6749) + PKCE (RFC 7636) as a pure sans-IO state machine for Standard ML.
Part of the sjqtentacles monorepo of SML libraries. It builds on
sml-codec (Base64 + SHA-256 for
PKCE), sml-uri (URL building),
sml-json (token response
parsing), and sml-random
(seeded verifier generation).
OAuthPkce— generate{verifier, challenge, method}from a deterministic seed. Challenge =Base64.encodeUrl (Sha256.digest verifier)per RFC 7636 section 4.2. Verified against the RFC 7636 Appendix B test vector.OAuthUrl— authorization-endpoint URL builder with all required params (response_type=code,client_id,redirect_uri,scope,state,code_challenge,code_challenge_method).OAuthToken— token record parsing from RFC 6749 section 5.1 JSON responses.OAuthClient— pure FSM: statesIdle | Authorizing | Exchanging | Authenticated | Refreshing | Failed.stepadvances on events (StartAuth,CodeReceived,TokenReceived,TokenExpired,RefreshReceived,Error).OAuthRequest— HTTP request records for the authorization-code and refresh-token grant types (form-encoded POST bodies).
Working — the PKCE test vector, authorization URL params, FSM transitions, token JSON parsing, and mismatched-state reset are all covered by the test suite.
Pure Standard ML using only the Basis library (plus the vendored sml-codec,
sml-uri, sml-json, and sml-random) — no FFI, no threads, no sockets.
Verified on MLton and Poly/ML, with identical, deterministic output
across both.
make test # build + run the suite under MLton (default)
make test-poly # run the suite under Poly/ML
make all-tests # run under both
make clean(* Generate a PKCE pair from a seed. *)
val rng = Random.fromInt 42
val (pkce, rng') = OAuth.OAuthPkce.generate rng
(* Build the authorization URL. *)
val url = OAuth.OAuthUrl.authorizationUrl
{ endpoint = "https://auth.example.com/authorize",
clientId = "client-123",
redirectUri = "https://app.example.com/callback",
scopes = ["read", "write"],
state = "xyz",
pkce = pkce }
(* Drive the FSM. *)
val session =
{ clientId = "client-123",
redirectUri = "https://app.example.com/callback",
tokenEndpoint = "https://auth.example.com/token",
clientSecret = NONE }
val (state1, _) = OAuth.OAuthClient.step session
OAuth.OAuthClient.Idle
(OAuth.OAuthClient.StartAuth (pkce, "xyz"))
(* ... redirect the user; receive callback with code + state ... *)
val (state2, _) = OAuth.OAuthClient.step session state1
(OAuth.OAuthClient.CodeReceived ("thecode", "xyz"))
(* ... execute the token exchange request; parse JSON ... *)
val (state3, _) = OAuth.OAuthClient.step session state2
(OAuth.OAuthClient.TokenReceived tokenJson)The library is verified against RFC 7636 Appendix B:
verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" produces challenge
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".
sml-codec—Base64.encodeUrlandSha256.digestfor PKCE.sml-uri—Percent.encodeFormfor query string construction.sml-json— token response parsing (transitively vendorssml-parsec).sml-random— SplitMix64-based seeded verifier generation.
sml-crypto is intentionally NOT a dependency — PKCE only needs SHA-256 and
Base64URL, both provided by sml-codec.
smlpkg add github.com/sjqtentacles/sml-oauth
smlpkg syncThen reference the library basis from your own .mlb:
lib/github.com/sjqtentacles/sml-oauth/sml-oauth.mlb
MIT. See LICENSE.