1use 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#[derive(Default)]
37pub struct MicrosoftProviderDriver;
38
39impl MicrosoftProviderDriver {
40 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(µsoft_oidc_config(provider)?, callback).await
86 }
87
88 async fn test_provider(
89 &self,
90 provider: &ExternalAuthProviderConfig,
91 ) -> Result<ExternalAuthProviderTestResult> {
92 test_microsoft_provider(µsoft_oidc_config(provider)?).await
93 }
94}
95
96pub 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
120pub 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
139fn 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
254fn 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 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
436fn microsoft_issuer_url_for_tenant(tenant: &str) -> String {
438 format!("{MICROSOFT_LOGIN_BASE}/{}/v2.0", tenant.trim())
439}
440
441fn 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
465fn 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
487fn 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
545fn 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 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}