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}