A better Rust ATProto crate
1use chrono::{TimeDelta, Utc}; 2use http::{Method, Request, StatusCode}; 3use jacquard_common::{ 4 CowStr, IntoStatic, 5 cowstr::ToCowStr, 6 http_client::HttpClient, 7 session::SessionStoreError, 8 types::{ 9 did::Did, 10 string::{AtStrError, Datetime}, 11 }, 12}; 13use jacquard_identity::resolver::IdentityError; 14use serde::Serialize; 15use serde_json::Value; 16use smol_str::ToSmolStr; 17use thiserror::Error; 18 19use crate::{ 20 FALLBACK_ALG, 21 atproto::atproto_client_metadata, 22 dpop::DpopExt, 23 jose::jwt::{RegisteredClaims, RegisteredClaimsAud}, 24 keyset::Keyset, 25 resolver::OAuthResolver, 26 scopes::Scope, 27 session::{ 28 AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData, 29 }, 30 types::{ 31 AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptionPrompt, 32 OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthParResponse, 33 OAuthTokenResponse, ParParameters, RefreshRequestParameters, RevocationRequestParameters, 34 TokenGrantType, TokenRequestParameters, TokenSet, 35 }, 36 utils::{compare_algos, generate_dpop_key, generate_nonce, generate_pkce}, 37}; 38 39// https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 40const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str = 41 "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 42 43#[derive(Error, Debug, miette::Diagnostic)] 44pub enum RequestError { 45 #[error("no {0} endpoint available")] 46 #[diagnostic(code(jacquard_oauth::request::no_endpoint), help("server does not advertise this endpoint"))] 47 NoEndpoint(CowStr<'static>), 48 #[error("token response verification failed")] 49 #[diagnostic(code(jacquard_oauth::request::token_verification))] 50 TokenVerification, 51 #[error("unsupported authentication method")] 52 #[diagnostic( 53 code(jacquard_oauth::request::unsupported_auth_method), 54 help("server must support `private_key_jwt` or `none`; configure client metadata accordingly") 55 )] 56 UnsupportedAuthMethod, 57 #[error("no refresh token available")] 58 #[diagnostic(code(jacquard_oauth::request::no_refresh_token))] 59 NoRefreshToken, 60 #[error("failed to parse DID: {0}")] 61 #[diagnostic(code(jacquard_oauth::request::invalid_did))] 62 InvalidDid(#[from] AtStrError), 63 #[error(transparent)] 64 #[diagnostic(code(jacquard_oauth::request::dpop))] 65 DpopClient(#[from] crate::dpop::Error), 66 #[error(transparent)] 67 #[diagnostic(code(jacquard_oauth::request::storage))] 68 Storage(#[from] SessionStoreError), 69 70 #[error(transparent)] 71 #[diagnostic(code(jacquard_oauth::request::resolver))] 72 ResolverError(#[from] crate::resolver::ResolverError), 73 // #[error(transparent)] 74 // OAuthSession(#[from] crate::oauth_session::Error), 75 #[error(transparent)] 76 #[diagnostic(code(jacquard_oauth::request::http_build))] 77 Http(#[from] http::Error), 78 #[error("http status: {0}")] 79 #[diagnostic(code(jacquard_oauth::request::http_status), help("see server response for details"))] 80 HttpStatus(StatusCode), 81 #[error("http status: {0}, body: {1:?}")] 82 #[diagnostic(code(jacquard_oauth::request::http_status_body), help("server returned error JSON; inspect fields like `error`, `error_description`"))] 83 HttpStatusWithBody(StatusCode, Value), 84 #[error(transparent)] 85 #[diagnostic(code(jacquard_oauth::request::identity))] 86 Identity(#[from] IdentityError), 87 #[error(transparent)] 88 #[diagnostic(code(jacquard_oauth::request::keyset))] 89 Keyset(#[from] crate::keyset::Error), 90 #[error(transparent)] 91 #[diagnostic(code(jacquard_oauth::request::serde_form))] 92 SerdeHtmlForm(#[from] serde_html_form::ser::Error), 93 #[error(transparent)] 94 #[diagnostic(code(jacquard_oauth::request::serde_json))] 95 SerdeJson(#[from] serde_json::Error), 96 #[error(transparent)] 97 #[diagnostic(code(jacquard_oauth::request::atproto))] 98 Atproto(#[from] crate::atproto::Error), 99} 100 101pub type Result<T> = core::result::Result<T, RequestError>; 102 103#[allow(dead_code)] 104pub enum OAuthRequest<'a> { 105 Token(TokenRequestParameters<'a>), 106 Refresh(RefreshRequestParameters<'a>), 107 Revocation(RevocationRequestParameters<'a>), 108 Introspection, 109 PushedAuthorizationRequest(ParParameters<'a>), 110} 111 112impl OAuthRequest<'_> { 113 pub fn name(&self) -> CowStr<'static> { 114 CowStr::new_static(match self { 115 Self::Token(_) => "token", 116 Self::Refresh(_) => "refresh", 117 Self::Revocation(_) => "revocation", 118 Self::Introspection => "introspection", 119 Self::PushedAuthorizationRequest(_) => "pushed_authorization_request", 120 }) 121 } 122 pub fn expected_status(&self) -> StatusCode { 123 match self { 124 Self::Token(_) | Self::Refresh(_) => StatusCode::OK, 125 Self::PushedAuthorizationRequest(_) => StatusCode::CREATED, 126 // Unlike https://datatracker.ietf.org/doc/html/rfc7009#section-2.2, oauth-provider seems to return `204`. 127 Self::Revocation(_) => StatusCode::NO_CONTENT, 128 _ => unimplemented!(), 129 } 130 } 131} 132 133#[cfg(test)] 134mod tests { 135 use super::*; 136 use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata}; 137 use http::{Response as HttpResponse, StatusCode}; 138 use bytes::Bytes; 139 use jacquard_common::http_client::HttpClient; 140 use jacquard_identity::resolver::IdentityResolver; 141 use std::sync::Arc; 142 use tokio::sync::Mutex; 143 144 #[derive(Clone, Default)] 145 struct MockClient { 146 resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>, 147 } 148 149 impl HttpClient for MockClient { 150 type Error = std::convert::Infallible; 151 fn send_http( 152 &self, 153 _request: http::Request<Vec<u8>>, 154 ) -> impl core::future::Future< 155 Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>, 156 > + Send { 157 let resp = self.resp.clone(); 158 async move { Ok(resp.lock().await.take().unwrap()) } 159 } 160 } 161 162 // IdentityResolver methods won't be called in these tests; provide stubs. 163 #[async_trait::async_trait] 164 impl IdentityResolver for MockClient { 165 fn options(&self) -> &jacquard_identity::resolver::ResolverOptions { 166 use std::sync::LazyLock; 167 static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> = 168 LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default()); 169 &OPTS 170 } 171 async fn resolve_handle( 172 &self, 173 _handle: &jacquard_common::types::string::Handle<'_>, 174 ) -> std::result::Result< 175 jacquard_common::types::string::Did<'static>, 176 jacquard_identity::resolver::IdentityError, 177 > { 178 Ok(jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap()) 179 } 180 async fn resolve_did_doc( 181 &self, 182 _did: &jacquard_common::types::string::Did<'_>, 183 ) -> std::result::Result< 184 jacquard_identity::resolver::DidDocResponse, 185 jacquard_identity::resolver::IdentityError, 186 > { 187 let doc = serde_json::json!({ 188 "id": "did:plc:alice", 189 "service": [{ 190 "id": "#pds", 191 "type": "AtprotoPersonalDataServer", 192 "serviceEndpoint": "https://pds" 193 }] 194 }); 195 let buf = Bytes::from(serde_json::to_vec(&doc).unwrap()); 196 Ok(jacquard_identity::resolver::DidDocResponse { 197 buffer: buf, 198 status: StatusCode::OK, 199 requested: None, 200 }) 201 } 202 } 203 204 // Allow using DPoP helpers on MockClient 205 impl crate::dpop::DpopExt for MockClient {} 206 impl crate::resolver::OAuthResolver for MockClient {} 207 208 fn base_metadata() -> OAuthMetadata { 209 let mut server = OAuthAuthorizationServerMetadata::default(); 210 server.issuer = CowStr::from("https://issuer"); 211 server.authorization_endpoint = CowStr::from("https://issuer/authorize"); 212 server.token_endpoint = CowStr::from("https://issuer/token"); 213 OAuthMetadata { 214 server_metadata: server, 215 client_metadata: OAuthClientMetadata { 216 client_id: url::Url::parse("https://client").unwrap(), 217 client_uri: None, 218 redirect_uris: vec![url::Url::parse("https://client/cb").unwrap()], 219 scope: Some(CowStr::from("atproto")), 220 grant_types: None, 221 token_endpoint_auth_method: Some(CowStr::from("none")), 222 dpop_bound_access_tokens: None, 223 jwks_uri: None, 224 jwks: None, 225 token_endpoint_auth_signing_alg: None, 226 }, 227 keyset: None, 228 } 229 } 230 231 #[tokio::test] 232 async fn par_missing_endpoint() { 233 let mut meta = base_metadata(); 234 meta.server_metadata.require_pushed_authorization_requests = Some(true); 235 meta.server_metadata.pushed_authorization_request_endpoint = None; 236 // require_pushed_authorization_requests is true and no endpoint 237 let err = super::par(&MockClient::default(), None, None, &meta) 238 .await 239 .unwrap_err(); 240 match err { 241 RequestError::NoEndpoint(name) => { 242 assert_eq!(name.as_ref(), "pushed_authorization_request"); 243 } 244 other => panic!("unexpected: {other:?}"), 245 } 246 } 247 248 #[tokio::test] 249 async fn refresh_no_refresh_token() { 250 let client = MockClient::default(); 251 let meta = base_metadata(); 252 let mut session = ClientSessionData { 253 account_did: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(), 254 session_id: CowStr::from("state"), 255 host_url: url::Url::parse("https://pds").unwrap(), 256 authserver_url: url::Url::parse("https://issuer").unwrap(), 257 authserver_token_endpoint: CowStr::from("https://issuer/token"), 258 authserver_revocation_endpoint: None, 259 scopes: vec![], 260 dpop_data: DpopClientData { 261 dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(), 262 dpop_authserver_nonce: CowStr::from(""), 263 dpop_host_nonce: CowStr::from(""), 264 }, 265 token_set: crate::types::TokenSet { 266 iss: CowStr::from("https://issuer"), 267 sub: jacquard_common::types::string::Did::new_static("did:plc:alice").unwrap(), 268 aud: CowStr::from("https://pds"), 269 scope: None, 270 refresh_token: None, 271 access_token: CowStr::from("abc"), 272 token_type: crate::types::OAuthTokenType::DPoP, 273 expires_at: None, 274 }, 275 }; 276 let err = super::refresh(&client, session, &meta).await.unwrap_err(); 277 matches!(err, RequestError::NoRefreshToken); 278 } 279 280 #[tokio::test] 281 async fn exchange_code_missing_sub() { 282 let client = MockClient::default(); 283 // set mock HTTP response body: token response without `sub` 284 *client.resp.lock().await = Some( 285 HttpResponse::builder() 286 .status(StatusCode::OK) 287 .body(serde_json::to_vec(&serde_json::json!({ 288 "access_token":"tok", 289 "token_type":"DPoP", 290 "expires_in": 3600 291 })).unwrap()) 292 .unwrap(), 293 ); 294 let meta = base_metadata(); 295 let mut dpop = DpopReqData { 296 dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(), 297 dpop_authserver_nonce: None, 298 }; 299 let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta) 300 .await 301 .unwrap_err(); 302 matches!(err, RequestError::TokenVerification); 303 } 304} 305 306#[derive(Debug, Serialize)] 307pub struct RequestPayload<'a, T> 308where 309 T: Serialize, 310{ 311 client_id: CowStr<'a>, 312 #[serde(skip_serializing_if = "Option::is_none")] 313 client_assertion_type: Option<CowStr<'a>>, 314 #[serde(skip_serializing_if = "Option::is_none")] 315 client_assertion: Option<CowStr<'a>>, 316 #[serde(flatten)] 317 parameters: T, 318} 319 320#[derive(Debug, Clone)] 321pub struct OAuthMetadata { 322 pub server_metadata: OAuthAuthorizationServerMetadata<'static>, 323 pub client_metadata: OAuthClientMetadata<'static>, 324 pub keyset: Option<Keyset>, 325} 326 327impl OAuthMetadata { 328 pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>( 329 client: &T, 330 ClientData { keyset, config }: &ClientData<'r>, 331 session_data: &ClientSessionData<'r>, 332 ) -> Result<Self> { 333 Ok(OAuthMetadata { 334 server_metadata: client 335 .get_authorization_server_metadata(&session_data.authserver_url) 336 .await?, 337 client_metadata: atproto_client_metadata(config.clone(), &keyset) 338 .unwrap() 339 .into_static(), 340 keyset: keyset.clone(), 341 }) 342 } 343} 344 345pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>( 346 client: &T, 347 login_hint: Option<CowStr<'r>>, 348 prompt: Option<AuthorizeOptionPrompt>, 349 metadata: &OAuthMetadata, 350) -> crate::request::Result<AuthRequestData<'r>> { 351 let state = generate_nonce(); 352 let (code_challenge, verifier) = generate_pkce(); 353 354 let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else { 355 return Err(RequestError::TokenVerification); 356 }; 357 let mut dpop_data = DpopReqData { 358 dpop_key, 359 dpop_authserver_nonce: None, 360 }; 361 let parameters = ParParameters { 362 response_type: AuthorizationResponseType::Code, 363 redirect_uri: metadata.client_metadata.redirect_uris[0].to_cowstr(), 364 state: state.clone(), 365 scope: metadata.client_metadata.scope.clone(), 366 response_mode: None, 367 code_challenge, 368 code_challenge_method: AuthorizationCodeChallengeMethod::S256, 369 login_hint: login_hint, 370 prompt: prompt.map(CowStr::from), 371 }; 372 if metadata 373 .server_metadata 374 .pushed_authorization_request_endpoint 375 .is_some() 376 { 377 let par_response = oauth_request::<OAuthParResponse, T, DpopReqData>( 378 &client, 379 &mut dpop_data, 380 OAuthRequest::PushedAuthorizationRequest(parameters), 381 metadata, 382 ) 383 .await?; 384 385 let scopes = if let Some(scope) = &metadata.client_metadata.scope { 386 Scope::parse_multiple_reduced(&scope) 387 .expect("Failed to parse scopes") 388 .into_static() 389 } else { 390 vec![] 391 }; 392 let auth_req_data = AuthRequestData { 393 state, 394 authserver_url: url::Url::parse(&metadata.server_metadata.issuer) 395 .expect("Failed to parse issuer URL"), 396 account_did: None, 397 scopes, 398 request_uri: par_response.request_uri.to_cowstr().into_static(), 399 authserver_token_endpoint: metadata.server_metadata.token_endpoint.clone(), 400 authserver_revocation_endpoint: metadata.server_metadata.revocation_endpoint.clone(), 401 pkce_verifier: verifier, 402 dpop_data, 403 }; 404 405 Ok(auth_req_data) 406 } else if metadata 407 .server_metadata 408 .require_pushed_authorization_requests 409 == Some(true) 410 { 411 Err(RequestError::NoEndpoint(CowStr::new_static( 412 "pushed_authorization_request", 413 ))) 414 } else { 415 todo!("use of PAR is mandatory") 416 } 417} 418 419pub async fn refresh<'r, T>( 420 client: &T, 421 mut session_data: ClientSessionData<'r>, 422 metadata: &OAuthMetadata, 423) -> Result<ClientSessionData<'r>> 424where 425 T: OAuthResolver + DpopExt + Send + Sync + 'static, 426{ 427 let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else { 428 return Err(RequestError::NoRefreshToken); 429 }; 430 431 // /!\ IMPORTANT /!\ 432 // 433 // The "sub" MUST be a DID, whose issuer authority is indeed the server we 434 // are trying to obtain credentials from. Note that we are doing this 435 // *before* we actually try to refresh the token: 436 // 1) To avoid unnecessary refresh 437 // 2) So that the refresh is the last async operation, ensuring as few 438 // async operations happen before the result gets a chance to be stored. 439 let aud = client 440 .verify_issuer(&metadata.server_metadata, &session_data.token_set.sub) 441 .await?; 442 let iss = metadata.server_metadata.issuer.clone(); 443 444 let response = oauth_request::<OAuthTokenResponse, T, DpopClientData>( 445 client, 446 &mut session_data.dpop_data, 447 OAuthRequest::Refresh(RefreshRequestParameters { 448 grant_type: TokenGrantType::RefreshToken, 449 refresh_token: refresh_token.clone(), 450 scope: None, 451 }), 452 metadata, 453 ) 454 .await?; 455 456 let expires_at = response.expires_in.and_then(|expires_in| { 457 let now = Datetime::now(); 458 now.as_ref() 459 .checked_add_signed(TimeDelta::seconds(expires_in)) 460 .map(Datetime::new) 461 }); 462 463 session_data.update_with_tokens(TokenSet { 464 iss, 465 sub: session_data.token_set.sub.clone(), 466 aud: CowStr::Owned(aud.to_smolstr()), 467 scope: response.scope.map(CowStr::Owned), 468 access_token: CowStr::Owned(response.access_token), 469 refresh_token: response.refresh_token.map(CowStr::Owned), 470 token_type: response.token_type, 471 expires_at, 472 }); 473 474 Ok(session_data) 475} 476 477pub async fn exchange_code<'r, T, D>( 478 client: &T, 479 data_source: &'r mut D, 480 code: &str, 481 verifier: &str, 482 metadata: &OAuthMetadata, 483) -> Result<TokenSet<'r>> 484where 485 T: OAuthResolver + DpopExt + Send + Sync + 'static, 486 D: DpopDataSource, 487{ 488 let token_response = oauth_request::<OAuthTokenResponse, T, D>( 489 client, 490 data_source, 491 OAuthRequest::Token(TokenRequestParameters { 492 grant_type: TokenGrantType::AuthorizationCode, 493 code: code.into(), 494 redirect_uri: CowStr::Owned( 495 metadata.client_metadata.redirect_uris[0] 496 .clone() 497 .to_smolstr(), 498 ), // ? 499 code_verifier: verifier.into(), 500 }), 501 metadata, 502 ) 503 .await?; 504 let Some(sub) = token_response.sub else { 505 return Err(RequestError::TokenVerification); 506 }; 507 let sub = Did::new_owned(sub)?; 508 let iss = metadata.server_metadata.issuer.clone(); 509 // /!\ IMPORTANT /!\ 510 // 511 // The token_response MUST always be valid before the "sub" it contains 512 // can be trusted (see Atproto's OAuth spec for details). 513 let aud = client 514 .verify_issuer(&metadata.server_metadata, &sub) 515 .await?; 516 517 let expires_at = token_response.expires_in.and_then(|expires_in| { 518 Datetime::now() 519 .as_ref() 520 .checked_add_signed(TimeDelta::seconds(expires_in)) 521 .map(Datetime::new) 522 }); 523 Ok(TokenSet { 524 iss, 525 sub, 526 aud: CowStr::Owned(aud.to_smolstr()), 527 scope: token_response.scope.map(CowStr::Owned), 528 access_token: CowStr::Owned(token_response.access_token), 529 refresh_token: token_response.refresh_token.map(CowStr::Owned), 530 token_type: token_response.token_type, 531 expires_at, 532 }) 533} 534 535pub async fn revoke<'r, T, D>( 536 client: &T, 537 data_source: &'r mut D, 538 token: &str, 539 metadata: &OAuthMetadata, 540) -> Result<()> 541where 542 T: OAuthResolver + DpopExt + Send + Sync + 'static, 543 D: DpopDataSource, 544{ 545 oauth_request::<(), T, D>( 546 client, 547 data_source, 548 OAuthRequest::Revocation(RevocationRequestParameters { 549 token: token.into(), 550 }), 551 metadata, 552 ) 553 .await?; 554 Ok(()) 555} 556 557pub async fn oauth_request<'de: 'r, 'r, O, T, D>( 558 client: &T, 559 data_source: &'r mut D, 560 request: OAuthRequest<'r>, 561 metadata: &OAuthMetadata, 562) -> Result<O> 563where 564 T: OAuthResolver + DpopExt + Send + Sync + 'static, 565 O: serde::de::DeserializeOwned, 566 D: DpopDataSource, 567{ 568 let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else { 569 return Err(RequestError::NoEndpoint(request.name())); 570 }; 571 let client_assertions = build_auth( 572 metadata.keyset.as_ref(), 573 &metadata.server_metadata, 574 &metadata.client_metadata, 575 )?; 576 let body = match &request { 577 OAuthRequest::Token(params) => build_oauth_req_body(client_assertions, params)?, 578 OAuthRequest::Refresh(params) => build_oauth_req_body(client_assertions, params)?, 579 OAuthRequest::Revocation(params) => build_oauth_req_body(client_assertions, params)?, 580 OAuthRequest::PushedAuthorizationRequest(params) => { 581 build_oauth_req_body(client_assertions, params)? 582 } 583 _ => unimplemented!(), 584 }; 585 let req = Request::builder() 586 .uri(url.to_string()) 587 .method(Method::POST) 588 .header("Content-Type", "application/x-www-form-urlencoded") 589 .body(body.into_bytes())?; 590 let res = client 591 .dpop_server_call(data_source) 592 .send(req) 593 .await 594 .map_err(RequestError::DpopClient)?; 595 if res.status() == request.expected_status() { 596 let body = res.body(); 597 if body.is_empty() { 598 // since an empty body cannot be deserialized, use “null” temporarily to allow deserialization to `()`. 599 Ok(serde_json::from_slice(b"null")?) 600 } else { 601 let output: O = serde_json::from_slice(body)?; 602 Ok(output) 603 } 604 } else if res.status().is_client_error() { 605 Err(RequestError::HttpStatusWithBody( 606 res.status(), 607 serde_json::from_slice(res.body())?, 608 )) 609 } else { 610 Err(RequestError::HttpStatus(res.status())) 611 } 612} 613 614#[inline] 615fn endpoint_for_req<'a, 'r>( 616 server_metadata: &'r OAuthAuthorizationServerMetadata<'a>, 617 request: &'r OAuthRequest, 618) -> Option<&'r CowStr<'a>> { 619 match request { 620 OAuthRequest::Token(_) | OAuthRequest::Refresh(_) => Some(&server_metadata.token_endpoint), 621 OAuthRequest::Revocation(_) => server_metadata.revocation_endpoint.as_ref(), 622 OAuthRequest::Introspection => server_metadata.introspection_endpoint.as_ref(), 623 OAuthRequest::PushedAuthorizationRequest(_) => server_metadata 624 .pushed_authorization_request_endpoint 625 .as_ref(), 626 } 627} 628 629#[inline] 630fn build_oauth_req_body<'a, S>(client_assertions: ClientAuth<'a>, parameters: S) -> Result<String> 631where 632 S: Serialize, 633{ 634 Ok(serde_html_form::to_string(RequestPayload { 635 client_id: client_assertions.client_id, 636 client_assertion_type: client_assertions.assertion_type, 637 client_assertion: client_assertions.assertion, 638 parameters, 639 })?) 640} 641 642#[derive(Debug, Clone, Default)] 643pub struct ClientAuth<'a> { 644 client_id: CowStr<'a>, 645 assertion_type: Option<CowStr<'a>>, // either none or `CLIENT_ASSERTION_TYPE_JWT_BEARER` 646 assertion: Option<CowStr<'a>>, 647} 648 649impl<'s> ClientAuth<'s> { 650 pub fn new_id(client_id: CowStr<'s>) -> Self { 651 Self { 652 client_id, 653 assertion_type: None, 654 assertion: None, 655 } 656 } 657} 658 659fn build_auth<'a>( 660 keyset: Option<&Keyset>, 661 server_metadata: &OAuthAuthorizationServerMetadata<'a>, 662 client_metadata: &OAuthClientMetadata<'a>, 663) -> Result<ClientAuth<'a>> { 664 let method_supported = server_metadata 665 .token_endpoint_auth_methods_supported 666 .as_ref(); 667 668 let client_id = client_metadata.client_id.to_cowstr().into_static(); 669 if let Some(method) = client_metadata.token_endpoint_auth_method.as_ref() { 670 match (*method).as_ref() { 671 "private_key_jwt" 672 if method_supported 673 .as_ref() 674 .is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) => 675 { 676 if let Some(keyset) = &keyset { 677 let mut algs = server_metadata 678 .token_endpoint_auth_signing_alg_values_supported 679 .clone() 680 .unwrap_or(vec![FALLBACK_ALG.into()]); 681 algs.sort_by(compare_algos); 682 let iat = Utc::now().timestamp(); 683 return Ok(ClientAuth { 684 client_id: client_id.clone(), 685 assertion_type: Some(CowStr::new_static(CLIENT_ASSERTION_TYPE_JWT_BEARER)), 686 assertion: Some( 687 keyset.create_jwt( 688 &algs, 689 // https://datatracker.ietf.org/doc/html/rfc7523#section-3 690 RegisteredClaims { 691 iss: Some(client_id.clone()), 692 sub: Some(client_id), 693 aud: Some(RegisteredClaimsAud::Single( 694 server_metadata.issuer.clone(), 695 )), 696 exp: Some(iat + 60), 697 // "iat" is required and **MUST** be less than one minute 698 // https://datatracker.ietf.org/doc/html/rfc9101 699 iat: Some(iat), 700 // atproto oauth-provider requires "jti" to be present 701 jti: Some(generate_nonce()), 702 ..Default::default() 703 } 704 .into(), 705 )?, 706 ), 707 }); 708 } 709 } 710 "none" 711 if method_supported 712 .as_ref() 713 .is_some_and(|v| v.contains(&CowStr::new_static("none"))) => 714 { 715 return Ok(ClientAuth::new_id(client_id)); 716 } 717 _ => {} 718 } 719 } 720 721 Err(RequestError::UnsupportedAuthMethod) 722}