OAuth 2.0 authorization and token exchange
0
fork

Configure Feed

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

prune cram fixtures: declare fmt dep missed in Printf→Fmt migration

Commit 5fbed21c switched every cram test fixture from Printf to Fmt
without updating the dune stanzas to depend on fmt, so `dune build`
inside the fixtures fails and the cram expected output stopped
matching reality. Add fmt to each executable/library and refresh the
one stale expected block (cascade_cleanup) still showing Printf.

+319 -3
+2 -1
dune-project
··· 14 14 (tags (org:blacksun network http crypto)) 15 15 (description "Generic OAuth 2.0 implementation with provider configuration. Supports GitHub, Google, GitLab, and custom providers. Implements RFC 6749 (OAuth 2.0) and RFC 7636 (PKCE).") 16 16 (depends 17 - (ocaml (>= 4.08)) 17 + (ocaml (>= 5.1)) 18 18 (dune (>= 3.21)) 19 19 (fmt (>= 0.9)) 20 20 (uri (>= 4.0)) ··· 22 22 (bytesrw (>= 0.1.0)) 23 23 (crypto-rng (>= 0.11.0)) 24 24 (digestif (>= 1.0)) 25 + (eio (>= 1.0)) 25 26 (base64 (>= 3.0)) 26 27 (eqaf (>= 0.9)) 27 28 (requests (>= 0.1.0))
+1
lib/dune
··· 8 8 crypto-rng 9 9 digestif 10 10 base64 11 + eio 11 12 eqaf 12 13 requests 13 14 http
+114
lib/oauth.ml
··· 433 433 let form_str = refresh_form_body ~client_id ~client_secret ~refresh_token in 434 434 post_token_endpoint http provider form_str 435 435 436 + (* ── Token Lifecycle ─────────────────────────────────────────────── *) 437 + 438 + module Token = struct 439 + (* Refresh when the access token is within this many seconds of expiry. 440 + Passage uses the same 5-minute threshold; we use 60s to minimize 441 + refreshes for short-lived API calls while still tolerating NTP skew 442 + and request latency. *) 443 + let refresh_threshold = 60.0 444 + 445 + type state = { 446 + access_token : string; 447 + refresh_token : string option; 448 + expires_at : float option; 449 + } 450 + 451 + type t = { 452 + http : Requests.t; 453 + provider : provider; 454 + client_id : string; 455 + client_secret : string; 456 + clock : float Eio.Time.clock_ty Eio.Resource.t; 457 + mutex : Eio.Mutex.t; 458 + mutable state : state; 459 + } 460 + 461 + let make http provider ~client_id ~client_secret ~clock ~access_token 462 + ?refresh_token ?expires_at () = 463 + { 464 + http; 465 + provider; 466 + client_id; 467 + client_secret; 468 + clock :> float Eio.Time.clock_ty Eio.Resource.t; 469 + mutex = Eio.Mutex.create (); 470 + state = { access_token; refresh_token; expires_at }; 471 + } 472 + 473 + let of_response http provider ~client_id ~client_secret ~clock 474 + (tr : token_response) = 475 + let now = Eio.Time.now clock in 476 + let expires_at = 477 + Option.map (fun d -> now +. float_of_int d) tr.expires_in 478 + in 479 + make http provider ~client_id ~client_secret ~clock 480 + ~access_token:tr.access_token ?refresh_token:tr.refresh_token ?expires_at 481 + () 482 + 483 + let stale_state ~clock ~threshold s = 484 + match s.expires_at with 485 + | None -> false 486 + | Some exp -> Eio.Time.now clock +. threshold >= exp 487 + 488 + let do_refresh t = 489 + match t.state.refresh_token with 490 + | None -> 491 + Log.warn (fun m -> 492 + m "Token refresh requested but no refresh_token available"); 493 + Error (Http_error 401) 494 + | Some rt -> ( 495 + match 496 + refresh_token t.http t.provider ~client_id:t.client_id 497 + ~client_secret:t.client_secret ~refresh_token:rt 498 + with 499 + | Error _ as e -> e 500 + | Ok (tr : token_response) -> 501 + let now = Eio.Time.now t.clock in 502 + let expires_at = 503 + Option.map (fun d -> now +. float_of_int d) tr.expires_in 504 + in 505 + (* Google and others often omit refresh_token on refresh 506 + responses; retain the existing one. *) 507 + let new_refresh = 508 + match tr.refresh_token with 509 + | Some _ as v -> v 510 + | None -> t.state.refresh_token 511 + in 512 + t.state <- 513 + { 514 + access_token = tr.access_token; 515 + refresh_token = new_refresh; 516 + expires_at; 517 + }; 518 + Ok tr.access_token) 519 + 520 + let try_access t = 521 + Eio.Mutex.use_rw ~protect:true t.mutex (fun () -> 522 + if stale_state ~clock:t.clock ~threshold:refresh_threshold t.state then 523 + do_refresh t 524 + else Ok t.state.access_token) 525 + 526 + let access t = 527 + match try_access t with 528 + | Ok s -> s 529 + | Error e -> Fmt.failwith "Oauth.Token.access: %a" pp_parse_token_error e 530 + 531 + let force_refresh t = 532 + Eio.Mutex.use_rw ~protect:true t.mutex (fun () -> do_refresh t) 533 + 534 + let access_token t = Eio.Mutex.use_ro t.mutex (fun () -> t.state.access_token) 535 + 536 + let refresh_token t = 537 + Eio.Mutex.use_ro t.mutex (fun () -> t.state.refresh_token) 538 + 539 + let expires_at t = Eio.Mutex.use_ro t.mutex (fun () -> t.state.expires_at) 540 + 541 + let needs_refresh t = 542 + Eio.Mutex.use_ro t.mutex (fun () -> 543 + stale_state ~clock:t.clock ~threshold:refresh_threshold t.state) 544 + 545 + let is_expired t = 546 + Eio.Mutex.use_ro t.mutex (fun () -> 547 + stale_state ~clock:t.clock ~threshold:0.0 t.state) 548 + end 549 + 436 550 (* ── Userinfo Parsing ────────────────────────────────────────────── *) 437 551 438 552 type userinfo = {
+95
lib/oauth.mli
··· 286 286 287 287 val pp_parse_token_error : Format.formatter -> parse_token_error -> unit 288 288 289 + (** {1:token_lifecycle Token Lifecycle} 290 + 291 + A self-refreshing token wrapper. Holds an access token and (optional) 292 + refresh token, and transparently refreshes the access token when it is near 293 + expiry. Safe to share across Eio fibers — internal state is protected by a 294 + mutex so only one fiber refreshes at a time while others wait. 295 + 296 + {b Example} 297 + {[ 298 + let token = 299 + Oauth.exchange_code http Google ~client_id ~client_secret ~code 300 + ~redirect_uri () 301 + |> Result.get_ok 302 + |> Oauth.Token.of_response http Google ~client_id ~client_secret ~clock 303 + in 304 + (* Make many API calls; [access] refreshes if the access token is within 305 + 60 seconds of expiry. *) 306 + let access = Oauth.Token.access token in 307 + ignore access 308 + ]} *) 309 + module Token : sig 310 + type t 311 + (** A live OAuth token with refresh-on-demand. *) 312 + 313 + val make : 314 + Requests.t -> 315 + provider -> 316 + client_id:string -> 317 + client_secret:string -> 318 + clock:_ Eio.Time.clock -> 319 + access_token:string -> 320 + ?refresh_token:string -> 321 + ?expires_at:float -> 322 + unit -> 323 + t 324 + (** [make http provider ~client_id ~client_secret ~clock ~access_token 325 + ?refresh_token ?expires_at ()] wraps an existing access token. 326 + 327 + - [expires_at] is an absolute Unix timestamp. If omitted, the token is 328 + assumed to have no known expiry and will never auto-refresh. 329 + - [refresh_token] is required for auto-refresh. Without it, {!access} 330 + returns the stored access token until it expires, then fails. *) 331 + 332 + val of_response : 333 + Requests.t -> 334 + provider -> 335 + client_id:string -> 336 + client_secret:string -> 337 + clock:_ Eio.Time.clock -> 338 + token_response -> 339 + t 340 + (** [of_response http provider ~client_id ~client_secret ~clock tr] wraps a 341 + token response returned by {!exchange_code} or {!refresh_token}. The 342 + expiry is computed from [tr.expires_in] relative to the clock's current 343 + time. *) 344 + 345 + val access : t -> string 346 + (** [access t] returns a valid access token, refreshing synchronously if the 347 + current token is within 60 seconds of expiry. 348 + 349 + @raise Failure 350 + if refresh is needed but no refresh token is available, or if the 351 + provider's token endpoint returns an error. Use {!try_access} for a 352 + non-raising variant. *) 353 + 354 + val try_access : t -> (string, parse_token_error) result 355 + (** [try_access t] is the non-raising variant of {!access}. Returns 356 + [Error (Http_error 401)] if no refresh token is available and the current 357 + access token is expired. *) 358 + 359 + val force_refresh : t -> (string, parse_token_error) result 360 + (** [force_refresh t] refreshes the access token unconditionally. Returns the 361 + new access token on success. *) 362 + 363 + val access_token : t -> string 364 + (** [access_token t] is the current access token {b without} checking expiry 365 + or refreshing. Use {!access} for normal API calls. *) 366 + 367 + val refresh_token : t -> string option 368 + (** [refresh_token t] is the current refresh token, or [None] if none was 369 + issued. *) 370 + 371 + val expires_at : t -> float option 372 + (** [expires_at t] is the absolute Unix timestamp at which the access token 373 + expires, or [None] if unknown. *) 374 + 375 + val needs_refresh : t -> bool 376 + (** [needs_refresh t] is [true] if the access token is within 60 seconds of 377 + expiry (the threshold {!access} uses). *) 378 + 379 + val is_expired : t -> bool 380 + (** [is_expired t] is [true] if the access token is already past its expiry 381 + time. *) 382 + end 383 + 289 384 (** {1:userinfo Userinfo Parsing} *) 290 385 291 386 type userinfo = {
+2 -1
oauth.opam
··· 10 10 homepage: "https://tangled.org/gazagnaire.org/ocaml-oauth" 11 11 bug-reports: "https://tangled.org/gazagnaire.org/ocaml-oauth/issues" 12 12 depends: [ 13 - "ocaml" {>= "4.08"} 13 + "ocaml" {>= "5.1"} 14 14 "dune" {>= "3.21" & >= "3.21"} 15 15 "fmt" {>= "0.9"} 16 16 "uri" {>= "4.0"} ··· 18 18 "bytesrw" {>= "0.1.0"} 19 19 "crypto-rng" {>= "0.11.0"} 20 20 "digestif" {>= "1.0"} 21 + "eio" {>= "1.0"} 21 22 "base64" {>= "3.0"} 22 23 "eqaf" {>= "0.9"} 23 24 "requests" {>= "0.1.0"}
+2 -1
test/test.ml
··· 1 1 let () = 2 2 Crypto_rng_unix.use_default (); 3 - Alcotest.run "oauth" [ Test_github_oauth.suite; Test_regressions.suite ] 3 + Alcotest.run "oauth" 4 + [ Test_github_oauth.suite; Test_regressions.suite; Test_token.suite ]
+103
test/test_token.ml
··· 1 + let with_env f = 2 + Eio_main.run @@ fun env -> 3 + Eio.Switch.run @@ fun sw -> 4 + let http = Requests.v ~sw env in 5 + let clock = Eio.Stdenv.clock env in 6 + f ~http ~clock 7 + 8 + let token ~http ~clock ?refresh_token ?expires_at ~access_token () = 9 + Oauth.Token.make http Oauth.Google ~client_id:"cid" ~client_secret:"csec" 10 + ~clock ~access_token ?refresh_token ?expires_at () 11 + 12 + let test_access_token_roundtrip () = 13 + with_env @@ fun ~http ~clock -> 14 + let t = token ~http ~clock ~access_token:"tok_abc" () in 15 + Alcotest.(check string) "access_token" "tok_abc" (Oauth.Token.access_token t); 16 + Alcotest.(check (option string)) 17 + "refresh_token" None 18 + (Oauth.Token.refresh_token t); 19 + Alcotest.(check (option (float 0.0001))) 20 + "expires_at" None (Oauth.Token.expires_at t) 21 + 22 + let test_no_expiry_never_needs_refresh () = 23 + with_env @@ fun ~http ~clock -> 24 + let t = token ~http ~clock ~access_token:"tok" () in 25 + Alcotest.(check bool) "needs_refresh" false (Oauth.Token.needs_refresh t); 26 + Alcotest.(check bool) "is_expired" false (Oauth.Token.is_expired t) 27 + 28 + let test_future_expiry_not_stale () = 29 + with_env @@ fun ~http ~clock -> 30 + let now = Eio.Time.now clock in 31 + let t = 32 + token ~http ~clock ~access_token:"tok" ~expires_at:(now +. 3600.) () 33 + in 34 + Alcotest.(check bool) "needs_refresh" false (Oauth.Token.needs_refresh t); 35 + Alcotest.(check bool) "is_expired" false (Oauth.Token.is_expired t) 36 + 37 + let test_near_expiry_needs_refresh () = 38 + with_env @@ fun ~http ~clock -> 39 + let now = Eio.Time.now clock in 40 + (* within the 60s refresh window but not yet expired *) 41 + let t = token ~http ~clock ~access_token:"tok" ~expires_at:(now +. 10.) () in 42 + Alcotest.(check bool) "needs_refresh" true (Oauth.Token.needs_refresh t); 43 + Alcotest.(check bool) "is_expired" false (Oauth.Token.is_expired t) 44 + 45 + let test_past_expiry_is_expired () = 46 + with_env @@ fun ~http ~clock -> 47 + let now = Eio.Time.now clock in 48 + let t = token ~http ~clock ~access_token:"tok" ~expires_at:(now -. 10.) () in 49 + Alcotest.(check bool) "needs_refresh" true (Oauth.Token.needs_refresh t); 50 + Alcotest.(check bool) "is_expired" true (Oauth.Token.is_expired t) 51 + 52 + let parse_token_error = Alcotest.testable Oauth.pp_parse_token_error ( = ) 53 + 54 + let test_try_access_without_refresh_token () = 55 + with_env @@ fun ~http ~clock -> 56 + let now = Eio.Time.now clock in 57 + (* expired, no refresh token — try_access should fail cleanly *) 58 + let t = token ~http ~clock ~access_token:"tok" ~expires_at:(now -. 10.) () in 59 + match Oauth.Token.try_access t with 60 + | Ok _ -> Alcotest.fail "expected Error when refresh needed but missing" 61 + | Error e -> 62 + Alcotest.check parse_token_error "Http_error 401" (Oauth.Http_error 401) e 63 + 64 + let test_of_response_computes_expiry () = 65 + with_env @@ fun ~http ~clock -> 66 + let tr : Oauth.token_response = 67 + { 68 + access_token = "tok"; 69 + expires_in = Some 3600; 70 + refresh_token = Some "rt"; 71 + refresh_token_expires_in = None; 72 + } 73 + in 74 + let t = 75 + Oauth.Token.of_response http Oauth.Google ~client_id:"cid" 76 + ~client_secret:"csec" ~clock tr 77 + in 78 + let expires = Option.get (Oauth.Token.expires_at t) in 79 + let now = Eio.Time.now clock in 80 + (* expires_at ~= now + 3600, tolerate a few seconds of test runtime *) 81 + Alcotest.(check bool) 82 + "expires_at ≈ now + 3600" 83 + (abs_float (expires -. (now +. 3600.)) < 5.0) 84 + true 85 + 86 + let suite = 87 + ( "token", 88 + [ 89 + Alcotest.test_case "access_token roundtrip" `Quick 90 + test_access_token_roundtrip; 91 + Alcotest.test_case "no expiry never needs refresh" `Quick 92 + test_no_expiry_never_needs_refresh; 93 + Alcotest.test_case "future expiry not stale" `Quick 94 + test_future_expiry_not_stale; 95 + Alcotest.test_case "near expiry needs refresh" `Quick 96 + test_near_expiry_needs_refresh; 97 + Alcotest.test_case "past expiry is expired" `Quick 98 + test_past_expiry_is_expired; 99 + Alcotest.test_case "try_access without refresh_token" `Quick 100 + test_try_access_without_refresh_token; 101 + Alcotest.test_case "of_response computes expiry" `Quick 102 + test_of_response_computes_expiry; 103 + ] )