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