Skip to main content

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