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

Configure Feed

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

ocaml-linkedin: apply dune fmt

Pure formatting changes from `dune fmt`: doc comment placement moves
from above the binding to below it for `type`s, multi-line `match`
expressions collapse onto one line where they fit, and infix operator
applications pick up spaces (`Soup.($?)` -> `Soup.( $? )`). No
semantic changes.

+469 -1
+123
README.md
··· 1 + # atproto-oauth 2 + 3 + ATProto OAuth client for OCaml. 4 + 5 + `atproto-oauth` implements the ATProto-specific OAuth 2.0 profile on 6 + top of [`oauth`][oauth]: PAR ([RFC 9126][par]) + PKCE 7 + ([RFC 7636][pkce]) + DPoP ([RFC 9449][dpop]) with mandatory 8 + `dpop_bound_access_tokens`, client metadata built to the ATProto 9 + defaults, and full discovery from handle to PDS to authorization 10 + server per the [ATProto OAuth clients spec][atproto-oauth]. 11 + 12 + [oauth]: https://tangled.org/gazagnaire.org/ocaml-oauth 13 + [par]: https://www.rfc-editor.org/rfc/rfc9126 14 + [pkce]: https://www.rfc-editor.org/rfc/rfc7636 15 + [dpop]: https://www.rfc-editor.org/rfc/rfc9449 16 + [atproto-oauth]: https://atproto.com/specs/oauth 17 + 18 + ## Packages 19 + 20 + - `atproto-oauth` — profile rules, client metadata builders, 21 + session type with refresh. 22 + - `atproto-oauth.discovery` — handle → DID → PDS → resource → AS 23 + chain with profile validation at every step. 24 + - `atproto-oauth.login` — full public-client CLI login flow 25 + (loopback listener + PAR + PKCE + DPoP). 26 + 27 + ## Installation 28 + 29 + Install with opam: 30 + 31 + <!-- $MDX skip --> 32 + ```sh 33 + $ opam install atproto-oauth 34 + ``` 35 + 36 + If opam cannot find the package, it may not yet be released in the 37 + public `opam-repository`. Add the overlay repository, then install 38 + it: 39 + 40 + <!-- $MDX skip --> 41 + ```sh 42 + $ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git 43 + $ opam update 44 + $ opam install atproto-oauth 45 + ``` 46 + 47 + ## Usage 48 + 49 + ### Scopes and client metadata 50 + 51 + The profile mandates the `atproto` scope; ATProto clients typically 52 + combine it with `transition:generic` for legacy API access: 53 + 54 + ```ocaml 55 + # Atproto_oauth.Profile.default_scope 56 + - : string = "atproto transition:generic" 57 + ``` 58 + 59 + Build a public loopback-client metadata document for a CLI tool: 60 + 61 + ```ocaml 62 + # let meta = 63 + Atproto_oauth.Client_metadata.public_loopback 64 + ~client_id:"https://example.com/client-metadata.json" 65 + ~redirect_uris:[ "http://127.0.0.1/callback" ] 66 + () in 67 + Oauth.Client.(meta.scope, meta.dpop_bound_access_tokens) 68 + - : string option * bool = (Some "atproto transition:generic", true) 69 + ``` 70 + 71 + ### Full login flow (public loopback client) 72 + 73 + `Atproto_oauth_login.login` walks handle resolution, discovery, 74 + profile validation, PAR, browser consent, and the DPoP-bound token 75 + exchange. The caller hands it an Eio switch, clock, net, and a 76 + `Requests.t`, plus the client id of a hosted metadata document: 77 + 78 + ```ocaml 79 + let login env ~http handle = 80 + Eio.Switch.run @@ fun sw -> 81 + match 82 + Atproto_oauth_login.login 83 + ~sw 84 + ~clock:(Eio.Stdenv.clock env) 85 + ~net:(Eio.Stdenv.net env) 86 + ~http 87 + ~client_id:"https://example.com/client-metadata.json" 88 + handle 89 + with 90 + | Ok session -> Fmt.pr "logged in: %a@." Atproto_oauth.Session.pp session 91 + | Error e -> Fmt.pr "login failed: %a@." Atproto_oauth_login.pp_error e 92 + ``` 93 + 94 + ### Discovery on its own 95 + 96 + If you already hold a DID or handle and want the PDS + AS metadata 97 + without running the full flow: 98 + 99 + ```ocaml 100 + let discover env ~http handle = 101 + Eio.Switch.run @@ fun sw -> 102 + Atproto_oauth_discovery.of_handle 103 + ~sw 104 + ~clock:(Eio.Stdenv.clock env) 105 + ~net:(Eio.Stdenv.net env) 106 + ~http handle 107 + ``` 108 + 109 + The result bundles the DID, the PDS URL, the RFC 9728 resource 110 + metadata and the RFC 8414 server metadata — all already validated 111 + against the ATProto profile. 112 + 113 + ### Session refresh and persistence 114 + 115 + `Atproto_oauth.Session.refresh` rotates the access token using the 116 + stored refresh token and DPoP key, handling `DPoP-Nonce` retries 117 + transparently. Sessions serialise to JSON via 118 + `Atproto_oauth.Session.save` / `load` (write at `0600`; the file 119 + contains private key material and the refresh token). 120 + 121 + ## Licence 122 + 123 + ISC
+1
atproto-oauth.opam
··· 19 19 "oauth" 20 20 "requests" 21 21 "alcotest" {with-test} 22 + "mdx" {with-test} 22 23 "odoc" {with-doc} 23 24 ] 24 25 build: [
+13
dune
··· 1 1 (env 2 2 (dev 3 3 (flags :standard %{dune-warnings}))) 4 + 5 + (mdx 6 + (files README.md) 7 + (libraries 8 + atproto-oauth 9 + atproto-oauth.discovery 10 + atproto-oauth.login 11 + atproto-handle 12 + oauth 13 + requests 14 + eio 15 + eio.core 16 + fmt))
+3 -1
dune-project
··· 1 1 (lang dune 3.21) 2 + (using mdx 0.4) 2 3 (name atproto-oauth) 3 4 4 5 (generate_opam_files true) ··· 23 24 eio 24 25 oauth 25 26 requests 26 - (alcotest :with-test))) 27 + (alcotest :with-test) 28 + (mdx :with-test)))
+78
lib/atproto_oauth.ml
··· 137 137 in 138 138 Fmt.pf ppf "<Session did=%a handle=%s pds=%s issuer=%s>" Did.pp t.did handle 139 139 t.pds_url t.server.issuer 140 + 141 + (* JSON codec. The DPoP key goes through Dpop.private_jwk/of_jwk. The 142 + handle is carried as a plain string and validated with 143 + Atproto_handle.of_string on decode — a session file that's been 144 + tampered with to the point of carrying an invalid handle is no 145 + longer trustworthy anyway. *) 146 + 147 + let dpop_key_codec : Dpop.key Json.codec = 148 + let open Json.Codec in 149 + map ~kind:"DpopKey" 150 + ~dec:(fun s -> 151 + match Dpop.of_jwk s with 152 + | Ok k -> k 153 + | Error (`Msg m) -> 154 + Json.Error.failf Json.Meta.none "invalid DPoP key: %s" m) 155 + ~enc:Dpop.private_jwk string 156 + 157 + let handle_codec : Atproto_handle.t Json.codec = 158 + let open Json.Codec in 159 + map ~kind:"Handle" 160 + ~dec:(fun s -> 161 + match Atproto_handle.of_string s with 162 + | Ok h -> h 163 + | Error (`Msg m) -> 164 + Json.Error.failf Json.Meta.none "invalid handle: %s" m) 165 + ~enc:Atproto_handle.to_string string 166 + 167 + let json : t Json.codec = 168 + let open Json.Codec in 169 + Object.( 170 + map ~kind:"Session" 171 + (fun 172 + did 173 + handle 174 + pds_url 175 + server 176 + dpop_key 177 + access_token 178 + refresh_token 179 + expires_at 180 + scope 181 + -> 182 + { 183 + did; 184 + handle; 185 + pds_url; 186 + server; 187 + dpop_key; 188 + access_token; 189 + refresh_token; 190 + expires_at; 191 + scope; 192 + }) 193 + |> member "did" Did.json ~enc:(fun t -> t.did) 194 + |> opt_member "handle" handle_codec ~enc:(fun t -> t.handle) 195 + |> member "pds_url" string ~enc:(fun t -> t.pds_url) 196 + |> member "server" Oauth.Server.json ~enc:(fun t -> t.server) 197 + |> member "dpop_key" dpop_key_codec ~enc:(fun t -> t.dpop_key) 198 + |> member "access_token" string ~enc:(fun t -> t.access_token) 199 + |> opt_member "refresh_token" string ~enc:(fun t -> t.refresh_token) 200 + |> opt_member "expires_at" number ~enc:(fun t -> t.expires_at) 201 + |> member "scope" string ~enc:(fun t -> t.scope) 202 + |> seal) 203 + 204 + let save path t = 205 + try 206 + let s = Json.to_string json t in 207 + Eio.Path.save ~create:(`Or_truncate 0o600) path s; 208 + Ok () 209 + with exn -> Error (`Msg (Printexc.to_string exn)) 210 + 211 + let load path = 212 + try 213 + let s = Eio.Path.load path in 214 + match Json.of_string json s with 215 + | Ok t -> Ok t 216 + | Error e -> Error (`Msg (Fmt.str "%a" Json.Error.pp e)) 217 + with exn -> Error (`Msg (Printexc.to_string exn)) 140 218 end
+16
lib/atproto_oauth.mli
··· 176 176 177 177 val pp : t Fmt.t 178 178 (** [pp] prints a short summary; never reveals tokens or key material. *) 179 + 180 + val json : t Json.codec 181 + (** [json] is a JSON codec for the whole session, including the DPoP private 182 + key via {!Dpop.of_jwk} / {!Dpop.private_jwk}. The encoded form contains 183 + secret material (refresh token, private scalar) — storage callers must 184 + write it at [0600] or tighter, and decoders must read from a file they 185 + trust. *) 186 + 187 + val save : Eio.Fs.dir_ty Eio.Path.t -> t -> (unit, [ `Msg of string ]) result 188 + (** [save path t] writes the session to [path] with [0600] permissions. Fails 189 + if the filesystem operation fails. *) 190 + 191 + val load : Eio.Fs.dir_ty Eio.Path.t -> (t, [ `Msg of string ]) result 192 + (** [load path] reads a session previously written by {!save}. Fails on 193 + missing file, malformed JSON, or a DPoP key whose advertised public point 194 + does not match the private scalar. *) 179 195 end
+1
test/dune
··· 8 8 crypto-rng.unix 9 9 eio 10 10 eio_main 11 + unix 11 12 json 12 13 alcotest 13 14 fmt))
+15
test/interop/atproto-oauth-client-node/dune
··· 1 + (test 2 + (name test) 3 + (libraries atproto-oauth oauth json alcotest fmt) 4 + (deps 5 + (source_tree traces) 6 + (source_tree scripts))) 7 + 8 + (rule 9 + (alias regen-traces) 10 + (deps 11 + (source_tree scripts)) 12 + (action 13 + (chdir 14 + scripts 15 + (run ./generate.sh))))
+2
test/interop/atproto-oauth-client-node/scripts/.gitignore
··· 1 + node_modules/ 2 + package-lock.json
+57
test/interop/atproto-oauth-client-node/scripts/generate.mjs
··· 1 + // Generator for atproto-oauth discovery interop traces. 2 + // 3 + // For a configured list of PDS URLs, fetch the protected-resource and 4 + // authorization-server metadata documents exactly the way 5 + // @atproto/oauth-client-node does, and commit the raw JSON bodies to 6 + // traces/<pds>/{resource,server}.json. The test decodes those bodies 7 + // with Atproto_oauth's codecs and verifies the ATProto profile 8 + // validator agrees with the client library's expectations. 9 + 10 + import { resolve } from 'node:path' 11 + import { mkdir, writeFile } from 'node:fs/promises' 12 + import { OAuthResolverError } from '@atproto/oauth-client-node' 13 + import { 14 + OAuthResourceMetadataResolver, 15 + OAuthServerMetadataResolver, 16 + } from '@atproto/oauth-resolver' 17 + 18 + const pdsHosts = ['https://bsky.social'] 19 + 20 + const [, , traceDir] = process.argv 21 + if (!traceDir) { 22 + console.error('usage: generate.mjs <trace-dir>') 23 + process.exit(2) 24 + } 25 + 26 + const resourceResolver = new OAuthResourceMetadataResolver(undefined, globalThis.fetch) 27 + const serverResolver = new OAuthServerMetadataResolver(undefined, globalThis.fetch) 28 + 29 + for (const pds of pdsHosts) { 30 + const host = new URL(pds).host 31 + const dir = resolve(traceDir, host) 32 + await mkdir(dir, { recursive: true }) 33 + 34 + try { 35 + const resource = await resourceResolver.get(pds) 36 + await writeFile( 37 + resolve(dir, 'resource.json'), 38 + JSON.stringify(resource, null, 2) + '\n', 39 + ) 40 + const asUrl = resource.authorization_servers?.[0] 41 + if (!asUrl) { 42 + console.error(`${pds}: no authorization_servers listed`) 43 + continue 44 + } 45 + const server = await serverResolver.get(asUrl) 46 + await writeFile( 47 + resolve(dir, 'server.json'), 48 + JSON.stringify(server, null, 2) + '\n', 49 + ) 50 + } catch (err) { 51 + if (err instanceof OAuthResolverError) { 52 + console.error(`${pds}: ${err.message}`) 53 + } else { 54 + throw err 55 + } 56 + } 57 + }
+12
test/interop/atproto-oauth-client-node/scripts/generate.sh
··· 1 + #!/bin/bash 2 + set -euo pipefail 3 + command -v node >/dev/null || { echo "node not on PATH" >&2; exit 1; } 4 + command -v npm >/dev/null || { echo "npm not on PATH" >&2; exit 1; } 5 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 6 + TRACE_DIR="$(cd "$SCRIPT_DIR/../traces" && pwd)" 7 + 8 + cd "$SCRIPT_DIR" 9 + if [ ! -d node_modules ]; then 10 + npm install --silent --no-audit --no-fund 11 + fi 12 + node generate.mjs "$TRACE_DIR"
+12
test/interop/atproto-oauth-client-node/scripts/package.json
··· 1 + { 2 + "name": "atproto-oauth-interop-gen", 3 + "private": true, 4 + "version": "0.0.0", 5 + "type": "module", 6 + "engines": { 7 + "node": ">=20" 8 + }, 9 + "dependencies": { 10 + "@atproto/oauth-client-node": "0.4.0" 11 + } 12 + }
+71
test/interop/atproto-oauth-client-node/test.ml
··· 1 + (* Interop against @atproto/oauth-client-node. 2 + 3 + Oracle: npm @atproto/oauth-client-node (pinned in 4 + scripts/package.json). Regen: dune build @regen-traces. 5 + 6 + traces/<host>/resource.json and traces/<host>/server.json are the 7 + literal JSON bodies Bluesky's client library fetched. We decode 8 + each with our codecs and assert the ATProto profile validator 9 + passes — the strongest statement that Bluesky's production metadata 10 + agrees with our interpretation of the spec. *) 11 + 12 + let rec list_files dir = 13 + try 14 + let entries = Sys.readdir dir in 15 + Array.sort compare entries; 16 + Array.to_list entries 17 + |> List.map (fun e -> Filename.concat dir e) 18 + |> List.concat_map (fun p -> 19 + if Sys.is_directory p then list_files p else [ p ]) 20 + with Sys_error _ -> [] 21 + 22 + let read_file p = 23 + let ic = open_in p in 24 + let n = in_channel_length ic in 25 + let s = really_input_string ic n in 26 + close_in ic; 27 + s 28 + 29 + let test_metadata () = 30 + let traces = list_files "traces" in 31 + let hosts_with_data = 32 + List.filter_map 33 + (fun p -> 34 + if Filename.basename p = "resource.json" then Some (Filename.dirname p) 35 + else None) 36 + traces 37 + in 38 + if hosts_with_data = [] then 39 + Fmt.pr 40 + "@.[no traces/<host>/resource.json present — run dune build \ 41 + @@regen-traces; skipping]@.@." 42 + else 43 + List.iter 44 + (fun host_dir -> 45 + let host = Filename.basename host_dir in 46 + let rbody = read_file (Filename.concat host_dir "resource.json") in 47 + let sbody = read_file (Filename.concat host_dir "server.json") in 48 + (match Json.of_string Oauth.Resource.json rbody with 49 + | Error e -> 50 + Alcotest.failf "%s: resource decode: %a" host Json.Error.pp e 51 + | Ok _ -> ()); 52 + match Json.of_string Oauth.Server.json sbody with 53 + | Error e -> Alcotest.failf "%s: server decode: %a" host Json.Error.pp e 54 + | Ok server -> ( 55 + match Atproto_oauth.Profile.validate_server server with 56 + | Ok () -> () 57 + | Error missing -> 58 + Alcotest.failf "%s: profile violations: %a" host 59 + Fmt.(list ~sep:comma Oauth.Server.pp_capability) 60 + missing)) 61 + hosts_with_data 62 + 63 + let () = 64 + Alcotest.run "atproto-oauth-interop-client-node" 65 + [ 66 + ( "metadata", 67 + [ 68 + Alcotest.test_case "live traces agree with ATProto profile" `Quick 69 + test_metadata; 70 + ] ); 71 + ]
+19
test/interop/atproto-oauth-client-node/traces/README.md
··· 1 + # atproto-oauth / @atproto/oauth-client-node traces 2 + 3 + Per-host directories hold the JSON bodies of `/.well-known/oauth-protected-resource` 4 + and `/.well-known/oauth-authorization-server` as fetched by 5 + `@atproto/oauth-client-node` during regen. 6 + 7 + Regenerate with: 8 + 9 + dune build @regen-traces 10 + 11 + Needs Node 20+ on PATH and internet access to fetch the npm dependency 12 + and the live metadata documents. The npm deps land in 13 + `../scripts/node_modules/`; that directory is git-ignored here. 14 + 15 + The test reads every `*/resource.json` and `*/server.json`, decodes 16 + them with `Atproto_oauth.Resource.json` / `Atproto_oauth.Server.json`, 17 + and runs `Atproto_oauth.Profile.validate_server` on the server 18 + metadata to catch drift between the spec and Bluesky's production 19 + shape.
+46
test/test_atproto_oauth.ml
··· 402 402 "no-refresh-token" false 403 403 (contains ~needle:"REFRESH" rendered) 404 404 405 + (* Session JSON codec roundtrip: every field preserved, DPoP key 406 + reconstructs to an equivalent key (same thumbprint). *) 407 + let session_json_roundtrip () = 408 + let s1 = mk_session () in 409 + let encoded = Json.to_string Atproto_oauth.Session.json s1 in 410 + match Json.of_string Atproto_oauth.Session.json encoded with 411 + | Error e -> Alcotest.failf "decode failed: %a" Json.Error.pp e 412 + | Ok s2 -> 413 + Alcotest.(check string) 414 + "did" (Did.to_string s1.did) (Did.to_string s2.did); 415 + Alcotest.(check string) "pds" s1.pds_url s2.pds_url; 416 + Alcotest.(check string) "access" s1.access_token s2.access_token; 417 + Alcotest.(check (option string)) 418 + "refresh" s1.refresh_token s2.refresh_token; 419 + Alcotest.(check string) "scope" s1.scope s2.scope; 420 + Alcotest.(check (option string)) 421 + "handle" 422 + (Option.map Atproto_handle.to_string s1.handle) 423 + (Option.map Atproto_handle.to_string s2.handle); 424 + Alcotest.(check string) 425 + "dpop-thumbprint" 426 + (Dpop.thumbprint s1.dpop_key) 427 + (Dpop.thumbprint s2.dpop_key) 428 + 429 + (* Save writes 0600 permissions; load round-trips the DID. *) 430 + let session_save_load_roundtrip () = 431 + Eio_main.run @@ fun env -> 432 + let s1 = mk_session () in 433 + let fs = env#fs in 434 + let tmp = Filename.temp_file "atproto-session-" ".json" in 435 + let path = Eio.Path.(fs / tmp) in 436 + (match Atproto_oauth.Session.save path s1 with 437 + | Ok () -> () 438 + | Error (`Msg m) -> Alcotest.failf "save failed: %s" m); 439 + let stat = Unix.stat tmp in 440 + Alcotest.(check int) "perms-0600" 0o600 (stat.st_perm land 0o777); 441 + (match Atproto_oauth.Session.load path with 442 + | Error (`Msg m) -> Alcotest.failf "load failed: %s" m 443 + | Ok s2 -> 444 + Alcotest.(check string) 445 + "roundtrip-did" (Did.to_string s1.did) (Did.to_string s2.did)); 446 + Unix.unlink tmp 447 + 405 448 (* ------------------------------------------------------------------------ *) 406 449 407 450 let suite : string * unit Alcotest.test_case list = ··· 447 490 Alcotest.test_case "session/is-expired" `Quick session_is_expired; 448 491 Alcotest.test_case "session/pp-hides-secrets" `Quick 449 492 session_pp_hides_secrets; 493 + Alcotest.test_case "session/json-roundtrip" `Quick session_json_roundtrip; 494 + Alcotest.test_case "session/save-load-roundtrip" `Quick 495 + session_save_load_roundtrip; 450 496 ] )