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(¶ms).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(¶ms).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}