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