ATProto OAuth: client, discovery, and session management
1
fork

Configure Feed

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

atproto-oauth: add Session type

New Session module in the atproto-oauth core library. Holds the
durable identity (DID), the current PDS and AS metadata, a DPoP
keypair, the access/refresh tokens, and scope.

- DID is the anchor; handle is cached for display and may go stale
after a handle rotation.
- Dpop.key is held opaque. Disk persistence waits on ocaml-dpop
growing a private-key serialization API.
- is_expired takes a clock and a leeway (default 60s) so callers
don't race the server.
- pp surfaces durable identifiers (did, handle, pds, issuer) and
never tokens or key material; tested explicitly.

4 new tests (accessors, no-handle-or-tokens defaults, is-expired
with leeway boundaries, pp-hides-secrets).

Refresh/rotation logic needs DPoP-aware token endpoint calls that
aren't yet in ocaml-oauth; lands with the login flow (PAR + loopback
+ token exchange) in a follow-up.

+282 -2
+52
lib/atproto_oauth.ml
··· 79 79 base ~client_id ~redirect_uris ~auth_method:"private_key_jwt" ?scope 80 80 ?client_name ?client_uri ~jwks_uri () 81 81 end 82 + 83 + module Session = struct 84 + type t = { 85 + did : Did.t; 86 + handle : Atproto_handle.t option; 87 + pds_url : string; 88 + server : Oauth.Server.metadata; 89 + dpop_key : Dpop.key; 90 + access_token : string; 91 + refresh_token : string option; 92 + expires_at : float option; 93 + scope : string; 94 + } 95 + 96 + let v ~did ?handle ~pds_url ~server ~dpop_key ~access_token ?refresh_token 97 + ?expires_at ~scope () = 98 + { 99 + did; 100 + handle; 101 + pds_url; 102 + server; 103 + dpop_key; 104 + access_token; 105 + refresh_token; 106 + expires_at; 107 + scope; 108 + } 109 + 110 + let did t = t.did 111 + let handle t = t.handle 112 + let pds_url t = t.pds_url 113 + let server t = t.server 114 + let dpop_key t = t.dpop_key 115 + let access_token t = t.access_token 116 + let refresh_token t = t.refresh_token 117 + let expires_at t = t.expires_at 118 + let scope t = t.scope 119 + 120 + let is_expired ?(leeway = 60.0) ~clock t = 121 + match t.expires_at with 122 + | None -> false 123 + | Some at -> 124 + let now = Eio.Time.now clock in 125 + now +. leeway >= at 126 + 127 + let pp ppf t = 128 + let handle = 129 + match t.handle with Some h -> Atproto_handle.to_string h | None -> "-" 130 + in 131 + Fmt.pf ppf "<Session did=%a handle=%s pds=%s issuer=%s>" Did.pp t.did handle 132 + t.pds_url t.server.issuer 133 + end
+67
lib/atproto_oauth.mli
··· 110 110 Same defaults as {!public_loopback} except 111 111 [token_endpoint_auth_method = "private_key_jwt"]. *) 112 112 end 113 + 114 + (** {1:session Session} 115 + 116 + An authenticated ATProto session: the DID identity, the PDS and 117 + authorization server metadata it resolves to right now, the DPoP keypair 118 + that binds tokens to this client, and the access/refresh tokens themselves. 119 + Sessions are in-memory only at this layer — disk persistence needs {!Dpop} 120 + to grow private-key serialization and will land then. *) 121 + 122 + module Session : sig 123 + type t 124 + 125 + val v : 126 + did:Did.t -> 127 + ?handle:Atproto_handle.t -> 128 + pds_url:string -> 129 + server:Oauth.Server.metadata -> 130 + dpop_key:Dpop.key -> 131 + access_token:string -> 132 + ?refresh_token:string -> 133 + ?expires_at:float -> 134 + scope:string -> 135 + unit -> 136 + t 137 + (** [v ~did ~pds_url ~server ~dpop_key ~access_token ~scope ()] builds a 138 + session from its pieces. [handle] is the handle the user typed to log in, 139 + cached for display; it can become stale after a handle rotation (the DID 140 + is the durable identity). [expires_at] is an absolute Unix timestamp. *) 141 + 142 + val did : t -> Did.t 143 + (** [did t] is the DID anchoring this session. *) 144 + 145 + val handle : t -> Atproto_handle.t option 146 + (** [handle t] is the handle at the time of login, if any. May be stale after 147 + a handle rotation — {!did} is authoritative. *) 148 + 149 + val pds_url : t -> string 150 + (** [pds_url t] is the PDS URL current for this session. *) 151 + 152 + val server : t -> Oauth.Server.metadata 153 + (** [server t] is the authorization server metadata. *) 154 + 155 + val dpop_key : t -> Dpop.key 156 + (** [dpop_key t] is the DPoP keypair bound to this session's tokens. Leaking 157 + the private material allows anyone to replay the tokens. *) 158 + 159 + val access_token : t -> string 160 + (** [access_token t] is the current access token. Use with a DPoP proof (and 161 + an [ath] claim) on every resource request. *) 162 + 163 + val refresh_token : t -> string option 164 + (** [refresh_token t] is the refresh token, when the server issued one. *) 165 + 166 + val expires_at : t -> float option 167 + (** [expires_at t] is the access token's absolute expiry, when known. *) 168 + 169 + val scope : t -> string 170 + (** [scope t] is the granted scope string. *) 171 + 172 + val is_expired : ?leeway:float -> clock:_ Eio.Time.clock -> t -> bool 173 + (** [is_expired ?leeway ~clock t] is [true] iff the access token has expired 174 + (or will within [leeway] seconds — default 60). Returns [false] when no 175 + expiry was advertised. *) 176 + 177 + val pp : t Fmt.t 178 + (** [pp] prints a short summary; never reveals tokens or key material. *) 179 + end
+1 -1
lib/dune
··· 1 1 (library 2 2 (name atproto_oauth) 3 3 (public_name atproto-oauth) 4 - (libraries oauth fmt)) 4 + (libraries atproto-handle did dpop oauth eio fmt))
+11 -1
test/dune
··· 1 1 (test 2 2 (name test) 3 - (libraries atproto-oauth json alcotest fmt)) 3 + (libraries 4 + atproto-oauth 5 + atproto-handle 6 + did 7 + dpop 8 + crypto-rng.unix 9 + eio 10 + eio_main 11 + json 12 + alcotest 13 + fmt))
+151
test/test_atproto_oauth.ml
··· 233 233 (* ------------------------------------------------------------------------ *) 234 234 (* Suite *) 235 235 (* ------------------------------------------------------------------------ *) 236 + (* Session *) 237 + (* ------------------------------------------------------------------------ *) 238 + 239 + let contains ~needle hay = 240 + let nlen = String.length needle and hlen = String.length hay in 241 + if nlen > hlen then false 242 + else 243 + let rec loop i = 244 + if i > hlen - nlen then false 245 + else if String.sub hay i nlen = needle then true 246 + else loop (i + 1) 247 + in 248 + loop 0 249 + 250 + let sample_server : Oauth.Server.metadata = 251 + { 252 + issuer = "https://bsky.social"; 253 + authorization_endpoint = "https://bsky.social/oauth/authorize"; 254 + token_endpoint = "https://bsky.social/oauth/token"; 255 + pushed_authorization_request_endpoint = None; 256 + require_pushed_authorization_requests = false; 257 + introspection_endpoint = None; 258 + revocation_endpoint = None; 259 + jwks_uri = None; 260 + response_types_supported = []; 261 + grant_types_supported = []; 262 + code_challenge_methods_supported = []; 263 + token_endpoint_auth_methods_supported = []; 264 + token_endpoint_auth_signing_alg_values_supported = []; 265 + scopes_supported = []; 266 + dpop_signing_alg_values_supported = []; 267 + } 268 + 269 + let with_session k = 270 + Crypto_rng_unix.use_default (); 271 + Eio_main.run @@ fun env -> 272 + let key = Dpop.generate ES256 in 273 + let session = 274 + Atproto_oauth.Session.v 275 + ~did:(Did.of_string_exn "did:plc:z72i7hdynmk6r22z27h6tvur") 276 + ~handle:(Atproto_handle.of_string_exn "alice.bsky.social") 277 + ~pds_url:"https://bsky.social" ~server:sample_server ~dpop_key:key 278 + ~access_token:"ACCESS" ~refresh_token:"REFRESH" ~expires_at:1_000_000.0 279 + ~scope:"atproto transition:generic" () 280 + in 281 + k env session 282 + 283 + let session_accessors () = 284 + with_session @@ fun _env s -> 285 + Alcotest.(check string) 286 + "did" "did:plc:z72i7hdynmk6r22z27h6tvur" 287 + (Did.to_string (Atproto_oauth.Session.did s)); 288 + Alcotest.(check (option string)) 289 + "handle" (Some "alice.bsky.social") 290 + (Option.map Atproto_handle.to_string (Atproto_oauth.Session.handle s)); 291 + Alcotest.(check string) 292 + "pds" "https://bsky.social" 293 + (Atproto_oauth.Session.pds_url s); 294 + Alcotest.(check string) 295 + "access" "ACCESS" 296 + (Atproto_oauth.Session.access_token s); 297 + Alcotest.(check (option string)) 298 + "refresh" (Some "REFRESH") 299 + (Atproto_oauth.Session.refresh_token s); 300 + Alcotest.(check (option (float 0.001))) 301 + "expires" (Some 1_000_000.0) 302 + (Atproto_oauth.Session.expires_at s); 303 + Alcotest.(check string) 304 + "scope" "atproto transition:generic" 305 + (Atproto_oauth.Session.scope s) 306 + 307 + let session_no_handle_or_tokens () = 308 + Crypto_rng_unix.use_default (); 309 + Eio_main.run @@ fun _env -> 310 + let key = Dpop.generate ES256 in 311 + let s = 312 + Atproto_oauth.Session.v 313 + ~did:(Did.of_string_exn "did:plc:z72i7hdynmk6r22z27h6tvur") 314 + ~pds_url:"https://bsky.social" ~server:sample_server ~dpop_key:key 315 + ~access_token:"a" ~scope:"atproto" () 316 + in 317 + Alcotest.(check (option string)) 318 + "no-handle" None 319 + (Option.map Atproto_handle.to_string (Atproto_oauth.Session.handle s)); 320 + Alcotest.(check (option string)) 321 + "no-refresh" None 322 + (Atproto_oauth.Session.refresh_token s); 323 + Alcotest.(check bool) 324 + "no-expiry" true 325 + (Option.is_none (Atproto_oauth.Session.expires_at s)) 326 + 327 + let session_is_expired () = 328 + Crypto_rng_unix.use_default (); 329 + Eio_main.run @@ fun env -> 330 + let key = Dpop.generate ES256 in 331 + let build ?expires_at () = 332 + Atproto_oauth.Session.v 333 + ~did:(Did.of_string_exn "did:plc:z") 334 + ~pds_url:"https://x" ~server:sample_server ~dpop_key:key ~access_token:"a" 335 + ?expires_at ~scope:"atproto" () 336 + in 337 + let s_no_expiry = build () in 338 + Alcotest.(check bool) 339 + "no-expiry-not-expired" false 340 + (Atproto_oauth.Session.is_expired ~clock:env#clock s_no_expiry); 341 + let past = Eio.Time.now env#clock -. 100.0 in 342 + let s_past = build ~expires_at:past () in 343 + Alcotest.(check bool) 344 + "past-is-expired" true 345 + (Atproto_oauth.Session.is_expired ~clock:env#clock s_past); 346 + let future = Eio.Time.now env#clock +. 10_000.0 in 347 + let s_future = build ~expires_at:future () in 348 + Alcotest.(check bool) 349 + "future-not-expired" false 350 + (Atproto_oauth.Session.is_expired ~clock:env#clock s_future); 351 + (* Leeway: a token expiring 30s from now is considered expired at 352 + default leeway (60s). *) 353 + let near = Eio.Time.now env#clock +. 30.0 in 354 + let s_near = build ~expires_at:near () in 355 + Alcotest.(check bool) 356 + "within-leeway-expired" true 357 + (Atproto_oauth.Session.is_expired ~clock:env#clock s_near); 358 + Alcotest.(check bool) 359 + "outside-leeway-not-expired" false 360 + (Atproto_oauth.Session.is_expired ~leeway:1.0 ~clock:env#clock s_near) 361 + 362 + let session_pp_hides_secrets () = 363 + with_session @@ fun _env s -> 364 + let rendered = Fmt.str "%a" Atproto_oauth.Session.pp s in 365 + (* Must surface durable identifiers. *) 366 + Alcotest.(check bool) 367 + "has-did" true 368 + (contains ~needle:"did:plc:z72i7hdynmk6r22z27h6tvur" rendered); 369 + Alcotest.(check bool) 370 + "has-handle" true 371 + (contains ~needle:"alice.bsky.social" rendered); 372 + (* Must never surface tokens or key material. *) 373 + Alcotest.(check bool) 374 + "no-access-token" false 375 + (contains ~needle:"ACCESS" rendered); 376 + Alcotest.(check bool) 377 + "no-refresh-token" false 378 + (contains ~needle:"REFRESH" rendered) 379 + 380 + (* ------------------------------------------------------------------------ *) 236 381 237 382 let suite : string * unit Alcotest.test_case list = 238 383 ( "atproto-oauth", ··· 261 406 Alcotest.test_case "client/confidential-defaults" `Quick 262 407 confidential_defaults; 263 408 Alcotest.test_case "client/distinct" `Quick confidential_and_public_differ; 409 + Alcotest.test_case "session/accessors" `Quick session_accessors; 410 + Alcotest.test_case "session/no-handle-or-tokens" `Quick 411 + session_no_handle_or_tokens; 412 + Alcotest.test_case "session/is-expired" `Quick session_is_expired; 413 + Alcotest.test_case "session/pp-hides-secrets" `Quick 414 + session_pp_hides_secrets; 264 415 ] )