A better Rust ATProto crate
1use crate::types::OAuthClientMetadata;
2use crate::{keyset::Keyset, scopes::Scope};
3use jacquard_common::cowstr::ToCowStr;
4use jacquard_common::deps::fluent_uri::Uri;
5use jacquard_common::{CowStr, IntoStatic};
6use serde::{Deserialize, Serialize};
7use smol_str::{SmolStr, ToSmolStr};
8use thiserror::Error;
9
10/// Errors that can occur when building AT Protocol OAuth client metadata.
11#[derive(Error, Debug)]
12#[non_exhaustive]
13pub enum Error {
14 /// The `client_id` is not a valid URL.
15 #[error("`client_id` must be a valid URL")]
16 InvalidClientId,
17 /// The `grant_types` list does not include `authorization_code`, which is required by atproto.
18 #[error("`grant_types` must include `authorization_code`")]
19 InvalidGrantTypes,
20 /// The `scope` list does not include `atproto`, which is required for all atproto clients.
21 #[error("`scope` must not include `atproto`")]
22 InvalidScope,
23 /// No redirect URIs were provided; at least one is required.
24 #[error("`redirect_uris` must not be empty")]
25 EmptyRedirectUris,
26 /// The `private_key_jwt` auth method was requested but no JWK keys were provided.
27 #[error("`private_key_jwt` auth method requires `jwks` keys")]
28 EmptyJwks,
29 /// Signing algorithm mismatch: `private_key_jwt` requires `token_endpoint_auth_signing_alg`,
30 /// and non-`private_key_jwt` methods must not provide it.
31 #[error(
32 "`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided"
33 )]
34 AuthSigningAlg,
35 /// HTML form serialization of the loopback `client_id` query string failed.
36 #[error(transparent)]
37 SerdeHtmlForm(#[from] serde_html_form::ser::Error),
38 /// A localhost-specific validation error occurred.
39 #[error(transparent)]
40 LocalhostClient(#[from] LocalhostClientError),
41}
42
43/// Errors specific to validating a loopback (localhost) OAuth client's redirect URIs.
44///
45/// The AT Protocol spec has specific requirements for loopback clients: redirect URIs must
46/// use the `http` scheme and must point to actual loopback addresses (not the hostname `localhost`).
47#[derive(Error, Debug)]
48#[non_exhaustive]
49pub enum LocalhostClientError {
50 /// The redirect URI could not be parsed.
51 #[error("invalid redirect_uri: {0}")]
52 Invalid(#[from] jacquard_common::deps::fluent_uri::ParseError),
53 /// Loopback redirect URIs must use `http:`, not `https:` or any other scheme.
54 #[error("loopback client_id must use `http:` redirect_uri")]
55 NotHttpScheme,
56 /// The hostname `localhost` is not allowed; use a numeric loopback address instead.
57 #[error("loopback client_id must not use `localhost` as redirect_uri hostname")]
58 Localhost,
59 /// The redirect URI host is not a loopback address (127.x.x.x or ::1).
60 #[error("loopback client_id must not use loopback addresses as redirect_uri")]
61 NotLoopbackHost,
62}
63
64/// Convenience result type for AT Protocol client metadata operations.
65pub type Result<T> = core::result::Result<T, Error>;
66
67/// The token endpoint authentication method for an OAuth client.
68///
69/// AT Protocol clients either authenticate with no client secret (public/loopback clients)
70/// or with a private key JWT signed by a key from the client's JWK set.
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum AuthMethod {
74 /// No client authentication; used for public and loopback clients.
75 None,
76 /// Authenticate using a JWT signed with a private key from the client's JWK set.
77 /// <https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication>
78 PrivateKeyJwt,
79}
80
81impl From<AuthMethod> for CowStr<'static> {
82 fn from(value: AuthMethod) -> Self {
83 match value {
84 AuthMethod::None => CowStr::new_static("none"),
85 AuthMethod::PrivateKeyJwt => CowStr::new_static("private_key_jwt"),
86 }
87 }
88}
89
90/// OAuth 2.0 grant types supported by AT Protocol clients.
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum GrantType {
94 /// Standard authorization code grant, required by atproto.
95 AuthorizationCode,
96 /// Refresh token grant, used to obtain new access tokens without re-authorization.
97 RefreshToken,
98}
99
100impl From<GrantType> for CowStr<'static> {
101 fn from(value: GrantType) -> Self {
102 match value {
103 GrantType::AuthorizationCode => CowStr::new_static("authorization_code"),
104 GrantType::RefreshToken => CowStr::new_static("refresh_token"),
105 }
106 }
107}
108
109/// AT Protocol-specific OAuth client metadata, used to describe a client before converting to
110/// the generic [`OAuthClientMetadata`] format for server registration.
111///
112/// This type provides a validated, atproto-aware view of client registration data, with
113/// typed fields for URIs and scopes rather than raw strings. Use [`atproto_client_metadata`]
114/// to convert this into the wire format expected by OAuth servers.
115#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
116pub struct AtprotoClientMetadata<'m> {
117 /// The unique identifier for this client, typically the URL of its metadata document.
118 pub client_id: Uri<String>,
119 /// The URI of the client's homepage or information page.
120 pub client_uri: Option<Uri<String>>,
121 /// The list of allowed redirect URIs for the authorization code flow.
122 pub redirect_uris: Vec<Uri<String>>,
123 /// The grant types this client will use.
124 pub grant_types: Vec<GrantType>,
125 /// The OAuth scopes this client requests; must include `atproto`.
126 #[serde(borrow)]
127 pub scopes: Vec<Scope<'m>>,
128 /// URI pointing to the client's JWK Set; mutually exclusive with inline `jwks`.
129 pub jwks_uri: Option<Uri<String>>,
130 /// Human-readable display name for the client.
131 pub client_name: Option<SmolStr>,
132 /// URI of the client's logo image.
133 pub logo_uri: Option<Uri<String>>,
134 /// URI of the client's terms of service document.
135 pub tos_uri: Option<Uri<String>>,
136 /// URI of the client's privacy policy document.
137 pub privacy_policy_uri: Option<Uri<String>>,
138}
139
140impl<'m> IntoStatic for AtprotoClientMetadata<'m> {
141 type Output = AtprotoClientMetadata<'static>;
142 fn into_static(self) -> AtprotoClientMetadata<'static> {
143 AtprotoClientMetadata {
144 client_id: self.client_id,
145 client_uri: self.client_uri,
146 redirect_uris: self.redirect_uris,
147 grant_types: self.grant_types,
148 scopes: self.scopes.into_static(),
149 jwks_uri: self.jwks_uri,
150 client_name: self.client_name,
151 logo_uri: self.logo_uri,
152 tos_uri: self.tos_uri,
153 privacy_policy_uri: None,
154 }
155 }
156}
157
158impl<'m> AtprotoClientMetadata<'m> {
159 /// Attach optional production branding fields to the metadata.
160 ///
161 /// Chainable builder method for setting display name, logo, and policy URLs after
162 /// constructing the base metadata.
163 pub fn with_prod_info(
164 mut self,
165 client_name: &str,
166 logo_uri: Option<Uri<String>>,
167 tos_uri: Option<Uri<String>>,
168 privacy_policy_uri: Option<Uri<String>>,
169 ) -> Self {
170 self.client_name = Some(client_name.to_smolstr());
171 self.logo_uri = logo_uri;
172 self.tos_uri = tos_uri;
173 self.privacy_policy_uri = privacy_policy_uri;
174 self
175 }
176
177 /// Create a default loopback client metadata with the `atproto` and `transition:generic` scopes.
178 ///
179 /// This is a convenience constructor for local development and CLI tools. The resulting
180 /// metadata uses `http://localhost` as the `client_id` with both IPv4 and IPv6 loopback
181 /// redirect URIs.
182 pub fn default_localhost() -> Self {
183 Self::new_localhost(
184 None,
185 Some(Scope::parse_multiple("atproto transition:generic").unwrap()),
186 )
187 }
188
189 /// Create loopback client metadata with optional custom redirect URIs and scopes.
190 ///
191 /// Encodes non-default redirect URIs and scopes into the `client_id` query string as
192 /// required by the AT Protocol loopback client specification. When `redirect_uris` or
193 /// `scopes` are `None`, sensible defaults (IPv4 + IPv6 loopback addresses, `atproto` scope)
194 /// are used.
195 pub fn new_localhost(
196 redirect_uris: Option<Vec<Uri<String>>>,
197 scopes: Option<Vec<Scope<'static>>>,
198 ) -> AtprotoClientMetadata<'static> {
199 // determine client_id
200 #[derive(serde::Serialize)]
201 struct Parameters<'a> {
202 #[serde(skip_serializing_if = "Option::is_none")]
203 redirect_uri: Option<Vec<CowStr<'a>>>,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 scope: Option<CowStr<'a>>,
206 }
207 let redir_str = redirect_uris.as_ref().map(|uris| {
208 uris.iter()
209 .map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static())
210 .collect()
211 });
212 let query = serde_html_form::to_string(Parameters {
213 redirect_uri: redir_str,
214 scope: scopes
215 .as_ref()
216 .map(|s| Scope::serialize_multiple(s.as_slice())),
217 })
218 .ok();
219 let mut client_id = String::from("http://localhost/");
220 if let Some(query) = query
221 && !query.is_empty()
222 {
223 client_id.push_str(&format!("?{query}"));
224 }
225 AtprotoClientMetadata {
226 client_id: Uri::parse(client_id).unwrap(),
227 client_uri: None,
228 redirect_uris: redirect_uris.unwrap_or(vec![
229 Uri::parse("http://127.0.0.1".to_string()).unwrap(),
230 Uri::parse("http://[::1]".to_string()).unwrap(),
231 ]),
232 grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
233 scopes: scopes.unwrap_or(vec![Scope::Atproto]),
234 jwks_uri: None,
235 client_name: None,
236 logo_uri: None,
237 tos_uri: None,
238 privacy_policy_uri: None,
239 }
240 }
241}
242
243/// Convert [`AtprotoClientMetadata`] into the [`OAuthClientMetadata`] wire format.
244///
245/// Validates all atproto-specific constraints (required scopes, grant types, redirect URIs),
246/// selects the appropriate `token_endpoint_auth_method` based on whether a keyset is provided,
247/// and serializes scopes and grant types into their string representations. Returns an error
248/// if any required field is missing or invalid.
249pub fn atproto_client_metadata<'m>(
250 metadata: AtprotoClientMetadata<'m>,
251 keyset: &Option<Keyset>,
252) -> Result<OAuthClientMetadata<'static>> {
253 let is_loopback = metadata.client_id.scheme().as_str() == "http"
254 && metadata.client_id.authority().map(|a| a.host()) == Some("localhost");
255 let application_type = if is_loopback {
256 Some(CowStr::new_static("native"))
257 } else {
258 Some(CowStr::new_static("web"))
259 };
260 if metadata.redirect_uris.is_empty() {
261 return Err(Error::EmptyRedirectUris);
262 }
263 if !metadata.grant_types.contains(&GrantType::AuthorizationCode) {
264 return Err(Error::InvalidGrantTypes);
265 }
266 if !metadata.scopes.contains(&Scope::Atproto) {
267 return Err(Error::InvalidScope);
268 }
269 let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset {
270 let jwks = if metadata.jwks_uri.is_none() {
271 Some(keyset.public_jwks())
272 } else {
273 None
274 };
275 (AuthMethod::PrivateKeyJwt, metadata.jwks_uri, jwks)
276 } else {
277 (AuthMethod::None, None, None)
278 };
279 let client_id = metadata
280 .client_id
281 .as_str()
282 .trim_end_matches("/")
283 .to_string();
284 let client_uri = metadata
285 .client_uri
286 .as_ref()
287 .map(|u| u.as_str().trim_end_matches("/").to_string().into());
288 let redirect_uris = metadata
289 .redirect_uris
290 .iter()
291 .map(|u| u.as_str().trim_end_matches("/").to_string().into())
292 .collect();
293 let jwks_uri = jwks_uri.map(|u| u.as_str().trim_end_matches("/").to_string().into());
294 Ok(OAuthClientMetadata {
295 client_id: client_id.into(),
296 client_uri,
297 redirect_uris,
298 application_type,
299 token_endpoint_auth_method: Some(auth_method.into()),
300 grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()),
301 response_types: vec!["code".to_cowstr()],
302 scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())),
303 dpop_bound_access_tokens: Some(true),
304 jwks_uri,
305 jwks,
306 token_endpoint_auth_signing_alg: if keyset.is_some() {
307 Some(CowStr::new_static("ES256"))
308 } else {
309 None
310 },
311 client_name: metadata.client_name,
312 logo_uri: metadata
313 .logo_uri
314 .as_ref()
315 .map(|u| u.as_str().to_string().into()),
316 tos_uri: metadata
317 .tos_uri
318 .as_ref()
319 .map(|u| u.as_str().to_string().into()),
320 privacy_policy_uri: metadata
321 .privacy_policy_uri
322 .as_ref()
323 .map(|u| u.as_str().to_string().into()),
324 })
325}
326
327#[cfg(test)]
328mod tests {
329 use crate::scopes::TransitionScope;
330
331 use super::*;
332 use elliptic_curve::SecretKey;
333 use jose_jwk::{Jwk, Key, Parameters};
334 use p256::pkcs8::DecodePrivateKey;
335
336 const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
337MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T
3384i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P
339gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3
340-----END PRIVATE KEY-----"#;
341
342 #[test]
343 fn test_localhost_client_metadata_default() {
344 assert_eq!(
345 atproto_client_metadata(AtprotoClientMetadata::new_localhost(None, None), &None)
346 .unwrap(),
347 OAuthClientMetadata {
348 client_id: CowStr::new_static("http://localhost"),
349 client_uri: None,
350 redirect_uris: vec![
351 CowStr::new_static("http://127.0.0.1"),
352 CowStr::new_static("http://[::1]"),
353 ],
354 application_type: Some(CowStr::new_static("native")),
355 scope: Some(CowStr::new_static("atproto")),
356 grant_types: Some(vec![
357 "authorization_code".to_cowstr(),
358 "refresh_token".to_cowstr()
359 ]),
360 response_types: vec!["code".to_cowstr()],
361 token_endpoint_auth_method: Some(AuthMethod::None.into()),
362 dpop_bound_access_tokens: Some(true),
363 jwks_uri: None,
364 jwks: None,
365 token_endpoint_auth_signing_alg: None,
366 tos_uri: None,
367 privacy_policy_uri: None,
368 client_name: None,
369 logo_uri: None,
370 }
371 );
372 }
373
374 #[test]
375 fn test_localhost_client_metadata_custom() {
376 assert_eq!(
377 atproto_client_metadata(
378 AtprotoClientMetadata::new_localhost(
379 Some(vec![
380 Uri::parse("http://127.0.0.1/callback".to_string()).unwrap(),
381 Uri::parse("http://[::1]/callback".to_string()).unwrap(),
382 ]),
383 Some(vec![
384 Scope::Atproto,
385 Scope::Transition(TransitionScope::Generic),
386 Scope::parse("account:email").unwrap()
387 ])
388 ),
389 &None
390 )
391 .expect("failed to convert metadata"),
392 OAuthClientMetadata {
393 client_id: CowStr::new_static(
394 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric"
395 ),
396 client_uri: None,
397 redirect_uris: vec![
398 CowStr::new_static("http://127.0.0.1/callback"),
399 CowStr::new_static("http://[::1]/callback"),
400 ],
401 scope: Some(CowStr::new_static(
402 "account:email atproto transition:generic"
403 )),
404 application_type: Some(CowStr::new_static("native")),
405 grant_types: Some(vec![
406 "authorization_code".to_cowstr(),
407 "refresh_token".to_cowstr()
408 ]),
409 response_types: vec!["code".to_cowstr()],
410 token_endpoint_auth_method: Some(AuthMethod::None.into()),
411 dpop_bound_access_tokens: Some(true),
412 jwks_uri: None,
413 jwks: None,
414 token_endpoint_auth_signing_alg: None,
415 tos_uri: None,
416 privacy_policy_uri: None,
417 client_name: None,
418 logo_uri: None,
419 }
420 );
421 }
422
423 #[test]
424 fn test_localhost_client_metadata_invalid() {
425 // Invalid inputs are coerced to http://localhost rather than failing
426 {
427 let out = atproto_client_metadata(
428 AtprotoClientMetadata::new_localhost(
429 Some(vec![Uri::parse("https://127.0.0.1".to_string()).unwrap()]),
430 None,
431 ),
432 &None,
433 )
434 .expect("should coerce to 127.0.0.1");
435 assert_eq!(
436 out,
437 OAuthClientMetadata {
438 client_id: CowStr::new_static(
439 "http://localhost/?redirect_uri=https%3A%2F%2F127.0.0.1"
440 ),
441 application_type: Some(CowStr::new_static("native")),
442 client_uri: None,
443 redirect_uris: vec![CowStr::new_static("https://127.0.0.1")],
444 scope: Some(CowStr::new_static("atproto")),
445 grant_types: Some(vec![
446 "authorization_code".to_cowstr(),
447 "refresh_token".to_cowstr()
448 ]),
449 response_types: vec!["code".to_cowstr()],
450 token_endpoint_auth_method: Some(AuthMethod::None.into()),
451 dpop_bound_access_tokens: Some(true),
452 jwks_uri: None,
453 jwks: None,
454 token_endpoint_auth_signing_alg: None,
455 tos_uri: None,
456 privacy_policy_uri: None,
457 client_name: None,
458 logo_uri: None,
459 }
460 );
461 }
462 {
463 let out = atproto_client_metadata(
464 AtprotoClientMetadata::new_localhost(
465 Some(vec![
466 Uri::parse("http://localhost:8000".to_string()).unwrap(),
467 ]),
468 None,
469 ),
470 &None,
471 )
472 .expect("should coerce to 127.0.0.1");
473 assert_eq!(
474 out,
475 OAuthClientMetadata {
476 client_id: CowStr::new_static(
477 "http://localhost/?redirect_uri=http%3A%2F%2Flocalhost%3A8000"
478 ),
479 client_uri: None,
480 redirect_uris: vec![CowStr::new_static("http://localhost:8000")],
481 scope: Some(CowStr::new_static("atproto")),
482 grant_types: Some(vec![
483 "authorization_code".to_cowstr(),
484 "refresh_token".to_cowstr()
485 ]),
486 application_type: Some(CowStr::new_static("native")),
487 response_types: vec!["code".to_cowstr()],
488 token_endpoint_auth_method: Some(AuthMethod::None.into()),
489 dpop_bound_access_tokens: Some(true),
490 jwks_uri: None,
491 jwks: None,
492 token_endpoint_auth_signing_alg: None,
493 tos_uri: None,
494 privacy_policy_uri: None,
495 client_name: None,
496 logo_uri: None,
497 }
498 );
499 }
500 {
501 let out = atproto_client_metadata(
502 AtprotoClientMetadata::new_localhost(
503 Some(vec![Uri::parse("http://192.168.0.0/".to_string()).unwrap()]),
504 None,
505 ),
506 &None,
507 )
508 .expect("should coerce to 127.0.0.1");
509 assert_eq!(
510 out,
511 OAuthClientMetadata {
512 client_id: CowStr::new_static(
513 "http://localhost/?redirect_uri=http%3A%2F%2F192.168.0.0"
514 ),
515 client_uri: None,
516 redirect_uris: vec![CowStr::new_static("http://192.168.0.0")],
517 scope: Some(CowStr::new_static("atproto")),
518 grant_types: Some(vec![
519 "authorization_code".to_cowstr(),
520 "refresh_token".to_cowstr()
521 ]),
522 application_type: Some(CowStr::new_static("native")),
523 response_types: vec!["code".to_cowstr()],
524 token_endpoint_auth_method: Some(AuthMethod::None.into()),
525 dpop_bound_access_tokens: Some(true),
526 jwks_uri: None,
527 jwks: None,
528 token_endpoint_auth_signing_alg: None,
529 tos_uri: None,
530 privacy_policy_uri: None,
531 client_name: None,
532 logo_uri: None,
533 }
534 );
535 }
536 }
537
538 #[test]
539 fn test_client_metadata() {
540 let metadata = AtprotoClientMetadata {
541 client_id: Uri::parse("https://example.com/client_metadata.json".to_string()).unwrap(),
542 client_uri: Some(Uri::parse("https://example.com".to_string()).unwrap()),
543 redirect_uris: vec![Uri::parse("https://example.com/callback".to_string()).unwrap()],
544 grant_types: vec![GrantType::AuthorizationCode],
545 scopes: vec![Scope::Atproto],
546 jwks_uri: None,
547 client_name: None,
548 logo_uri: None,
549 tos_uri: None,
550 privacy_policy_uri: None,
551 };
552 {
553 // Non-loopback clients without a keyset should fail (must provide JWKS)
554 let metadata = metadata.clone();
555 let err = atproto_client_metadata(metadata, &None);
556 assert!(err.is_ok());
557 }
558 {
559 let metadata = metadata.clone();
560 let secret_key = SecretKey::<p256::NistP256>::from_pkcs8_pem(PRIVATE_KEY)
561 .expect("failed to parse private key");
562 let keys = vec![Jwk {
563 key: Key::from(&secret_key.into()),
564 prm: Parameters {
565 kid: Some(String::from("kid00")),
566 ..Default::default()
567 },
568 }];
569 let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset");
570 assert_eq!(
571 atproto_client_metadata(metadata, &Some(keyset.clone()))
572 .expect("failed to convert metadata"),
573 OAuthClientMetadata {
574 client_id: CowStr::new_static("https://example.com/client_metadata.json"),
575 client_uri: Some(CowStr::new_static("https://example.com")),
576 redirect_uris: vec![CowStr::new_static("https://example.com/callback")],
577 application_type: Some(CowStr::new_static("web")),
578 scope: Some(CowStr::new_static("atproto")),
579 grant_types: Some(vec![CowStr::new_static("authorization_code")]),
580 token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()),
581 dpop_bound_access_tokens: Some(true),
582 response_types: vec!["code".to_cowstr()],
583 jwks_uri: None,
584 jwks: Some(keyset.public_jwks()),
585 token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
586 client_name: None,
587 logo_uri: None,
588 tos_uri: None,
589 privacy_policy_uri: None,
590 }
591 );
592 }
593 }
594}