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