OAuth 2.0 authorization and token exchange
0
fork

Configure Feed

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

Enforce HTTPS on custom OAuth provider URLs (RFC 6749 §3.1–3.2)

Add custom_provider smart constructor that validates all endpoint URLs
use HTTPS, as required by RFC 6749 for authorization and token
endpoints. Document TLS requirements on exchange_form_body and
refresh_form_body since both transmit client_secret in cleartext.

4 new tests verify rejection of http:// URLs and acceptance of https://.

+105 -5
+1 -1
README.md
··· 35 35 Github_oauth.exchange_request_body ~client_id:"your_client_id" 36 36 ~client_secret:"your_secret" ~code ~redirect_uri:"https://yourapp.com/callback" 37 37 in 38 - (* POST body to Github_oauth.access_token_url with headers: 38 + (* POST body over HTTPS to Github_oauth.access_token_url with headers: 39 39 Content-Type: application/json 40 40 Accept: application/json *) 41 41
+18
lib/oauth.ml
··· 16 16 uid_field : string; 17 17 } 18 18 19 + let is_https url = 20 + String.length url >= 8 21 + && String.sub (String.lowercase_ascii url) 0 8 = "https://" 22 + 23 + let require_https label url = 24 + if is_https url then Ok () 25 + else Error (`Msg (Fmt.str "%s must use HTTPS, got: %s" label url)) 26 + 27 + let custom_provider ~name ~authorize_url ~token_url ~userinfo_url ~uid_field = 28 + match 29 + ( require_https "authorize_url" authorize_url, 30 + require_https "token_url" token_url, 31 + require_https "userinfo_url" userinfo_url ) 32 + with 33 + | Ok (), Ok (), Ok () -> 34 + Ok { name; authorize_url; token_url; userinfo_url; uid_field } 35 + | (Error _ as e), _, _ | _, (Error _ as e), _ | _, _, (Error _ as e) -> e 36 + 19 37 (* Sanitize a string for use as a URL path segment per RFC 3986 §3.3: 20 38 lowercase, keep only unreserved chars [a-z0-9-], collapse runs of 21 39 dashes, strip leading/trailing dashes. Non-ASCII bytes (UTF-8) are
+31 -4
lib/oauth.mli
··· 27 27 ~code ~redirect_uri:"https://app.com/callback" 28 28 ~code_verifier:verifier () 29 29 in 30 - (* POST [body] to [Oauth.token_url Github] with 30 + (* POST [body] over TLS to [Oauth.token_url Github] with 31 31 Content-Type: application/x-www-form-urlencoded 32 32 Accept: application/json *) 33 33 ]} *) ··· 57 57 uid_field : string; (** JSON field containing the unique user identifier. *) 58 58 } 59 59 (** Configuration for a custom OAuth provider not covered by the built-in 60 - variants. *) 60 + variants. 61 + 62 + {b Security}: All URLs must use HTTPS. Per 63 + {{:https://datatracker.ietf.org/doc/html/rfc6749#section-3.1} RFC 6749 §3.1} 64 + the authorization endpoint must use TLS, and per 65 + {{:https://datatracker.ietf.org/doc/html/rfc6749#section-3.2} §3.2} the 66 + token endpoint must use TLS. Use {!custom_provider} to construct values with 67 + HTTPS validation. *) 68 + 69 + val custom_provider : 70 + name:string -> 71 + authorize_url:string -> 72 + token_url:string -> 73 + userinfo_url:string -> 74 + uid_field:string -> 75 + (custom_provider, [ `Msg of string ]) result 76 + (** [custom_provider ~name ~authorize_url ~token_url ~userinfo_url ~uid_field] 77 + constructs a custom provider configuration after validating that all 78 + endpoint URLs use HTTPS, as required by RFC 6749 §3.1–3.2. 79 + 80 + Returns [Error (`Msg reason)] if any URL does not start with ["https://"]. 81 + *) 61 82 62 83 val provider_name : provider -> string 63 84 (** [provider_name p] is the canonical provider identifier used for identity ··· 157 178 exchanging an authorization code for an access token (RFC 6749 §4.1.3). 158 179 159 180 When [~code_verifier] is provided the [code_verifier] parameter is included 160 - per RFC 7636 §4.5. *) 181 + per RFC 7636 §4.5. 182 + 183 + {b Security}: This body contains [client_secret] in cleartext. It must only 184 + be sent over TLS to the provider's token endpoint (RFC 6749 §3.2). *) 161 185 162 186 (** {1:token Token Response} *) 163 187 ··· 184 208 val refresh_form_body : 185 209 client_id:string -> client_secret:string -> refresh_token:string -> string 186 210 (** [refresh_form_body ~client_id ~client_secret ~refresh_token] is a 187 - form-encoded string for refreshing an access token (RFC 6749 §6). *) 211 + form-encoded string for refreshing an access token (RFC 6749 §6). 212 + 213 + {b Security}: This body contains [client_secret] in cleartext. It must only 214 + be sent over TLS to the provider's token endpoint (RFC 6749 §3.2). *) 188 215 189 216 (** {1:userinfo Userinfo Parsing} *) 190 217
+55
test/test_regressions.ml
··· 181 181 "opam should declare crypto-rng.unix when tests require it" true 182 182 (contains opam ~substring:"\"crypto-rng.unix\"") 183 183 184 + (* ── Transport security ──────────────────────────────────────────── *) 185 + 186 + let test_custom_provider_rejects_http_token_url () = 187 + match 188 + Oauth.custom_provider ~name:"bad" ~authorize_url:"https://example.com/auth" 189 + ~token_url:"http://example.com/token" 190 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" 191 + with 192 + | Error (`Msg msg) -> 193 + Alcotest.(check bool) 194 + "mentions token_url" true 195 + (contains msg ~substring:"token_url") 196 + | Ok _ -> Alcotest.fail "expected Error for http:// token_url" 197 + 198 + let test_custom_provider_rejects_http_authorize_url () = 199 + match 200 + Oauth.custom_provider ~name:"bad" ~authorize_url:"http://example.com/auth" 201 + ~token_url:"https://example.com/token" 202 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" 203 + with 204 + | Error (`Msg msg) -> 205 + Alcotest.(check bool) 206 + "mentions authorize_url" true 207 + (contains msg ~substring:"authorize_url") 208 + | Ok _ -> Alcotest.fail "expected Error for http:// authorize_url" 209 + 210 + let test_custom_provider_rejects_http_userinfo_url () = 211 + match 212 + Oauth.custom_provider ~name:"bad" ~authorize_url:"https://example.com/auth" 213 + ~token_url:"https://example.com/token" 214 + ~userinfo_url:"http://example.com/user" ~uid_field:"id" 215 + with 216 + | Error (`Msg msg) -> 217 + Alcotest.(check bool) 218 + "mentions userinfo_url" true 219 + (contains msg ~substring:"userinfo_url") 220 + | Ok _ -> Alcotest.fail "expected Error for http:// userinfo_url" 221 + 222 + let test_custom_provider_accepts_https () = 223 + match 224 + Oauth.custom_provider ~name:"good" ~authorize_url:"https://example.com/auth" 225 + ~token_url:"https://example.com/token" 226 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" 227 + with 228 + | Ok p -> Alcotest.(check string) "name" "good" p.name 229 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 230 + 184 231 let suite = 185 232 ( "regressions", 186 233 [ ··· 203 250 Alcotest.test_case "provider_name is raw" `Quick test_provider_name_is_raw; 204 251 Alcotest.test_case "provider_slug is path-safe" `Quick 205 252 test_provider_slug_is_path_safe; 253 + Alcotest.test_case "custom_provider rejects http:// token_url" `Quick 254 + test_custom_provider_rejects_http_token_url; 255 + Alcotest.test_case "custom_provider rejects http:// authorize_url" `Quick 256 + test_custom_provider_rejects_http_authorize_url; 257 + Alcotest.test_case "custom_provider rejects http:// userinfo_url" `Quick 258 + test_custom_provider_rejects_http_userinfo_url; 259 + Alcotest.test_case "custom_provider accepts https://" `Quick 260 + test_custom_provider_accepts_https; 206 261 ] )