aster_forge_external_auth/providers/
microsoft.rs

1//! Microsoft Entra ID / Microsoft Account provider driver.
2//!
3//! Microsoft uses OIDC with tenant-aware issuer handling. This module normalizes tenant selectors,
4//! builds Microsoft discovery URLs, accepts Microsoft multi-tenant issuer templates where
5//! appropriate, and validates token issuers against the configured tenant policy.
6
7use async_trait::async_trait;
8use openidconnect::core::{
9    CoreClient, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreProviderMetadata,
10};
11use openidconnect::{ClientId, IssuerUrl, Nonce};
12
13use crate::driver::{
14    ExternalAuthAuthorizationStart, ExternalAuthCallback, ExternalAuthProfile,
15    ExternalAuthProviderConfig, ExternalAuthProviderDescriptor, ExternalAuthProviderDriver,
16    ExternalAuthProviderTestCheck, ExternalAuthProviderTestResult,
17};
18use crate::types::{ExternalAuthProtocol, ExternalAuthProviderKind};
19use crate::{ExternalAuthError, MapExternalAuthErr, Result};
20use aster_forge_utils::net::is_loopback_host;
21
22use super::oidc::{
23    OIDC_RESPONSE_MAX_BYTES, OidcClient, oidc_http_client, profile_from_id_token,
24    start_authorization_with_oidc_client,
25};
26
27const MICROSOFT_DEFAULT_TENANT: &str = "common";
28const MICROSOFT_LOGIN_HOST: &str = "login.microsoftonline.com";
29const MICROSOFT_LOGIN_BASE: &str = "https://login.microsoftonline.com";
30const MICROSOFT_DEFAULT_SCOPES: &str = "openid profile email";
31const MICROSOFT_ACCOUNT_TENANT_ID: &str = "9188040d-6c67-4c5b-b112-36a304b66dad";
32const MICROSOFT_TENANT_ID_LEN: usize = 36;
33const MICROSOFT_TENANT_ID_HYPHEN_POSITIONS: [usize; 4] = [8, 13, 18, 23];
34
35/// Dedicated Microsoft sign-in provider backed by the generic OIDC driver.
36#[derive(Default)]
37pub struct MicrosoftProviderDriver;
38
39impl MicrosoftProviderDriver {
40    /// Creates a Microsoft provider driver with fixed Microsoft OIDC defaults.
41    #[must_use]
42    pub fn new() -> Self {
43        Self
44    }
45}
46
47#[async_trait]
48impl ExternalAuthProviderDriver for MicrosoftProviderDriver {
49    fn kind(&self) -> ExternalAuthProviderKind {
50        ExternalAuthProviderKind::Microsoft
51    }
52
53    fn descriptor(&self) -> ExternalAuthProviderDescriptor {
54        ExternalAuthProviderDescriptor {
55            kind: ExternalAuthProviderKind::Microsoft,
56            protocol: ExternalAuthProtocol::Oidc,
57            display_name: "Microsoft",
58            description: "Microsoft Entra ID OpenID Connect sign-in with tenant-aware issuer handling.",
59            default_scopes: MICROSOFT_DEFAULT_SCOPES,
60            issuer_url_required: false,
61            manual_endpoint_configuration_supported: false,
62            authorization_url_required: false,
63            token_url_required: false,
64            userinfo_url_required: false,
65            supports_discovery: true,
66            supports_pkce: true,
67            supports_email_verified_claim: false,
68        }
69    }
70
71    async fn start_authorization(
72        &self,
73        provider: &ExternalAuthProviderConfig,
74        redirect_uri: &str,
75    ) -> Result<ExternalAuthAuthorizationStart> {
76        let provider = microsoft_oidc_config(provider)?;
77        let client = build_microsoft_client(&provider, redirect_uri).await?;
78        Ok(start_authorization_with_oidc_client(&provider, &client))
79    }
80
81    async fn exchange_callback(
82        &self,
83        provider: &ExternalAuthProviderConfig,
84        callback: ExternalAuthCallback,
85    ) -> Result<ExternalAuthProfile> {
86        exchange_microsoft_callback(&microsoft_oidc_config(provider)?, callback).await
87    }
88
89    async fn test_provider(
90        &self,
91        provider: &ExternalAuthProviderConfig,
92    ) -> Result<ExternalAuthProviderTestResult> {
93        test_microsoft_provider(&microsoft_oidc_config(provider)?).await
94    }
95}
96
97/// Normalizes admin input into the Microsoft OIDC issuer stored on the provider.
98///
99/// Empty input falls back to `common`. Non-URL input is interpreted as a tenant
100/// selector (`common`, `organizations`, `consumers`, or a tenant UUID), while a
101/// full URL must already point at `login.microsoftonline.com/{tenant}/v2.0`.
102///
103/// # Errors
104///
105/// Returns [`ExternalAuthError`] when the Microsoft tenant or issuer input is invalid.
106pub fn normalize_microsoft_tenant_or_issuer_url(value: Option<String>) -> Result<Option<String>> {
107    let Some(value) = value else {
108        return Ok(Some(microsoft_issuer_url_for_tenant(
109            MICROSOFT_DEFAULT_TENANT,
110        )));
111    };
112    let trimmed = value.trim();
113    if trimmed.is_empty() {
114        return Ok(Some(microsoft_issuer_url_for_tenant(
115            MICROSOFT_DEFAULT_TENANT,
116        )));
117    }
118    if starts_with_http_url(trimmed) {
119        return normalize_microsoft_issuer_url(trimmed).map(Some);
120    }
121    let tenant = canonicalize_microsoft_tenant(trimmed)?;
122    Ok(Some(microsoft_issuer_url_for_tenant(&tenant)))
123}
124
125/// Normalizes a Microsoft tenant selector from admin input.
126///
127/// Empty input returns the default `common` tenant. A full Microsoft issuer URL is accepted and
128/// reduced to its tenant segment. The result is always lowercase for tenant aliases and tenant IDs.
129///
130/// # Errors
131///
132/// Returns [`ExternalAuthError`] when the Microsoft tenant or issuer input is invalid.
133pub fn normalize_microsoft_tenant_input(value: Option<String>) -> Result<String> {
134    let Some(value) = value else {
135        return Ok(MICROSOFT_DEFAULT_TENANT.to_string());
136    };
137    let trimmed = value.trim();
138    if trimmed.is_empty() {
139        return Ok(MICROSOFT_DEFAULT_TENANT.to_string());
140    }
141    if starts_with_http_url(trimmed) {
142        return normalize_microsoft_issuer_url(trimmed)
143            .and_then(|issuer| microsoft_tenant_from_issuer_string(&issuer));
144    }
145    canonicalize_microsoft_tenant(trimmed)
146}
147
148/// Applies fixed Microsoft defaults before delegating to the generic OIDC driver.
149///
150/// Tests may still inject a loopback issuer so local mock OIDC servers can
151/// exercise the callback path without pretending to be Microsoft.
152fn microsoft_oidc_config(
153    provider: &ExternalAuthProviderConfig,
154) -> Result<ExternalAuthProviderConfig> {
155    let mut provider = provider.clone();
156    provider.provider_kind = ExternalAuthProviderKind::Microsoft;
157    provider.protocol = ExternalAuthProtocol::Oidc;
158    provider.issuer_url = Some(microsoft_effective_issuer_url(&provider)?);
159    provider.authorization_url = None;
160    provider.token_url = None;
161    provider.userinfo_url = None;
162    provider.scopes = if provider.scopes.trim().is_empty() {
163        MICROSOFT_DEFAULT_SCOPES.to_string()
164    } else {
165        provider.scopes.trim().to_string()
166    };
167    provider.subject_claim = provider.subject_claim.or_else(|| Some("sub".to_string()));
168    provider.display_name_claim = provider
169        .display_name_claim
170        .or_else(|| Some("name".to_string()));
171    provider.email_claim = provider.email_claim.or_else(|| Some("email".to_string()));
172    provider.email_verified_claim = None;
173    provider.avatar_url_claim = None;
174    Ok(provider)
175}
176
177fn microsoft_effective_issuer_url(provider: &ExternalAuthProviderConfig) -> Result<String> {
178    if let Some(tenant) = provider
179        .options
180        .microsoft
181        .as_ref()
182        .map(|options| options.tenant.trim())
183        .filter(|tenant| !tenant.is_empty())
184    {
185        let tenant = canonicalize_microsoft_tenant(tenant)?;
186        return Ok(microsoft_issuer_url_for_tenant(&tenant));
187    }
188
189    let Some(issuer_url) = provider.issuer_url.as_deref() else {
190        return Ok(microsoft_issuer_url_for_tenant(MICROSOFT_DEFAULT_TENANT));
191    };
192    let issuer_url = issuer_url.trim();
193    if issuer_url.is_empty() {
194        return Ok(microsoft_issuer_url_for_tenant(MICROSOFT_DEFAULT_TENANT));
195    }
196    if starts_with_http_url(issuer_url) {
197        return normalize_microsoft_issuer_url(issuer_url);
198    }
199    normalize_microsoft_tenant_or_issuer_url(Some(issuer_url.to_string()))?
200        .ok_or_else(|| ExternalAuthError::validation_error("Microsoft issuer URL is missing"))
201}
202
203async fn exchange_microsoft_callback(
204    provider: &ExternalAuthProviderConfig,
205    callback: ExternalAuthCallback,
206) -> Result<ExternalAuthProfile> {
207    let nonce = callback
208        .nonce
209        .ok_or_else(|| ExternalAuthError::database_operation("stored OIDC nonce is missing"))?;
210    let pkce_verifier = callback.pkce_verifier.clone().ok_or_else(|| {
211        ExternalAuthError::database_operation("stored OIDC PKCE verifier is missing")
212    })?;
213    let metadata = discover_microsoft_provider(provider).await?;
214    let client = build_client_from_metadata(provider, &callback.redirect_uri, metadata.clone())?;
215    let http_client = oidc_http_client(provider)?;
216    let token_request = client
217        .exchange_code(openidconnect::AuthorizationCode::new(callback.code))
218        .map_external_auth_err_ctx(
219            "OIDC provider metadata missing token endpoint",
220            ExternalAuthError::config_error,
221        )?;
222    let token_response = token_request
223        .set_pkce_verifier(openidconnect::PkceCodeVerifier::new(pkce_verifier))
224        .set_redirect_uri(std::borrow::Cow::Owned(
225            openidconnect::RedirectUrl::new(callback.redirect_uri.clone())
226                .map_external_auth_err_ctx(
227                    "invalid stored OIDC redirect URI",
228                    ExternalAuthError::database_operation,
229                )?,
230        ))
231        .request_async(&http_client)
232        .await
233        .map_external_auth_err_ctx(
234            "OIDC token exchange failed",
235            ExternalAuthError::auth_invalid_credentials,
236        )?;
237
238    let id_token = token_response.extra_fields().id_token().ok_or_else(|| {
239        ExternalAuthError::auth_invalid_credentials("OIDC token response missing id_token")
240    })?;
241    let verifier = CoreIdTokenVerifier::new_public_client(
242        ClientId::new(provider.client_id.clone()),
243        IssuerUrl::new(provider.require_issuer_url()?.to_string()).map_external_auth_err_ctx(
244            "invalid Microsoft issuer URL",
245            ExternalAuthError::validation_error,
246        )?,
247        metadata.jwks().clone(),
248    )
249    .require_issuer_match(false);
250    let nonce = Nonce::new(nonce);
251    let claims = id_token
252        .claims(&verifier, &nonce)
253        .map_external_auth_err_ctx(
254            "OIDC ID token verification failed",
255            ExternalAuthError::auth_invalid_credentials,
256        )?;
257    let mut profile = profile_from_id_token(claims)?;
258    apply_microsoft_profile_overrides(&mut profile);
259    validate_microsoft_token_issuer(provider.require_issuer_url()?, &profile.identity_namespace)?;
260    Ok(profile)
261}
262
263/// Applies Microsoft-specific trust overrides to the extracted profile.
264///
265/// Microsoft ID tokens may carry `email` and `email_verified` claims, but in multi-tenant
266/// deployments (`common` / `organizations`) any Entra tenant administrator controls the
267/// claims their tenant emits through claims-mapping policies. The flag therefore says
268/// nothing about mailbox ownership, and products must never auto-link accounts on it. The
269/// descriptor already declares `supports_email_verified_claim: false`; enforce it here so
270/// the untrusted claim can never pass through.
271fn apply_microsoft_profile_overrides(profile: &mut ExternalAuthProfile) {
272    profile.email_verified = false;
273}
274
275async fn test_microsoft_provider(
276    provider: &ExternalAuthProviderConfig,
277) -> Result<ExternalAuthProviderTestResult> {
278    let metadata = discover_microsoft_provider(provider).await?;
279    let token_endpoint = metadata.token_endpoint().ok_or_else(|| {
280        ExternalAuthError::validation_error("OIDC discovery metadata missing token_endpoint")
281    })?;
282    let jwks_key_count = metadata.jwks().keys().len();
283    Ok(ExternalAuthProviderTestResult {
284        provider: "Microsoft".to_string(),
285        issuer: Some(metadata.issuer().as_str().to_string()),
286        authorization_endpoint: Some(metadata.authorization_endpoint().as_str().to_string()),
287        token_endpoint: Some(token_endpoint.as_str().to_string()),
288        userinfo_endpoint: metadata
289            .userinfo_endpoint()
290            .map(|url| url.as_str().to_string()),
291        jwks_key_count: Some(jwks_key_count),
292        checks: vec![
293            ExternalAuthProviderTestCheck {
294                name: "discovery".to_string(),
295                success: true,
296                message: "Microsoft OIDC discovery metadata was loaded".to_string(),
297            },
298            ExternalAuthProviderTestCheck {
299                name: "jwks".to_string(),
300                success: true,
301                message: format!("JWKS contains {jwks_key_count} key(s)"),
302            },
303        ],
304    })
305}
306
307async fn build_microsoft_client(
308    provider: &ExternalAuthProviderConfig,
309    redirect_uri: &str,
310) -> Result<OidcClient> {
311    let metadata = discover_microsoft_provider(provider).await?;
312    build_client_from_metadata(provider, redirect_uri, metadata)
313}
314
315fn build_client_from_metadata(
316    provider: &ExternalAuthProviderConfig,
317    redirect_uri: &str,
318    metadata: CoreProviderMetadata,
319) -> Result<OidcClient> {
320    let client_secret = provider
321        .client_secret
322        .clone()
323        .filter(|secret| !secret.is_empty())
324        .map(openidconnect::ClientSecret::new);
325    let redirect_uri = openidconnect::RedirectUrl::new(redirect_uri.to_string())
326        .map_external_auth_err_ctx(
327            "invalid OIDC redirect URI",
328            ExternalAuthError::validation_error,
329        )?;
330    Ok(CoreClient::from_provider_metadata(
331        metadata,
332        ClientId::new(provider.client_id.clone()),
333        client_secret,
334    )
335    .set_redirect_uri(redirect_uri))
336}
337
338async fn discover_microsoft_provider(
339    provider: &ExternalAuthProviderConfig,
340) -> Result<CoreProviderMetadata> {
341    let configured_issuer = provider.require_issuer_url()?;
342    let discovery_url = microsoft_discovery_url(configured_issuer)?;
343    let http_client = oidc_http_client(provider)?;
344    let response = http_client
345        .reqwest()
346        .get(discovery_url.as_str())
347        .send()
348        .await
349        .map_external_auth_err_ctx("OIDC discovery failed", ExternalAuthError::validation_error)?;
350    let status = response.status();
351    if !status.is_success() {
352        return Err(ExternalAuthError::validation_error(format!(
353            "OIDC discovery failed: HTTP status code {status} at {discovery_url}"
354        )));
355    }
356    let body = aster_forge_http::read_reqwest_body_limited(
357        response,
358        "Microsoft OIDC discovery response",
359        OIDC_RESPONSE_MAX_BYTES,
360        ExternalAuthError::validation_error,
361    )
362    .await?;
363    let metadata: CoreProviderMetadata = serde_json::from_slice(&body).map_external_auth_err_ctx(
364        "OIDC discovery metadata parse failed",
365        ExternalAuthError::validation_error,
366    )?;
367    validate_microsoft_discovery_issuer(configured_issuer, metadata.issuer().as_str())?;
368    let jwks = CoreJsonWebKeySet::fetch_async(metadata.jwks_uri(), &http_client)
369        .await
370        .map_external_auth_err_ctx(
371            "OIDC JWKS fetch failed",
372            ExternalAuthError::validation_error,
373        )?;
374    Ok(metadata.set_jwks(jwks))
375}
376
377fn microsoft_discovery_url(configured_issuer: &str) -> Result<reqwest::Url> {
378    let issuer = reqwest::Url::parse(configured_issuer).map_external_auth_err_ctx(
379        "invalid Microsoft issuer URL",
380        ExternalAuthError::validation_error,
381    )?;
382    if issuer.scheme() == "https" && issuer.host_str() == Some(MICROSOFT_LOGIN_HOST) {
383        let tenant = microsoft_tenant_from_issuer_url(&issuer)?;
384        return reqwest::Url::parse(&format!(
385            "{MICROSOFT_LOGIN_BASE}/{tenant}/v2.0/.well-known/openid-configuration"
386        ))
387        .map_external_auth_err_ctx(
388            "invalid Microsoft discovery URL",
389            ExternalAuthError::validation_error,
390        );
391    }
392    append_well_known_openid_configuration(issuer)
393}
394
395fn append_well_known_openid_configuration(mut issuer: reqwest::Url) -> Result<reqwest::Url> {
396    {
397        let mut paths = issuer
398            .path_segments_mut()
399            .map_err(|()| ExternalAuthError::validation_error("invalid Microsoft issuer URL"))?;
400        paths.pop_if_empty();
401        paths.push(".well-known");
402        paths.push("openid-configuration");
403    }
404    issuer.set_query(None);
405    issuer.set_fragment(None);
406    Ok(issuer)
407}
408
409fn validate_microsoft_discovery_issuer(
410    configured_issuer: &str,
411    discovery_issuer: &str,
412) -> Result<()> {
413    if configured_issuer == discovery_issuer {
414        return Ok(());
415    }
416    let configured = reqwest::Url::parse(configured_issuer).map_external_auth_err_ctx(
417        "invalid configured Microsoft issuer URL",
418        ExternalAuthError::validation_error,
419    )?;
420    if configured.scheme() == "http" && configured.host_str().is_some_and(is_loopback_host) {
421        return Err(ExternalAuthError::validation_error(
422            "Microsoft discovery issuer does not match configured provider",
423        ));
424    }
425    if configured.scheme() != "https" || configured.host_str() != Some(MICROSOFT_LOGIN_HOST) {
426        return Err(ExternalAuthError::validation_error(
427            "Microsoft discovery issuer is not trusted",
428        ));
429    }
430    let configured_tenant = microsoft_tenant_from_issuer_url(&configured)?;
431    if !is_microsoft_multi_tenant_alias(&configured_tenant) {
432        return Err(ExternalAuthError::validation_error(
433            "Microsoft discovery issuer does not match configured provider",
434        ));
435    }
436    // Microsoft tenant-independent metadata returns a templated issuer instead
437    // of the exact configured alias, for example common -> {tenantid}.
438    let expected_template = microsoft_issuer_url_for_tenant("{tenantid}");
439    if discovery_issuer == expected_template {
440        return Ok(());
441    }
442    validate_microsoft_token_issuer(configured_issuer, discovery_issuer).map_err(|_| {
443        ExternalAuthError::validation_error(
444            "Microsoft discovery issuer does not match configured provider",
445        )
446    })
447}
448
449/// Builds the public Microsoft identity platform issuer for a tenant selector.
450fn microsoft_issuer_url_for_tenant(tenant: &str) -> String {
451    format!("{MICROSOFT_LOGIN_BASE}/{}/v2.0", tenant.trim())
452}
453
454/// Normalizes a full Microsoft issuer URL and rejects non-Microsoft hosts.
455fn normalize_microsoft_issuer_url(value: &str) -> Result<String> {
456    let normalized = value.trim_end_matches('/');
457    let parsed = reqwest::Url::parse(normalized).map_external_auth_err_ctx(
458        "invalid Microsoft issuer URL",
459        ExternalAuthError::validation_error,
460    )?;
461    if parsed.scheme() == "http" && parsed.host_str().is_some_and(is_loopback_host) {
462        return Ok(normalized.to_string());
463    }
464    if parsed.scheme() != "https" || parsed.host_str() != Some(MICROSOFT_LOGIN_HOST) {
465        return Err(ExternalAuthError::validation_error(
466            "Microsoft issuer URL must use login.microsoftonline.com",
467        ));
468    }
469    if parsed.query().is_some() || parsed.fragment().is_some() {
470        return Err(ExternalAuthError::validation_error(
471            "Microsoft issuer URL must not include query or fragment",
472        ));
473    }
474    let tenant = microsoft_tenant_from_issuer_url(&parsed)?;
475    Ok(microsoft_issuer_url_for_tenant(&tenant))
476}
477
478/// Extracts and validates the tenant segment from a Microsoft issuer URL.
479fn microsoft_tenant_from_issuer_url(parsed: &reqwest::Url) -> Result<String> {
480    let segments = parsed
481        .path_segments()
482        .ok_or_else(|| ExternalAuthError::validation_error("Microsoft issuer URL missing tenant"))?
483        .collect::<Vec<_>>();
484    if segments.len() != 2 || !segments[1].eq_ignore_ascii_case("v2.0") {
485        return Err(ExternalAuthError::validation_error(
486            "Microsoft issuer URL must end with /{tenant}/v2.0",
487        ));
488    }
489    canonicalize_microsoft_tenant(segments[0])
490}
491
492fn microsoft_tenant_from_issuer_string(value: &str) -> Result<String> {
493    let parsed = reqwest::Url::parse(value).map_external_auth_err_ctx(
494        "invalid Microsoft issuer URL",
495        ExternalAuthError::validation_error,
496    )?;
497    microsoft_tenant_from_issuer_url(&parsed)
498}
499
500/// Validates the issuer returned by the ID token against Microsoft tenant rules.
501///
502/// Concrete tenant issuers must match exactly. Multi-tenant aliases such as
503/// `common`, `organizations`, and `consumers` may receive tokens issued by a
504/// concrete tenant under `login.microsoftonline.com`, constrained by each
505/// alias' account-type semantics.
506fn validate_microsoft_token_issuer(configured_issuer: &str, token_issuer: &str) -> Result<()> {
507    if configured_issuer == token_issuer {
508        return Ok(());
509    }
510    let configured = reqwest::Url::parse(configured_issuer).map_external_auth_err_ctx(
511        "invalid configured Microsoft issuer URL",
512        ExternalAuthError::validation_error,
513    )?;
514    let token = reqwest::Url::parse(token_issuer).map_external_auth_err_ctx(
515        "invalid Microsoft token issuer",
516        ExternalAuthError::auth_invalid_credentials,
517    )?;
518    if configured.scheme() == "http" && configured.host_str().is_some_and(is_loopback_host) {
519        return Err(ExternalAuthError::auth_invalid_credentials(
520            "OIDC issuer does not match configured provider",
521        ));
522    }
523    if configured.host_str() != Some(MICROSOFT_LOGIN_HOST)
524        || token.host_str() != Some(MICROSOFT_LOGIN_HOST)
525        || token.scheme() != "https"
526    {
527        return Err(ExternalAuthError::auth_invalid_credentials(
528            "Microsoft token issuer is not trusted",
529        ));
530    }
531    let configured_tenant = microsoft_tenant_from_issuer_url(&configured)?;
532    let token_tenant = microsoft_tenant_from_issuer_url(&token).map_err(|_| {
533        ExternalAuthError::auth_invalid_credentials("Microsoft token issuer is invalid")
534    })?;
535    if is_microsoft_token_tenant_allowed_for_configured_tenant(&configured_tenant, &token_tenant) {
536        return Ok(());
537    }
538    Err(ExternalAuthError::auth_invalid_credentials(
539        "OIDC issuer does not match configured provider",
540    ))
541}
542
543fn is_microsoft_token_tenant_allowed_for_configured_tenant(
544    configured_tenant: &str,
545    token_tenant: &str,
546) -> bool {
547    if !is_microsoft_tenant_id(token_tenant) {
548        return false;
549    }
550    match configured_tenant {
551        "common" => true,
552        "organizations" => !token_tenant.eq_ignore_ascii_case(MICROSOFT_ACCOUNT_TENANT_ID),
553        "consumers" => token_tenant.eq_ignore_ascii_case(MICROSOFT_ACCOUNT_TENANT_ID),
554        _ => false,
555    }
556}
557
558/// Allows Microsoft multi-tenant aliases and UUID tenant IDs.
559fn validate_microsoft_tenant(tenant: &str) -> Result<()> {
560    if is_microsoft_multi_tenant_alias(tenant) || is_microsoft_tenant_id(tenant) {
561        return Ok(());
562    }
563    Err(ExternalAuthError::validation_error(
564        "Microsoft tenant must be common, organizations, consumers, or a tenant ID",
565    ))
566}
567
568fn canonicalize_microsoft_tenant(tenant: &str) -> Result<String> {
569    let tenant = tenant.trim().to_ascii_lowercase();
570    validate_microsoft_tenant(&tenant)?;
571    Ok(tenant)
572}
573
574fn is_microsoft_multi_tenant_alias(value: &str) -> bool {
575    matches!(value, "common" | "organizations" | "consumers")
576}
577
578fn is_microsoft_tenant_id(value: &str) -> bool {
579    value.len() == MICROSOFT_TENANT_ID_LEN
580        && value.chars().enumerate().all(|(index, ch)| {
581            if MICROSOFT_TENANT_ID_HYPHEN_POSITIONS.contains(&index) {
582                ch == '-'
583            } else {
584                ch.is_ascii_hexdigit()
585            }
586        })
587}
588
589fn starts_with_http_url(value: &str) -> bool {
590    value
591        .get(..7)
592        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
593        || value
594            .get(..8)
595            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    const TENANT_ID: &str = "11111111-2222-3333-4444-555555555555";
603    const OTHER_TENANT_ID: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
604
605    fn provider() -> ExternalAuthProviderConfig {
606        ExternalAuthProviderConfig {
607            id: 1,
608            key: "microsoft".to_string(),
609            provider_kind: ExternalAuthProviderKind::Microsoft,
610            protocol: ExternalAuthProtocol::Oidc,
611            options: crate::types::ExternalAuthProviderOptions::default(),
612            issuer_url: None,
613            authorization_url: Some("https://ignored.example.com/auth".to_string()),
614            token_url: Some("https://ignored.example.com/token".to_string()),
615            userinfo_url: Some("https://ignored.example.com/userinfo".to_string()),
616            client_id: "client-id".to_string(),
617            client_secret: Some("secret".to_string()),
618            scopes: String::new(),
619            subject_claim: None,
620            username_claim: None,
621            display_name_claim: None,
622            email_claim: None,
623            email_verified_claim: Some("email_verified".to_string()),
624            groups_claim: None,
625            avatar_url_claim: Some("picture".to_string()),
626            outbound_http_user_agent: None,
627        }
628    }
629
630    #[test]
631    fn microsoft_profile_overrides_force_email_unverified() {
632        // Multi-tenant Entra tokens can carry attacker-controlled `email_verified: true`
633        // claims; the override must strip them regardless of what the token said.
634        let mut profile = ExternalAuthProfile {
635            identity_namespace: format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
636            subject: "subject".to_string(),
637            email: Some("user@example.com".to_string()),
638            email_verified: true,
639            display_name: Some("User".to_string()),
640            preferred_username: None,
641        };
642
643        apply_microsoft_profile_overrides(&mut profile);
644
645        assert!(!profile.email_verified);
646        assert_eq!(profile.email.as_deref(), Some("user@example.com"));
647    }
648
649    #[test]
650    fn microsoft_config_uses_default_common_issuer_and_claims() {
651        let config = microsoft_oidc_config(&provider()).unwrap();
652
653        assert_eq!(config.provider_kind, ExternalAuthProviderKind::Microsoft);
654        assert_eq!(config.protocol, ExternalAuthProtocol::Oidc);
655        assert_eq!(
656            config.issuer_url.as_deref(),
657            Some("https://login.microsoftonline.com/common/v2.0")
658        );
659        assert_eq!(config.authorization_url, None);
660        assert_eq!(config.token_url, None);
661        assert_eq!(config.userinfo_url, None);
662        assert_eq!(config.scopes, MICROSOFT_DEFAULT_SCOPES);
663        assert_eq!(config.subject_claim.as_deref(), Some("sub"));
664        assert_eq!(config.display_name_claim.as_deref(), Some("name"));
665        assert_eq!(config.email_claim.as_deref(), Some("email"));
666        assert_eq!(config.email_verified_claim, None);
667        assert_eq!(config.avatar_url_claim, None);
668    }
669
670    #[test]
671    fn microsoft_config_canonicalizes_stored_tenant_options() {
672        let mut provider = provider();
673        provider.options.microsoft = Some(crate::types::MicrosoftExternalAuthProviderOptions::new(
674            "Organizations",
675        ));
676
677        let config = microsoft_oidc_config(&provider).unwrap();
678        assert_eq!(
679            config.issuer_url.as_deref(),
680            Some("https://login.microsoftonline.com/organizations/v2.0")
681        );
682    }
683
684    #[test]
685    fn microsoft_tenant_normalization_accepts_supported_tenants() {
686        for tenant in ["common", "organizations", "consumers", TENANT_ID] {
687            assert_eq!(
688                normalize_microsoft_tenant_or_issuer_url(Some(tenant.to_string())).unwrap(),
689                Some(format!("https://login.microsoftonline.com/{tenant}/v2.0"))
690            );
691        }
692        assert_eq!(
693            normalize_microsoft_tenant_or_issuer_url(Some(format!(
694                "https://login.microsoftonline.com/{TENANT_ID}/v2.0/"
695            )))
696            .unwrap(),
697            Some(format!(
698                "https://login.microsoftonline.com/{TENANT_ID}/v2.0"
699            ))
700        );
701        assert_eq!(
702            normalize_microsoft_tenant_or_issuer_url(Some(" Organizations ".to_string())).unwrap(),
703            Some("https://login.microsoftonline.com/organizations/v2.0".to_string())
704        );
705        assert_eq!(
706            normalize_microsoft_tenant_or_issuer_url(Some(
707                "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE".to_string()
708            ))
709            .unwrap(),
710            Some(
711                "https://login.microsoftonline.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/v2.0"
712                    .to_string()
713            )
714        );
715        assert_eq!(
716            normalize_microsoft_tenant_or_issuer_url(Some(
717                "HTTPS://LOGIN.MICROSOFTONLINE.COM/Organizations/V2.0/".to_string()
718            ))
719            .unwrap(),
720            Some("https://login.microsoftonline.com/organizations/v2.0".to_string())
721        );
722        assert_eq!(
723            normalize_microsoft_tenant_or_issuer_url(Some("http://127.0.0.1:3000".to_string()))
724                .unwrap(),
725            Some("http://127.0.0.1:3000".to_string())
726        );
727    }
728
729    #[test]
730    fn microsoft_tenant_input_normalization_canonicalizes_case() {
731        for (input, expected) in [
732            (None, MICROSOFT_DEFAULT_TENANT),
733            (Some(""), MICROSOFT_DEFAULT_TENANT),
734            (Some(" Organizations "), "organizations"),
735            (Some("Consumers"), "consumers"),
736            (
737                Some("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"),
738                "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
739            ),
740            (
741                Some("HTTPS://LOGIN.MICROSOFTONLINE.COM/Organizations/V2.0/"),
742                "organizations",
743            ),
744            (
745                Some("https://login.microsoftonline.com/AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE/v2.0"),
746                "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
747            ),
748        ] {
749            assert_eq!(
750                normalize_microsoft_tenant_input(input.map(str::to_string)).unwrap(),
751                expected,
752            );
753        }
754    }
755
756    #[test]
757    fn microsoft_tenant_normalization_rejects_unsupported_tenants() {
758        for tenant in [
759            "tenant.example.com",
760            "https://login.example.com/common/v2.0",
761            "https://login.microsoftonline.com/common",
762            "common/v2.0",
763            "https://login.microsoftonline.com/common/v2.0/extra",
764            "https://login.microsoftonline.com/common/v2.0?x=1",
765            "https://login.microsoftonline.com/common/v2.0#fragment",
766        ] {
767            assert!(
768                normalize_microsoft_tenant_or_issuer_url(Some(tenant.to_string())).is_err(),
769                "{tenant} should be rejected"
770            );
771        }
772    }
773
774    #[test]
775    fn microsoft_config_rejects_invalid_issuer_instead_of_falling_back_to_common() {
776        let mut provider = provider();
777        provider.issuer_url = Some("https://login.example.com/common/v2.0".to_string());
778
779        assert!(microsoft_oidc_config(&provider).is_err());
780    }
781
782    #[test]
783    fn microsoft_discovery_url_keeps_the_v2_metadata_path() {
784        assert_eq!(
785            microsoft_discovery_url("https://login.microsoftonline.com/common/v2.0")
786                .unwrap()
787                .as_str(),
788            "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration"
789        );
790        assert_eq!(
791            microsoft_discovery_url(&format!(
792                "https://login.microsoftonline.com/{TENANT_ID}/v2.0"
793            ))
794            .unwrap()
795            .as_str(),
796            format!(
797                "https://login.microsoftonline.com/{TENANT_ID}/v2.0/.well-known/openid-configuration"
798            )
799        );
800        assert_eq!(
801            microsoft_discovery_url("http://127.0.0.1:3000/mock/tenant/v2.0")
802                .unwrap()
803                .as_str(),
804            "http://127.0.0.1:3000/mock/tenant/v2.0/.well-known/openid-configuration"
805        );
806    }
807
808    #[test]
809    fn microsoft_issuer_validation_accepts_multi_tenant_token_issuers() {
810        for configured_tenant in ["common", "organizations"] {
811            validate_microsoft_token_issuer(
812                &format!("https://login.microsoftonline.com/{configured_tenant}/v2.0"),
813                &format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
814            )
815            .expect("multi-tenant issuer should accept concrete tenant token issuer");
816        }
817        for configured_tenant in ["common", "consumers"] {
818            validate_microsoft_token_issuer(
819                &format!("https://login.microsoftonline.com/{configured_tenant}/v2.0"),
820                &format!("https://login.microsoftonline.com/{MICROSOFT_ACCOUNT_TENANT_ID}/v2.0"),
821            )
822            .expect("Microsoft Account tenant issuer should match consumer-capable aliases");
823        }
824        validate_microsoft_token_issuer(
825            &format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
826            &format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
827        )
828        .expect("specific tenant should accept exact issuer");
829    }
830
831    #[test]
832    fn microsoft_discovery_validation_accepts_multi_tenant_template_issuer() {
833        for configured_tenant in ["common", "organizations", "consumers"] {
834            validate_microsoft_discovery_issuer(
835                &format!("https://login.microsoftonline.com/{configured_tenant}/v2.0"),
836                "https://login.microsoftonline.com/{tenantid}/v2.0",
837            )
838            .expect("multi-tenant discovery should accept Microsoft template issuer");
839        }
840        validate_microsoft_discovery_issuer(
841            &format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
842            &format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
843        )
844        .expect("specific tenant discovery should accept exact issuer");
845    }
846
847    #[test]
848    fn microsoft_discovery_validation_rejects_template_for_specific_tenant() {
849        validate_microsoft_discovery_issuer(
850            &format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
851            "https://login.microsoftonline.com/{tenantid}/v2.0",
852        )
853        .expect_err("specific tenant discovery should not accept template issuer");
854    }
855
856    #[test]
857    fn microsoft_issuer_validation_rejects_mismatches() {
858        for (configured, token) in [
859            (
860                format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
861                format!("https://login.microsoftonline.com/{OTHER_TENANT_ID}/v2.0"),
862            ),
863            (
864                "https://login.microsoftonline.com/organizations/v2.0".to_string(),
865                format!("https://login.microsoftonline.com/{MICROSOFT_ACCOUNT_TENANT_ID}/v2.0"),
866            ),
867            (
868                "https://login.microsoftonline.com/consumers/v2.0".to_string(),
869                format!("https://login.microsoftonline.com/{TENANT_ID}/v2.0"),
870            ),
871            (
872                "https://login.microsoftonline.com/common/v2.0".to_string(),
873                "https://login.example.com/11111111-2222-3333-4444-555555555555/v2.0".to_string(),
874            ),
875            (
876                "http://127.0.0.1:3000".to_string(),
877                "http://127.0.0.1:4000".to_string(),
878            ),
879        ] {
880            assert!(
881                validate_microsoft_token_issuer(&configured, &token).is_err(),
882                "{configured} should reject {token}"
883            );
884        }
885    }
886}