OAuth 2.0 authorization and token exchange
0
fork

Configure Feed

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

Make code_verifier an abstract type with RFC 7636 §4.1 validation

code_verifier is now abstract — can only be created via
generate_code_verifier or code_verifier_of_string (which validates
43-128 chars from [A-Za-z0-9._~-]). Prevents passing low-entropy
strings to code_challenge.

code_verifier_to_string provided for session storage. exchange_code
now takes code_verifier instead of string. 2 new validation tests.

+75 -23
+13 -7
fuzz/fuzz_github_oauth.ml
··· 66 66 check (access_token = "") 67 67 68 68 (* Test that code_challenge never crashes and produces non-empty output *) 69 - let test_code_challenge_no_crash verifier = 70 - let c = Oauth.code_challenge S256 verifier in 71 - check (String.length c > 0) 69 + let test_code_challenge_no_crash input = 70 + match Oauth.code_verifier_of_string input with 71 + | Error _ -> check true (* invalid verifier correctly rejected *) 72 + | Ok v -> 73 + let c = Oauth.code_challenge S256 v in 74 + check (String.length c > 0) 72 75 73 76 (* Test that PKCE roundtrip: challenge(S256, verifier) is deterministic *) 74 - let test_pkce_deterministic verifier = 75 - let c1 = Oauth.code_challenge S256 verifier in 76 - let c2 = Oauth.code_challenge S256 verifier in 77 - check (c1 = c2) 77 + let test_pkce_deterministic input = 78 + match Oauth.code_verifier_of_string input with 79 + | Error _ -> check true 80 + | Ok v -> 81 + let c1 = Oauth.code_challenge S256 v in 82 + let c2 = Oauth.code_challenge S256 v in 83 + check (c1 = c2) 78 84 79 85 let suite = 80 86 ( "oauth",
+18 -1
lib/oauth.ml
··· 181 181 (* ── PKCE (RFC 7636) ─────────────────────────────────────────────── *) 182 182 183 183 type challenge_method = S256 | Plain 184 + type code_verifier = string 184 185 185 186 (* Base64url encoding per RFC 4648 §5, no padding. *) 186 187 let base64url_encode_no_pad s = 187 188 let b64 = Base64.encode_exn ~pad:false ~alphabet:Base64.uri_safe_alphabet s in 188 189 b64 189 190 191 + let is_unreserved c = 192 + (c >= 'A' && c <= 'Z') 193 + || (c >= 'a' && c <= 'z') 194 + || (c >= '0' && c <= '9') 195 + || c = '-' || c = '.' || c = '_' || c = '~' 196 + 197 + let code_verifier_of_string s = 198 + let len = String.length s in 199 + if len < 43 || len > 128 then 200 + Error (`Msg (Fmt.str "code_verifier must be 43-128 characters, got %d" len)) 201 + else if not (String.for_all is_unreserved s) then 202 + Error (`Msg "code_verifier contains characters outside [A-Za-z0-9._~-]") 203 + else Ok s 204 + 205 + let code_verifier_to_string s = s 206 + 190 207 let generate_code_verifier () = 191 - (* 32 random bytes → 43 base64url chars (RFC 7636 §4.1) *) 208 + (* 32 random bytes -> 43 base64url chars (RFC 7636 §4.1) *) 192 209 base64url_encode_no_pad (Crypto_rng.generate 32) 193 210 194 211 let code_challenge method_ verifier =
+17 -7
lib/oauth.mli
··· 183 183 | S256 (** SHA-256 transform (recommended). *) 184 184 | Plain (** Plain text (only when S256 is unsupported by the server). *) 185 185 186 - val generate_code_verifier : unit -> string 187 - (** [generate_code_verifier ()] is a 43-character Base64url-encoded string (32 188 - random bytes) suitable as the [code_verifier] parameter. 186 + type code_verifier 187 + (** A validated PKCE code verifier. Abstract to prevent passing strings with 188 + insufficient entropy. *) 189 189 190 - Per RFC 7636 §4.1 the verifier must be 43–128 characters from the unreserved 191 - set [[A-Za-z0-9._~-]]. 190 + val generate_code_verifier : unit -> code_verifier 191 + (** [generate_code_verifier ()] generates a code verifier with 32 random bytes 192 + (43 base64url characters), satisfying RFC 7636 §4.1. 192 193 193 194 @raise Crypto_rng.Unseeded_generator if the RNG is not initialized. *) 194 195 195 - val code_challenge : challenge_method -> string -> string 196 + val code_verifier_to_string : code_verifier -> string 197 + (** [code_verifier_to_string v] is the raw verifier string. Needed for session 198 + storage between the authorization redirect and the callback. *) 199 + 200 + val code_verifier_of_string : 201 + string -> (code_verifier, [ `Msg of string ]) result 202 + (** [code_verifier_of_string s] validates [s] as a PKCE code verifier per RFC 203 + 7636 §4.1: 43–128 characters from the unreserved set [[A-Za-z0-9._~-]]. *) 204 + 205 + val code_challenge : challenge_method -> code_verifier -> string 196 206 (** [code_challenge method_ verifier] is the [code_challenge] derived from 197 207 [verifier]. For [S256] this is [BASE64URL(SHA256(verifier))]; for [Plain] it 198 208 is the verifier itself (RFC 7636 §4.2). *) ··· 244 254 client_secret:string -> 245 255 code:string -> 246 256 redirect_uri:redirect_uri -> 247 - ?code_verifier:string -> 257 + ?code_verifier:code_verifier -> 248 258 unit -> 249 259 (token_response, parse_token_error) result 250 260 (** [exchange_code http provider ~client_id ~client_secret ~code ~redirect_uri
+27 -8
test/test_github_oauth.ml
··· 170 170 171 171 let test_code_verifier_length () = 172 172 let v = Oauth.generate_code_verifier () in 173 - (* 32 bytes → 43 base64url chars *) 174 - Alcotest.(check int) "verifier length is 43" 43 (String.length v) 173 + let s = Oauth.code_verifier_to_string v in 174 + Alcotest.(check int) "verifier length is 43" 43 (String.length s) 175 175 176 176 let test_code_verifier_charset () = 177 177 let v = Oauth.generate_code_verifier () in ··· 184 184 || c = '-' || c = '_' || c = '.' || c = '~' 185 185 in 186 186 Alcotest.(check bool) "unreserved char" true ok) 187 - v 187 + (Oauth.code_verifier_to_string v) 188 188 189 189 let test_code_verifier_unique () = 190 - let v1 = Oauth.generate_code_verifier () in 191 - let v2 = Oauth.generate_code_verifier () in 190 + let v1 = Oauth.code_verifier_to_string (Oauth.generate_code_verifier ()) in 191 + let v2 = Oauth.code_verifier_to_string (Oauth.generate_code_verifier ()) in 192 192 Alcotest.(check bool) "verifiers differ" true (v1 <> v2) 193 193 194 + let test_code_verifier_rejects_short () = 195 + match Oauth.code_verifier_of_string "too-short" with 196 + | Error _ -> () 197 + | Ok _ -> Alcotest.fail "expected Error for short verifier" 198 + 199 + let test_code_verifier_rejects_invalid_chars () = 200 + let s = String.make 43 ' ' in 201 + match Oauth.code_verifier_of_string s with 202 + | Error _ -> () 203 + | Ok _ -> Alcotest.fail "expected Error for invalid chars" 204 + 194 205 let test_code_challenge_s256_rfc_vector () = 195 206 (* RFC 7636 Appendix B test vector *) 196 - let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" in 207 + let verifier = 208 + Oauth.code_verifier_of_string "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" 209 + |> Result.get_ok 210 + in 197 211 let challenge = Oauth.code_challenge S256 verifier in 198 212 Alcotest.(check string) 199 213 "RFC 7636 Appendix B" "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" 200 214 challenge 201 215 202 216 let test_code_challenge_plain () = 203 - let verifier = "some_verifier_string" in 217 + let verifier = Oauth.generate_code_verifier () in 218 + let verifier_str = Oauth.code_verifier_to_string verifier in 204 219 let challenge = Oauth.code_challenge Plain verifier in 205 - Alcotest.(check string) "plain = verifier" verifier challenge 220 + Alcotest.(check string) "plain = verifier" verifier_str challenge 206 221 207 222 let test_authorization_url_with_pkce () = 208 223 let verifier = Oauth.generate_code_verifier () in ··· 274 289 Alcotest.test_case "PKCE verifier charset" `Quick 275 290 test_code_verifier_charset; 276 291 Alcotest.test_case "PKCE verifier unique" `Quick test_code_verifier_unique; 292 + Alcotest.test_case "PKCE verifier rejects short" `Quick 293 + test_code_verifier_rejects_short; 294 + Alcotest.test_case "PKCE verifier rejects invalid chars" `Quick 295 + test_code_verifier_rejects_invalid_chars; 277 296 Alcotest.test_case "PKCE S256 RFC vector" `Quick 278 297 test_code_challenge_s256_rfc_vector; 279 298 Alcotest.test_case "PKCE plain challenge" `Quick test_code_challenge_plain;