OAuth 2.0 authorization and token exchange
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement RFC 7636 (PKCE) in ocaml-oauth

The dune-project claimed PKCE support but no implementation existed.
Add code_verifier generation, S256/Plain code_challenge computation,
and integrate into authorization_url and exchange_form_body via
optional parameters. Verified against RFC 7636 Appendix B test vector.

New API: generate_code_verifier, code_challenge, challenge_method type.
Updated: authorization_url and exchange_form_body now accept unit arg
with optional PKCE params. All downstream callers in ocaml-auth updated.

10 unit tests + 2 fuzz tests added. Dependencies: digestif, base64.

+116 -13
+2
dune-project
··· 20 20 (jsont (>= 0.1.0)) 21 21 (bytesrw (>= 0.1.0)) 22 22 (crypto-rng (>= 0.11.0)) 23 + (digestif (>= 1.0)) 24 + (base64 (>= 3.0)) 23 25 (ohex (>= 0.2)) 24 26 (logs (>= 0.7)) 25 27 (alcotest :with-test)
+15 -1
fuzz/fuzz_github_oauth.ml
··· 11 11 let test_authorization_url_valid client_id redirect_uri state scope = 12 12 let url = 13 13 Oauth.authorization_url Oauth.Github ~client_id ~redirect_uri ~state ~scope 14 + () 14 15 in 15 16 check (String.length url > 0); 16 17 check (String.sub url 0 8 = "https://") ··· 18 19 (* Test that exchange_form_body produces valid form-encoded body *) 19 20 let test_exchange_body_valid client_id client_secret code redirect_uri = 20 21 let body = 21 - Oauth.exchange_form_body ~client_id ~client_secret ~code ~redirect_uri 22 + Oauth.exchange_form_body ~client_id ~client_secret ~code ~redirect_uri () 22 23 in 23 24 check (String.length body > 0); 24 25 check (String.contains body '=') ··· 53 54 | Ok t -> check (t.access_token = access_token) 54 55 | Error _ -> check true 55 56 57 + (* Test that code_challenge never crashes and produces non-empty output *) 58 + let test_code_challenge_no_crash verifier = 59 + let c = Oauth.code_challenge S256 verifier in 60 + check (String.length c > 0) 61 + 62 + (* Test that PKCE roundtrip: challenge(S256, verifier) is deterministic *) 63 + let test_pkce_deterministic verifier = 64 + let c1 = Oauth.code_challenge S256 verifier in 65 + let c2 = Oauth.code_challenge S256 verifier in 66 + check (c1 = c2) 67 + 56 68 let suite = 57 69 ( "oauth", 58 70 [ ··· 68 80 test_case "token roundtrip" 69 81 [ bytes; option int; option bytes ] 70 82 test_token_roundtrip; 83 + test_case "pkce challenge no crash" [ bytes ] test_code_challenge_no_crash; 84 + test_case "pkce deterministic" [ bytes ] test_pkce_deterministic; 71 85 ] )
+1 -1
lib/dune
··· 1 1 (library 2 2 (name oauth) 3 3 (public_name oauth) 4 - (libraries uri jsont jsont.bytesrw crypto-rng fmt ohex logs)) 4 + (libraries uri jsont jsont.bytesrw crypto-rng digestif base64 fmt ohex logs))
+46 -3
lib/oauth.ml
··· 86 86 87 87 let generate_state () = Ohex.encode (Crypto_rng.generate 32) 88 88 89 + (* ── PKCE (RFC 7636) ─────────────────────────────────────────────── *) 90 + 91 + type challenge_method = S256 | Plain 92 + 93 + (* Base64url encoding per RFC 4648 §5, no padding. *) 94 + let base64url_encode_no_pad s = 95 + let b64 = Base64.encode_exn ~pad:false ~alphabet:Base64.uri_safe_alphabet s in 96 + b64 97 + 98 + let generate_code_verifier () = 99 + (* 32 random bytes → 43 base64url chars (RFC 7636 §4.1) *) 100 + base64url_encode_no_pad (Crypto_rng.generate 32) 101 + 102 + let code_challenge method_ verifier = 103 + match method_ with 104 + | Plain -> verifier 105 + | S256 -> 106 + (* BASE64URL(SHA256(ASCII(code_verifier))) per RFC 7636 §4.2 *) 107 + let hash = Digestif.SHA256.(digest_string verifier |> to_raw_string) in 108 + base64url_encode_no_pad hash 109 + 110 + let challenge_method_to_string = function S256 -> "S256" | Plain -> "plain" 111 + 89 112 (* ── Authorization URL ───────────────────────────────────────────── *) 90 113 91 - let authorization_url provider ~client_id ~redirect_uri ~state ~scope = 114 + let authorization_url provider ~client_id ~redirect_uri ~state ~scope 115 + ?code_challenge:cc ?code_challenge_method () = 92 116 let uri = Uri.of_string (authorize_url provider) in 93 117 let base_query = 94 118 [ ··· 102 126 match scope with 103 127 | [] -> base_query 104 128 | lst -> ("scope", [ String.concat " " lst ]) :: base_query 129 + in 130 + let query = 131 + match cc with 132 + | None -> query 133 + | Some challenge -> 134 + let method_ = 135 + match code_challenge_method with Some m -> m | None -> S256 136 + in 137 + ("code_challenge", [ challenge ]) 138 + :: ("code_challenge_method", [ challenge_method_to_string method_ ]) 139 + :: query 105 140 in 106 141 Uri.with_query uri query |> Uri.to_string 107 142 ··· 122 157 String.concat "&" 123 158 (List.map (fun (k, v) -> pct_encode k ^ "=" ^ pct_encode v) params) 124 159 125 - let exchange_form_body ~client_id ~client_secret ~code ~redirect_uri = 126 - form_encode 160 + let exchange_form_body ~client_id ~client_secret ~code ~redirect_uri 161 + ?code_verifier () = 162 + let base = 127 163 [ 128 164 ("grant_type", "authorization_code"); 129 165 ("client_id", client_id); ··· 131 167 ("code", code); 132 168 ("redirect_uri", redirect_uri); 133 169 ] 170 + in 171 + let params = 172 + match code_verifier with 173 + | None -> base 174 + | Some v -> base @ [ ("code_verifier", v) ] 175 + in 176 + form_encode params 134 177 135 178 (* ── Token Response ──────────────────────────────────────────────── *) 136 179
+50 -8
lib/oauth.mli
··· 1 1 (** OAuth 2.0 authorization and token exchange. 2 2 3 3 Implements the Authorization Code grant of 4 - {{:https://datatracker.ietf.org/doc/html/rfc6749} RFC 6749 (OAuth 2.0)}. 4 + {{:https://datatracker.ietf.org/doc/html/rfc6749} RFC 6749 (OAuth 2.0)} with 5 + {{:https://datatracker.ietf.org/doc/html/rfc7636} RFC 7636 (PKCE)} support. 5 6 Provides state generation for CSRF protection, authorization URL 6 7 construction, and token exchange/refresh with form-encoded bodies per 7 8 {{:https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3} RFC 6749 ··· 11 12 12 13 {[ 13 14 let state = Oauth.generate_state () in 15 + let verifier = Oauth.generate_code_verifier () in 16 + let challenge = Oauth.code_challenge S256 verifier in 14 17 let url = 15 18 Oauth.authorization_url Github ~client_id:"xxx" 16 19 ~redirect_uri:"https://app.com/callback" ~state 17 - ~scope:[ "user:email" ] 20 + ~scope:[ "user:email" ] ~code_challenge:challenge () 18 21 in 19 22 (* redirect user to [url] *) 20 23 ··· 22 25 let body = 23 26 Oauth.exchange_form_body ~client_id:"xxx" ~client_secret:"yyy" 24 27 ~code ~redirect_uri:"https://app.com/callback" 28 + ~code_verifier:verifier () 25 29 in 26 30 (* POST [body] to [Oauth.token_url Github] with 27 31 Content-Type: application/x-www-form-urlencoded ··· 92 96 93 97 @raise Crypto_rng.Unseeded_generator if the RNG is not initialized. *) 94 98 99 + (** {1:pkce PKCE (RFC 7636)} 100 + 101 + {{:https://datatracker.ietf.org/doc/html/rfc7636} RFC 7636} Proof Key for 102 + Code Exchange mitigates authorization code interception attacks against 103 + public clients. *) 104 + 105 + (** Code challenge method per RFC 7636 §4.2. *) 106 + type challenge_method = 107 + | S256 (** SHA-256 transform (recommended). *) 108 + | Plain (** Plain text (only when S256 is unsupported by the server). *) 109 + 110 + val generate_code_verifier : unit -> string 111 + (** [generate_code_verifier ()] is a 43-character Base64url-encoded string (32 112 + random bytes) suitable as the [code_verifier] parameter. 113 + 114 + Per RFC 7636 §4.1 the verifier must be 43–128 characters from the unreserved 115 + set [[A-Za-z0-9._~-]]. 116 + 117 + @raise Crypto_rng.Unseeded_generator if the RNG is not initialized. *) 118 + 119 + val code_challenge : challenge_method -> string -> string 120 + (** [code_challenge method_ verifier] is the [code_challenge] derived from 121 + [verifier]. For [S256] this is [BASE64URL(SHA256(verifier))]; for [Plain] it 122 + is the verifier itself (RFC 7636 §4.2). *) 123 + 95 124 (** {1:authz Authorization URL} *) 96 125 97 126 val authorization_url : ··· 100 129 redirect_uri:string -> 101 130 state:string -> 102 131 scope:string list -> 132 + ?code_challenge:string -> 133 + ?code_challenge_method:challenge_method -> 134 + unit -> 103 135 string 104 - (** [authorization_url provider ~client_id ~redirect_uri ~state ~scope] is an 105 - authorization URL for the given provider. Scopes are space-joined per RFC 106 - 6749. An empty [scope] list omits the parameter. *) 136 + (** [authorization_url provider ~client_id ~redirect_uri ~state ~scope 137 + ?code_challenge ?code_challenge_method ()] is an authorization URL for the 138 + given provider. Scopes are space-joined per RFC 6749. An empty [scope] list 139 + omits the parameter. 140 + 141 + When [~code_challenge] is provided the [code_challenge] and 142 + [code_challenge_method] query parameters are included per RFC 7636 §4.3. 143 + [code_challenge_method] defaults to [S256]. *) 107 144 108 145 (** {1:exchange Token Exchange} *) 109 146 ··· 112 149 client_secret:string -> 113 150 code:string -> 114 151 redirect_uri:string -> 152 + ?code_verifier:string -> 153 + unit -> 115 154 string 116 - (** [exchange_form_body ~client_id ~client_secret ~code ~redirect_uri] is an 117 - [application/x-www-form-urlencoded] string for exchanging an authorization 118 - code for an access token (RFC 6749 §4.1.3). *) 155 + (** [exchange_form_body ~client_id ~client_secret ~code ~redirect_uri 156 + ?code_verifier ()] is an [application/x-www-form-urlencoded] string for 157 + exchanging an authorization code for an access token (RFC 6749 §4.1.3). 158 + 159 + When [~code_verifier] is provided the [code_verifier] parameter is included 160 + per RFC 7636 §4.5. *) 119 161 120 162 (** {1:token Token Response} *) 121 163
+2
oauth.opam
··· 16 16 "jsont" {>= "0.1.0"} 17 17 "bytesrw" {>= "0.1.0"} 18 18 "crypto-rng" {>= "0.11.0"} 19 + "digestif" {>= "1.0"} 20 + "base64" {>= "3.0"} 19 21 "ohex" {>= "0.2"} 20 22 "logs" {>= "0.7"} 21 23 "alcotest" {with-test}