1use async_trait::async_trait;
9use base64::Engine as _;
10use rand::RngExt;
11use reqwest::header;
12use serde::Deserialize;
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15use url::Url;
16
17use crate::driver::{
18 ExternalAuthAuthorizationStart, ExternalAuthCallback, ExternalAuthProfile,
19 ExternalAuthProviderConfig, ExternalAuthProviderDescriptor, ExternalAuthProviderDriver,
20 ExternalAuthProviderTestCheck, ExternalAuthProviderTestResult,
21};
22use crate::outbound_http_user_agent;
23use crate::types::{ExternalAuthProtocol, ExternalAuthProviderKind};
24use crate::{ExternalAuthError, MapExternalAuthErr, Result};
25use aster_forge_utils::url::parse_url;
26
27const OAUTH2_DEFAULT_SCOPES: &str = "openid email profile";
28const OAUTH2_NAMESPACE_MAX_LEN: usize = 512;
29const OAUTH2_SUBJECT_MAX_LEN: usize = 255;
30const OAUTH2_SNAPSHOT_MAX_LEN: usize = 255;
31const TOKEN_ENDPOINT_TIMEOUT_SECS: u64 = 15;
32
33#[derive(Clone, Copy)]
34enum OAuth2TokenAuthMethod {
35 ClientSecretPost,
36 PublicClient,
37}
38
39#[derive(Clone, Copy)]
40struct OAuth2TokenRequest<'a> {
41 token_url: &'a str,
42 provider: &'a ExternalAuthProviderConfig,
43 code: &'a str,
44 redirect_uri: &'a str,
45 pkce_verifier: &'a str,
46 auth_method: OAuth2TokenAuthMethod,
47 client_secret: Option<&'a str>,
48}
49
50impl<'a> OAuth2TokenRequest<'a> {
51 fn with_auth_method(self, auth_method: OAuth2TokenAuthMethod) -> Self {
52 Self {
53 auth_method,
54 ..self
55 }
56 }
57}
58
59#[derive(Default)]
61pub struct OAuth2ProviderDriver;
62
63#[derive(Debug, Deserialize)]
64struct OAuth2TokenResponse {
65 access_token: String,
66 #[serde(default)]
67 token_type: Option<String>,
68}
69
70#[derive(Debug, Deserialize)]
71struct OAuth2ErrorResponse {
72 #[serde(default)]
73 error: Option<String>,
74 #[serde(default)]
75 error_description: Option<String>,
76}
77
78impl OAuth2ProviderDriver {
79 pub fn new() -> Self {
81 Self
82 }
83}
84
85#[async_trait]
86impl ExternalAuthProviderDriver for OAuth2ProviderDriver {
87 fn kind(&self) -> ExternalAuthProviderKind {
88 ExternalAuthProviderKind::GenericOAuth2
89 }
90
91 fn descriptor(&self) -> ExternalAuthProviderDescriptor {
92 ExternalAuthProviderDescriptor {
93 kind: ExternalAuthProviderKind::GenericOAuth2,
94 protocol: ExternalAuthProtocol::OAuth2,
95 display_name: "Generic OAuth2",
96 description: "OAuth2 authorization-code sign-in using manually configured authorization, token and userinfo endpoints.",
97 default_scopes: OAUTH2_DEFAULT_SCOPES,
98 issuer_url_required: false,
99 manual_endpoint_configuration_supported: true,
100 authorization_url_required: true,
101 token_url_required: true,
102 userinfo_url_required: true,
103 supports_discovery: false,
104 supports_pkce: true,
105 supports_email_verified_claim: true,
106 }
107 }
108
109 async fn start_authorization(
110 &self,
111 provider: &ExternalAuthProviderConfig,
112 redirect_uri: &str,
113 ) -> Result<ExternalAuthAuthorizationStart> {
114 let authorization_url = require_url(
115 provider.authorization_url.as_deref(),
116 "authorization_url",
117 ExternalAuthError::config_error,
118 )?;
119 let mut authorization_url = validate_url(
120 authorization_url,
121 "authorization_url",
122 ExternalAuthError::config_error,
123 )?;
124 let state = format!("oauth2_{}", aster_forge_utils::id::new_short_token());
125 let pkce_verifier = build_pkce_verifier();
126 let pkce_challenge = build_pkce_challenge(&pkce_verifier);
127
128 {
129 let mut query = authorization_url.query_pairs_mut();
130 query.append_pair("response_type", "code");
131 query.append_pair("client_id", &provider.client_id);
132 query.append_pair("redirect_uri", redirect_uri);
133 query.append_pair("scope", provider.scopes.trim());
134 query.append_pair("state", &state);
135 query.append_pair("code_challenge", &pkce_challenge);
136 query.append_pair("code_challenge_method", "S256");
137 }
138
139 Ok(ExternalAuthAuthorizationStart {
140 authorization_url: authorization_url.to_string(),
141 state,
142 nonce: None,
143 pkce_verifier: Some(pkce_verifier),
144 })
145 }
146
147 async fn exchange_callback(
148 &self,
149 provider: &ExternalAuthProviderConfig,
150 callback: ExternalAuthCallback,
151 ) -> Result<ExternalAuthProfile> {
152 let pkce_verifier = callback.pkce_verifier.ok_or_else(|| {
153 ExternalAuthError::database_operation("stored OAuth2 PKCE verifier is missing")
154 })?;
155 let http_client = oauth2_http_client(provider)?;
156 let token = exchange_code_for_token(
157 &http_client,
158 provider,
159 &callback.code,
160 &callback.redirect_uri,
161 &pkce_verifier,
162 )
163 .await?;
164 let profile_json = fetch_userinfo(&http_client, provider, &token).await?;
165 profile_from_userinfo(provider, &profile_json)
166 }
167
168 async fn test_provider(
169 &self,
170 provider: &ExternalAuthProviderConfig,
171 ) -> Result<ExternalAuthProviderTestResult> {
172 let authorization_url = require_url(
173 provider.authorization_url.as_deref(),
174 "authorization_url",
175 ExternalAuthError::validation_error,
176 )?;
177 let token_url = require_url(
178 provider.token_url.as_deref(),
179 "token_url",
180 ExternalAuthError::validation_error,
181 )?;
182 let userinfo_url = require_url(
183 provider.userinfo_url.as_deref(),
184 "userinfo_url",
185 ExternalAuthError::validation_error,
186 )?;
187 validate_url(
188 authorization_url,
189 "authorization_url",
190 ExternalAuthError::validation_error,
191 )?;
192 validate_url(token_url, "token_url", ExternalAuthError::validation_error)?;
193 validate_url(
194 userinfo_url,
195 "userinfo_url",
196 ExternalAuthError::validation_error,
197 )?;
198 if provider.client_id.trim().is_empty() {
199 return Err(ExternalAuthError::validation_error("client_id is required"));
200 }
201
202 Ok(ExternalAuthProviderTestResult {
203 provider: self.descriptor().display_name.to_string(),
204 issuer: provider.issuer_url.clone(),
205 authorization_endpoint: Some(authorization_url.to_string()),
206 token_endpoint: Some(token_url.to_string()),
207 userinfo_endpoint: Some(userinfo_url.to_string()),
208 jwks_key_count: None,
209 checks: vec![
210 ExternalAuthProviderTestCheck {
211 name: "manual_endpoints".to_string(),
212 success: true,
213 message: "OAuth2 authorization, token and userinfo endpoints are configured"
214 .to_string(),
215 },
216 ExternalAuthProviderTestCheck {
217 name: "authorization_code".to_string(),
218 success: true,
219 message:
220 "OAuth2 client credentials require a real authorization code to validate"
221 .to_string(),
222 },
223 ],
224 })
225 }
226}
227
228pub(super) fn oauth2_http_client(provider: &ExternalAuthProviderConfig) -> Result<reqwest::Client> {
234 reqwest::ClientBuilder::new()
235 .redirect(reqwest::redirect::Policy::none())
236 .timeout(std::time::Duration::from_secs(TOKEN_ENDPOINT_TIMEOUT_SECS))
237 .user_agent(outbound_http_user_agent(provider))
238 .build()
239 .map_external_auth_err_ctx(
240 "failed to build OAuth2 HTTP client",
241 ExternalAuthError::internal_error,
242 )
243}
244
245pub(super) fn require_url<'a>(
246 value: Option<&'a str>,
247 field: &str,
248 error_fn: fn(String) -> ExternalAuthError,
249) -> Result<&'a str> {
250 value
251 .map(str::trim)
252 .filter(|value| !value.is_empty())
253 .ok_or_else(|| error_fn(format!("OAuth2 provider missing {field}")))
254}
255
256pub(super) fn validate_url(
257 value: &str,
258 field: &str,
259 error_fn: fn(String) -> ExternalAuthError,
260) -> Result<Url> {
261 let parsed = parse_url(value, &format!("invalid OAuth2 {field}"))
262 .map_err(|error| error_fn(error.to_string()))?;
263 if !aster_forge_utils::url::has_http_scheme(&parsed) {
264 return Err(error_fn(format!(
265 "unsupported URL scheme for OAuth2 {field}, only http/https allowed"
266 )));
267 }
268 Ok(parsed)
269}
270
271fn build_pkce_verifier() -> String {
272 let mut bytes = [0_u8; 32];
273 let mut rng = rand::rng();
274 rng.fill(&mut bytes);
275 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
276}
277
278fn build_pkce_challenge(verifier: &str) -> String {
279 let digest = Sha256::digest(verifier.as_bytes());
280 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
281}
282
283pub(super) async fn exchange_code_for_token(
284 http_client: &reqwest::Client,
285 provider: &ExternalAuthProviderConfig,
286 code: &str,
287 redirect_uri: &str,
288 pkce_verifier: &str,
289) -> Result<String> {
290 let token_url = require_url(
291 provider.token_url.as_deref(),
292 "token_url",
293 ExternalAuthError::config_error,
294 )?;
295 validate_url(token_url, "token_url", ExternalAuthError::config_error)?;
296
297 let client_secret = provider
298 .client_secret
299 .as_deref()
300 .map(str::trim)
301 .filter(|secret| !secret.is_empty());
302 let token_request = OAuth2TokenRequest {
303 token_url,
304 provider,
305 code,
306 redirect_uri,
307 pkce_verifier,
308 auth_method: OAuth2TokenAuthMethod::PublicClient,
309 client_secret,
310 };
311 let response = if client_secret.is_some() {
312 send_token_request(
313 http_client,
314 token_request.with_auth_method(OAuth2TokenAuthMethod::ClientSecretPost),
315 )
316 .await?
317 } else {
318 send_token_request(http_client, token_request).await?
319 };
320 if !response.status().is_success() {
321 return Err(oauth2_endpoint_error(response, "OAuth2 token exchange").await);
322 }
323 let token_response = response
324 .json::<OAuth2TokenResponse>()
325 .await
326 .map_external_auth_err_ctx(
327 "OAuth2 token response is invalid",
328 ExternalAuthError::auth_invalid_credentials,
329 )?;
330 if token_response.access_token.trim().is_empty() {
331 return Err(ExternalAuthError::auth_invalid_credentials(
332 "OAuth2 token response missing access_token",
333 ));
334 }
335 if let Some(token_type) = token_response.token_type.as_deref()
336 && !token_type.eq_ignore_ascii_case("bearer")
337 {
338 return Err(ExternalAuthError::auth_invalid_credentials(
339 "OAuth2 token response returned unsupported token_type",
340 ));
341 }
342 Ok(token_response.access_token)
343}
344
345async fn send_token_request(
346 http_client: &reqwest::Client,
347 token_request: OAuth2TokenRequest<'_>,
348) -> Result<reqwest::Response> {
349 let form = {
350 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
351 serializer.append_pair("grant_type", "authorization_code");
352 serializer.append_pair("code", token_request.code);
353 serializer.append_pair("redirect_uri", token_request.redirect_uri);
354 serializer.append_pair("code_verifier", token_request.pkce_verifier);
355 match token_request.auth_method {
356 OAuth2TokenAuthMethod::ClientSecretPost => {
357 serializer.append_pair("client_id", &token_request.provider.client_id);
358 if let Some(secret) = token_request.client_secret {
359 serializer.append_pair("client_secret", secret);
360 }
361 }
362 OAuth2TokenAuthMethod::PublicClient => {
363 serializer.append_pair("client_id", &token_request.provider.client_id);
364 }
365 }
366 serializer.finish()
367 };
368
369 let request = http_client
370 .post(token_request.token_url)
371 .header(header::ACCEPT, "application/json")
372 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
373 .body(form);
374
375 request.send().await.map_external_auth_err_ctx(
376 "OAuth2 token exchange failed",
377 ExternalAuthError::auth_invalid_credentials,
378 )
379}
380
381pub(super) async fn oauth2_endpoint_error(
382 response: reqwest::Response,
383 context: &str,
384) -> ExternalAuthError {
385 let status = response.status();
386 let www_authenticate = response
387 .headers()
388 .get(header::WWW_AUTHENTICATE)
389 .and_then(|value| value.to_str().ok())
390 .map(sanitize_error_fragment)
391 .filter(|value| !value.is_empty());
392 let provider_error = response.json::<OAuth2ErrorResponse>().await.ok();
393
394 let mut details = Vec::new();
395 if let Some(error) = provider_error
396 .as_ref()
397 .and_then(|body| body.error.as_deref())
398 .map(sanitize_error_fragment)
399 .filter(|error| !error.is_empty())
400 {
401 details.push(format!("error={error}"));
402 }
403 if let Some(description) = provider_error
404 .as_ref()
405 .and_then(|body| body.error_description.as_deref())
406 .map(sanitize_error_fragment)
407 .filter(|description| !description.is_empty())
408 {
409 details.push(format!("description={description}"));
410 }
411 if let Some(www_authenticate) = www_authenticate {
412 details.push(format!("www-authenticate={www_authenticate}"));
413 }
414
415 if details.is_empty() {
416 ExternalAuthError::auth_invalid_credentials(format!("{context} failed ({status})"))
417 } else {
418 ExternalAuthError::auth_invalid_credentials(format!(
419 "{context} failed ({status}; {})",
420 details.join("; ")
421 ))
422 }
423}
424
425fn sanitize_error_fragment(value: &str) -> String {
426 value
427 .chars()
428 .filter(|ch| !ch.is_control())
429 .take(128)
430 .collect::<String>()
431 .trim()
432 .to_string()
433}
434
435pub(super) async fn fetch_userinfo(
436 http_client: &reqwest::Client,
437 provider: &ExternalAuthProviderConfig,
438 access_token: &str,
439) -> Result<Value> {
440 let userinfo_url = require_url(
441 provider.userinfo_url.as_deref(),
442 "userinfo_url",
443 ExternalAuthError::config_error,
444 )?;
445 validate_url(
446 userinfo_url,
447 "userinfo_url",
448 ExternalAuthError::config_error,
449 )?;
450 let response = http_client
451 .get(userinfo_url)
452 .bearer_auth(access_token)
453 .header(header::ACCEPT, "application/json")
454 .send()
455 .await
456 .map_external_auth_err_ctx(
457 "OAuth2 userinfo request failed",
458 ExternalAuthError::auth_invalid_credentials,
459 )?;
460 if !response.status().is_success() {
461 return Err(oauth2_endpoint_error(response, "OAuth2 userinfo request").await);
462 }
463 response.json::<Value>().await.map_external_auth_err_ctx(
464 "OAuth2 userinfo response is invalid",
465 ExternalAuthError::auth_invalid_credentials,
466 )
467}
468
469pub(super) fn profile_from_userinfo(
470 provider: &ExternalAuthProviderConfig,
471 userinfo: &Value,
472) -> Result<ExternalAuthProfile> {
473 let subject_claim = provider.subject_claim.as_deref().unwrap_or("sub");
474 let subject = extract_claim_string(userinfo, subject_claim)
475 .or_else(|| {
476 if subject_claim == "sub" {
477 extract_claim_string(userinfo, "id")
478 } else {
479 None
480 }
481 })
482 .ok_or_else(|| {
483 ExternalAuthError::auth_invalid_credentials("OAuth2 userinfo missing subject")
484 })?;
485 let subject = validate_required_claim(&subject, "OAuth2 subject", OAUTH2_SUBJECT_MAX_LEN)?;
486
487 let email = extract_claim_string(userinfo, provider.email_claim.as_deref().unwrap_or("email"))
488 .map(|email| email.trim().to_string())
489 .filter(|email| !email.is_empty());
490 if let Some(email) = email.as_deref() {
491 aster_forge_validation::email::validate_email(email).map_err(|_| {
492 ExternalAuthError::auth_invalid_credentials("OAuth2 email claim is invalid")
493 })?;
494 }
495
496 Ok(ExternalAuthProfile {
497 identity_namespace: identity_namespace(provider)?,
498 subject,
499 email,
500 email_verified: extract_claim_bool(
501 userinfo,
502 provider
503 .email_verified_claim
504 .as_deref()
505 .unwrap_or("email_verified"),
506 )
507 .unwrap_or(false),
508 display_name: normalize_optional_snapshot(extract_claim_string(
509 userinfo,
510 provider.display_name_claim.as_deref().unwrap_or("name"),
511 )),
512 preferred_username: normalize_optional_snapshot(extract_claim_string(
513 userinfo,
514 provider
515 .username_claim
516 .as_deref()
517 .unwrap_or("preferred_username"),
518 )),
519 })
520}
521
522fn identity_namespace(provider: &ExternalAuthProviderConfig) -> Result<String> {
523 if let Some(issuer) = provider
524 .issuer_url
525 .as_deref()
526 .map(str::trim)
527 .filter(|issuer| !issuer.is_empty())
528 {
529 return validate_required_claim(issuer, "OAuth2 issuer", OAUTH2_NAMESPACE_MAX_LEN);
530 }
531 let authorization_url = require_url(
532 provider.authorization_url.as_deref(),
533 "authorization_url",
534 ExternalAuthError::config_error,
535 )?;
536 let parsed = validate_url(
537 authorization_url,
538 "authorization_url",
539 ExternalAuthError::config_error,
540 )?;
541 let origin = parsed.origin().ascii_serialization();
542 validate_required_claim(&origin, "OAuth2 origin", OAUTH2_NAMESPACE_MAX_LEN)
543}
544
545fn extract_claim_string(value: &Value, claim: &str) -> Option<String> {
546 extract_claim_value(value, claim).and_then(|value| match value {
547 Value::String(value) => Some(value.trim().to_string()),
548 Value::Number(value) => Some(value.to_string()),
549 _ => None,
550 })
551}
552
553fn extract_claim_bool(value: &Value, claim: &str) -> Option<bool> {
554 extract_claim_value(value, claim).and_then(|value| match value {
555 Value::Bool(value) => Some(*value),
556 Value::String(value) if value.eq_ignore_ascii_case("true") => Some(true),
557 Value::String(value) if value.eq_ignore_ascii_case("false") => Some(false),
558 _ => None,
559 })
560}
561
562fn extract_claim_value<'a>(value: &'a Value, claim: &str) -> Option<&'a Value> {
563 let claim = claim.trim();
564 if claim.is_empty() {
565 return None;
566 }
567 if claim.starts_with('/') {
568 return value.pointer(claim);
569 }
570 if let Some(found) = value.get(claim) {
571 return Some(found);
572 }
573 claim
574 .split('.')
575 .try_fold(value, |current, segment| current.get(segment))
576}
577
578fn validate_required_claim(value: &str, field: &str, max_len: usize) -> Result<String> {
579 let value = value.trim();
580 if value.is_empty() || value.len() > max_len || value.chars().any(char::is_control) {
581 return Err(ExternalAuthError::auth_invalid_credentials(format!(
582 "{field} claim is invalid"
583 )));
584 }
585 Ok(value.to_string())
586}
587
588fn truncate_to_utf8_boundary(value: &str, max_len: usize) -> String {
589 if value.len() <= max_len {
590 return value.to_string();
591 }
592 let mut end = max_len;
593 while !value.is_char_boundary(end) {
594 end -= 1;
595 }
596 value[..end].to_string()
597}
598
599fn normalize_optional_snapshot(value: Option<String>) -> Option<String> {
600 value
601 .map(|value| {
602 value
603 .chars()
604 .filter(|ch| !ch.is_control())
605 .collect::<String>()
606 })
607 .map(|value| value.trim().to_string())
608 .filter(|value| !value.is_empty())
609 .map(|value| truncate_to_utf8_boundary(&value, OAUTH2_SNAPSHOT_MAX_LEN))
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 fn provider() -> ExternalAuthProviderConfig {
617 ExternalAuthProviderConfig {
618 id: 1,
619 key: "generic".to_string(),
620 provider_kind: ExternalAuthProviderKind::GenericOAuth2,
621 protocol: ExternalAuthProtocol::OAuth2,
622 options: Default::default(),
623 issuer_url: None,
624 authorization_url: Some("https://id.example.com/oauth/authorize".to_string()),
625 token_url: Some("https://id.example.com/oauth/token".to_string()),
626 userinfo_url: Some("https://id.example.com/oauth/userinfo".to_string()),
627 client_id: "client".to_string(),
628 client_secret: None,
629 scopes: OAUTH2_DEFAULT_SCOPES.to_string(),
630 subject_claim: None,
631 username_claim: None,
632 display_name_claim: None,
633 email_claim: None,
634 email_verified_claim: None,
635 groups_claim: None,
636 avatar_url_claim: None,
637 outbound_http_user_agent: None,
638 }
639 }
640
641 #[test]
642 fn profile_supports_json_pointer_and_dotted_claims() {
643 let mut provider = provider();
644 provider.subject_claim = Some("/user/id".to_string());
645 provider.username_claim = Some("user.login".to_string());
646 provider.email_claim = Some("mail.primary".to_string());
647 provider.email_verified_claim = Some("mail.verified".to_string());
648 let userinfo = serde_json::json!({
649 "user": { "id": 123, "login": "octo" },
650 "mail": { "primary": "octo@example.com", "verified": "true" },
651 "name": "Octo Cat"
652 });
653
654 let profile = profile_from_userinfo(&provider, &userinfo).expect("profile should parse");
655
656 assert_eq!(profile.subject, "123");
657 assert_eq!(profile.email.as_deref(), Some("octo@example.com"));
658 assert!(profile.email_verified);
659 assert_eq!(profile.preferred_username.as_deref(), Some("octo"));
660 }
661
662 #[test]
663 fn profile_defaults_unverified_when_claim_is_missing() {
664 let userinfo = serde_json::json!({
665 "id": "github-1",
666 "email": "user@example.com"
667 });
668
669 let profile = profile_from_userinfo(&provider(), &userinfo).expect("profile should parse");
670
671 assert_eq!(profile.subject, "github-1");
672 assert!(!profile.email_verified);
673 }
674
675 #[test]
676 fn validate_url_rejects_non_http_schemes() {
677 let err = validate_url(
678 "file:///tmp/token",
679 "token_url",
680 ExternalAuthError::config_error,
681 )
682 .expect_err("non-http OAuth2 URL should be rejected");
683
684 assert!(
685 err.to_string()
686 .contains("unsupported URL scheme for OAuth2 token_url")
687 );
688 }
689
690 #[test]
691 fn pkce_verifier_uses_valid_rfc7636_shape() {
692 let verifier = build_pkce_verifier();
693
694 assert!(verifier.len() >= 43);
695 assert!(verifier.len() <= 128);
696 assert!(
697 verifier
698 .chars()
699 .all(|ch| { ch.is_ascii_alphanumeric() || matches!(ch, '-' | '.' | '_' | '~') })
700 );
701 }
702
703 #[actix_web::test]
704 async fn oauth2_http_client_uses_default_crate_user_agent_when_app_value_is_missing() {
705 let user_agent = observed_user_agent(None).await;
706
707 assert_eq!(user_agent.as_deref(), Some(crate::OUTBOUND_HTTP_USER_AGENT));
708 }
709
710 #[actix_web::test]
711 async fn oauth2_http_client_uses_application_user_agent() {
712 let user_agent = observed_user_agent(Some("AsterYggdrasil/0.1.0-beta.1")).await;
713
714 assert_eq!(user_agent.as_deref(), Some("AsterYggdrasil/0.1.0-beta.1"));
715 }
716
717 #[actix_web::test]
718 async fn oauth2_http_client_falls_back_for_blank_application_user_agent() {
719 let user_agent = observed_user_agent(Some(" \t\n ")).await;
720
721 assert_eq!(user_agent.as_deref(), Some(crate::OUTBOUND_HTTP_USER_AGENT));
722 }
723
724 async fn observed_user_agent(configured_user_agent: Option<&str>) -> Option<String> {
725 use actix_web::{App, HttpRequest, HttpResponse, HttpServer, web};
726
727 async fn echo_user_agent(req: HttpRequest) -> HttpResponse {
728 let user_agent = req
729 .headers()
730 .get("User-Agent")
731 .and_then(|value| value.to_str().ok())
732 .map(str::to_string);
733 HttpResponse::Ok().json(user_agent)
734 }
735
736 let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
737 let addr = listener
738 .local_addr()
739 .expect("listener local address should resolve");
740 let server = HttpServer::new(|| App::new().route("/", web::get().to(echo_user_agent)))
741 .listen(listener)
742 .expect("server should listen")
743 .run();
744 let handle = server.handle();
745 actix_web::rt::spawn(server);
746
747 let mut provider = provider();
748 provider.outbound_http_user_agent = configured_user_agent.map(str::to_string);
749 let http_client = oauth2_http_client(&provider).expect("HTTP client should build");
750 let response = http_client
751 .get(format!("http://{addr}/"))
752 .send()
753 .await
754 .expect("request should succeed");
755 let user_agent = response
756 .json::<Option<String>>()
757 .await
758 .expect("response should decode");
759
760 handle.stop(true).await;
761 user_agent
762 }
763
764 #[actix_web::test]
765 async fn userinfo_error_includes_safe_provider_diagnostics() {
766 use actix_web::{App, HttpResponse, HttpServer, web};
767
768 async fn unauthorized_userinfo() -> HttpResponse {
769 HttpResponse::Unauthorized()
770 .append_header((
771 "WWW-Authenticate",
772 r#"Bearer error="insufficient_scope", error_description="missing openid""#,
773 ))
774 .json(serde_json::json!({
775 "error": "invalid_token",
776 "error_description": "missing openid scope"
777 }))
778 }
779
780 let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("listener should bind");
781 let addr = listener
782 .local_addr()
783 .expect("listener address should exist");
784 let server =
785 HttpServer::new(|| App::new().route("/userinfo", web::get().to(unauthorized_userinfo)))
786 .listen(listener)
787 .expect("mock server should listen")
788 .run();
789 let handle = server.handle();
790 tokio::spawn(server);
791
792 let mut provider = provider();
793 provider.userinfo_url = Some(format!("http://127.0.0.1:{}/userinfo", addr.port()));
794 let http_client = oauth2_http_client(&provider).expect("HTTP client should build");
795
796 let error = fetch_userinfo(&http_client, &provider, "opaque-access-token")
797 .await
798 .expect_err("userinfo should fail");
799 let message = error.to_string();
800
801 assert!(message.contains("OAuth2 userinfo request failed (401 Unauthorized"));
802 assert!(message.contains("error=invalid_token"));
803 assert!(message.contains("description=missing openid scope"));
804 assert!(message.contains("www-authenticate=Bearer"));
805
806 handle.stop(true).await;
807 }
808}