Skip to main content

fraiseql_auth/oauth/
client.rs

1//! OAuth2 and OIDC client implementations.
2
3use std::{sync::Arc, time::Duration as StdDuration};
4
5/// Timeout for all outbound OAuth2 / OIDC HTTP requests.
6pub(crate) const OAUTH_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(30);
7
8use std::fmt::Write as _;
9
10use serde::{Deserialize, Serialize};
11use zeroize::Zeroizing;
12
13use super::{
14    super::jwks::{JwksCache, JwksError},
15    pkce::{PKCEChallenge, gen_random_token},
16    types::{IdTokenClaims, TokenResponse, UserInfo},
17};
18
19/// OIDC provider configuration
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct OIDCProviderConfig {
22    /// Provider issuer URL
23    pub issuer:                   String,
24    /// Authorization endpoint
25    pub authorization_endpoint:   String,
26    /// Token endpoint
27    pub token_endpoint:           String,
28    /// Userinfo endpoint
29    pub userinfo_endpoint:        Option<String>,
30    /// JWKS URI for public keys
31    pub jwks_uri:                 String,
32    /// Scopes supported by provider
33    pub scopes_supported:         Vec<String>,
34    /// Response types supported
35    pub response_types_supported: Vec<String>,
36}
37
38impl OIDCProviderConfig {
39    /// Create new provider configuration
40    #[must_use]
41    pub fn new(
42        issuer: String,
43        authorization_endpoint: String,
44        token_endpoint: String,
45        jwks_uri: String,
46    ) -> Self {
47        Self {
48            issuer,
49            authorization_endpoint,
50            token_endpoint,
51            userinfo_endpoint: None,
52            jwks_uri,
53            scopes_supported: vec![
54                "openid".to_string(),
55                "profile".to_string(),
56                "email".to_string(),
57            ],
58            response_types_supported: vec!["code".to_string()],
59        }
60    }
61}
62
63/// Result of [`OAuth2Client::authorization_url`].
64///
65/// The caller MUST store `state` (for CSRF verification at callback), when
66/// present the PKCE `pkce.code_verifier` (for token exchange), and when
67/// present the `nonce` value (must be verified against the ID token at
68/// callback via [`OIDCClient::verify_id_token`]).
69#[derive(Debug, Clone)]
70pub struct AuthorizationRequest {
71    /// The full authorization URL to redirect the user to.
72    pub url:   String,
73    /// CSRF state value — verify this matches the `state` query param at callback.
74    pub state: String,
75    /// PKCE challenge, present only when `use_pkce = true`.
76    pub pkce:  Option<PKCEChallenge>,
77    /// OIDC nonce for replay protection.
78    ///
79    /// Present only when the authorization URL was generated by
80    /// [`OIDCClient::authorization_url`].  The caller must store this value
81    /// and pass `Some(&nonce.nonce)` to [`OIDCClient::verify_id_token`] at
82    /// callback time.
83    pub nonce: Option<super::pkce::NonceParameter>,
84}
85
86/// OAuth2 client for authorization code flow.
87#[derive(Clone)]
88pub struct OAuth2Client {
89    /// Client ID from provider.
90    pub client_id:              String,
91    /// Client secret from provider.
92    /// Stored as `Zeroizing<String>` so the key material is wiped from memory
93    /// when this struct is dropped.
94    pub(crate) client_secret:   Zeroizing<String>,
95    /// Authorization endpoint.
96    pub authorization_endpoint: String,
97    /// Token endpoint.
98    token_endpoint:             String,
99    /// Registered redirect URI.
100    ///
101    /// When set, `exchange_code` validates that the caller-supplied `redirect_uri`
102    /// matches this value (exact match after trailing-`/` trim) before issuing
103    /// the token request.  This prevents an attacker who can influence the
104    /// `redirect_uri` parameter from steering the code exchange to an
105    /// unregistered destination (open-redirect / code-interception attack).
106    redirect_uri:               Option<String>,
107    /// Scopes to request.
108    pub scopes:                 Vec<String>,
109    /// Use PKCE for additional security.
110    pub use_pkce:               bool,
111    /// HTTP client for token requests.
112    http_client:                reqwest::Client,
113}
114
115/// Custom `Debug` that redacts the client secret.
116#[allow(clippy::missing_fields_in_debug)] // Reason: http_client omitted intentionally (not useful in debug output)
117impl std::fmt::Debug for OAuth2Client {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("OAuth2Client")
120            .field("client_id", &self.client_id)
121            .field("client_secret", &"[REDACTED]")
122            .field("authorization_endpoint", &self.authorization_endpoint)
123            .field("scopes", &self.scopes)
124            .field("use_pkce", &self.use_pkce)
125            .finish_non_exhaustive()
126    }
127}
128
129impl OAuth2Client {
130    /// Maximum byte size accepted from an OAuth token endpoint response.
131    ///
132    /// A well-formed token response (access_token, id_token, refresh_token) is a
133    /// few kilobytes at most.  1 MiB prevents a malicious provider from sending a
134    /// response large enough to exhaust server memory.
135    pub(crate) const MAX_OAUTH_RESPONSE_BYTES: usize = 1024 * 1024;
136
137    /// Create new OAuth2 client.
138    pub fn new(
139        client_id: impl Into<String>,
140        client_secret: impl Into<String>,
141        authorization_endpoint: impl Into<String>,
142        token_endpoint: impl Into<String>,
143    ) -> Self {
144        Self {
145            client_id:              client_id.into(),
146            client_secret:          Zeroizing::new(client_secret.into()),
147            authorization_endpoint: authorization_endpoint.into(),
148            token_endpoint:         token_endpoint.into(),
149            redirect_uri:           None,
150            scopes:                 vec![
151                "openid".to_string(),
152                "profile".to_string(),
153                "email".to_string(),
154            ],
155            use_pkce:               false,
156            http_client:            reqwest::Client::builder()
157                .timeout(OAUTH_REQUEST_TIMEOUT)
158                .build()
159                .unwrap_or_default(),
160        }
161    }
162
163    /// Register the expected redirect URI.
164    ///
165    /// When set, [`Self::exchange_code`] validates that the caller-supplied
166    /// `redirect_uri` exactly matches this value (after stripping trailing `/`)
167    /// before issuing the token request.  This prevents open-redirect and
168    /// authorization-code-interception attacks.
169    ///
170    /// Call this builder method immediately after construction for any client
171    /// that will call `exchange_code` with a caller-supplied redirect URI.
172    pub fn with_redirect_uri(mut self, uri: impl Into<String>) -> Self {
173        self.redirect_uri = Some(uri.into());
174        self
175    }
176
177    /// Set scopes for request.
178    #[must_use]
179    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
180        self.scopes = scopes;
181        self
182    }
183
184    /// Enable PKCE protection.
185    #[must_use]
186    pub const fn with_pkce(mut self, enabled: bool) -> Self {
187        self.use_pkce = enabled;
188        self
189    }
190
191    /// Generate authorization URL.
192    ///
193    /// Returns an [`AuthorizationRequest`] containing the URL, the CSRF state
194    /// value (must be stored and verified at callback), and an optional PKCE
195    /// challenge (when `use_pkce = true`; the `code_verifier` must be stored
196    /// and sent during token exchange).
197    #[must_use]
198    pub fn authorization_url(&self, redirect_uri: &str) -> AuthorizationRequest {
199        // SECURITY: 32-byte OsRng → 43-char URL-safe base64 (~256-bit entropy, RFC 7636 §4.1)
200        let state = gen_random_token();
201        let scope = self.scopes.join(" ");
202
203        let mut url = format!(
204            "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}",
205            self.authorization_endpoint,
206            urlencoding::encode(&self.client_id),
207            urlencoding::encode(redirect_uri),
208            urlencoding::encode(&scope),
209            urlencoding::encode(&state),
210        );
211
212        let pkce = if self.use_pkce {
213            let challenge = PKCEChallenge::new();
214            let _ = write!(
215                url,
216                "&code_challenge={}&code_challenge_method=S256",
217                urlencoding::encode(&challenge.code_challenge),
218            );
219            Some(challenge)
220        } else {
221            None
222        };
223
224        AuthorizationRequest {
225            url,
226            state,
227            pkce,
228            nonce: None,
229        }
230    }
231
232    // 1 MiB
233
234    /// Post a form request to the token endpoint and parse the response.
235    async fn post_token_request(&self, params: &[(&str, &str)]) -> Result<TokenResponse, String> {
236        let response = self
237            .http_client
238            .post(&self.token_endpoint)
239            .form(params)
240            .send()
241            .await
242            .map_err(|e| format!("Token request failed: {e}"))?;
243
244        // Read the entire body once so we can apply a size cap regardless of
245        // whether the response is a success or an error.
246        let status = response.status();
247        let body_bytes = response
248            .bytes()
249            .await
250            .map_err(|e| format!("Failed to read token response body: {e}"))?;
251
252        if !status.is_success() {
253            let capped = &body_bytes[..body_bytes.len().min(Self::MAX_OAUTH_RESPONSE_BYTES)];
254            let body = String::from_utf8_lossy(capped);
255            return Err(format!("Token endpoint returned error: {body}"));
256        }
257
258        if body_bytes.len() > Self::MAX_OAUTH_RESPONSE_BYTES {
259            return Err(format!(
260                "Token response body too large ({} bytes, max {})",
261                body_bytes.len(),
262                Self::MAX_OAUTH_RESPONSE_BYTES
263            ));
264        }
265
266        serde_json::from_slice::<TokenResponse>(&body_bytes)
267            .map_err(|e| format!("Failed to parse token response: {e}"))
268    }
269
270    /// Exchange authorization code for tokens.
271    ///
272    /// # Errors
273    ///
274    /// Returns an error if the HTTP request to the token endpoint fails or the response
275    /// cannot be parsed as a `TokenResponse`.
276    pub async fn exchange_code(
277        &self,
278        code: &str,
279        redirect_uri: &str,
280    ) -> Result<TokenResponse, String> {
281        // SECURITY: If a registered redirect URI was set via with_redirect_uri(),
282        // validate the caller-supplied value before it reaches the token endpoint.
283        // Exact match after trailing-slash trim — no prefix or scheme stripping.
284        if let Some(registered) = &self.redirect_uri {
285            if registered.trim_end_matches('/') != redirect_uri.trim_end_matches('/') {
286                return Err(format!(
287                    "redirect_uri mismatch: supplied '{}' does not match registered '{}'",
288                    redirect_uri, registered
289                ));
290            }
291        }
292        let params = [
293            ("grant_type", "authorization_code"),
294            ("code", code),
295            ("client_id", self.client_id.as_str()),
296            ("client_secret", self.client_secret.as_str()),
297            ("redirect_uri", redirect_uri),
298        ];
299        self.post_token_request(&params).await
300    }
301
302    /// Refresh access token using a refresh token.
303    ///
304    /// # Errors
305    ///
306    /// Propagates errors from the token endpoint request (network failure,
307    /// non-2xx HTTP status, oversized response body, or JSON parse error).
308    pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse, String> {
309        let params = [
310            ("grant_type", "refresh_token"),
311            ("refresh_token", refresh_token),
312            ("client_id", self.client_id.as_str()),
313            ("client_secret", self.client_secret.as_str()),
314        ];
315        self.post_token_request(&params).await
316    }
317}
318
319/// Asymmetric signing algorithms permitted for OIDC ID tokens.
320///
321/// OIDC providers MUST use asymmetric signing so the relying party can verify
322/// signatures without possessing key material that could forge tokens.
323/// Symmetric algorithms (HS*) are forbidden on this path — see
324/// [`FORBIDDEN_OIDC_ALGORITHMS`].
325///
326/// Reference: RFC 8725 §2.1, OpenID Connect Core §10.1.
327pub const ALLOWED_OIDC_ALGORITHMS: &[jsonwebtoken::Algorithm] = &[
328    jsonwebtoken::Algorithm::RS256,
329    jsonwebtoken::Algorithm::RS384,
330    jsonwebtoken::Algorithm::RS512,
331    jsonwebtoken::Algorithm::ES256,
332    jsonwebtoken::Algorithm::ES384,
333];
334
335/// Symmetric algorithms explicitly forbidden on the OIDC ID token path.
336///
337/// An OIDC provider that issues tokens with a symmetric algorithm is either
338/// misconfigured or an attacker performing an algorithm-substitution attack
339/// (RFC 8725 §2.1).  Any token header claiming one of these algorithms is
340/// rejected with [`crate::error::AuthError::ForbiddenAlgorithm`] before
341/// signature verification is attempted.
342pub const FORBIDDEN_OIDC_ALGORITHMS: &[jsonwebtoken::Algorithm] = &[
343    jsonwebtoken::Algorithm::HS256,
344    jsonwebtoken::Algorithm::HS384,
345    jsonwebtoken::Algorithm::HS512,
346];
347
348/// Required `typ` header value for OIDC ID tokens (RFC 7519 §5.1, RFC 8725 §3.11).
349///
350/// When the `typ` header is present it MUST equal `"JWT"` (case-insensitive).
351/// A token with a different `typ` (e.g., `"at+JWT"` for access tokens) is
352/// rejected to prevent token-type confusion attacks.
353pub const REQUIRED_JWT_TYP: &str = "JWT";
354
355/// JWT header parameters that must never appear in a legitimate OIDC ID token.
356///
357/// These fields (`jku`, `jwk`, `x5u`, `x5c`) could steer key resolution in a
358/// vulnerable JWT library to an attacker-controlled endpoint (RFC 8725 §2.6).
359/// The `jsonwebtoken` crate ignores them for key resolution, so this guard is
360/// defence-in-depth: their presence in any token issued by a compliant OIDC
361/// provider is anomalous and indicates a crafted or malicious token.
362///
363/// Note on `crit` (RFC 7515 §4.1.11): the `jsonwebtoken` v9 `Header` struct
364/// does not expose a `crit` field.  The crate's decoder handles unknown
365/// extensions at the decode level, so no explicit `crit` guard is required.
366pub const FORBIDDEN_KEY_INJECTION_HEADERS: &[&str] = &["jku", "jwk", "x5u", "x5c"];
367
368/// OIDC client for OpenID Connect flow.
369pub struct OIDCClient {
370    /// Provider configuration.
371    pub config:               OIDCProviderConfig,
372    /// Client ID.
373    pub client_id:            String,
374    /// Client secret — retained for token revocation and introspection endpoints.
375    /// Stored as `Zeroizing<String>` so the key material is wiped from memory
376    /// when this struct is dropped.
377    #[allow(dead_code)] // Reason: retained for token revocation and introspection endpoints
378    pub(crate) client_secret: Zeroizing<String>,
379    /// JWKS key cache for ID token signature verification.
380    pub jwks_cache:           Arc<JwksCache>,
381    /// HTTP client for userinfo requests.
382    http_client:              reqwest::Client,
383}
384
385/// Custom `Debug` that redacts the client secret.
386#[allow(clippy::missing_fields_in_debug)] // Reason: http_client omitted intentionally (not useful in debug output)
387impl std::fmt::Debug for OIDCClient {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        f.debug_struct("OIDCClient")
390            .field("config", &self.config)
391            .field("client_id", &self.client_id)
392            .field("client_secret", &"[REDACTED]")
393            .finish_non_exhaustive()
394    }
395}
396
397impl OIDCClient {
398    /// Maximum byte size for a userinfo endpoint response.
399    ///
400    /// Userinfo payloads carry a small set of JWT-derived claims.
401    /// 1 `MiB` is generous while blocking allocation-bomb responses.
402    pub(crate) const MAX_USERINFO_RESPONSE_BYTES: usize = 1024 * 1024;
403
404    // 1 MiB
405
406    /// Create new OIDC client with JWKS caching.
407    ///
408    /// The JWKS cache TTL defaults to 1 hour.
409    ///
410    /// # Errors
411    ///
412    /// Returns [`JwksError`] if `config.jwks_uri` is not a valid HTTPS URL
413    /// (HTTP is allowed only for localhost).
414    pub fn new(
415        config: OIDCProviderConfig,
416        client_id: impl Into<String>,
417        client_secret: impl Into<String>,
418    ) -> Result<Self, JwksError> {
419        let jwks_cache = Arc::new(JwksCache::new(&config.jwks_uri, StdDuration::from_secs(3600))?);
420        Ok(Self {
421            config,
422            client_id: client_id.into(),
423            client_secret: Zeroizing::new(client_secret.into()),
424            jwks_cache,
425            http_client: reqwest::Client::builder()
426                .timeout(OAUTH_REQUEST_TIMEOUT)
427                .build()
428                .unwrap_or_default(),
429        })
430    }
431
432    /// Create OIDC client with a pre-built JWKS cache (for testing).
433    pub fn with_jwks_cache(
434        config: OIDCProviderConfig,
435        client_id: impl Into<String>,
436        client_secret: impl Into<String>,
437        jwks_cache: Arc<JwksCache>,
438    ) -> Self {
439        Self {
440            config,
441            client_id: client_id.into(),
442            client_secret: Zeroizing::new(client_secret.into()),
443            jwks_cache,
444            http_client: reqwest::Client::builder()
445                .timeout(OAUTH_REQUEST_TIMEOUT)
446                .build()
447                .unwrap_or_default(),
448        }
449    }
450
451    /// Generate an OIDC authorization URL with a fresh nonce for replay protection.
452    ///
453    /// This extends the standard OAuth2 flow by appending a `nonce` parameter to
454    /// the authorization URL. The returned [`AuthorizationRequest::nonce`] **must**
455    /// be stored (e.g. in the encrypted session state) and passed to
456    /// [`verify_id_token`](Self::verify_id_token) at callback time.
457    ///
458    /// PKCE is always enabled for OIDC flows started via this method.
459    #[must_use]
460    pub fn authorization_url(&self, redirect_uri: &str) -> AuthorizationRequest {
461        // SECURITY: 32-byte OsRng → 43-char URL-safe base64 (~256-bit entropy, RFC 7636 §4.1)
462        let state = gen_random_token();
463        let scope = self.config.scopes_supported.join(" ");
464        let nonce = super::pkce::NonceParameter::new();
465        let challenge = PKCEChallenge::new();
466
467        let url = format!(
468            "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}\
469             &nonce={}&code_challenge={}&code_challenge_method=S256",
470            self.config.authorization_endpoint,
471            urlencoding::encode(&self.client_id),
472            urlencoding::encode(redirect_uri),
473            urlencoding::encode(&scope),
474            urlencoding::encode(&state),
475            urlencoding::encode(&nonce.nonce),
476            urlencoding::encode(&challenge.code_challenge),
477        );
478
479        AuthorizationRequest {
480            url,
481            state,
482            pkce: Some(challenge),
483            nonce: Some(nonce),
484        }
485    }
486
487    /// Verify an ID token's JWT signature and claims.
488    ///
489    /// Decodes the JWT header to extract the `kid`, fetches the matching public
490    /// key from the JWKS cache, then validates signature, issuer, audience, and
491    /// required claims.
492    ///
493    /// **Nonce**: when `expected_nonce` is `Some`, the token's `nonce` claim must
494    /// match exactly.  When it is `None` but the token *contains* a `nonce` claim,
495    /// validation still succeeds — callers that generated the authorization URL
496    /// via [`authorization_url`](Self::authorization_url) MUST pass the stored
497    /// nonce here.
498    ///
499    /// **`max_age`**: when `max_age_secs` is `Some`, the token's `auth_time` claim
500    /// is required and must be within `max_age_secs` seconds of the current time.
501    /// This prevents accepting tokens from sessions that were authenticated too
502    /// long ago (RFC 6749 §3.1.2.1 / OIDC Core §3.1.2.1).
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the token is malformed, the signature is invalid,
507    /// claims validation fails, the nonce doesn't match, or the `auth_time` /
508    /// `max_age` constraint is violated.
509    pub async fn verify_id_token(
510        &self,
511        id_token: &str,
512        expected_nonce: Option<&str>,
513        max_age_secs: Option<u64>,
514    ) -> Result<IdTokenClaims, String> {
515        // 1. Decode header to get kid
516        let header = jsonwebtoken::decode_header(id_token)
517            .map_err(|e| format!("Invalid JWT header: {e}"))?;
518
519        // 1a. Algorithm whitelist (S41 — RFC 8725 §2.1): reject forbidden symmetric algorithms
520        //     and any algorithm not in the OIDC allowlist before touching the JWKS cache.
521        if FORBIDDEN_OIDC_ALGORITHMS.contains(&header.alg) {
522            let alg_str = format!("{:?}", header.alg);
523            return Err(format!("Forbidden OIDC algorithm: {alg_str}"));
524        }
525        if !ALLOWED_OIDC_ALGORITHMS.contains(&header.alg) {
526            let alg_str = format!("{:?}", header.alg);
527            return Err(format!("OIDC algorithm not in allowlist: {alg_str}"));
528        }
529
530        // 1b. typ header assertion (S41 — RFC 8725 §3.11 / RFC 7519 §5.1): when present,
531        //     `typ` must equal "JWT" (case-insensitive) to prevent token-type confusion.
532        if let Some(ref typ) = header.typ {
533            if typ.to_uppercase() != REQUIRED_JWT_TYP {
534                return Err(format!(
535                    "Unexpected JWT typ header '{typ}': expected '{REQUIRED_JWT_TYP}'"
536                ));
537            }
538        }
539
540        // 1c. Key-injection header rejection (S42 — RFC 8725 §2.6): reject tokens that
541        //     carry jku, jwk, x5u, or x5c headers.  In a vulnerable JWT library these
542        //     fields can steer key resolution to an attacker-controlled endpoint.  The
543        //     `jsonwebtoken` crate ignores them for key resolution (defence-in-depth),
544        //     but their presence in an OIDC ID token is anomalous — no compliant provider
545        //     sets them — and the only correct response is rejection.
546        //     See also: FORBIDDEN_KEY_INJECTION_HEADERS constant.
547        if header.jku.is_some()
548            || header.jwk.is_some()
549            || header.x5u.is_some()
550            || header.x5c.is_some()
551        {
552            return Err("JWT header contains forbidden key-injection parameter".to_string());
553        }
554
555        let kid = header.kid.ok_or("JWT missing 'kid' in header")?;
556
557        // 2. Get key from JWKS cache
558        let key = self
559            .jwks_cache
560            .get_key(&kid)
561            .await
562            .map_err(|e| format!("JWKS fetch error: {e}"))?
563            .ok_or_else(|| format!("No key found for kid '{kid}'"))?;
564
565        // 3. Build validation criteria
566        let mut validation = jsonwebtoken::Validation::new(header.alg);
567        validation.set_issuer(&[&self.config.issuer]);
568        validation.set_audience(&[&self.client_id]);
569        validation.set_required_spec_claims(&["exp", "iat", "iss", "aud", "sub"]);
570
571        // 4. Decode and validate
572        let token_data = jsonwebtoken::decode::<IdTokenClaims>(id_token, &key, &validation)
573            .map_err(|e| format!("ID token validation failed: {e}"))?;
574        let claims = token_data.claims;
575
576        // 4.5. Validate temporal claims: iat staleness/skew and nbf not-before (S40).
577        claims
578            .validate_temporal_claims()
579            .map_err(|e| format!("ID token temporal validation failed: {e}"))?;
580
581        // 5. Verify nonce using constant-time comparison (replay protection — RFC 6749 §10.12 /
582        //    OIDC Core §3.1.3.7).
583        if let Some(expected) = expected_nonce {
584            super::claims_validator::validate_nonce_claim(&claims, expected)
585                .map_err(|e| e.to_string())?;
586        }
587
588        // 6. Validate auth_time against max_age (OIDC Core §3.1.2.1).
589        if let Some(max_age) = max_age_secs {
590            let now_secs = std::time::SystemTime::now()
591                .duration_since(std::time::UNIX_EPOCH)
592                .map_or(i64::MAX, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
593            super::claims_validator::validate_auth_time_claim(&claims, max_age, now_secs)
594                .map_err(|e| e.to_string())?;
595        }
596
597        Ok(claims)
598    }
599
600    /// Fetch user information from the provider's userinfo endpoint.
601    ///
602    /// # Errors
603    ///
604    /// Returns an error if no userinfo endpoint is configured, the HTTP request
605    /// fails, or the response cannot be parsed.
606    pub async fn get_userinfo(&self, access_token: &str) -> Result<UserInfo, String> {
607        let endpoint = self
608            .config
609            .userinfo_endpoint
610            .as_ref()
611            .ok_or("No userinfo endpoint configured for this provider")?;
612
613        let response = self
614            .http_client
615            .get(endpoint)
616            .bearer_auth(access_token)
617            .send()
618            .await
619            .map_err(|e| format!("Userinfo request failed: {e}"))?;
620
621        if !response.status().is_success() {
622            return Err(format!("Userinfo endpoint returned {}", response.status()));
623        }
624
625        let body = response
626            .bytes()
627            .await
628            .map_err(|e| format!("Failed to read userinfo response: {e}"))?;
629        if body.len() > Self::MAX_USERINFO_RESPONSE_BYTES {
630            return Err(format!(
631                "Userinfo response too large ({} bytes, max {})",
632                body.len(),
633                Self::MAX_USERINFO_RESPONSE_BYTES
634            ));
635        }
636        serde_json::from_slice::<UserInfo>(&body)
637            .map_err(|e| format!("Failed to parse userinfo response: {e}"))
638    }
639}