1use base64::{Engine, engine::general_purpose::STANDARD};
9use http::HeaderValue;
10use std::{sync::Arc, time::Duration};
11
12use serde::{Deserialize, Serialize};
13use volga_oauth_core::{AuthorizationServerMetadata, OAuthErrorCode};
14
15#[cfg(feature = "dpop")]
16use crate::Dpop;
17#[cfg(feature = "private-key-jwt")]
18use crate::PrivateKeyJwt;
19use crate::{
20 ClientConfig, ClientError, Pkce, TokenResponse, TokenSet, TokenStore, client_auth, grant,
21 pkce::{PKCE_METHOD, random_urlsafe},
22 transport::Transport,
23};
24
25pub(crate) const EXPIRY_LEEWAY: Duration = Duration::from_secs(30);
28
29const TOKEN_STORE_NOT_CONFIGURED: &str =
30 "OAuth client: token store is not configured; attach one with with_token_store(..)";
31
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum ClientAuthMethod {
36 #[default]
39 Basic,
40
41 Post,
44
45 #[cfg(feature = "private-key-jwt")]
56 PrivateKeyJwt(PrivateKeyJwt),
57}
58
59pub struct OAuthClient {
97 transport: Transport,
98 client_id: String,
99 client_secret: Option<String>,
100 auth_method: ClientAuthMethod,
101 redirect_uri: Option<String>,
102 store: Option<Arc<dyn TokenStore>>,
103 registered_grant_types: Option<Vec<String>>,
109 #[cfg(feature = "dpop")]
112 dpop: Option<Dpop>,
113}
114
115impl OAuthClient {
116 pub fn new(client_id: impl Into<String>) -> Self {
119 Self {
120 transport: Transport::new(ClientConfig::new()),
121 client_id: client_id.into(),
122 client_secret: None,
123 auth_method: ClientAuthMethod::default(),
124 redirect_uri: None,
125 store: None,
126 registered_grant_types: None,
127 #[cfg(feature = "dpop")]
128 dpop: None,
129 }
130 }
131
132 pub fn from_registration(
152 response: &volga_oauth_core::ClientRegistrationResponse,
153 ) -> Result<Self, ClientError> {
154 let mut client = Self::new(response.client_id.clone());
155
156 match response
158 .metadata
159 .token_endpoint_auth_method
160 .as_deref()
161 .unwrap_or(client_auth::CLIENT_SECRET_BASIC)
162 {
163 client_auth::NONE => {}
164 client_auth::CLIENT_SECRET_BASIC => {
165 if let Some(secret) = &response.client_secret {
166 client = client.with_secret(secret.clone());
167 }
168 }
169 client_auth::CLIENT_SECRET_POST => {
170 if let Some(secret) = &response.client_secret {
171 client = client
172 .with_secret(secret.clone())
173 .with_auth_method(ClientAuthMethod::Post);
174 }
175 }
176 #[cfg(feature = "private-key-jwt")]
178 client_auth::PRIVATE_KEY_JWT => {
179 return Err(ClientError::validation(
180 "registered token_endpoint_auth_method 'private_key_jwt' needs a signing \
181 key; use OAuthClient::from_registration_with_key",
182 ));
183 }
184 unsupported => {
185 return Err(ClientError::validation(format!(
186 "registered token_endpoint_auth_method '{unsupported}' is not supported; \
187 this client supports {}, {} and {}{}",
188 client_auth::CLIENT_SECRET_BASIC,
189 client_auth::CLIENT_SECRET_POST,
190 client_auth::NONE,
191 if cfg!(feature = "private-key-jwt") {
192 ", plus private_key_jwt through from_registration_with_key"
193 } else {
194 " (private_key_jwt needs the `private-key-jwt` feature)"
195 },
196 )));
197 }
198 }
199
200 Ok(client.adopt_registered_metadata(response))
201 }
202
203 #[cfg(feature = "private-key-jwt")]
217 pub fn from_registration_with_key(
218 response: &volga_oauth_core::ClientRegistrationResponse,
219 key: PrivateKeyJwt,
220 ) -> Result<Self, ClientError> {
221 if response.metadata.token_endpoint_auth_method.as_deref()
222 != Some(client_auth::PRIVATE_KEY_JWT)
223 {
224 return Self::from_registration(response);
225 }
226
227 if let Some(registered) = response.metadata.token_endpoint_auth_signing_alg.as_deref()
228 && registered != key.algorithm().as_str()
229 {
230 return Err(ClientError::validation(format!(
231 "the registration signs client assertions with {registered}, but the key \
232 supplied signs with {}",
233 key.algorithm()
234 )));
235 }
236
237 if let Some(key_id) = key.key_id() {
241 ensure_key_id_registered(response, key_id)?;
242 }
243
244 Ok(Self::new(response.client_id.clone())
245 .with_private_key_jwt(key)
246 .adopt_registered_metadata(response))
247 }
248
249 fn adopt_registered_metadata(
252 mut self,
253 response: &volga_oauth_core::ClientRegistrationResponse,
254 ) -> Self {
255 if let [redirect_uri] = response.metadata.redirect_uris.as_slice() {
256 self = self.with_redirect_uri(redirect_uri.clone());
257 }
258
259 self.registered_grant_types = Some(response.metadata.grant_types.clone());
260 self
261 }
262
263 pub(crate) fn ensure_grant_registered(&self, grant_type: &str) -> Result<(), ClientError> {
277 let Some(registered) = &self.registered_grant_types else {
278 return Ok(());
279 };
280
281 if grant_type == grant::REFRESH_TOKEN || grant::covers(registered, grant_type) {
282 return Ok(());
283 }
284
285 Err(ClientError::validation(format!(
286 "this client is not registered for the '{grant_type}' grant; its registration \
287 approved {registered:?}"
288 )))
289 }
290
291 pub fn with_config(mut self, config: ClientConfig) -> Self {
293 self.transport = Transport::new(config);
294 self
295 }
296
297 pub fn with_secret(mut self, client_secret: impl Into<String>) -> Self {
300 self.client_secret = Some(client_secret.into());
301 self
302 }
303
304 pub fn with_auth_method(mut self, method: ClientAuthMethod) -> Self {
312 self.auth_method = method;
313 self
314 }
315
316 #[cfg(feature = "private-key-jwt")]
322 pub fn with_private_key_jwt(mut self, key: PrivateKeyJwt) -> Self {
323 self.auth_method = ClientAuthMethod::PrivateKeyJwt(key);
324 self
325 }
326
327 #[cfg(feature = "dpop")]
353 pub fn with_dpop(mut self, dpop: Dpop) -> Self {
354 self.dpop = Some(dpop);
355 self
356 }
357
358 #[cfg(feature = "dpop")]
361 #[inline]
362 pub fn dpop(&self) -> Option<&Dpop> {
363 self.dpop.as_ref()
364 }
365
366 pub fn with_redirect_uri(mut self, redirect_uri: impl Into<String>) -> Self {
368 self.redirect_uri = Some(redirect_uri.into());
369 self
370 }
371
372 pub fn with_token_store(mut self, store: Arc<dyn TokenStore>) -> Self {
375 self.store = Some(store);
376 self
377 }
378
379 pub fn authorization_request<'a>(
384 &'a self,
385 metadata: &'a AuthorizationServerMetadata,
386 ) -> AuthorizationRequestBuilder<'a> {
387 AuthorizationRequestBuilder {
388 client: self,
389 metadata,
390 scopes: Vec::new(),
391 resources: Vec::new(),
392 state: None,
393 extra: Vec::new(),
394 }
395 }
396
397 pub async fn exchange_code(
404 &self,
405 metadata: &AuthorizationServerMetadata,
406 code: &str,
407 request: &AuthorizationRequest,
408 ) -> Result<TokenSet, ClientError> {
409 self.ensure_grant_registered(grant::AUTHORIZATION_CODE)?;
410
411 let endpoint = token_endpoint(metadata)?;
412 let build = || {
415 let mut form = form_urlencoded::Serializer::new(String::new());
416
417 form.append_pair("grant_type", grant::AUTHORIZATION_CODE)
418 .append_pair("code", code)
419 .append_pair("code_verifier", request.pkce.verifier());
420
421 if let Some(redirect_uri) = &self.redirect_uri {
422 form.append_pair("redirect_uri", redirect_uri);
423 }
424
425 for resource in &request.resources {
426 form.append_pair("resource", resource);
427 }
428
429 let authorization = self.apply_client_auth(&mut form, metadata)?;
430 Ok((form.finish(), authorization))
431 };
432
433 self.request_tokens(metadata, endpoint, build).await
434 }
435
436 pub async fn refresh(
442 &self,
443 metadata: &AuthorizationServerMetadata,
444 refresh_token: &str,
445 ) -> Result<TokenSet, ClientError> {
446 let endpoint = token_endpoint(metadata)?;
447 let build = || {
451 let mut form = form_urlencoded::Serializer::new(String::new());
452
453 form.append_pair("grant_type", grant::REFRESH_TOKEN)
454 .append_pair("refresh_token", refresh_token);
455
456 let authorization = self.apply_client_auth(&mut form, metadata)?;
457 Ok((form.finish(), authorization))
458 };
459
460 self.request_tokens(metadata, endpoint, build).await
461 }
462
463 pub async fn token(
480 &self,
481 key: &str,
482 metadata: &AuthorizationServerMetadata,
483 ) -> Result<Option<TokenSet>, ClientError> {
484 let store = self.token_store();
485 let Some(tokens) = store.get(key) else {
486 return Ok(None);
487 };
488
489 if !self.can_present(&tokens) {
493 store.remove(key);
494 return Ok(None);
495 }
496
497 if !tokens.expires_within(EXPIRY_LEEWAY) {
498 return Ok(Some(tokens));
499 }
500
501 let Some(refresh_token) = tokens.refresh_token else {
502 store.remove(key);
503 return Ok(None);
504 };
505
506 match self.refresh(metadata, &refresh_token).await {
507 Ok(mut fresh) => {
508 if fresh.refresh_token.is_none() {
510 fresh.refresh_token = Some(refresh_token);
511 }
512 store.put(key, &fresh);
513 Ok(Some(fresh))
514 }
515 Err(ClientError::Protocol(err)) if err.error == OAuthErrorCode::InvalidGrant => {
516 store.remove(key);
517 Ok(None)
518 }
519 Err(err) => Err(err),
520 }
521 }
522
523 pub fn store_tokens(&self, key: &str, tokens: &TokenSet) {
530 self.token_store().put(key, tokens);
531 }
532
533 pub(crate) fn token_store(&self) -> &dyn TokenStore {
539 self.store.as_deref().expect(TOKEN_STORE_NOT_CONFIGURED)
540 }
541
542 pub(crate) async fn request_tokens(
543 &self,
544 metadata: &AuthorizationServerMetadata,
545 endpoint: &str,
546 build: impl Fn() -> Result<TokenRequestParts, ClientError>,
547 ) -> Result<TokenSet, ClientError> {
548 let response: TokenResponse = self.post_token_request(metadata, endpoint, build).await?;
549
550 self.adopt_tokens(response)
551 }
552
553 pub(crate) fn adopt_tokens(&self, response: TokenResponse) -> Result<TokenSet, ClientError> {
565 #[allow(unused_mut)]
566 let mut tokens = TokenSet::from(response);
567
568 #[cfg(feature = "dpop")]
569 if let Some(dpop) = &self.dpop {
570 if !tokens.is_dpop() {
571 return Err(ClientError::validation(format!(
572 "this client requested a DPoP-bound token, but the authorization server \
573 issued one of type '{}'; it is not bound to the key and presenting it \
574 would be an unconstrained bearer credential",
575 tokens.token_type
576 )));
577 }
578
579 tokens.dpop_jkt = Some(dpop.thumbprint().to_owned());
582 }
583
584 Ok(tokens)
585 }
586
587 pub(crate) fn can_present(&self, tokens: &TokenSet) -> bool {
597 #[cfg(feature = "dpop")]
598 if let Some(dpop) = &self.dpop {
599 return tokens.is_dpop() && tokens.dpop_jkt.as_deref() == Some(dpop.thumbprint());
602 }
603
604 !tokens.is_dpop()
609 }
610
611 pub(crate) async fn post_token_request<T: serde::de::DeserializeOwned>(
622 &self,
623 metadata: &AuthorizationServerMetadata,
624 endpoint: &str,
625 build: impl Fn() -> Result<TokenRequestParts, ClientError>,
626 ) -> Result<T, ClientError> {
627 #[cfg(feature = "dpop")]
628 if let Some(dpop) = &self.dpop {
629 return self
630 .post_dpop_token_request(dpop, metadata, endpoint, build)
631 .await;
632 }
633
634 let _ = metadata;
636
637 let (body, authorization) = build()?;
638 let value = self
639 .transport
640 .post_form(endpoint, body, authorization_headers(authorization))
641 .await?
642 .into_json()?;
643
644 serde_json::from_value(value).map_err(Into::into)
645 }
646
647 #[cfg(feature = "dpop")]
657 async fn post_dpop_token_request<T: serde::de::DeserializeOwned>(
658 &self,
659 dpop: &Dpop,
660 metadata: &AuthorizationServerMetadata,
661 endpoint: &str,
662 build: impl Fn() -> Result<TokenRequestParts, ClientError>,
663 ) -> Result<T, ClientError> {
664 use crate::dpop::NonceScope;
665 use volga_oauth_core::OAuthErrorCode;
666
667 const SCOPE: NonceScope = NonceScope::AuthorizationServer;
670
671 dpop.ensure_supported(metadata)?;
675
676 let method = http::Method::POST;
679
680 let attempt = |nonce: Option<&str>| -> Result<(String, http::HeaderMap), ClientError> {
683 let (body, authorization) = build()?;
684 let mut headers = authorization_headers(authorization);
685
686 let mut proof = dpop.proof_in(SCOPE, &method, endpoint);
687 if let Some(nonce) = nonce {
688 proof = proof.with_nonce(nonce);
689 }
690 headers.insert(
691 crate::dpop::DPOP_HEADER,
692 crate::dpop::proof_header(proof.sign()?)?,
693 );
694
695 Ok((body, headers))
696 };
697
698 let sent = dpop.nonce_in(SCOPE, endpoint);
703
704 let (body, headers) = attempt(sent.as_deref())?;
705 let response = self.transport.post_form(endpoint, body, headers).await?;
706
707 let demanded = dpop.accept_nonce_in(SCOPE, endpoint, response.headers());
709
710 let retry_with = demanded.filter(|demanded| {
719 response.is_error(&OAuthErrorCode::UseDpopNonce)
720 && Some(demanded.as_str()) != sent.as_deref()
721 });
722
723 let Some(nonce) = retry_with else {
724 return serde_json::from_value(response.into_json()?).map_err(Into::into);
725 };
726
727 let (body, headers) = attempt(Some(&nonce))?;
728 let response = self.transport.post_form(endpoint, body, headers).await?;
729
730 dpop.accept_nonce_in(SCOPE, endpoint, response.headers());
734
735 serde_json::from_value(response.into_json()?).map_err(Into::into)
736 }
737
738 pub(crate) fn apply_client_auth(
748 &self,
749 form: &mut form_urlencoded::Serializer<'_, String>,
750 metadata: &AuthorizationServerMetadata,
751 ) -> Result<Option<HeaderValue>, ClientError> {
752 #[cfg(feature = "private-key-jwt")]
754 if let ClientAuthMethod::PrivateKeyJwt(key) = &self.auth_method {
755 ensure_auth_method_supported(metadata, client_auth::PRIVATE_KEY_JWT)?;
756
757 form.append_pair("client_id", &self.client_id)
758 .append_pair(
759 "client_assertion_type",
760 client_auth::ASSERTION_TYPE_JWT_BEARER,
761 )
762 .append_pair(
763 "client_assertion",
764 &key.assertion(&self.client_id, metadata)?,
765 );
766
767 return Ok(None);
768 }
769
770 Ok(match (&self.client_secret, &self.auth_method) {
771 (Some(secret), ClientAuthMethod::Basic) => {
772 ensure_auth_method_supported(metadata, client_auth::CLIENT_SECRET_BASIC)?;
773 Some(basic_credentials(&self.client_id, secret))
774 }
775 (Some(secret), ClientAuthMethod::Post) => {
776 ensure_auth_method_supported(metadata, client_auth::CLIENT_SECRET_POST)?;
777 form.append_pair("client_id", &self.client_id)
778 .append_pair("client_secret", secret);
779 None
780 }
781 _ => {
782 form.append_pair("client_id", &self.client_id);
783 None
784 }
785 })
786 }
787}
788
789impl std::fmt::Debug for OAuthClient {
790 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
791 let mut debug = f.debug_struct("OAuthClient");
792 let debug = debug
793 .field("transport", &self.transport)
794 .field("client_id", &self.client_id)
795 .field(
796 "client_secret",
797 &self.client_secret.as_ref().map(|_| "[redacted]"),
798 )
799 .field("auth_method", &self.auth_method)
800 .field("redirect_uri", &self.redirect_uri)
801 .field("store", &self.store.as_ref().map(|_| "dyn TokenStore"))
802 .field("registered_grant_types", &self.registered_grant_types);
803 #[cfg(feature = "dpop")]
804 let debug = debug.field("dpop", &self.dpop);
805 debug.finish()
806 }
807}
808
809pub struct AuthorizationRequestBuilder<'a> {
812 client: &'a OAuthClient,
813 metadata: &'a AuthorizationServerMetadata,
814 scopes: Vec<String>,
815 resources: Vec<String>,
816 state: Option<String>,
817 extra: Vec<(String, String)>,
818}
819
820impl AuthorizationRequestBuilder<'_> {
821 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
823 where
824 I: IntoIterator<Item = S>,
825 S: Into<String>,
826 {
827 self.scopes = scopes.into_iter().map(Into::into).collect();
828 self
829 }
830
831 pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
834 self.resources.push(resource.into());
835 self
836 }
837
838 pub fn with_state(mut self, state: impl Into<String>) -> Self {
841 self.state = Some(state.into());
842 self
843 }
844
845 pub fn with_param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
848 self.extra.push((name.into(), value.into()));
849 self
850 }
851
852 pub fn build(self) -> Result<AuthorizationRequest, ClientError> {
859 self.client
862 .ensure_grant_registered(grant::AUTHORIZATION_CODE)?;
863
864 let endpoint = self
865 .metadata
866 .authorization_endpoint
867 .as_deref()
868 .ok_or_else(|| {
869 ClientError::validation("server metadata declares no authorization_endpoint")
870 })?;
871
872 self.client.transport.check_scheme(endpoint)?;
873
874 let methods = &self.metadata.code_challenge_methods_supported;
875 if !methods.is_empty() && !methods.iter().any(|method| method == PKCE_METHOD) {
876 return Err(ClientError::validation(format!(
877 "authorization server does not support the {PKCE_METHOD} PKCE method"
878 )));
879 }
880
881 let pkce = Pkce::new();
882 let state = self.state.unwrap_or_else(|| random_urlsafe(16));
883
884 let mut query = form_urlencoded::Serializer::new(String::new());
885 query
886 .append_pair("response_type", "code")
887 .append_pair("client_id", &self.client.client_id)
888 .append_pair("state", &state)
889 .append_pair("code_challenge", pkce.challenge())
890 .append_pair("code_challenge_method", PKCE_METHOD);
891
892 if let Some(redirect_uri) = &self.client.redirect_uri {
893 query.append_pair("redirect_uri", redirect_uri);
894 }
895
896 #[cfg(feature = "dpop")]
901 if let Some(dpop) = &self.client.dpop {
902 query.append_pair("dpop_jkt", dpop.thumbprint());
903 }
904
905 if !self.scopes.is_empty() {
906 query.append_pair("scope", &self.scopes.join(" "));
907 }
908
909 for resource in &self.resources {
910 query.append_pair("resource", resource);
911 }
912
913 for (name, value) in &self.extra {
914 query.append_pair(name, value);
915 }
916
917 let query = query.finish();
918
919 let separator = if endpoint.contains('?') { '&' } else { '?' };
920 Ok(AuthorizationRequest {
921 url: format!("{endpoint}{separator}{query}"),
922 state,
923 pkce,
924 resources: self.resources,
925 })
926 }
927}
928
929impl std::fmt::Debug for AuthorizationRequestBuilder<'_> {
930 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
931 f.debug_struct("AuthorizationRequestBuilder")
932 .field("scopes", &self.scopes)
933 .field("resources", &self.resources)
934 .field("state", &self.state)
935 .field("extra", &self.extra)
936 .finish_non_exhaustive()
937 }
938}
939
940#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
946pub struct AuthorizationRequest {
947 pub url: String,
949
950 pub state: String,
953
954 pub pkce: Pkce,
956
957 pub resources: Vec<String>,
960}
961
962impl AuthorizationRequest {
963 #[inline]
967 pub fn matches_state(&self, state: &str) -> bool {
968 self.state == state
969 }
970
971 pub fn validate_callback(
999 &self,
1000 metadata: &AuthorizationServerMetadata,
1001 state: &str,
1002 iss: Option<&str>,
1003 ) -> Result<(), ClientError> {
1004 if !self.matches_state(state) {
1005 return Err(ClientError::validation(
1006 "authorization response `state` does not match the request",
1007 ));
1008 }
1009
1010 match (iss, metadata.authorization_response_iss_parameter_supported) {
1011 (Some(iss), _) if iss != metadata.issuer => Err(ClientError::validation(format!(
1012 "authorization response `iss` mismatch: expected {}, got {iss}",
1013 metadata.issuer
1014 ))),
1015 (None, true) => Err(ClientError::validation(
1016 "authorization server advertises RFC 9207 but the response carries no `iss`",
1017 )),
1018 _ => Ok(()),
1019 }
1020 }
1021}
1022
1023fn basic_credentials(client_id: &str, client_secret: &str) -> HeaderValue {
1026 let encode =
1027 |value: &str| -> String { form_urlencoded::byte_serialize(value.as_bytes()).collect() };
1028
1029 let credentials = STANDARD.encode(format!("{}:{}", encode(client_id), encode(client_secret)));
1030
1031 HeaderValue::from_str(&format!("Basic {credentials}"))
1032 .expect("base64 output is always a valid header value")
1033}
1034
1035#[cfg(feature = "private-key-jwt")]
1043fn ensure_key_id_registered(
1044 response: &volga_oauth_core::ClientRegistrationResponse,
1045 key_id: &str,
1046) -> Result<(), ClientError> {
1047 let Some(keys) = response
1050 .metadata
1051 .jwks
1052 .as_ref()
1053 .and_then(|jwks| jwks.get("keys"))
1054 .and_then(|keys| keys.as_array())
1055 else {
1056 return Ok(());
1057 };
1058
1059 let named: Vec<&str> = keys
1060 .iter()
1061 .filter_map(|key| key.get("kid")?.as_str())
1062 .collect();
1063
1064 if named.contains(&key_id) {
1065 return Ok(());
1066 }
1067
1068 if named.is_empty() && keys.len() <= 1 {
1074 return Ok(());
1075 }
1076
1077 Err(ClientError::validation(format!(
1078 "the key is identified by '{key_id}', which the registered JWK Set cannot resolve; \
1079 it published {named:?} across {} keys",
1080 keys.len()
1081 )))
1082}
1083
1084fn ensure_auth_method_supported(
1094 metadata: &AuthorizationServerMetadata,
1095 method: &str,
1096) -> Result<(), ClientError> {
1097 let advertised = &metadata.token_endpoint_auth_methods_supported;
1098 if advertised.is_empty() || advertised.iter().any(|candidate| candidate == method) {
1099 return Ok(());
1100 }
1101
1102 Err(ClientError::validation(format!(
1103 "authorization server does not accept {method} at the token endpoint; \
1104 it advertises {advertised:?}"
1105 )))
1106}
1107
1108pub(crate) type TokenRequestParts = (String, Option<HeaderValue>);
1111
1112fn authorization_headers(authorization: Option<HeaderValue>) -> http::HeaderMap {
1114 let mut headers = http::HeaderMap::new();
1115 if let Some(authorization) = authorization {
1116 headers.insert(http::header::AUTHORIZATION, authorization);
1117 }
1118 headers
1119}
1120
1121pub(crate) fn token_endpoint(metadata: &AuthorizationServerMetadata) -> Result<&str, ClientError> {
1122 metadata
1123 .token_endpoint
1124 .as_deref()
1125 .ok_or_else(|| ClientError::validation("server metadata declares no token_endpoint"))
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131
1132 fn metadata() -> AuthorizationServerMetadata {
1133 let mut metadata = AuthorizationServerMetadata::new("https://auth.example.com");
1134 metadata.authorization_endpoint = Some("https://auth.example.com/authorize".into());
1135 metadata.token_endpoint = Some("https://auth.example.com/token".into());
1136 metadata
1137 }
1138
1139 fn query_pairs(url: &str) -> Vec<(String, String)> {
1140 let query = url.split_once('?').unwrap().1;
1141 form_urlencoded::parse(query.as_bytes())
1142 .into_owned()
1143 .collect()
1144 }
1145
1146 #[test]
1147 fn it_builds_a_spec_compliant_authorization_url() {
1148 let client =
1149 OAuthClient::new("my-client").with_redirect_uri("https://app.example.com/callback");
1150 let request = client
1151 .authorization_request(&metadata())
1152 .with_scopes(["read", "write"])
1153 .with_resource("https://api.example.com")
1154 .with_param("nonce", "n-1")
1155 .build()
1156 .unwrap();
1157
1158 assert!(
1159 request
1160 .url
1161 .starts_with("https://auth.example.com/authorize?")
1162 );
1163 let pairs = query_pairs(&request.url);
1164 let get = |name: &str| {
1165 pairs
1166 .iter()
1167 .find(|(key, _)| key == name)
1168 .map(|(_, value)| value.as_str())
1169 };
1170 assert_eq!(get("response_type"), Some("code"));
1171 assert_eq!(get("client_id"), Some("my-client"));
1172 assert_eq!(
1173 get("redirect_uri"),
1174 Some("https://app.example.com/callback")
1175 );
1176 assert_eq!(get("scope"), Some("read write"));
1177 assert_eq!(get("resource"), Some("https://api.example.com"));
1178 assert_eq!(get("code_challenge"), Some(request.pkce.challenge()));
1179 assert_eq!(get("code_challenge_method"), Some("S256"));
1180 assert_eq!(get("state"), Some(request.state.as_str()));
1181 assert_eq!(get("nonce"), Some("n-1"));
1182 assert!(request.matches_state(&request.state.clone()));
1183 assert!(!request.matches_state("other"));
1184 }
1185
1186 #[cfg(feature = "dpop")]
1187 #[test]
1188 fn it_binds_the_authorization_code_to_the_dpop_key() {
1189 let dpop = crate::Dpop::generate().unwrap();
1190 let client = OAuthClient::new("my-client").with_dpop(dpop.clone());
1191 let request = client.authorization_request(&metadata()).build().unwrap();
1192
1193 let pairs = query_pairs(&request.url);
1196 assert_eq!(
1197 pairs
1198 .iter()
1199 .find(|(key, _)| key == "dpop_jkt")
1200 .map(|(_, value)| value.as_str()),
1201 Some(dpop.thumbprint())
1202 );
1203
1204 let request = OAuthClient::new("my-client")
1206 .authorization_request(&metadata())
1207 .build()
1208 .unwrap();
1209 assert!(!request.url.contains("dpop_jkt"));
1210 }
1211
1212 #[test]
1213 fn it_appends_to_an_existing_query_and_respects_custom_state() {
1214 let mut metadata = metadata();
1215 metadata.authorization_endpoint =
1216 Some("https://auth.example.com/authorize?tenant=t1".into());
1217 let request = OAuthClient::new("my-client")
1218 .authorization_request(&metadata)
1219 .with_state("custom-state")
1220 .build()
1221 .unwrap();
1222 assert!(request.url.contains("tenant=t1&response_type=code"));
1223 assert_eq!(request.state, "custom-state");
1224 }
1225
1226 #[test]
1227 fn it_validates_metadata_before_building_requests() {
1228 let client = OAuthClient::new("my-client");
1229
1230 let mut incomplete = metadata();
1231 incomplete.authorization_endpoint = None;
1232 assert!(matches!(
1233 client.authorization_request(&incomplete).build(),
1234 Err(ClientError::Validation(reason)) if reason.contains("authorization_endpoint")
1235 ));
1236
1237 let mut plain_only = metadata();
1238 plain_only.code_challenge_methods_supported = vec!["plain".into()];
1239 assert!(matches!(
1240 client.authorization_request(&plain_only).build(),
1241 Err(ClientError::Validation(reason)) if reason.contains("S256")
1242 ));
1243
1244 let mut insecure = metadata();
1246 insecure.authorization_endpoint = Some("http://auth.example.com/authorize".into());
1247 assert!(matches!(
1248 client.authorization_request(&insecure).build(),
1249 Err(ClientError::InsecureUrl(_))
1250 ));
1251 }
1252
1253 #[test]
1254 fn it_encodes_basic_credentials_per_rfc6749() {
1255 let header = basic_credentials("client with space", "s&cret");
1257 let encoded = header
1258 .to_str()
1259 .unwrap()
1260 .strip_prefix("Basic ")
1261 .unwrap()
1262 .to_owned();
1263 let decoded = String::from_utf8(STANDARD.decode(encoded).unwrap()).unwrap();
1264 assert_eq!(decoded, "client+with+space:s%26cret");
1265 }
1266
1267 #[test]
1268 fn it_applies_the_configured_client_authentication() {
1269 let metadata = metadata();
1270 let apply = |client: &OAuthClient| {
1271 let mut form = form_urlencoded::Serializer::new(String::new());
1272 let authorization = client.apply_client_auth(&mut form, &metadata).unwrap();
1273 (authorization, form.finish())
1274 };
1275
1276 let (authorization, body) = apply(&OAuthClient::new("my-client"));
1277 assert!(authorization.is_none());
1278 assert_eq!(body, "client_id=my-client");
1279
1280 let (authorization, body) = apply(&OAuthClient::new("my-client").with_secret("s3cret"));
1281 assert!(authorization.is_some());
1282 assert_eq!(body, "");
1283
1284 let (authorization, body) = apply(
1285 &OAuthClient::new("my-client")
1286 .with_secret("s3cret")
1287 .with_auth_method(ClientAuthMethod::Post),
1288 );
1289 assert!(authorization.is_none());
1290 assert_eq!(body, "client_id=my-client&client_secret=s3cret");
1291 }
1292
1293 #[cfg(feature = "private-key-jwt")]
1294 #[test]
1295 fn it_authenticates_with_a_signed_client_assertion() {
1296 let client = OAuthClient::new("my-client")
1299 .with_secret("s3cret")
1300 .with_private_key_jwt(crate::assertion::test_key());
1301
1302 let metadata = metadata();
1303 let mut form = form_urlencoded::Serializer::new(String::new());
1304 let authorization = client.apply_client_auth(&mut form, &metadata).unwrap();
1305 assert!(authorization.is_none());
1306
1307 let body = form.finish();
1308 let pairs: Vec<_> = form_urlencoded::parse(body.as_bytes())
1309 .into_owned()
1310 .collect();
1311 let get = |name: &str| {
1312 pairs
1313 .iter()
1314 .find(|(key, _)| key == name)
1315 .map(|(_, value)| value.clone())
1316 };
1317 assert_eq!(get("client_id").as_deref(), Some("my-client"));
1318 assert_eq!(
1319 get("client_assertion_type").as_deref(),
1320 Some(client_auth::ASSERTION_TYPE_JWT_BEARER)
1321 );
1322 assert_eq!(get("client_secret"), None);
1323 assert_eq!(
1324 get("client_assertion").unwrap().split('.').count(),
1325 3,
1326 "the assertion must be a compact JWS"
1327 );
1328 }
1329
1330 #[test]
1331 fn it_refuses_an_unadvertised_client_authentication_method() {
1332 let apply = |client: &OAuthClient, metadata: &AuthorizationServerMetadata| {
1333 let mut form = form_urlencoded::Serializer::new(String::new());
1334 client.apply_client_auth(&mut form, metadata).map(|_| ())
1335 };
1336 let advertising = |methods: &[&str]| {
1337 let mut metadata = metadata();
1338 metadata.token_endpoint_auth_methods_supported =
1339 methods.iter().map(|method| (*method).to_owned()).collect();
1340 metadata
1341 };
1342
1343 let basic = OAuthClient::new("my-client").with_secret("s3cret");
1344 let post = OAuthClient::new("my-client")
1345 .with_secret("s3cret")
1346 .with_auth_method(ClientAuthMethod::Post);
1347 #[cfg(feature = "private-key-jwt")]
1348 let assertion =
1349 OAuthClient::new("my-client").with_private_key_jwt(crate::assertion::test_key());
1350
1351 #[allow(unused_mut)]
1352 let mut clients = vec![
1353 (&basic, client_auth::CLIENT_SECRET_BASIC),
1354 (&post, client_auth::CLIENT_SECRET_POST),
1355 ];
1356 #[cfg(feature = "private-key-jwt")]
1357 clients.push((&assertion, client_auth::PRIVATE_KEY_JWT));
1358
1359 for (client, method) in clients {
1360 let others = advertising(&["client_secret_jwt", "tls_client_auth"]);
1362 let err = apply(client, &others).unwrap_err();
1363 assert!(
1364 matches!(&err, ClientError::Validation(reason) if reason.contains(method)),
1365 "{method}: {err}"
1366 );
1367
1368 assert!(apply(client, &advertising(&[method])).is_ok(), "{method}");
1370
1371 assert!(apply(client, &metadata()).is_ok(), "{method}");
1374 }
1375
1376 let discovered: AuthorizationServerMetadata = serde_json::from_value(serde_json::json!({
1379 "issuer": "https://auth.example.com",
1380 "response_types_supported": ["code"],
1381 }))
1382 .unwrap();
1383 assert!(apply(&basic, &discovered).is_ok());
1384 assert!(apply(&post, &discovered).is_err());
1385
1386 assert!(apply(&OAuthClient::new("my-client"), &discovered).is_ok());
1389 }
1390
1391 #[cfg(feature = "private-key-jwt")]
1392 #[test]
1393 fn it_reports_a_failing_client_assertion() {
1394 let mut metadata = metadata();
1395 metadata.token_endpoint_auth_signing_alg_values_supported = vec!["RS256".into()];
1396
1397 let client =
1398 OAuthClient::new("my-client").with_private_key_jwt(crate::assertion::test_key());
1399 let mut form = form_urlencoded::Serializer::new(String::new());
1400 assert!(matches!(
1401 client.apply_client_auth(&mut form, &metadata),
1402 Err(ClientError::Validation(reason)) if reason.contains("ES256")
1403 ));
1404 }
1405
1406 #[test]
1407 fn it_adopts_registered_credentials_per_auth_method() {
1408 let registration = |auth_method: serde_json::Value| {
1409 serde_json::from_value::<volga_oauth_core::ClientRegistrationResponse>(
1410 serde_json::json!({
1411 "client_id": "generated-id",
1412 "client_secret": "generated-secret",
1413 "token_endpoint_auth_method": auth_method,
1414 "redirect_uris": ["https://app.example.com/callback"]
1415 }),
1416 )
1417 .unwrap()
1418 };
1419
1420 let client =
1422 OAuthClient::from_registration(®istration(serde_json::Value::Null)).unwrap();
1423 assert_eq!(client.auth_method, ClientAuthMethod::Basic);
1424 assert_eq!(client.client_secret.as_deref(), Some("generated-secret"));
1425 assert_eq!(
1426 client.redirect_uri.as_deref(),
1427 Some("https://app.example.com/callback")
1428 );
1429
1430 let client =
1431 OAuthClient::from_registration(®istration("client_secret_post".into())).unwrap();
1432 assert_eq!(client.auth_method, ClientAuthMethod::Post);
1433 assert_eq!(client.client_secret.as_deref(), Some("generated-secret"));
1434
1435 let client = OAuthClient::from_registration(®istration("none".into())).unwrap();
1438 assert_eq!(client.client_secret, None);
1439
1440 let err =
1443 OAuthClient::from_registration(®istration("client_secret_jwt".into())).unwrap_err();
1444 assert!(matches!(
1445 err,
1446 ClientError::Validation(reason) if reason.contains("client_secret_jwt")
1447 ));
1448
1449 let err =
1452 OAuthClient::from_registration(®istration("private_key_jwt".into())).unwrap_err();
1453 let expected = if cfg!(feature = "private-key-jwt") {
1454 "from_registration_with_key"
1455 } else {
1456 "private-key-jwt"
1457 };
1458 assert!(
1459 matches!(&err, ClientError::Validation(reason) if reason.contains(expected)),
1460 "got: {err}"
1461 );
1462 }
1463
1464 #[cfg(feature = "private-key-jwt")]
1465 #[test]
1466 fn it_adopts_a_registration_signing_with_a_key() {
1467 let registration = |auth_method: &str| {
1468 serde_json::from_value::<volga_oauth_core::ClientRegistrationResponse>(
1469 serde_json::json!({
1470 "client_id": "generated-id",
1471 "client_secret": "generated-secret",
1472 "token_endpoint_auth_method": auth_method,
1473 "redirect_uris": ["https://app.example.com/callback"]
1474 }),
1475 )
1476 .unwrap()
1477 };
1478
1479 let key = crate::assertion::test_key();
1480 let client =
1481 OAuthClient::from_registration_with_key(®istration("private_key_jwt"), key.clone())
1482 .unwrap();
1483 assert_eq!(client.auth_method, ClientAuthMethod::PrivateKeyJwt(key));
1484 assert_eq!(client.client_secret, None);
1485 assert_eq!(
1486 client.redirect_uri.as_deref(),
1487 Some("https://app.example.com/callback")
1488 );
1489
1490 let client = OAuthClient::from_registration_with_key(
1493 ®istration("client_secret_post"),
1494 crate::assertion::test_key(),
1495 )
1496 .unwrap();
1497 assert_eq!(client.auth_method, ClientAuthMethod::Post);
1498 assert_eq!(client.client_secret.as_deref(), Some("generated-secret"));
1499 }
1500
1501 #[cfg(feature = "private-key-jwt")]
1502 #[test]
1503 fn it_honors_the_registered_client_assertion_algorithm() {
1504 let registration = |signing_alg: serde_json::Value| {
1505 serde_json::from_value::<volga_oauth_core::ClientRegistrationResponse>(
1506 serde_json::json!({
1507 "client_id": "generated-id",
1508 "token_endpoint_auth_method": "private_key_jwt",
1509 "token_endpoint_auth_signing_alg": signing_alg,
1510 }),
1511 )
1512 .unwrap()
1513 };
1514
1515 let err = OAuthClient::from_registration_with_key(
1519 ®istration("RS256".into()),
1520 crate::assertion::test_key(),
1521 )
1522 .unwrap_err();
1523 assert!(
1524 matches!(&err, ClientError::Validation(reason)
1525 if reason.contains("RS256") && reason.contains("ES256")),
1526 "got: {err}"
1527 );
1528
1529 for signing_alg in [serde_json::json!("ES256"), serde_json::Value::Null] {
1531 assert!(
1532 OAuthClient::from_registration_with_key(
1533 ®istration(signing_alg.clone()),
1534 crate::assertion::test_key(),
1535 )
1536 .is_ok(),
1537 "refused {signing_alg}"
1538 );
1539 }
1540 }
1541
1542 #[cfg(feature = "private-key-jwt")]
1543 #[test]
1544 fn it_matches_the_key_against_the_registered_jwk_set() {
1545 let registration = |jwks: serde_json::Value| {
1546 serde_json::from_value::<volga_oauth_core::ClientRegistrationResponse>(
1547 serde_json::json!({
1548 "client_id": "generated-id",
1549 "token_endpoint_auth_method": "private_key_jwt",
1550 "jwks": jwks,
1551 }),
1552 )
1553 .unwrap()
1554 };
1555 let published = |kid: &str| serde_json::json!({ "keys": [{ "kty": "EC", "kid": kid }] });
1556 let with_key_id = || crate::assertion::test_key().with_key_id("2026-08");
1557
1558 let err = OAuthClient::from_registration_with_key(
1561 ®istration(published("other")),
1562 with_key_id(),
1563 )
1564 .unwrap_err();
1565 assert!(
1566 matches!(&err, ClientError::Validation(reason)
1567 if reason.contains("2026-08") && reason.contains("other")),
1568 "got: {err}"
1569 );
1570
1571 assert!(
1573 OAuthClient::from_registration_with_key(
1574 ®istration(published("2026-08")),
1575 with_key_id()
1576 )
1577 .is_ok()
1578 );
1579
1580 for jwks in [
1583 serde_json::json!({ "keys": [{ "kty": "EC", "kid": "other" }, { "kty": "RSA" }] }),
1584 serde_json::json!({ "keys": [{ "kty": "EC" }, { "kty": "RSA" }] }),
1586 ] {
1587 assert!(
1588 OAuthClient::from_registration_with_key(®istration(jwks.clone()), with_key_id())
1589 .is_err(),
1590 "accepted {jwks}"
1591 );
1592 }
1593
1594 assert!(
1597 OAuthClient::from_registration_with_key(
1598 ®istration(published("other")),
1599 crate::assertion::test_key(),
1600 )
1601 .is_ok()
1602 );
1603 for jwks in [
1604 serde_json::json!({ "keys": [{ "kty": "EC" }] }),
1607 serde_json::json!({ "keys": [] }),
1608 serde_json::Value::Null,
1610 ] {
1611 assert!(
1612 OAuthClient::from_registration_with_key(®istration(jwks.clone()), with_key_id())
1613 .is_ok(),
1614 "refused {jwks}"
1615 );
1616 }
1617 }
1618
1619 #[test]
1620 fn it_refuses_a_grant_the_registration_did_not_approve() {
1621 let registration = |grant_types: serde_json::Value| {
1622 serde_json::from_value::<volga_oauth_core::ClientRegistrationResponse>(
1623 serde_json::json!({
1624 "client_id": "generated-id",
1625 "client_secret": "generated-secret",
1626 "grant_types": grant_types,
1627 }),
1628 )
1629 .unwrap()
1630 };
1631
1632 let client = OAuthClient::from_registration(®istration(serde_json::json!([
1633 "client_credentials"
1634 ])))
1635 .unwrap();
1636 assert!(
1637 client
1638 .ensure_grant_registered(grant::CLIENT_CREDENTIALS)
1639 .is_ok()
1640 );
1641
1642 let err = client
1643 .ensure_grant_registered(grant::JWT_BEARER)
1644 .unwrap_err();
1645 assert!(
1646 matches!(&err, ClientError::Validation(reason)
1647 if reason.contains("jwt-bearer") && reason.contains("client_credentials")),
1648 "got: {err}"
1649 );
1650
1651 assert!(
1654 client
1655 .ensure_grant_registered(grant::AUTHORIZATION_CODE)
1656 .is_err()
1657 );
1658
1659 let defaulted =
1662 OAuthClient::from_registration(®istration(serde_json::json!([]))).unwrap();
1663 assert!(
1664 defaulted
1665 .ensure_grant_registered(grant::AUTHORIZATION_CODE)
1666 .is_ok()
1667 );
1668 assert!(
1669 defaulted
1670 .ensure_grant_registered(grant::CLIENT_CREDENTIALS)
1671 .is_err()
1672 );
1673
1674 for approved in [
1677 serde_json::json!([]),
1678 serde_json::json!(["client_credentials"]),
1679 ] {
1680 let client = OAuthClient::from_registration(®istration(approved)).unwrap();
1681 assert!(client.ensure_grant_registered(grant::REFRESH_TOKEN).is_ok());
1682 }
1683
1684 assert!(
1686 OAuthClient::new("my-client")
1687 .ensure_grant_registered(grant::JWT_BEARER)
1688 .is_ok()
1689 );
1690 }
1691
1692 #[test]
1693 fn it_returns_send_token_endpoint_futures() {
1694 fn assert_send(_: impl Send) {}
1698
1699 let client = OAuthClient::new("my-client")
1700 .with_token_store(Arc::new(crate::InMemoryTokenStore::default()));
1701 let metadata = metadata();
1702 let request = client.authorization_request(&metadata).build().unwrap();
1703
1704 assert_send(client.exchange_code(&metadata, "the-code", &request));
1705 assert_send(client.refresh(&metadata, "the-refresh-token"));
1706 assert_send(client.token("alice", &metadata));
1707 }
1708
1709 #[test]
1710 fn it_validates_the_authorization_callback() {
1711 let client = OAuthClient::new("my-client");
1712 let metadata = metadata();
1713 let request = client.authorization_request(&metadata).build().unwrap();
1714 let state = request.state.clone();
1715
1716 assert!(request.validate_callback(&metadata, &state, None).is_ok());
1718 assert!(
1719 request
1720 .validate_callback(&metadata, &state, Some(&metadata.issuer))
1721 .is_ok()
1722 );
1723
1724 let err = request
1726 .validate_callback(&metadata, "forged", None)
1727 .unwrap_err();
1728 assert!(matches!(err, ClientError::Validation(reason) if reason.contains("state")));
1729
1730 let err = request
1732 .validate_callback(&metadata, &state, Some("https://evil.example.com"))
1733 .unwrap_err();
1734 assert!(
1735 matches!(err, ClientError::Validation(reason) if reason.contains("`iss` mismatch"))
1736 );
1737
1738 let advertised = metadata.with_authorization_response_iss_parameter(true);
1740 assert!(
1741 request
1742 .validate_callback(&advertised, &state, Some(&advertised.issuer))
1743 .is_ok()
1744 );
1745 let err = request
1746 .validate_callback(&advertised, &state, None)
1747 .unwrap_err();
1748 assert!(matches!(err, ClientError::Validation(reason) if reason.contains("RFC 9207")));
1749 }
1750
1751 fn stored(token_type: &str, jkt: Option<&str>) -> TokenSet {
1753 TokenSet {
1754 access_token: "at".into(),
1755 token_type: token_type.into(),
1756 refresh_token: None,
1757 scope: None,
1758 id_token: None,
1759 expires_at: None,
1760 dpop_jkt: jkt.map(ToOwned::to_owned),
1761 }
1762 }
1763
1764 #[test]
1765 fn it_refuses_a_bound_token_with_no_key_to_present_it() {
1766 let client = OAuthClient::new("my-client");
1771 assert!(client.can_present(&stored("Bearer", None)));
1772 assert!(!client.can_present(&stored("DPoP", Some("jkt"))));
1773 assert!(!client.can_present(&stored("DPoP", None)));
1774 }
1775
1776 #[cfg(feature = "dpop")]
1777 #[test]
1778 fn it_judges_whether_a_stored_entry_can_still_be_presented() {
1779 let dpop = crate::Dpop::generate().unwrap();
1780 let client = OAuthClient::new("my-client").with_dpop(dpop.clone());
1781
1782 assert!(client.can_present(&stored("DPoP", Some(dpop.thumbprint()))));
1786 assert!(!client.can_present(&stored("Bearer", None)));
1787 assert!(!client.can_present(&stored("DPoP", Some("some-other-key"))));
1791 assert!(!client.can_present(&stored("DPoP", None)));
1792 }
1793
1794 #[test]
1795 #[should_panic(expected = "token store is not configured")]
1796 fn it_panics_on_store_access_without_a_store() {
1797 OAuthClient::new("my-client").store_tokens(
1798 "alice",
1799 &TokenSet {
1800 access_token: "at".into(),
1801 token_type: "Bearer".into(),
1802 refresh_token: None,
1803 scope: None,
1804 id_token: None,
1805 expires_at: None,
1806 dpop_jkt: None,
1807 },
1808 );
1809 }
1810}