1use 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#[derive(Default)]
37pub struct MicrosoftProviderDriver;
38
39impl MicrosoftProviderDriver {
40 #[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(µsoft_oidc_config(provider)?, callback).await
87 }
88
89 async fn test_provider(
90 &self,
91 provider: &ExternalAuthProviderConfig,
92 ) -> Result<ExternalAuthProviderTestResult> {
93 test_microsoft_provider(µsoft_oidc_config(provider)?).await
94 }
95}
96
97pub 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
125pub 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
148fn 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
263fn 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 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
449fn microsoft_issuer_url_for_tenant(tenant: &str) -> String {
451 format!("{MICROSOFT_LOGIN_BASE}/{}/v2.0", tenant.trim())
452}
453
454fn 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
478fn 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
500fn 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
558fn 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 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}