A better Rust ATProto crate
103
fork

Configure Feed

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

at pretty-codegen 594 lines 24 kB view raw
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}