Skip to main content

aster_forge_external_auth/providers/
oauth2.rs

1//! Generic OAuth2 authorization-code provider driver.
2//!
3//! This driver supports manually configured authorization, token, and userinfo endpoints. It owns
4//! the shared OAuth2 mechanics reused by fixed-endpoint providers: PKCE generation, token exchange,
5//! userinfo retrieval, safe endpoint diagnostics, and JSON claim extraction with dotted-path and
6//! JSON-pointer support.
7
8use 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/// Generic OAuth2 provider driver with manually configured endpoints.
60#[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    /// Creates a generic OAuth2 provider driver.
80    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
228/// Builds the outbound HTTP client shared by Generic OAuth2 and specialized
229/// OAuth2-backed providers such as GitHub.
230///
231/// GitHub rejects API calls without a User-Agent header, so keep the project
232/// user agent on the shared client instead of setting it per request.
233pub(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}