OAuth 2.0 authorization and token exchange
0
fork

Configure Feed

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

Add validate_state with constant-time comparison for CSRF protection

The library generated state but provided no validation function,
leaving callers to roll their own (potentially timing-vulnerable)
comparison or skip validation entirely.

Add validate_state using constant-time byte comparison. Update the
section heading and docs to spell out the caller's obligation: store
state in session before redirect, validate on callback, reject on
mismatch. Update module example to show the full generate/validate
flow.

4 new tests: matching, mismatch, empty, and length-mismatch cases.

+68 -5
+13 -1
lib/oauth.ml
··· 148 148 149 149 let decode codec s = Jsont_bytesrw.decode_string codec s 150 150 151 - (* ── State ───────────────────────────────────────────────────────── *) 151 + (* ── CSRF State ─────────────────────────────────────────────────── *) 152 152 153 153 let generate_state () = Ohex.encode (Crypto_rng.generate 32) 154 + 155 + let validate_state ~expected ~actual = 156 + let len_e = String.length expected in 157 + let len_a = String.length actual in 158 + let len = max len_e len_a in 159 + let result = ref (len_e lxor len_a) in 160 + for i = 0 to len - 1 do 161 + let c_e = if i < len_e then Char.code expected.[i] else 0 in 162 + let c_a = if i < len_a then Char.code actual.[i] else 0 in 163 + result := !result lor (c_e lxor c_a) 164 + done; 165 + !result = 0 154 166 155 167 (* ── PKCE (RFC 7636) ─────────────────────────────────────────────── *) 156 168
+25 -4
lib/oauth.mli
··· 11 11 {2 Example} 12 12 13 13 {[ 14 + (* 1. Before redirect: generate state and PKCE, store in session *) 14 15 let redirect_uri = 15 16 Oauth.redirect_uri "https://app.com/callback" |> Result.get_ok 16 17 in 17 18 let state = Oauth.generate_state () in 18 19 let verifier = Oauth.generate_code_verifier () in 19 20 let challenge = Oauth.code_challenge S256 verifier in 21 + (* store [state] and [verifier] in the user's session *) 20 22 let url = 21 23 Oauth.authorization_url Github ~client_id:"xxx" 22 24 ~redirect_uri ~state ··· 24 26 in 25 27 (* redirect user to [url] *) 26 28 27 - (* On callback, exchange code for token *) 29 + (* 2. On callback: validate state, then exchange code *) 30 + let callback_state = (* [state] query param from callback URL *) in 31 + if not (Oauth.validate_state ~expected:state ~actual:callback_state) then 32 + failwith "CSRF state mismatch"; 28 33 let body = 29 34 Oauth.exchange_form_body ~client_id:"xxx" ~client_secret:"yyy" 30 35 ~code ~redirect_uri ··· 142 147 val redirect_uri_to_string : redirect_uri -> string 143 148 (** [redirect_uri_to_string uri] is the string representation. *) 144 149 145 - (** {1:state State Generation} *) 150 + (** {1:state CSRF State} 151 + 152 + The [state] parameter is the sole CSRF protection in the OAuth 2.0 153 + authorization code flow. The caller {b must}: 154 + 155 + + Call {!generate_state} and store the result in the user's session (e.g. a 156 + server-side session or a signed cookie) {i before} redirecting. 157 + + On the authorization callback, call {!validate_state} to compare the 158 + [state] query parameter with the stored value. 159 + + Reject the callback if validation fails. *) 146 160 147 161 val generate_state : unit -> string 148 - (** [generate_state ()] is a 64-character lowercase hex string (32 random bytes) 149 - suitable as the [state] parameter for CSRF protection. 162 + (** [generate_state ()] is a 64-character lowercase hex string (32 random 163 + bytes). 150 164 151 165 @raise Crypto_rng.Unseeded_generator if the RNG is not initialized. *) 166 + 167 + val validate_state : expected:string -> actual:string -> bool 168 + (** [validate_state ~expected ~actual] is [true] if [expected] and [actual] are 169 + equal. Uses constant-time comparison to prevent timing side-channels. 170 + 171 + [expected] is the state stored in the user's session; [actual] is the 172 + [state] query parameter from the authorization callback. *) 152 173 153 174 (** {1:pkce PKCE (RFC 7636)} 154 175
+30
test/test_github_oauth.ml
··· 25 25 let state2 = Oauth.generate_state () in 26 26 Alcotest.(check bool) "states are different" true (state1 <> state2) 27 27 28 + let test_validate_state_matching () = 29 + let state = Oauth.generate_state () in 30 + Alcotest.(check bool) 31 + "same state validates" true 32 + (Oauth.validate_state ~expected:state ~actual:state) 33 + 34 + let test_validate_state_mismatch () = 35 + let s1 = Oauth.generate_state () in 36 + let s2 = Oauth.generate_state () in 37 + Alcotest.(check bool) 38 + "different states reject" false 39 + (Oauth.validate_state ~expected:s1 ~actual:s2) 40 + 41 + let test_validate_state_empty () = 42 + Alcotest.(check bool) 43 + "empty vs non-empty rejects" false 44 + (Oauth.validate_state ~expected:(Oauth.generate_state ()) ~actual:"") 45 + 46 + let test_validate_state_length_mismatch () = 47 + Alcotest.(check bool) 48 + "different lengths reject" false 49 + (Oauth.validate_state ~expected:"abc" ~actual:"abcd") 50 + 28 51 let test_authorization_url_basic () = 29 52 let url = 30 53 Oauth.authorization_url Oauth.Github ~client_id:"test_client" ··· 239 262 let suite = 240 263 ( "oauth", 241 264 [ 265 + Alcotest.test_case "validate_state matching" `Quick 266 + test_validate_state_matching; 267 + Alcotest.test_case "validate_state mismatch" `Quick 268 + test_validate_state_mismatch; 269 + Alcotest.test_case "validate_state empty" `Quick test_validate_state_empty; 270 + Alcotest.test_case "validate_state length mismatch" `Quick 271 + test_validate_state_length_mismatch; 242 272 Alcotest.test_case "generate_state length and format" `Quick 243 273 test_generate_state; 244 274 Alcotest.test_case "generate_state unique" `Quick