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}