Skip to main content

volga_oauth_client/
client.rs

1//! OAuth 2.1 client
2//!
3//! [`OAuthClient`] implements the Authorization Code flow with mandatory
4//! PKCE, refresh tokens and resource indicators (RFC 8707) on top of
5//! server metadata - typically discovered with
6//! [`DiscoveryClient`](crate::DiscoveryClient).
7
8use 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
25/// How early before its expiration a stored access token is considered
26/// stale by [`OAuthClient::token`] and refreshed
27pub(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/// How a confidential client authenticates to the token endpoint
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum ClientAuthMethod {
36    /// `client_secret_basic` - HTTP Basic authentication (RFC 6749
37    /// Section 2.3.1), the default and the method servers are required to support
38    #[default]
39    Basic,
40
41    /// `client_secret_post` - credentials in the request body, for
42    /// servers that do not accept HTTP Basic authentication
43    Post,
44
45    /// `private_key_jwt` - a client assertion signed with the client's own
46    /// key (RFC 7523 Section 2.2), so no shared secret ever leaves it
47    ///
48    /// Set through
49    /// [`OAuthClient::with_private_key_jwt`](OAuthClient::with_private_key_jwt);
50    /// unlike the two secret-based methods it needs no
51    /// [`with_secret`](OAuthClient::with_secret).
52    ///
53    /// Requires the `private-key-jwt` feature, the only part of this crate
54    /// that needs a JWS signing backend.
55    #[cfg(feature = "private-key-jwt")]
56    PrivateKeyJwt(PrivateKeyJwt),
57}
58
59/// OAuth 2.1 client for the Authorization Code + PKCE flow
60///
61/// Without a secret the client acts as a public client (PKCE is the
62/// protection, as OAuth 2.1 prescribes); with one it authenticates to the
63/// token endpoint per the configured [`ClientAuthMethod`].
64///
65/// # Example
66/// ```no_run
67/// use std::sync::Arc;
68/// use volga_oauth_client::{DiscoveryClient, InMemoryTokenStore, OAuthClient};
69///
70/// # async fn run() -> Result<(), volga_oauth_client::ClientError> {
71/// let metadata = DiscoveryClient::new()
72///     .fetch_server_metadata("https://auth.example.com")
73///     .await?;
74///
75/// let client = OAuthClient::new("my-client")
76///     .with_redirect_uri("https://app.example.com/callback")
77///     .with_token_store(Arc::new(InMemoryTokenStore::new()));
78///
79/// let auth = client
80///     .authorization_request(&metadata)
81///     .with_scopes(["read"])
82///     .with_resource("https://api.example.com")
83///     .build()?;
84///
85/// // send the user to `auth.url`; then, in the redirect callback:
86/// # let (code, state) = ("code", "state");
87/// assert!(auth.matches_state(state));
88/// let tokens = client.exchange_code(&metadata, code, &auth).await?;
89/// client.store_tokens("alice", &tokens);
90///
91/// // later - served from the store, transparently refreshed when stale:
92/// let tokens = client.token("alice", &metadata).await?;
93/// # Ok(())
94/// # }
95/// ```
96pub 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    /// The `grant_types` of the registration this client was built from,
104    /// as returned - `None` when it never went through one, which is the
105    /// only case that constrains nothing. An empty list is *not* that: per
106    /// RFC 7591 Section 2 it means `authorization_code` alone, which
107    /// [`grant::covers`] applies.
108    registered_grant_types: Option<Vec<String>>,
109    /// The key token requests are bound to, when this client presents DPoP
110    /// proofs (RFC 9449)
111    #[cfg(feature = "dpop")]
112    dpop: Option<Dpop>,
113}
114
115impl OAuthClient {
116    /// Creates a public client with the given `client_id` and the default
117    /// [`ClientConfig`]
118    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    /// Creates a client from a Dynamic Client Registration response
133    /// (RFC 7591), adopting the issued credentials
134    ///
135    /// The registered `token_endpoint_auth_method` selects the
136    /// [`ClientAuthMethod`] (`client_secret_basic` when omitted, per
137    /// RFC 7591 Section 2) and, when exactly one `redirect_uri` was registered,
138    /// it becomes the client's redirect URI.
139    ///
140    /// Fails with [`ClientError::Validation`] when the registered method
141    /// is one this client cannot perform (e.g. `client_secret_jwt`) -
142    /// authenticating differently from the registration would only yield
143    /// `invalid_client` at the token endpoint. A registration with `none`
144    /// produces a public client; a secret issued alongside it is ignored,
145    /// since that method sends no credentials.
146    ///
147    /// A registration for `private_key_jwt` is rejected here because this
148    /// constructor is given no key to sign assertions with - pass one to
149    /// `from_registration_with_key` instead, which the `private-key-jwt`
150    /// feature enables.
151    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        // an omitted method defaults to client_secret_basic (RFC 7591 Section 2)
157        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            // performable only with a key to sign the assertions with
177            #[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    /// Creates a client from a Dynamic Client Registration response that
204    /// registered `private_key_jwt`, signing its assertions with `key`
205    ///
206    /// Behaves exactly like [`from_registration`](Self::from_registration)
207    /// for every other registered method - `key` is then simply unused,
208    /// since sending an assertion the registration did not announce would
209    /// only yield `invalid_client`.
210    ///
211    /// Fails with [`ClientError::Validation`] when the registration pins
212    /// `token_endpoint_auth_signing_alg` and `key` signs with something
213    /// else. That field is per-client and narrower than the server-wide
214    /// list an assertion is checked against, so a key disagreeing with it
215    /// would satisfy discovery and still be refused at the token endpoint.
216    #[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        // when the registration inlines the client's JWK Set, the server
238        // resolves the assertion's `kid` in it - a key it cannot resolve is
239        // refused at the token endpoint, whatever it signs
240        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    /// Adopts what the registration approved: the redirect URI when exactly
250    /// one was registered, and the grant types when it named any.
251    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    /// Refuses a grant this client's registration did not approve.
264    ///
265    /// The token endpoint answers `unauthorized_client` for one it did not,
266    /// and that is a harder failure to read than this. Only a client that
267    /// never went through a registration is unconstrained - an omitted
268    /// `grant_types` is `authorization_code` alone, not carte blanche
269    /// (RFC 7591 Section 2, applied by [`grant::covers`]).
270    ///
271    /// `refresh_token` is exempt. RFC 6749 Section 6 makes it the
272    /// continuation of a grant the client already holds rather than a
273    /// separate authorization, and servers routinely issue refresh tokens
274    /// without naming the grant in a registration - refusing it here would
275    /// break those clients for a check the token endpoint never applies.
276    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    /// Replaces the transport configuration
292    pub fn with_config(mut self, config: ClientConfig) -> Self {
293        self.transport = Transport::new(config);
294        self
295    }
296
297    /// Makes this a confidential client authenticating to the token
298    /// endpoint with `client_secret`
299    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    /// Sets how the client authenticates to the token endpoint;
305    /// [`ClientAuthMethod::Basic`] by default
306    ///
307    /// The two secret-based methods are ignored without a secret - the
308    /// client then stays public. `ClientAuthMethod::PrivateKeyJwt` carries
309    /// its own credential and applies on its own; prefer
310    /// `with_private_key_jwt` for it (feature `private-key-jwt`).
311    pub fn with_auth_method(mut self, method: ClientAuthMethod) -> Self {
312        self.auth_method = method;
313        self
314    }
315
316    /// Makes this a confidential client authenticating with a
317    /// `private_key_jwt` client assertion (RFC 7523 Section 2.2)
318    ///
319    /// Supersedes any [`with_secret`](Self::with_secret): the assertion is
320    /// the credential, and no secret is sent alongside it.
321    #[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    /// Binds the tokens this client obtains to `dpop` (RFC 9449)
328    ///
329    /// Every token request then carries a DPoP proof, including the nonce
330    /// round an authorization server may demand (RFC 9449 Section 8), and
331    /// the tokens come back with `token_type: DPoP` - see
332    /// [`TokenSet::is_dpop`]. Requests to the *resource* are the caller's
333    /// to make: hold on to the [`Dpop`] (it is cheap to clone and shares
334    /// its state) and protect them with
335    /// [`Dpop::authorize`](crate::Dpop::authorize).
336    ///
337    /// This is orthogonal to client authentication: a DPoP proof says who
338    /// holds the token, a client credential says who asked for it, and a
339    /// request can carry both.
340    ///
341    /// # Example
342    /// ```no_run
343    /// # use volga_oauth_client::{ClientError, Dpop, OAuthClient};
344    /// # fn run() -> Result<(), ClientError> {
345    /// let dpop = Dpop::generate()?;
346    /// let client = OAuthClient::new("my-client")
347    ///     .with_secret("s3cret")
348    ///     .with_dpop(dpop.clone());
349    /// # Ok(())
350    /// # }
351    /// ```
352    #[cfg(feature = "dpop")]
353    pub fn with_dpop(mut self, dpop: Dpop) -> Self {
354        self.dpop = Some(dpop);
355        self
356    }
357
358    /// Returns the DPoP key this client binds its tokens to, if any
359    /// (see [`with_dpop`](Self::with_dpop))
360    #[cfg(feature = "dpop")]
361    #[inline]
362    pub fn dpop(&self) -> Option<&Dpop> {
363        self.dpop.as_ref()
364    }
365
366    /// Sets the `redirect_uri` sent in authorization and token requests
367    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    /// Attaches a [`TokenStore`] enabling [`token`](Self::token) and
373    /// [`store_tokens`](Self::store_tokens)
374    pub fn with_token_store(mut self, store: Arc<dyn TokenStore>) -> Self {
375        self.store = Some(store);
376        self
377    }
378
379    /// Starts building an authorization request against `metadata`
380    ///
381    /// [`AuthorizationRequestBuilder::build`] produces the URL to send the
382    /// user to, along with the generated `state` and PKCE pair.
383    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    /// Exchanges an authorization `code` for tokens (RFC 6749 Section 4.1.3)
398    ///
399    /// `request` is the [`AuthorizationRequest`] the code was obtained
400    /// with: it supplies the PKCE verifier and repeats the requested
401    /// resource indicators. Verify the callback `state` with
402    /// [`AuthorizationRequest::matches_state`] before calling this.
403    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        // the serializer is not `Sync`: confining it to this closure keeps
413        // it out of the future's state, so the future stays `Send`
414        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    /// Obtains fresh tokens with a refresh token (RFC 6749 Section 6)
437    ///
438    /// The server may rotate the refresh token; when the response carries
439    /// none, the one passed in remains valid - [`token`](Self::token)
440    /// handles that carry-over automatically.
441    pub async fn refresh(
442        &self,
443        metadata: &AuthorizationServerMetadata,
444        refresh_token: &str,
445    ) -> Result<TokenSet, ClientError> {
446        let endpoint = token_endpoint(metadata)?;
447        // confined to the closure so the non-`Sync` serializer never lives
448        // across an await: a future holding it would be `!Send` and could
449        // not be spawned
450        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    /// Returns valid tokens stored under `key`, refreshing a stale access
464    /// token transparently
465    ///
466    /// `Ok(None)` means interactive authorization is required: nothing is
467    /// stored, the stored entry has no refresh token to renew it with, or
468    /// the server rejected the refresh token (`invalid_grant`) - in the
469    /// latter cases the dead entry is removed from the store.
470    ///
471    /// This is the Authorization Code counterpart, where renewal means the
472    /// refresh token. A client acting for itself has none to renew with;
473    /// see [`ClientCredentialsRequest::token`](crate::ClientCredentialsRequest::token),
474    /// which re-runs the grant instead.
475    ///
476    /// # Panics
477    /// Panics when no [`TokenStore`] is attached
478    /// (see [`with_token_store`](Self::with_token_store)).
479    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        // a stored entry this client cannot present is not a token it has -
490        // and its refresh token is bound the same way, so there is nothing
491        // to renew it with either
492        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                // no rotation in the response - the old token stays valid
509                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    /// Stores `tokens` under `key` - typically right after
524    /// [`exchange_code`](Self::exchange_code)
525    ///
526    /// # Panics
527    /// Panics when no [`TokenStore`] is attached
528    /// (see [`with_token_store`](Self::with_token_store)).
529    pub fn store_tokens(&self, key: &str, tokens: &TokenSet) {
530        self.token_store().put(key, tokens);
531    }
532
533    /// The attached [`TokenStore`].
534    ///
535    /// # Panics
536    /// Panics when none is attached - the same contract every public
537    /// store-backed method carries.
538    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    /// Takes delivery of an issued access token, refusing one the server
554    /// did not bind to this client's DPoP key.
555    ///
556    /// A server that does not implement DPoP ignores the proof and answers
557    /// with an ordinary bearer token - RFC 9449 asks it to advertise
558    /// `dpop_signing_alg_values_supported`, but that field is optional and
559    /// its absence proves nothing either way, so the downgrade cannot be
560    /// caught before the request. Taking the token anyway would hand the
561    /// caller (and the token store) an unbound credential in answer to an
562    /// explicit `with_dpop`, which is the one thing that configuration
563    /// exists to prevent - so it is refused, loudly, instead.
564    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            // recorded so a stored entry can be told apart from one bound
580            // to a key this client no longer holds
581            tokens.dpop_jkt = Some(dpop.thumbprint().to_owned());
582        }
583
584        Ok(tokens)
585    }
586
587    /// Returns whether a stored entry is one this client can still present.
588    ///
589    /// The binding survives in the store while the key need not: a token
590    /// bound to a `jkt` this process cannot prove possession of is dead
591    /// weight however unexpired it looks, and so is a bearer token held by
592    /// a client that has since been given a DPoP key - taking either would
593    /// walk straight past the check [`adopt_tokens`](Self::adopt_tokens)
594    /// applies at issuance. Neither is an error: it is a stale cache, and
595    /// the answer is to discard it and obtain a token that fits.
596    pub(crate) fn can_present(&self, tokens: &TokenSet) -> bool {
597        #[cfg(feature = "dpop")]
598        if let Some(dpop) = &self.dpop {
599            // an entry stored before this key existed carries no
600            // thumbprint, and an unconfirmed binding is not a binding
601            return tokens.is_dpop() && tokens.dpop_jkt.as_deref() == Some(dpop.thumbprint());
602        }
603
604        // no key to present it with. This holds whether the client was
605        // simply not given one or the build has no DPoP support at all: a
606        // store is shared and outlives a deployment, so a bound token can
607        // reach a binary that has no way whatever to prove possession of it
608        !tokens.is_dpop()
609    }
610
611    /// Submits a token request and deserializes the successful response
612    /// into `T`; every grant goes through here.
613    ///
614    /// `build` renders the request rather than being handed its bytes,
615    /// because a request may be sent more than once: the DPoP nonce round
616    /// (RFC 9449 Section 8.2) repeats it, and a repeat has to be a new
617    /// request rather than a resend. A `private_key_jwt` credential is a
618    /// one-shot assertion carrying a random `jti`, and a server that
619    /// remembers those (RFC 7523 Section 3 invites it to) answers a second
620    /// sight of one with `invalid_client`.
621    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        // the metadata is only consulted by the DPoP path above
635        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    /// Submits a token request under DPoP: a proof over the request, and
648    /// the one retry a `use_dpop_nonce` refusal is answered with
649    /// (RFC 9449 Section 8.2).
650    ///
651    /// Exactly one retry, and only when the server demanded a nonce this
652    /// request did not carry - a server that keeps refusing is answered
653    /// with the error, not with another request. The bound is the
654    /// straight-line shape of this function rather than a condition, so no
655    /// answer a server can give turns it into a loop.
656    #[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        // the token endpoint's nonces are its own: a resource sharing this
668        // origin issues an unrelated sequence (RFC 9449 Sections 8 and 9)
669        const SCOPE: NonceScope = NonceScope::AuthorizationServer;
670
671        // caught before the request: the server would answer
672        // `invalid_dpop_proof`, which says nothing about which algorithm to
673        // pick instead
674        dpop.ensure_supported(metadata)?;
675
676        // bound outside the closure so the proof builder can borrow it
677        // across the `with_nonce` call below
678        let method = http::Method::POST;
679
680        // every attempt is rendered afresh, credential included - see
681        // `post_token_request` on why a retry is a new request
682        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        // what this request's proof carries, resolved here rather than left
699        // to the builder: the retry decision below compares what the server
700        // demanded against what was actually sent, and the shared state may
701        // have moved on by the time the answer arrives
702        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        // a nonce may arrive with any response, refusal or not
708        let demanded = dpop.accept_nonce_in(SCOPE, endpoint, response.headers());
709
710        // one retry, and only when the server demanded a nonce this request
711        // did not carry. Whether the *shared* state learned something is the
712        // wrong question: a concurrent request to the same origin may have
713        // stored this very nonce first, which says nothing about what this
714        // proof contained - and answering that refusal without retrying
715        // would fail a request the server was willing to serve. Repeating a
716        // proof with the nonce it was already refused for, on the other
717        // hand, buys nothing, so that case surfaces the error
718        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        // the retry may be answered with a nonce of its own - RFC 9449
731        // Section 8.2 permits one on a successful response too, and dropping
732        // it would cost the next request a round trip to be told again
733        dpop.accept_nonce_in(SCOPE, endpoint, response.headers());
734
735        serde_json::from_value(response.into_json()?).map_err(Into::into)
736    }
737
738    /// Applies client authentication to a token request: either an HTTP
739    /// Basic header or credentials appended to `form`, per the configured
740    /// method. Public clients identify themselves with `client_id` alone.
741    ///
742    /// Every credential-bearing method is checked against
743    /// `token_endpoint_auth_methods_supported` first: sending one the
744    /// server never announced only earns an `invalid_client` over the
745    /// network. A public client is not checked - it presents no credential
746    /// for the server to have a method for.
747    pub(crate) fn apply_client_auth(
748        &self,
749        form: &mut form_urlencoded::Serializer<'_, String>,
750        metadata: &AuthorizationServerMetadata,
751    ) -> Result<Option<HeaderValue>, ClientError> {
752        // the assertion is the credential; a secret, if any, is not sent
753        #[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
809/// Builder for an authorization request, created by
810/// [`OAuthClient::authorization_request`]
811pub 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    /// Sets the requested scopes, joined into the `scope` parameter
822    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    /// Adds a resource indicator (RFC 8707), repeatable; it is also sent
832    /// with the token request by [`OAuthClient::exchange_code`]
833    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
834        self.resources.push(resource.into());
835        self
836    }
837
838    /// Overrides the `state` parameter; a random value is generated when
839    /// not set
840    pub fn with_state(mut self, state: impl Into<String>) -> Self {
841        self.state = Some(state.into());
842        self
843    }
844
845    /// Adds an extra query parameter (e.g. the OIDC `nonce` or `prompt`),
846    /// repeatable
847    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    /// Builds the authorization URL together with the `state` and PKCE
853    /// pair the application must keep for the callback
854    ///
855    /// Fails with [`ClientError::Validation`] when the metadata declares
856    /// no `authorization_endpoint` or advertises PKCE methods without
857    /// `S256` (OAuth 2.1 requires it).
858    pub fn build(self) -> Result<AuthorizationRequest, ClientError> {
859        // caught before the URL exists, rather than after a user has been
860        // redirected into a flow this client may not complete
861        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        // RFC 9449 Section 10: naming the key up front binds the
897        // authorization code to it, so a stolen code cannot be redeemed by
898        // anyone else. A server that does not implement it ignores the
899        // parameter, as RFC 6749 Section 3.1 requires of unrecognized ones
900        #[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/// A prepared authorization request
941///
942/// Everything the application must keep between redirecting the user to
943/// [`url`](Self::url) and exchanging the callback code; serializable so a
944/// web application can stash it in the session.
945#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
946pub struct AuthorizationRequest {
947    /// The authorization URL to send the user to
948    pub url: String,
949
950    /// The `state` parameter embedded in the URL; the callback must echo
951    /// it back (checked with [`matches_state`](Self::matches_state))
952    pub state: String,
953
954    /// The PKCE pair; the verifier is sent with the token request
955    pub pkce: Pkce,
956
957    /// The requested resource indicators, repeated in the token request
958    /// per RFC 8707
959    pub resources: Vec<String>,
960}
961
962impl AuthorizationRequest {
963    /// Returns `true` when the `state` returned by the callback matches
964    /// the one this request was built with - always verify it before
965    /// exchanging the code (CSRF protection)
966    #[inline]
967    pub fn matches_state(&self, state: &str) -> bool {
968        self.state == state
969    }
970
971    /// Validates the parameters of the authorization callback before the
972    /// code is exchanged: the `state` (CSRF) and the RFC 9207 `iss`
973    /// (authorization server mix-up).
974    ///
975    /// `iss` is the callback's `iss` query parameter, `None` when the
976    /// response carried none. It must match the issuer whenever it is
977    /// present, and it is *required* when the metadata advertises
978    /// [`authorization_response_iss_parameter_supported`] - a response
979    /// missing it there may come from a different, possibly malicious,
980    /// authorization server.
981    ///
982    /// ```no_run
983    /// # use volga_oauth_client::{
984    /// #     AuthorizationRequest, AuthorizationServerMetadata, ClientError,
985    /// # };
986    /// # fn check(
987    /// #     request: &AuthorizationRequest,
988    /// #     metadata: &AuthorizationServerMetadata,
989    /// #     state: &str,
990    /// #     iss: Option<&str>,
991    /// # ) -> Result<(), ClientError> {
992    /// request.validate_callback(metadata, state, iss)?;
993    /// # Ok(())
994    /// # }
995    /// ```
996    ///
997    /// [`authorization_response_iss_parameter_supported`]: AuthorizationServerMetadata::authorization_response_iss_parameter_supported
998    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
1023/// Builds an RFC 6749 Section 2.3.1 HTTP Basic authorization header: identifier
1024/// and secret are form-urlencoded before being joined and base64-encoded.
1025fn 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/// Refuses a signing key whose `kid` the registration's inline JWK Set
1036/// cannot resolve.
1037///
1038/// The set is read leniently out of the raw document rather than through
1039/// [`JwkSet`](volga_oauth_core::JwkSet): a registration may echo one this
1040/// framework would refuse to model - an encryption key, a curve it does not
1041/// sign with - and that is no reason to fail. Only the identifiers matter.
1042#[cfg(feature = "private-key-jwt")]
1043fn ensure_key_id_registered(
1044    response: &volga_oauth_core::ClientRegistrationResponse,
1045    key_id: &str,
1046) -> Result<(), ClientError> {
1047    // no inline set: the registration named a `jwks_uri` we would have to
1048    // fetch, or nothing at all - either way it told us nothing to check
1049    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    // a set that labels nothing leaves at most one key to resolve to, and
1069    // `kid` is a hint rather than a selector (RFC 7515 Section 4.1.4) - this
1070    // is what volga's own JWKS reader does with a single unlabelled key.
1071    // Past one key the label is the only thing that could have chosen
1072    // between them, so an unresolvable one is a genuine contradiction
1073    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
1084/// Refuses a client authentication method the server does not announce in
1085/// `token_endpoint_auth_methods_supported`.
1086///
1087/// A metadata document that lists none is not second-guessed - that is what
1088/// a hand-built [`AuthorizationServerMetadata`] carries. A *discovered* one
1089/// always lists something: RFC 8414 Section 2 defines the default for an
1090/// omitted field as `client_secret_basic`, which deserialization
1091/// materializes, so a server silent about the field is taken at the spec's
1092/// word rather than assumed to accept anything.
1093fn 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
1108/// A rendered token request: the form body and the `Authorization` header
1109/// the configured client authentication produced, if any.
1110pub(crate) type TokenRequestParts = (String, Option<HeaderValue>);
1111
1112/// The request headers carrying `authorization`, if there is one.
1113fn 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        // RFC 9449 Section 10: the code is bound to the key that will be
1194        // proven at the token endpoint
1195        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        // ...and a client without a key names none
1205        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        // https enforcement applies to the authorization endpoint too
1245        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        // RFC 6749 Section 2.3.1: form-urlencode the id and secret first
1256        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        // a secret set alongside the key is never sent: the assertion is
1297        // the credential
1298        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            // a server announcing only other methods refuses this one
1361            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            // ...it goes through once announced
1369            assert!(apply(client, &advertising(&[method])).is_ok(), "{method}");
1370
1371            // ...and metadata announcing nothing is not second-guessed,
1372            // which is what a hand-built document carries
1373            assert!(apply(client, &metadata()).is_ok(), "{method}");
1374        }
1375
1376        // a discovered document silent about the field materializes the
1377        // RFC 8414 default, so it accepts Basic and nothing else
1378        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        // a public client presents no credential, so there is no method for
1387        // the server to have announced
1388        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        // an omitted method defaults to client_secret_basic
1421        let client =
1422            OAuthClient::from_registration(&registration(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(&registration("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        // `none` sends no credentials - the client stays public even
1436        // though the server issued a secret
1437        let client = OAuthClient::from_registration(&registration("none".into())).unwrap();
1438        assert_eq!(client.client_secret, None);
1439
1440        // a method this client cannot perform is rejected upfront rather
1441        // than failing with invalid_client at the token endpoint
1442        let err =
1443            OAuthClient::from_registration(&registration("client_secret_jwt".into())).unwrap_err();
1444        assert!(matches!(
1445            err,
1446            ClientError::Validation(reason) if reason.contains("client_secret_jwt")
1447        ));
1448
1449        // private_key_jwt is announced as needing a key when this client can
1450        // sign at all, and as needing the feature when it cannot
1451        let err =
1452            OAuthClient::from_registration(&registration("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(&registration("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        // ...and a key handed to a registration that announced something
1491        // else stays unused
1492        let client = OAuthClient::from_registration_with_key(
1493            &registration("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        // the test key signs ES256; a registration pinning RS256 would have
1516        // the token endpoint answer `invalid_client`, however capable the
1517        // authorization server itself is
1518        let err = OAuthClient::from_registration_with_key(
1519            &registration("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        // the matching algorithm, and an unpinned registration, go through
1530        for signing_alg in [serde_json::json!("ES256"), serde_json::Value::Null] {
1531            assert!(
1532                OAuthClient::from_registration_with_key(
1533                    &registration(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        // the server resolves the assertion's `kid` in the registered set;
1559        // one that is not there is refused at the token endpoint
1560        let err = OAuthClient::from_registration_with_key(
1561            &registration(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        // a matching `kid` goes through...
1572        assert!(
1573            OAuthClient::from_registration_with_key(
1574                &registration(published("2026-08")),
1575                with_key_id()
1576            )
1577            .is_ok()
1578        );
1579
1580        // a set that labels some key but not ours cannot resolve it, even
1581        // when the rest carry no label at all
1582        for jwks in [
1583            serde_json::json!({ "keys": [{ "kty": "EC", "kid": "other" }, { "kty": "RSA" }] }),
1584            // ...and past one key an unlabelled set has nothing to choose by
1585            serde_json::json!({ "keys": [{ "kty": "EC" }, { "kty": "RSA" }] }),
1586        ] {
1587            assert!(
1588                OAuthClient::from_registration_with_key(&registration(jwks.clone()), with_key_id())
1589                    .is_err(),
1590                "accepted {jwks}"
1591            );
1592        }
1593
1594        // ...and anything that constrains nothing goes through: no `kid` on
1595        // the key at all
1596        assert!(
1597            OAuthClient::from_registration_with_key(
1598                &registration(published("other")),
1599                crate::assertion::test_key(),
1600            )
1601            .is_ok()
1602        );
1603        for jwks in [
1604            // a lone unlabelled key is what the server resolves to whatever
1605            // the assertion is labelled - `kid` is a hint, not a selector
1606            serde_json::json!({ "keys": [{ "kty": "EC" }] }),
1607            serde_json::json!({ "keys": [] }),
1608            // a `jwks_uri` we would have to fetch, or nothing at all
1609            serde_json::Value::Null,
1610        ] {
1611            assert!(
1612                OAuthClient::from_registration_with_key(&registration(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(&registration(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        // ...the authorization code flow included, which this registration
1652        // did not approve either
1653        assert!(
1654            client
1655                .ensure_grant_registered(grant::AUTHORIZATION_CODE)
1656                .is_err()
1657        );
1658
1659        // an omitted `grant_types` is not carte blanche: RFC 7591 Section 2
1660        // makes it authorization_code alone
1661        let defaulted =
1662            OAuthClient::from_registration(&registration(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        // refresh is the continuation of a grant already held, and servers
1675        // routinely omit it from a registration - never refused
1676        for approved in [
1677            serde_json::json!([]),
1678            serde_json::json!(["client_credentials"]),
1679        ] {
1680            let client = OAuthClient::from_registration(&registration(approved)).unwrap();
1681            assert!(client.ensure_grant_registered(grant::REFRESH_TOKEN).is_ok());
1682        }
1683
1684        // a client that never went through a registration is unconstrained
1685        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        // the token-endpoint futures must be spawnable onto a multi-thread
1695        // runtime: nothing non-`Sync` (the form serializer) may be held
1696        // across their awaits
1697        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        // no `iss` and no advertisement - nothing more to check
1717        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        // CSRF: a foreign `state` never reaches the token endpoint
1725        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        // mix-up: a present `iss` must match the issuer, advertised or not
1731        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        // ...and it is mandatory once the server advertises RFC 9207
1739        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    /// A stored entry of `token_type`, bound to `jkt` when it claims one.
1752    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        // deliberately not gated on the `dpop` feature: a store is shared
1767        // and outlives a deployment, so an entry written by a DPoP-enabled
1768        // one can reach a binary that has no way whatever to prove
1769        // possession of it - and it must not be handed out there either
1770        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        // a client with a key can present exactly what that key is bound
1783        // to. A bearer entry cached before the key existed would otherwise
1784        // walk straight past the downgrade check
1785        assert!(client.can_present(&stored("DPoP", Some(dpop.thumbprint()))));
1786        assert!(!client.can_present(&stored("Bearer", None)));
1787        // a token bound to a key this process no longer holds - a store
1788        // outliving a generated key - and one whose binding was never
1789        // recorded: an unconfirmed binding is not a binding
1790        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}