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 OAuth2TokenRequest<'_> {
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    #[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
229/// Builds the outbound HTTP client shared by Generic `OAuth2` and specialized
230/// OAuth2-backed providers such as GitHub.
231///
232/// GitHub rejects API calls without a User-Agent header, so keep the project
233/// user agent on the shared client instead of setting it per request.
234pub(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}