OAuth 2.0 authorization and token exchange
0
fork

Configure Feed

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

oauth: Pushed Authorization Requests (RFC 9126)

Adds an optional par_endpoint to custom providers and an Oauth.Par
module covering the client side of PAR:

- Par.push form-encodes the same authorization parameters
authorization_url would have put in the query string, POSTs them to
par_endpoint under Client_auth, and parses the {request_uri,
expires_in} response. Accepts an optional dpop_proof for servers that
require DPoP on PAR (RFC 9449 §10).
- Par.authorization_url builds the minimal authorization URL carrying
only client_id and request_uri per RFC 9126 §4.
- Par.parse_response decodes the JSON response and distinguishes
missing request_uri, missing expires_in, bad HTTP status, and bad
JSON.

custom_provider now takes an optional ~par_endpoint which is HTTPS-
checked like the other URLs. Built-in providers (GitHub, Google,
GitLab) have no standard PAR endpoint so Par.push returns
No_par_endpoint for them.

8 new tests cover parse success, each parse error, push refusal without
an endpoint, the URL composition carrying only client_id+request_uri,
and HTTPS validation of par_endpoint.

+335 -16
+134 -5
lib/oauth.ml
··· 14 14 token_url : string; 15 15 userinfo_url : string; 16 16 uid_field : string; 17 + par_endpoint : string option; 17 18 } 18 19 19 20 (* Sanitize a string for use as a URL path segment per RFC 3986 §3.3: ··· 66 67 || c = '_' || c = '-') 67 68 s 68 69 69 - let custom_provider ~name ~authorize_url ~token_url ~userinfo_url ~uid_field = 70 + let custom_provider ~name ~authorize_url ~token_url ~userinfo_url ~uid_field 71 + ?par_endpoint () = 70 72 if not (is_valid_json_field_name uid_field) then 71 73 Error 72 74 (`Msg ··· 83 85 built-in provider" 84 86 name slug)) 85 87 else 88 + let par_check = 89 + match par_endpoint with 90 + | None -> Ok () 91 + | Some url -> require_https "par_endpoint" url 92 + in 86 93 match 87 94 ( require_https "authorize_url" authorize_url, 88 95 require_https "token_url" token_url, 89 - require_https "userinfo_url" userinfo_url ) 96 + require_https "userinfo_url" userinfo_url, 97 + par_check ) 90 98 with 91 - | Ok (), Ok (), Ok () -> 92 - Ok { name; authorize_url; token_url; userinfo_url; uid_field } 93 - | (Error _ as e), _, _ | _, (Error _ as e), _ | _, _, (Error _ as e) -> e 99 + | Ok (), Ok (), Ok (), Ok () -> 100 + Ok 101 + { 102 + name; 103 + authorize_url; 104 + token_url; 105 + userinfo_url; 106 + uid_field; 107 + par_endpoint; 108 + } 109 + | (Error _ as e), _, _, _ 110 + | _, (Error _ as e), _, _ 111 + | _, _, (Error _ as e), _ 112 + | _, _, _, (Error _ as e) -> 113 + e 94 114 95 115 let provider_name = function 96 116 | Github -> "github" ··· 473 493 let refresh_token http provider ~client_auth ~refresh_token = 474 494 let form_str, extra_headers = refresh_form_body ~client_auth ~refresh_token in 475 495 post_token_endpoint http provider ~extra_headers form_str 496 + 497 + (* -- Pushed Authorization Requests (RFC 9126) --------------------- *) 498 + 499 + let par_endpoint_of = function 500 + | Github | Google | Gitlab -> None 501 + | Custom c -> c.par_endpoint 502 + 503 + module Par = struct 504 + type response = { request_uri : string; expires_in : int } 505 + 506 + type error = 507 + | No_par_endpoint 508 + | Http_error of int 509 + | Invalid_json 510 + | Missing_request_uri 511 + | Invalid_expires_in 512 + 513 + let pp_error fmt = function 514 + | No_par_endpoint -> Fmt.pf fmt "provider has no PAR endpoint" 515 + | Http_error code -> Fmt.pf fmt "PAR endpoint returned HTTP %d" code 516 + | Invalid_json -> Fmt.pf fmt "Invalid JSON" 517 + | Missing_request_uri -> Fmt.pf fmt "Missing request_uri field" 518 + | Invalid_expires_in -> Fmt.pf fmt "Missing or invalid expires_in field" 519 + 520 + type raw = { request_uri : string; expires_in : int option } 521 + 522 + let raw_jsont = 523 + let open Json.Codec in 524 + Object.map ~kind:"par_response" (fun request_uri expires_in -> 525 + { request_uri; expires_in }) 526 + |> Object.mem "request_uri" string ~dec_absent:"" ~enc:(fun r -> 527 + r.request_uri) 528 + |> Object.opt_mem "expires_in" int ~enc:(fun r -> r.expires_in) 529 + |> Object.skip_unknown |> Object.finish 530 + 531 + let parse_response body = 532 + match decode raw_jsont body with 533 + | Error _ -> Error Invalid_json 534 + | Ok { request_uri = ""; _ } -> Error Missing_request_uri 535 + | Ok { expires_in = None; _ } -> Error Invalid_expires_in 536 + | Ok { request_uri; expires_in = Some secs } -> 537 + Ok ({ request_uri; expires_in = secs } : response) 538 + 539 + (* Same authorization parameters the non-PAR [authorization_url] would have 540 + added to the query string, minus [client_id] which Client_auth carries. *) 541 + let authz_fields ~redirect_uri ~state ~scope ?code_challenge 542 + ?code_challenge_method () = 543 + let base = 544 + [ 545 + ("response_type", "code"); 546 + ("redirect_uri", redirect_uri_to_string redirect_uri); 547 + ("state", state); 548 + ] 549 + in 550 + let base = 551 + match scope with 552 + | [] -> base 553 + | lst -> base @ [ ("scope", String.concat " " lst) ] 554 + in 555 + match code_challenge with 556 + | None -> base 557 + | Some cc -> 558 + let method_ = 559 + match code_challenge_method with Some m -> m | None -> S256 560 + in 561 + base 562 + @ [ 563 + ("code_challenge", cc); 564 + ("code_challenge_method", challenge_method_to_string method_); 565 + ] 566 + 567 + let push http provider ~client_auth ~redirect_uri ~state ~scope 568 + ?code_challenge ?code_challenge_method ?dpop_proof () = 569 + match par_endpoint_of provider with 570 + | None -> Error No_par_endpoint 571 + | Some url -> 572 + if not (Requests.verify_tls http) then 573 + invalid_arg 574 + "Oauth.Par.push: Requests.t handle must have TLS certificate \ 575 + verification enabled"; 576 + let auth_fields, auth_headers = client_auth_apply client_auth in 577 + let fields = 578 + auth_fields 579 + @ authz_fields ~redirect_uri ~state ~scope ?code_challenge 580 + ?code_challenge_method () 581 + in 582 + let form_str = form_encode fields in 583 + let dpop_headers = 584 + match dpop_proof with None -> [] | Some p -> [ ("DPoP", p) ] 585 + in 586 + let headers = 587 + Http.Headers.of_list (base_token_headers @ auth_headers @ dpop_headers) 588 + in 589 + let body = Requests.Body.text form_str in 590 + let resp = Requests.post http url ~body ~headers in 591 + let status = Requests.Response.status_code resp in 592 + if status < 200 || status >= 300 then begin 593 + Log.warn (fun m -> m "PAR endpoint returned HTTP %d" status); 594 + Error (Http_error status) 595 + end 596 + else parse_response (Requests.Response.text resp) 597 + 598 + let authorization_url provider ~client_id ~request_uri = 599 + let uri = Uri.of_string (authorize_url provider) in 600 + let q = 601 + [ ("client_id", [ client_id ]); ("request_uri", [ request_uri ]) ] 602 + in 603 + Uri.with_query uri q |> Uri.to_string 604 + end 476 605 477 606 (* -- Token Lifecycle ----------------------------------------------- *) 478 607
+77 -3
lib/oauth.mli
··· 64 64 token_url : string; 65 65 userinfo_url : string; 66 66 uid_field : string; (** JSON field containing the unique user identifier. *) 67 + par_endpoint : string option; 68 + (** Pushed Authorization Request endpoint, if the server advertises one 69 + (RFC 9126). [None] means {!Par.push} will refuse to push for this 70 + provider. *) 67 71 } 68 72 (** Configuration for a custom OAuth provider not covered by the built-in 69 73 variants. The type is private -- use {!custom_provider} to construct values. ··· 82 86 token_url:string -> 83 87 userinfo_url:string -> 84 88 uid_field:string -> 89 + ?par_endpoint:string -> 90 + unit -> 85 91 (custom_provider, [ `Msg of string ]) result 86 - (** [custom_provider ~name ~authorize_url ~token_url ~userinfo_url ~uid_field] 87 - constructs a custom provider configuration after validating that: 88 - - All endpoint URLs use HTTPS (RFC 6749 §3.1–3.2). 92 + (** [custom_provider ~name ~authorize_url ~token_url ~userinfo_url ~uid_field 93 + ?par_endpoint ()] constructs a custom provider configuration after 94 + validating that: 95 + - All endpoint URLs (including [par_endpoint] if supplied) use HTTPS (RFC 96 + 6749 §3.1–3.2, RFC 9126 §2). 89 97 - The slug derived from [name] does not collide with a built-in provider 90 98 (["github"], ["google"], ["gitlab"]), which would make callback routes 91 99 ambiguous. ··· 337 345 (** [parse_token_response body] parses a JSON token response body. *) 338 346 339 347 val pp_parse_token_error : Format.formatter -> parse_token_error -> unit 348 + 349 + (** {1:par Pushed Authorization Requests (RFC 9126)} 350 + 351 + PAR moves the authorization request parameters off the user's browser URL 352 + bar and onto a confidential, client-authenticated POST to the 353 + [par_endpoint]. The server returns a short-lived [request_uri]; the client 354 + redirects the user to the authorization endpoint carrying only [client_id] 355 + and [request_uri]. This prevents tampering and leakage through browser 356 + history, server logs, and referrer headers. See 357 + {{:https://datatracker.ietf.org/doc/html/rfc9126} RFC 9126}. 358 + 359 + PAR requires the provider to advertise a [par_endpoint]; only custom 360 + providers configured with one are supported. *) 361 + 362 + module Par : sig 363 + type response = { request_uri : string; expires_in : int } 364 + (** Successful PAR response per RFC 9126 §2.2. *) 365 + 366 + type error = 367 + | No_par_endpoint (** The provider has no configured PAR endpoint. *) 368 + | Http_error of int 369 + | Invalid_json 370 + | Missing_request_uri 371 + | Invalid_expires_in 372 + 373 + val pp_error : Format.formatter -> error -> unit 374 + 375 + val push : 376 + Requests.t -> 377 + provider -> 378 + client_auth:Client_auth.t -> 379 + redirect_uri:redirect_uri -> 380 + state:string -> 381 + scope:string list -> 382 + ?code_challenge:string -> 383 + ?code_challenge_method:challenge_method -> 384 + ?dpop_proof:string -> 385 + unit -> 386 + (response, error) result 387 + (** [push http provider ~client_auth ~redirect_uri ~state ~scope 388 + ?code_challenge ?code_challenge_method ?dpop_proof ()] sends the 389 + authorization parameters to the provider's PAR endpoint (RFC 9126 §2.1). 390 + 391 + The body carries the same [response_type=code] / [redirect_uri] / [state] 392 + / [scope] / PKCE parameters as {!authorization_url} would have placed in 393 + the query string. [client_auth] authenticates the request. 394 + 395 + If [dpop_proof] is supplied it is sent in the [DPoP] header, binding the 396 + authorization to the client's DPoP key at request time (RFC 9449 §10 when 397 + the server requires DPoP on PAR). 398 + 399 + Returns [Error No_par_endpoint] if [provider] has no PAR endpoint. 400 + 401 + @raise Invalid_argument 402 + if [http] has TLS certificate verification disabled. *) 403 + 404 + val authorization_url : 405 + provider -> client_id:string -> request_uri:string -> string 406 + (** [authorization_url provider ~client_id ~request_uri] builds the 407 + authorization-endpoint URL carrying only [client_id] and [request_uri], 408 + per RFC 9126 §4. All other authorization parameters are already stored 409 + server-side under [request_uri]. *) 410 + 411 + val parse_response : string -> (response, error) result 412 + (** [parse_response body] parses a PAR server response. *) 413 + end 340 414 341 415 (** {1:token_lifecycle Token Lifecycle} 342 416
+124 -8
test/test_regressions.ml
··· 113 113 match 114 114 Oauth.custom_provider ~name ~authorize_url:"https://example.com/auth" 115 115 ~token_url:"https://example.com/token" 116 - ~userinfo_url:"https://example.com/user" ~uid_field:"id" 116 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" () 117 117 with 118 118 | Ok p -> Oauth.Custom p 119 119 | Error (`Msg msg) -> failwith msg ··· 248 248 (Oauth.Client_auth.client_id 249 249 (Oauth.Client_auth.basic ~client_id:"c" ~client_secret:"x")) 250 250 251 + (* ── PAR (RFC 9126) ──────────────────────────────────────────────── *) 252 + 253 + let par_error = Alcotest.testable Oauth.Par.pp_error ( = ) 254 + 255 + let test_par_parse_response_ok () = 256 + let body = 257 + {|{"request_uri":"urn:ietf:params:oauth:request_uri:x","expires_in":60}|} 258 + in 259 + match Oauth.Par.parse_response body with 260 + | Ok r -> 261 + Alcotest.(check string) 262 + "request_uri" "urn:ietf:params:oauth:request_uri:x" r.request_uri; 263 + Alcotest.(check int) "expires_in" 60 r.expires_in 264 + | Error e -> Alcotest.failf "unexpected: %a" Oauth.Par.pp_error e 265 + 266 + let test_par_parse_response_missing_request_uri () = 267 + let body = {|{"expires_in":60}|} in 268 + Alcotest.(check (result reject par_error)) 269 + "missing request_uri" (Error Oauth.Par.Missing_request_uri) 270 + (Oauth.Par.parse_response body) 271 + 272 + let test_par_parse_response_missing_expires_in () = 273 + let body = {|{"request_uri":"urn:x"}|} in 274 + Alcotest.(check (result reject par_error)) 275 + "missing expires_in" (Error Oauth.Par.Invalid_expires_in) 276 + (Oauth.Par.parse_response body) 277 + 278 + let test_par_parse_response_invalid_json () = 279 + Alcotest.(check (result reject par_error)) 280 + "invalid json" (Error Oauth.Par.Invalid_json) 281 + (Oauth.Par.parse_response "not json") 282 + 283 + let test_par_push_requires_par_endpoint () = 284 + (* Built-in providers have no PAR endpoint; push should refuse cleanly. *) 285 + Eio_main.run @@ fun env -> 286 + Eio.Switch.run @@ fun sw -> 287 + let http = Requests.v ~sw env in 288 + Alcotest.(check (result reject par_error)) 289 + "no par_endpoint" (Error Oauth.Par.No_par_endpoint) 290 + (Oauth.Par.push http Oauth.Github 291 + ~client_auth:(Oauth.Client_auth.post ~client_id:"x" ~client_secret:"y") 292 + ~redirect_uri:(redir "https://example.com/cb") 293 + ~state:"s" ~scope:[ "r" ] ()) 294 + 295 + let test_par_authorization_url_only_carries_client_id_and_request_uri () = 296 + match 297 + Oauth.custom_provider ~name:"atp" ~authorize_url:"https://as.example/auth" 298 + ~token_url:"https://as.example/token" 299 + ~userinfo_url:"https://as.example/user" ~uid_field:"sub" 300 + ~par_endpoint:"https://as.example/par" () 301 + with 302 + | Error (`Msg msg) -> Alcotest.failf "custom_provider: %s" msg 303 + | Ok c -> 304 + let url = 305 + Oauth.Par.authorization_url (Oauth.Custom c) ~client_id:"cid" 306 + ~request_uri:"urn:example:req:1" 307 + in 308 + let uri = Uri.of_string url in 309 + Alcotest.(check (option string)) 310 + "client_id" (Some "cid") 311 + (Uri.get_query_param uri "client_id"); 312 + Alcotest.(check (option string)) 313 + "request_uri" (Some "urn:example:req:1") 314 + (Uri.get_query_param uri "request_uri"); 315 + Alcotest.(check (option string)) 316 + "no response_type" None 317 + (Uri.get_query_param uri "response_type"); 318 + Alcotest.(check (option string)) 319 + "no scope" None 320 + (Uri.get_query_param uri "scope"); 321 + Alcotest.(check (option string)) 322 + "no state" None 323 + (Uri.get_query_param uri "state") 324 + 325 + let test_custom_provider_accepts_par_endpoint () = 326 + match 327 + Oauth.custom_provider ~name:"atp" ~authorize_url:"https://as.example/auth" 328 + ~token_url:"https://as.example/token" 329 + ~userinfo_url:"https://as.example/user" ~uid_field:"sub" 330 + ~par_endpoint:"https://as.example/par" () 331 + with 332 + | Ok c -> 333 + Alcotest.(check (option string)) 334 + "par_endpoint" (Some "https://as.example/par") c.par_endpoint 335 + | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg 336 + 337 + let test_custom_provider_rejects_http_par_endpoint () = 338 + match 339 + Oauth.custom_provider ~name:"atp" ~authorize_url:"https://as.example/auth" 340 + ~token_url:"https://as.example/token" 341 + ~userinfo_url:"https://as.example/user" ~uid_field:"sub" 342 + ~par_endpoint:"http://as.example/par" () 343 + with 344 + | Error (`Msg msg) -> 345 + Alcotest.(check bool) 346 + "mentions par_endpoint" true 347 + (contains msg ~substring:"par_endpoint") 348 + | Ok _ -> Alcotest.fail "expected Error for http:// par_endpoint" 349 + 251 350 (* ── Transport security ──────────────────────────────────────────── *) 252 351 253 352 let test_custom_provider_rejects_http_token_url () = 254 353 match 255 354 Oauth.custom_provider ~name:"bad" ~authorize_url:"https://example.com/auth" 256 355 ~token_url:"http://example.com/token" 257 - ~userinfo_url:"https://example.com/user" ~uid_field:"id" 356 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" () 258 357 with 259 358 | Error (`Msg msg) -> 260 359 Alcotest.(check bool) ··· 266 365 match 267 366 Oauth.custom_provider ~name:"bad" ~authorize_url:"http://example.com/auth" 268 367 ~token_url:"https://example.com/token" 269 - ~userinfo_url:"https://example.com/user" ~uid_field:"id" 368 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" () 270 369 with 271 370 | Error (`Msg msg) -> 272 371 Alcotest.(check bool) ··· 278 377 match 279 378 Oauth.custom_provider ~name:"bad" ~authorize_url:"https://example.com/auth" 280 379 ~token_url:"https://example.com/token" 281 - ~userinfo_url:"http://example.com/user" ~uid_field:"id" 380 + ~userinfo_url:"http://example.com/user" ~uid_field:"id" () 282 381 with 283 382 | Error (`Msg msg) -> 284 383 Alcotest.(check bool) ··· 290 389 match 291 390 Oauth.custom_provider ~name:"good" ~authorize_url:"https://example.com/auth" 292 391 ~token_url:"https://example.com/token" 293 - ~userinfo_url:"https://example.com/user" ~uid_field:"id" 392 + ~userinfo_url:"https://example.com/user" ~uid_field:"id" () 294 393 with 295 394 | Ok p -> Alcotest.(check string) "name" "good" p.name 296 395 | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg ··· 301 400 match 302 401 Oauth.custom_provider ~name:"GitHub" ~authorize_url:"https://evil.com/auth" 303 402 ~token_url:"https://evil.com/token" ~userinfo_url:"https://evil.com/user" 304 - ~uid_field:"id" 403 + ~uid_field:"id" () 305 404 with 306 405 | Error (`Msg msg) -> 307 406 Alcotest.(check bool) ··· 313 412 match 314 413 Oauth.custom_provider ~name:"Google" ~authorize_url:"https://evil.com/auth" 315 414 ~token_url:"https://evil.com/token" ~userinfo_url:"https://evil.com/user" 316 - ~uid_field:"id" 415 + ~uid_field:"id" () 317 416 with 318 417 | Error (`Msg msg) -> 319 418 Alcotest.(check bool) ··· 326 425 Oauth.custom_provider ~name:"My Corp SSO" 327 426 ~authorize_url:"https://sso.corp.com/auth" 328 427 ~token_url:"https://sso.corp.com/token" 329 - ~userinfo_url:"https://sso.corp.com/user" ~uid_field:"sub" 428 + ~userinfo_url:"https://sso.corp.com/user" ~uid_field:"sub" () 330 429 with 331 430 | Ok p -> Alcotest.(check string) "name" "My Corp SSO" p.name 332 431 | Error (`Msg msg) -> Alcotest.failf "unexpected error: %s" msg ··· 464 563 `Quick test_client_auth_basic_percent_encodes_special_chars; 465 564 Alcotest.test_case "Client_auth.client_id accessor" `Quick 466 565 test_client_auth_client_id_accessor; 566 + Alcotest.test_case "PAR parse response ok" `Quick 567 + test_par_parse_response_ok; 568 + Alcotest.test_case "PAR parse response missing request_uri" `Quick 569 + test_par_parse_response_missing_request_uri; 570 + Alcotest.test_case "PAR parse response missing expires_in" `Quick 571 + test_par_parse_response_missing_expires_in; 572 + Alcotest.test_case "PAR parse response invalid json" `Quick 573 + test_par_parse_response_invalid_json; 574 + Alcotest.test_case "PAR push refuses when provider lacks endpoint" `Quick 575 + test_par_push_requires_par_endpoint; 576 + Alcotest.test_case 577 + "PAR authorization_url carries only client_id+request_uri" `Quick 578 + test_par_authorization_url_only_carries_client_id_and_request_uri; 579 + Alcotest.test_case "custom_provider accepts par_endpoint" `Quick 580 + test_custom_provider_accepts_par_endpoint; 581 + Alcotest.test_case "custom_provider rejects http:// par_endpoint" `Quick 582 + test_custom_provider_rejects_http_par_endpoint; 467 583 Alcotest.test_case "exchange_code rejects verify_tls:false" `Quick 468 584 test_exchange_code_rejects_verify_tls_false; 469 585 Alcotest.test_case "verify_tls getter" `Quick test_verify_tls_getter;