···1818 ]
19192020 let validate_server ?auth_method (m : Oauth.Server.metadata) =
2121- match Oauth.Server.missing m (required_server_capabilities ?auth_method ())
2121+ match
2222+ Oauth.Server.missing m (required_server_capabilities ?auth_method ())
2223 with
2324 | [] -> Ok ()
2425 | missing -> Error missing
+40-45
lib/atproto_oauth.mli
···31313232 val required_server_capabilities :
3333 ?auth_method:string -> unit -> Oauth.Server.capability list
3434- (** [required_server_capabilities ?auth_method ()] lists the capabilities
3535- an authorization server MUST advertise for an ATProto client to
3636- engage with it. [auth_method] defaults to ["none"] (public loopback
3737- CLI); pass ["private_key_jwt"] for confidential clients.
3434+ (** [required_server_capabilities ?auth_method ()] lists the capabilities an
3535+ authorization server MUST advertise for an ATProto client to engage with
3636+ it. [auth_method] defaults to ["none"] (public loopback CLI); pass
3737+ ["private_key_jwt"] for confidential clients.
38383939 The list contains:
4040- - {!Oauth.Server.Par_supported}, {!Oauth.Server.Par_required} —
4141- PAR (RFC 9126) is mandatory.
4040+ - {!Oauth.Server.Par_supported}, {!Oauth.Server.Par_required} — PAR (RFC
4141+ 9126) is mandatory.
4242 - {!Oauth.Server.Code_challenge_method} ["S256"] — PKCE (RFC 7636).
4343 - {!Oauth.Server.Dpop_alg} ["ES256"] — DPoP (RFC 9449).
4444 - {!Oauth.Server.Grant_type} ["authorization_code"] and
···5252 ?auth_method:string ->
5353 Oauth.Server.metadata ->
5454 (unit, Oauth.Server.capability list) result
5555- (** [validate_server ?auth_method m] is [Ok ()] when [m] advertises
5656- every capability in
5757- [required_server_capabilities ?auth_method ()]; otherwise [Error
5858- missing] listing every unmet requirement in one pass. *)
5555+ (** [validate_server ?auth_method m] is [Ok ()] when [m] advertises every
5656+ capability in [required_server_capabilities ?auth_method ()]; otherwise
5757+ [Error missing] listing every unmet requirement in one pass. *)
59586059 type resource_violation =
6160 | Bearer_method_dpop_missing
···6968 ?expected_issuer:string ->
7069 Oauth.Resource.metadata ->
7170 (unit, resource_violation list) result
7272- (** [validate_resource ?expected_issuer r] checks that the protected
7373- resource advertises [DPoP] in [bearer_methods_supported] and that,
7474- when [expected_issuer] is given, it appears in
7575- [authorization_servers].
7171+ (** [validate_resource ?expected_issuer r] checks that the protected resource
7272+ advertises [DPoP] in [bearer_methods_supported] and that, when
7373+ [expected_issuer] is given, it appears in [authorization_servers].
76747777- {b Issuer comparison is exact-string.} Both RFC 8414 and the
7878- ATProto profile treat AS issuer URLs as canonical; do not rely on
7979- trailing-slash or case-folding equivalence. Callers that need
8080- tolerance should normalize before passing [expected_issuer]. *)
7575+ {b Issuer comparison is exact-string.} Both RFC 8414 and the ATProto
7676+ profile treat AS issuer URLs as canonical; do not rely on trailing-slash
7777+ or case-folding equivalence. Callers that need tolerance should normalize
7878+ before passing [expected_issuer]. *)
8179end
82808381(** {1:client Client metadata builders}
···9593 unit ->
9694 Oauth.Client.t
9795 (** [public_loopback ~client_id ~redirect_uris ()] is an ATProto-compliant
9898- client metadata document for a public client using a loopback redirect
9999- URI (CLIs). Sets:
9696+ client metadata document for a public client using a loopback redirect URI
9797+ (CLIs). Sets:
10098 - [dpop_bound_access_tokens = true]
10199 - [token_endpoint_auth_method = "none"]
102100 - [application_type = "web"]
···105103 - [scope] defaulting to {!Profile.default_scope}.
106104107105 The [application_type = "web"] is ATProto-specific and may look
108108- counter-intuitive for a CLI: the ATProto OAuth profile requires
109109- [web] for every client regardless of form factor. Only [web]
110110- clients may use HTTPS redirect URIs and published client metadata,
111111- which the profile mandates. See the ATProto OAuth clients spec for
112112- the full rationale. *)
106106+ counter-intuitive for a CLI: the ATProto OAuth profile requires [web] for
107107+ every client regardless of form factor. Only [web] clients may use HTTPS
108108+ redirect URIs and published client metadata, which the profile mandates.
109109+ See the ATProto OAuth clients spec for the full rationale. *)
113110114111 val confidential :
115112 client_id:string ->
···141138 type t = {
142139 did : Did.t;
143140 handle : Atproto_handle.t option;
144144- (** Cached at login. May be stale after a handle rotation;
145145- {!did} is authoritative. *)
141141+ (** Cached at login. May be stale after a handle rotation; {!did} is
142142+ authoritative. *)
146143 pds_url : string;
147144 server : Oauth.Server.metadata;
148145 dpop_key : Dpop.key;
149149- (** Opaque private keypair binding tokens to this client.
150150- Leaking the private material lets an attacker replay the
151151- tokens. *)
146146+ (** Opaque private keypair binding tokens to this client. Leaking the
147147+ private material lets an attacker replay the tokens. *)
152148 access_token : string;
153149 refresh_token : string option;
154150 expires_at : float option; (** Absolute Unix timestamp. *)
155151 scope : string;
156152 }
157157- (** Record exposed so callers can construct sessions by copying fields
158158- out of a {!Atproto_oauth_discovery.t} and an {!Oauth.token_response}
159159- without the 10-argument constructor dance. *)
153153+ (** Record exposed so callers can construct sessions by copying fields out of
154154+ a {!Atproto_oauth_discovery.t} and an {!Oauth.token_response} without the
155155+ 10-argument constructor dance. *)
160156161157 val is_expired : ?leeway:float -> clock:_ Eio.Time.clock -> t -> bool
162162- (** [is_expired ?leeway ~clock t] is [true] iff the access token has
163163- expired (or will within [leeway] seconds — default 60). Returns
164164- [false] when no expiry was advertised. *)
158158+ (** [is_expired ?leeway ~clock t] is [true] iff the access token has expired
159159+ (or will within [leeway] seconds — default 60). Returns [false] when no
160160+ expiry was advertised. *)
165161166162 val refresh :
167163 Requests.t ->
···169165 client_auth:Oauth.Client_auth.t ->
170166 t ->
171167 (t, Oauth.parse_token_error) result
172172- (** [refresh http ~clock ~client_auth t] rotates the access token
173173- using the stored [refresh_token] and [dpop_key], and returns a
174174- new session with the updated tokens and [expires_at]. Uses
175175- {!Oauth.Flow.refresh_bound} under the hood so the DPoP proof and
176176- [DPoP-Nonce] retry are handled automatically.
168168+ (** [refresh http ~clock ~client_auth t] rotates the access token using the
169169+ stored [refresh_token] and [dpop_key], and returns a new session with the
170170+ updated tokens and [expires_at]. Uses {!Oauth.Flow.refresh_bound} under
171171+ the hood so the DPoP proof and [DPoP-Nonce] retry are handled
172172+ automatically.
177173178178- Returns [Error _] if the session has no refresh token (caller
179179- must re-authenticate). *)
174174+ Returns [Error _] if the session has no refresh token (caller must
175175+ re-authenticate). *)
180176181177 val pp : t Fmt.t
182182- (** [pp] prints a short summary; never reveals tokens or key material.
183183- *)
178178+ (** [pp] prints a short summary; never reveals tokens or key material. *)
184179end
+15-16
lib/discovery/atproto_oauth_discovery.mli
···8282 documents, inject alternative resolvers, etc.). *)
83838484val pds_of_document : Did.Document.t -> (string, error) result
8585-(** [pds_of_document d] is the [#atproto_pds] [serviceEndpoint] URL; if
8686- no service has that fragment id, falls back to the first service
8787- whose [type_] list contains ["AtprotoPersonalDataServer"]. Returns
8585+(** [pds_of_document d] is the [#atproto_pds] [serviceEndpoint] URL; if no
8686+ service has that fragment id, falls back to the first service whose [type_]
8787+ list contains ["AtprotoPersonalDataServer"]. Returns
8888 [Error (Pds_service_missing _)] when neither is present.
89899090- {b Security note.} The type-fallback trades strictness for
9191- compatibility with DID documents that don't use the canonical
9292- fragment id. A malicious or misconfigured document could carry
9393- [#atproto_pds] pointing at one host and an [AtprotoPersonalDataServer]
9494- service pointing at another; this helper picks the fragment id
9595- first, matching the behavior of the ATProto reference
9696- implementations. Callers that require strict fragment-id presence
9797- should use {!Did.Document.service_by_id} directly. *)
9090+ {b Security note.} The type-fallback trades strictness for compatibility
9191+ with DID documents that don't use the canonical fragment id. A malicious or
9292+ misconfigured document could carry [#atproto_pds] pointing at one host and
9393+ an [AtprotoPersonalDataServer] service pointing at another; this helper
9494+ picks the fragment id first, matching the behavior of the ATProto reference
9595+ implementations. Callers that require strict fragment-id presence should use
9696+ {!Did.Document.service_by_id} directly. *)
98979998(** {1:oauth Oauth bridge} *)
10099101100val to_provider : t -> (Oauth.custom_provider, [ `Msg of string ]) result
102101(** [to_provider d] lifts [d]'s authorization server metadata into an
103102 {!Oauth.custom_provider}, ready for {!Oauth.Flow.begin_authz}.
104104- [userinfo_url] is set to [d.pds_url ^
105105- "/xrpc/com.atproto.server.getSession"] and [uid_field] to ["did"],
106106- matching the ATProto XRPC server's own session endpoint.
103103+ [userinfo_url] is set to [d.pds_url ^ "/xrpc/com.atproto.server.getSession"]
104104+ and [uid_field] to ["did"], matching the ATProto XRPC server's own session
105105+ endpoint.
107106108107 Fails with [`Msg _] when the server metadata's endpoints fail HTTPS
109108 validation (see {!Oauth.provider_of_server}). *)
···118117 Atproto_oauth.Session.t
119118(** [session d ~dpop_key ~clock ~scope resp] builds an
120119 {!Atproto_oauth.Session.t} from a discovery result and a token-endpoint
121121- response, filling [did], [pds_url], [server] from [d]; [access_token]
122122- and [refresh_token] from [resp]; and [expires_at] from [resp.expires_in]
120120+ response, filling [did], [pds_url], [server] from [d]; [access_token] and
121121+ [refresh_token] from [resp]; and [expires_at] from [resp.expires_in]
123122 relative to [clock]'s current time. *)
···77 | Callback_error of { error : string; description : string option }
8899let pp_error ppf = function
1010- | Discovery e ->
1111- Fmt.pf ppf "discovery: %a" Atproto_oauth_discovery.pp_error e
1010+ | Discovery e -> Fmt.pf ppf "discovery: %a" Atproto_oauth_discovery.pp_error e
1211 | Provider (`Msg m) -> Fmt.pf ppf "provider: %s" m
1312 | Flow e -> Fmt.pf ppf "oauth flow: %a" Oauth.Flow.pp_error e
1413 | State_mismatch { expected; received } ->
···6160 let qs = String.sub target (i + 1) (String.length target - i - 1) in
6261 String.split_on_char '&' qs
6362 |> List.filter_map (fun kv ->
6464- match String.index_opt kv '=' with
6565- | None -> None
6666- | Some j ->
6767- let k = String.sub kv 0 j in
6868- let v = String.sub kv (j + 1) (String.length kv - j - 1) in
6969- Some (k, percent_decode v))
6363+ match String.index_opt kv '=' with
6464+ | None -> None
6565+ | Some j ->
6666+ let k = String.sub kv 0 j in
6767+ let v = String.sub kv (j + 1) (String.length kv - j - 1) in
6868+ Some (k, percent_decode v))
70697170(* ----- Loopback listener ----- *)
72717372let response_body =
7473 "<!doctype html><html><head><meta charset=utf-8><title>ATProto \
7575- login</title></head><body style=\"font-family:sans-serif;max-width:40em;margin:2em auto;padding:1em\"><h1>Login \
7676- complete</h1><p>You may close this tab and return to your \
7777- terminal.</p></body></html>"
7474+ login</title></head><body \
7575+ style=\"font-family:sans-serif;max-width:40em;margin:2em \
7676+ auto;padding:1em\"><h1>Login complete</h1><p>You may close this tab and \
7777+ return to your terminal.</p></body></html>"
78787979let write_response flow =
8080 let body = response_body in
8181 let headers =
8282 Fmt.str
8383- "HTTP/1.1 200 OK\r\nContent-Type: text/html; \
8484- charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
8383+ "HTTP/1.1 200 OK\r\n\
8484+ Content-Type: text/html; charset=utf-8\r\n\
8585+ Content-Length: %d\r\n\
8686+ Connection: close\r\n\
8787+ \r\n"
8588 (String.length body)
8689 in
8790 Eio.Flow.copy_string (headers ^ body) flow
···110113 in
111114 match accepted with
112115 | Error `Timeout -> Error (Loopback_timeout timeout_s)
113113- | Ok (flow, _) ->
116116+ | Ok (flow, _) -> (
114117 let line_opt = read_request_line flow in
115118 (try write_response flow with _ -> ());
116119 (try Eio.Flow.shutdown flow `All with _ -> ());
117117- (match line_opt with
118118- | None ->
119119- Error
120120- (Callback_error
121121- {
122122- error = "empty_request";
123123- description = Some "the browser did not send a request line";
124124- })
125125- | Some line ->
126126- (match String.split_on_char ' ' line with
127127- | [ _; target; _ ] -> Ok (parse_query target)
128128- | _ ->
129129- Error
130130- (Callback_error
131131- {
132132- error = "bad_request_line";
133133- description = Some line;
134134- })))
120120+ match line_opt with
121121+ | None ->
122122+ Error
123123+ (Callback_error
124124+ {
125125+ error = "empty_request";
126126+ description = Some "the browser did not send a request line";
127127+ })
128128+ | Some line -> (
129129+ match String.split_on_char ' ' line with
130130+ | [ _; target; _ ] -> Ok (parse_query target)
131131+ | _ ->
132132+ Error
133133+ (Callback_error
134134+ { error = "bad_request_line"; description = Some line })))
135135136136(* ----- Login ----- *)
137137138138let default_on_authz_url url =
139139- Fmt.pr "Open the following URL in your browser to authorize:@.@. %s@.@."
140140- url
139139+ Fmt.pr "Open the following URL in your browser to authorize:@.@. %s@.@." url
141140142141let extract_callback ~ctx params =
143142 match List.assoc_opt "error" params with
···145144 let description = List.assoc_opt "error_description" params in
146145 Error (Callback_error { error = err; description })
147146 | None -> (
148148- match List.assoc_opt "code" params, List.assoc_opt "state" params with
147147+ match (List.assoc_opt "code" params, List.assoc_opt "state" params) with
149148 | None, _ ->
150149 Error
151150 (Callback_error
···154153 description = Some "callback had no 'code' parameter";
155154 })
156155 | Some _, received when received <> Some (Oauth.Flow.state ctx) ->
157157- Error
158158- (State_mismatch
159159- { expected = Oauth.Flow.state ctx; received })
156156+ Error (State_mismatch { expected = Oauth.Flow.state ctx; received })
160157 | Some code, Some state -> Ok (code, state)
161158 | Some _, None ->
162159 Error
163163- (State_mismatch
164164- { expected = Oauth.Flow.state ctx; received = None }))
160160+ (State_mismatch { expected = Oauth.Flow.state ctx; received = None })
161161+ )
165162166163let run_flow ~http ~provider ~client_auth ~dpop_key ~ctx ~code ~returned_state
167164 ~discovery ~handle ~clock ~scope =
···175172 (Atproto_oauth_discovery.session discovery ~handle ~dpop_key ~clock
176173 ~scope resp)
177174178178-let login ~sw ~clock ~net ~http ~client_id ?verify_tls ?plc_registry
179179- ?(port = 0) ?(timeout_s = 180.0) ?scope
180180- ?(on_authz_url = default_on_authz_url) handle =
175175+let login ~sw ~clock ~net ~http ~client_id ?verify_tls ?plc_registry ?(port = 0)
176176+ ?(timeout_s = 180.0) ?scope ?(on_authz_url = default_on_authz_url) handle =
181177 let scope =
182182- match scope with
183183- | Some s -> s
184184- | None -> Atproto_oauth.Profile.default_scope
178178+ match scope with Some s -> s | None -> Atproto_oauth.Profile.default_scope
185179 in
186180 let ( let* ) = Result.bind in
187181 let* discovery =
+31-33
lib/login/atproto_oauth_login.mli
···4455 + Resolve the handle and discover the PDS + AS metadata
66 ({!Atproto_oauth_discovery.of_handle}).
77- + Validate the AS advertises the ATProto-required capabilities
88- (already performed by discovery).
77+ + Validate the AS advertises the ATProto-required capabilities (already
88+ performed by discovery).
99 + Generate a fresh DPoP keypair.
1010 + Push the authorization parameters via PAR ({!Oauth.Par}).
1111- + Start a short-lived localhost listener, open the authorization
1212- URL (caller-configurable), and wait for the OAuth callback.
1111+ + Start a short-lived localhost listener, open the authorization URL
1212+ (caller-configurable), and wait for the OAuth callback.
1313 + Exchange the code for tokens with a DPoP proof
1414 ({!Oauth.Flow.complete_authz}).
1515 + Wrap the result in an {!Atproto_oauth.Session.t}.
16161717- This module is intended for public-client CLI usage. Confidential
1818- clients need a different entry point (a future [login_confidential]
1919- that takes a [Dpop.key] and a [private_key_jwt] [Oauth.Client_auth.t]
2020- — the pieces will share most of the flow). *)
1717+ This module is intended for public-client CLI usage. Confidential clients
1818+ need a different entry point (a future [login_confidential] that takes a
1919+ [Dpop.key] and a [private_key_jwt] [Oauth.Client_auth.t] — the pieces will
2020+ share most of the flow). *)
21212222(** {1:errors Errors} *)
23232424type error =
2525 | Discovery of Atproto_oauth_discovery.error
2626 | Provider of [ `Msg of string ]
2727- (** {!Oauth.provider_of_server} rejected the discovered AS
2828- endpoints (typically an endpoint that isn't HTTPS). *)
2727+ (** {!Oauth.provider_of_server} rejected the discovered AS endpoints
2828+ (typically an endpoint that isn't HTTPS). *)
2929 | Flow of Oauth.Flow.error
3030 (** The authorization request or token exchange failed. *)
3131 | State_mismatch of { expected : string; received : string option }
3232- (** The [state] query parameter the AS redirected back with did not
3333- match the one we generated, or was absent. A client-side CSRF
3434- trip — the flow is aborted. *)
3232+ (** The [state] query parameter the AS redirected back with did not match
3333+ the one we generated, or was absent. A client-side CSRF trip — the
3434+ flow is aborted. *)
3535 | Loopback_timeout of float
3636- (** The caller's deadline elapsed before the browser hit the
3737- callback URL. The float is the waited-seconds budget. *)
3636+ (** The caller's deadline elapsed before the browser hit the callback URL.
3737+ The float is the waited-seconds budget. *)
3838 | Callback_error of { error : string; description : string option }
3939- (** The AS redirected to the callback with an [error] query
4040- parameter instead of [code] — the user denied consent, the
4141- request was malformed, etc. *)
3939+ (** The AS redirected to the callback with an [error] query parameter
4040+ instead of [code] — the user denied consent, the request was
4141+ malformed, etc. *)
42424343val pp_error : error Fmt.t
4444(** [pp_error] formats an error for humans. *)
···5959 ?on_authz_url:(string -> unit) ->
6060 Atproto_handle.t ->
6161 (Atproto_oauth.Session.t, error) result
6262-(** [login ~sw ~clock ~net ~http ~client_id handle] runs the full
6363- public-client login flow.
6262+(** [login ~sw ~clock ~net ~http ~client_id handle] runs the full public-client
6363+ login flow.
64646565- - [client_id] is the URL at which the client metadata document is
6666- hosted. For CLI loopback clients the ATProto profile allows
6767- [client_id] of the form
6868- [http://localhost?scope=...&redirect_uri=...] where the metadata
6969- is embedded in the query.
7070- - [port] is the loopback listener port; [0] (default) picks an
7171- ephemeral port. The chosen port is reflected in the computed
7272- redirect URI.
7373- - [timeout_s] is the maximum time the loopback listener waits for
7474- the callback. Default 180 seconds.
6565+ - [client_id] is the URL at which the client metadata document is hosted.
6666+ For CLI loopback clients the ATProto profile allows [client_id] of the
6767+ form [http://localhost?scope=...&redirect_uri=...] where the metadata is
6868+ embedded in the query.
6969+ - [port] is the loopback listener port; [0] (default) picks an ephemeral
7070+ port. The chosen port is reflected in the computed redirect URI.
7171+ - [timeout_s] is the maximum time the loopback listener waits for the
7272+ callback. Default 180 seconds.
7573 - [scope] defaults to {!Atproto_oauth.Profile.default_scope}.
7676- - [on_authz_url] is invoked once with the authorization URL the
7777- user must visit. Defaults to printing to stdout; CLIs can wire
7878- this to a browser-open helper. *)
7474+ - [on_authz_url] is invoked once with the authorization URL the user must
7575+ visit. Defaults to printing to stdout; CLIs can wire this to a
7676+ browser-open helper. *)