diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/LoggingEventId.cs b/src/Microsoft.Identity.Web.TokenAcquisition/LoggingEventId.cs index 8f1ea5b0f..c24a942e1 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/LoggingEventId.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/LoggingEventId.cs @@ -36,6 +36,7 @@ internal static class LoggingEventId // MergedOptions EventIds 500+ public static readonly EventId AuthorityIgnored = new EventId(500, "AuthorityIgnored"); + public static readonly EventId AuthorityUsedConsiderInstanceTenantId = new EventId(501, "AuthorityUsedConsiderInstanceTenantId"); #pragma warning restore IDE1006 // Naming Styles } diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MergedOptions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MergedOptions.cs index 8530e90b3..0401c0c1f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/MergedOptions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MergedOptions.cs @@ -20,11 +20,11 @@ namespace Microsoft.Identity.Web /// Options for configuring authentication using Azure Active Directory. It has both AAD and B2C configuration attributes. /// Merges the MicrosoftIdentityWebOptions and the ConfidentialClientApplicationOptions. /// - /* - * Used by Microsoft.Identity.Web, Microsoft.Identity.Web.OWIN - * Any changes to this member (including removal) can cause runtime failures. - * Treat as a public member. - */ + /* + * Used by Microsoft.Identity.Web, Microsoft.Identity.Web.OWIN + * Any changes to this member (including removal) can cause runtime failures. + * Treat as a public member. + */ internal sealed class MergedOptions : MicrosoftIdentityOptions { private ConfidentialClientApplicationOptions? _confidentialClientApplicationOptions; @@ -63,18 +63,18 @@ public ConfidentialClientApplicationOptions ConfidentialClientApplicationOptions public LogLevel LogLevel { get; set; } public string? RedirectUri { get; set; } public bool EnableCacheSynchronization { get; set; } - /* - * Used by Microsoft.Identity.Web.OWIN - * Any changes to this member (including removal) can cause runtime failures. - * Treat as a public member. - */ + /* + * Used by Microsoft.Identity.Web.OWIN + * Any changes to this member (including removal) can cause runtime failures. + * Treat as a public member. + */ internal bool MergedWithCca { get; set; } // This is for supporting for CIAM authorities including custom url domains, see https://github.com/AzureAD/microsoft-identity-web/issues/2690 - /* - * Used by Microsoft.Identity.Web - * Any changes to this member (including removal) can cause runtime failures. - * Treat as a public member. - */ + /* + * Used by Microsoft.Identity.Web + * Any changes to this member (including removal) can cause runtime failures. + * Treat as a public member. + */ internal bool PreserveAuthority { get; set; } /// @@ -353,11 +353,11 @@ internal static void UpdateMergedOptionsFromMicrosoftIdentityOptions(MicrosoftId } } - /* - * Used by Microsoft.Identity.Web - * Any changes to this member (including removal) can cause runtime failures. - * Treat as a public member. - */ + /* + * Used by Microsoft.Identity.Web + * Any changes to this member (including removal) can cause runtime failures. + * Treat as a public member. + */ internal static void UpdateMergedOptionsFromConfidentialClientApplicationOptions(ConfidentialClientApplicationOptions confidentialClientApplicationOptions, MergedOptions mergedOptions) { mergedOptions.MergedWithCca = true; @@ -466,11 +466,11 @@ internal static void UpdateConfidentialClientApplicationOptionsFromMergedOptions } } - /* - * Used by Microsoft.Identity.Web.OWIN - * Any changes to this member (including removal) can cause runtime failures. - * Treat as a public member. - */ + /* + * Used by Microsoft.Identity.Web.OWIN + * Any changes to this member (including removal) can cause runtime failures. + * Treat as a public member. + */ internal static void ParseAuthorityIfNecessary(MergedOptions mergedOptions, IdWebLogger.ILogger? logger = null) { // Check if Authority is configured but being ignored due to Instance/TenantId taking precedence @@ -492,6 +492,19 @@ internal static void ParseAuthorityIfNecessary(MergedOptions mergedOptions, IdWe if (string.IsNullOrEmpty(mergedOptions.TenantId) && string.IsNullOrEmpty(mergedOptions.Instance) && !string.IsNullOrEmpty(mergedOptions.Authority)) { + // Emit a warning whenever the single-string 'Authority' option is being used to derive + // Instance/TenantId. The 'Authority' option targets vanilla OIDC / CIAM scenarios and + // routes through MSAL.WithOidcAuthority(); first-party (1P) callers (e.g. services + // using Microsoft Identity Service Essentials / MISE) should configure 'Instance' + + // 'TenantId' separately so the request flows through MSAL.WithAuthority(). Third-party + // (3P) callers using CIAM / ADFS / generic OIDC can safely ignore this warning. + // Microsoft.Identity.Web is a 3P-targeted library and cannot reliably tell whether the + // caller is 1P or 3P at runtime, so we emit a hint rather than throwing. + if (logger != null) + { + MergedOptionsLogging.AuthorityUsedConsiderInstanceTenantId(logger, mergedOptions.Authority!); + } + ReadOnlySpan doubleSlash = "//".AsSpan(); ReadOnlySpan authoritySpan = mergedOptions.Authority.AsSpan().TrimEnd('/'); int doubleSlashIndex = authoritySpan.IndexOf(doubleSlash); @@ -504,6 +517,28 @@ internal static void ParseAuthorityIfNecessary(MergedOptions mergedOptions, IdWe int indexVersion = authoritySpan.Slice(indexTenant + 1).IndexOf('/'); int indexEndOfTenant = indexVersion == -1 ? authoritySpan.Length : indexVersion + indexTenant + 1; + // dSTS authorities have the shape https://{host}/dstsv2/{tenantGuid}, i.e. TWO path + // segments instead of the AAD-style single segment. The single 'Authority' string + // is reserved for vanilla OIDC / CIAM scenarios, which route through + // MSAL.WithOidcAuthority() — a path that is incompatible with dSTS. dSTS users MUST + // configure 'Instance' and 'TenantId' separately so that the request flows through + // MSAL.WithAuthority() instead. + // + // Detecting the "dstsv2" path segment here lets us bail with a clear, actionable + // error message instead of letting the generic AAD parser silently drop the + // tenant GUID, which would surface later as MSAL's opaque + // "The DSTS authority URI should have at least 2 segments..." + ReadOnlySpan firstPathSegment = authoritySpan.Slice(indexTenant + 1, indexEndOfTenant - indexTenant - 1); + if (firstPathSegment.Equals("dstsv2".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Configuring a dSTS authority via the single 'Authority' option is not supported. " + + "The 'Authority' option targets vanilla OIDC / CIAM scenarios and routes through " + + "MSAL.WithOidcAuthority(), which is incompatible with dSTS. " + + "For dSTS, configure 'Instance' (e.g. \"https://{host}/dstsv2\") and 'TenantId' " + + "(the dSTS tenant GUID) separately so the request flows through MSAL.WithAuthority()."); + } + // In CIAM and B2C, customers will use "authority", not Instance and TenantId mergedOptions.Instance = mergedOptions.PreserveAuthority ? mergedOptions.Authority! : authoritySpan.Slice(0, indexTenant).ToString(); mergedOptions.TenantId = mergedOptions.PreserveAuthority ? null : authoritySpan.Slice(indexTenant + 1, indexEndOfTenant - indexTenant - 1).ToString(); diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/MergedOptionsLogging.cs b/src/Microsoft.Identity.Web.TokenAcquisition/MergedOptionsLogging.cs index 3e8a42176..ef2f91c5c 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/MergedOptionsLogging.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/MergedOptionsLogging.cs @@ -32,5 +32,30 @@ public static void AuthorityIgnored( { s_authorityIgnored(logger, authority, instance, tenantId, null); } + + private static readonly Action s_authorityUsedConsiderInstanceTenantId = + LoggerMessage.Define( + LogLevel.Warning, + LoggingEventId.AuthorityUsedConsiderInstanceTenantId, + "[MsIdWeb] The 'Authority' option ('{Authority}') is configured. " + + "'Authority' is intended for vanilla OIDC / CIAM scenarios (3P) and routes through MSAL.WithOidcAuthority(). " + + "First-party (1P) callers — e.g. services using Microsoft Identity Service Essentials (MISE) — should NOT use 'Authority'; " + + "configure 'Instance' (e.g. \"https://login.microsoftonline.com\" or \"https://{{host}}/dstsv2\") and 'TenantId' separately, " + + "which routes through MSAL.WithAuthority() and works correctly with eSTS, dSTS, and B2C. " + + "Third-party (3P) callers using CIAM, ADFS, or generic OIDC issuers can safely ignore this warning."); + + /// + /// Logs a warning when an application configures the single-string Authority option, + /// hinting that first-party (1P) callers (e.g. MISE) should use Instance + TenantId instead. + /// Third-party (3P) callers using CIAM / ADFS / generic OIDC can safely ignore the warning. + /// + /// The logger instance. + /// The Authority value being parsed. + public static void AuthorityUsedConsiderInstanceTenantId( + ILogger logger, + string authority) + { + s_authorityUsedConsiderInstanceTenantId(logger, authority, null); + } } } diff --git a/tests/Microsoft.Identity.Web.Test/DstsTokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/DstsTokenAcquisitionTests.cs new file mode 100644 index 000000000..5e01dc846 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/DstsTokenAcquisitionTests.cs @@ -0,0 +1,437 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Test.Common; +using Microsoft.Identity.Web.Test.Common.Mocks; +using Microsoft.Identity.Web.TestOnly; +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + /// + /// Unit tests for vanilla dSTS (Dedicated Security Token Service) scenarios in Microsoft.Identity.Web. + /// + /// Vanilla dSTS uses a different authority format than AAD/Entra ID (eSTS), and must: + /// 1. Skip the AAD instance discovery call (login.microsoftonline.com/common/discovery/instance). + /// 2. POST the client credentials grant directly to the dSTS token endpoint: + /// https://{host}/dstsv2/{tenantGuid}/oauth2/v2.0/token + /// 3. Send x5c in the client_assertion JWT header when SendX5C=true + /// (required for dSTS certificate-based authentication). + /// + /// Configuration model: + /// - dSTS users MUST configure + /// and separately. This routes + /// the request through MSAL's WithAuthority() API, which is dSTS-compatible. + /// - The single-string option is + /// reserved for vanilla OIDC / CIAM scenarios and routes through MSAL's + /// WithOidcAuthority() API, which is NOT compatible with dSTS. Configuring a + /// dSTS-style URL there now throws an with a + /// clear, actionable error message (see ). + /// + /// These tests use the existing infrastructure to mock + /// the dSTS token endpoint, so no network/Key Vault/real certificate is required and the + /// tests can run in any CI environment. + /// + [Collection(nameof(TokenAcquirerFactorySingletonProtection))] + public class DstsTokenAcquisitionTests + { + // Vanilla dSTS authority pieces: https://{host}/dstsv2/{tenantGuid} + // NOTE: all values below are synthetic placeholders for unit-test purposes only. + // They do not correspond to any real Microsoft / Azure / dSTS deployment, tenant, + // or application registration. + private const string DstsHost = "fake-dsts.test.invalid"; + private const string DstsTenantId = "00000000-0000-0000-0000-000000000001"; + + // Canonical dSTS configuration: Instance + TenantId, used separately. + // Instance contains the literal "/dstsv2" path segment; TenantId is the GUID. + private const string DstsInstance = "https://" + DstsHost + "/dstsv2"; + + // Composite URL used only for assertions (expected request URI) and for the negative + // test that verifies the unsupported single-Authority form is rejected. + private const string DstsAuthorityFullUrl = "https://" + DstsHost + "/dstsv2/" + DstsTenantId; + private const string DstsTokenEndpoint = DstsAuthorityFullUrl + "/oauth2/v2.0/token"; + + // NOTE: each test uses a distinct ClientId so that they get distinct MSAL app token + // caches. MSAL's confidential-client app token cache is keyed by ClientId/Authority and + // is preserved across TokenAcquirerFactory resets, which would otherwise cause a token + // acquired by one test to be served (from cache) to another test that registered a + // different mock HTTP handler — making mock handlers unused and Dispose assertions fail. + private const string DefaultDstsClientId = "00000000-0000-0000-0000-00000000c11d"; + private const string DstsScope = "https://" + DstsHost + "/.default"; + + private static string NewDstsClientId() => Guid.NewGuid().ToString(); + + /// + /// Verifies that for a vanilla dSTS authority Id.Web/MSAL POSTs the client_credentials + /// grant to the dSTS token endpoint (and not to the AAD eSTS endpoint). + /// Uses to lock the endpoint. + /// + [Fact] + public async Task GetAccessTokenForApp_DstsAuthority_PostsToDstsTokenEndpointAsync() + { + // Arrange + var tokenAcquirerFactory = InitDstsTokenAcquirerFactoryWithSecret(NewDstsClientId()); + IServiceProvider serviceProvider = tokenAcquirerFactory.Build(); + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + var tokenHandler = MockHttpCreator.CreateClientCredentialTokenHandler(); + tokenHandler.ExpectedUrl = DstsTokenEndpoint; + mockHttpClient!.AddMockHandler(tokenHandler); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act + string result = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(DstsScope); + + // Assert + Assert.Equal("Bearer header.payload.signature", result); + Assert.NotNull(tokenHandler.ActualRequestMessage); + Assert.Equal(HttpMethod.Post, tokenHandler.ActualRequestMessage.Method); + Assert.NotNull(tokenHandler.ActualRequestMessage.RequestUri); + Assert.Equal(DstsTokenEndpoint, tokenHandler.ActualRequestMessage.RequestUri!.GetLeftPart(UriPartial.Path)); + } + + /// + /// Verifies that the client_credentials grant body sent to dSTS contains the expected + /// parameters (grant_type, scope, client_id, client_secret). + /// + [Fact] + public async Task GetAccessTokenForApp_DstsAuthority_SendsClientCredentialsGrantAsync() + { + // Arrange + string clientId = NewDstsClientId(); + var tokenAcquirerFactory = InitDstsTokenAcquirerFactoryWithSecret(clientId); + IServiceProvider serviceProvider = tokenAcquirerFactory.Build(); + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + mockHttpClient!.AddMockHandler(MockHttpCreator.CreateHandlerToValidatePostData( + HttpMethod.Post, + new Dictionary + { + { "grant_type", "client_credentials" }, + { "scope", DstsScope }, + { "client_id", clientId }, + { "client_secret", "someSecret" }, + })); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act + string result = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(DstsScope); + + // Assert - if any expected POST field were missing or different, MockHttpMessageHandler + // would have failed inside SendAsync via Assert.Equal/Assert.True. + Assert.Equal("Bearer header.payload.signature", result); + } + + /// + /// Verifies that two consecutive token acquisitions for the same dSTS scope only hit the + /// dSTS token endpoint once (i.e. the second call is served from MSAL's app token cache). + /// We register exactly one mock handler — if MSAL tried to call dSTS a second time, the + /// queue would be empty and the request would throw. + /// + [Fact] + public async Task GetAccessTokenForApp_DstsAuthority_SecondCallUsesCacheAsync() + { + // Arrange + var tokenAcquirerFactory = InitDstsTokenAcquirerFactoryWithSecret(NewDstsClientId()); + IServiceProvider serviceProvider = tokenAcquirerFactory.Build(); + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + // Register exactly ONE token-endpoint handler. + var tokenHandler = MockHttpCreator.CreateClientCredentialTokenHandler(); + mockHttpClient!.AddMockHandler(tokenHandler); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act - first call hits dSTS, second call should be a cache hit. + string first = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(DstsScope); + string second = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(DstsScope); + + // Assert + Assert.Equal("Bearer header.payload.signature", first); + Assert.Equal(first, second); + + // The single handler MUST have been consumed by the first call. + Assert.NotNull(tokenHandler.ActualRequestMessage); + + // And the second call MUST have come from MSAL's app token cache: if it had hit the + // network, MockHttpClientFactory would have thrown ("no more mock handlers") + // because we only registered one. Additionally, MockHttpClientFactory.Dispose + // asserts that the queue is empty (i.e. exactly the one handler was consumed). + } + + /// + /// Verifies that when the dSTS token endpoint returns an OAuth2 error, Id.Web surfaces it + /// as . + /// + [Fact] + public async Task GetAccessTokenForApp_DstsAuthority_TokenEndpointError_ThrowsMsalServiceExceptionAsync() + { + // Arrange + var tokenAcquirerFactory = InitDstsTokenAcquirerFactoryWithSecret(NewDstsClientId()); + IServiceProvider serviceProvider = tokenAcquirerFactory.Build(); + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + const string errorBody = + "{\"error\":\"invalid_scope\"," + + "\"error_description\":\"The scope is not valid for the dSTS resource.\"}"; + + mockHttpClient!.AddMockHandler(new MockHttpMessageHandler + { + ExpectedMethod = HttpMethod.Post, + ResponseMessage = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(errorBody), + }, + }); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + async () => await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(DstsScope)); + + Assert.Equal("invalid_scope", ex.ErrorCode); + } + + /// + /// Verifies that when a dSTS app is configured with a certificate credential and + /// is true, the JWT client_assertion + /// header sent to the dSTS token endpoint includes the x5c claim. This is required + /// for dSTS to validate the certificate chain. + /// + [Fact] + public async Task GetAccessTokenForApp_DstsAuthority_WithCertificateAndSendX5C_IncludesX5CHeaderAsync() + { + // Arrange + var tokenAcquirerFactory = InitDstsTokenAcquirerFactoryWithCertificate(sendX5C: true); + IServiceProvider serviceProvider = tokenAcquirerFactory.Build(); + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + var tokenHandler = MockHttpCreator.CreateClientCredentialTokenHandler(); + mockHttpClient!.AddMockHandler(tokenHandler); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act + string result = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(DstsScope); + + // Assert + Assert.Equal("Bearer header.payload.signature", result); + Assert.NotNull(tokenHandler.ActualRequestPostData); + + // dSTS certificate auth uses client_assertion (JWT signed by the cert). + Assert.True(tokenHandler.ActualRequestPostData.ContainsKey("client_assertion"), + "Expected client_assertion in the POST body for certificate-based dSTS auth."); + Assert.Equal( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + tokenHandler.ActualRequestPostData["client_assertion_type"]); + + string clientAssertion = tokenHandler.ActualRequestPostData["client_assertion"]; + string jwtHeader = DecodeJwtHeader(clientAssertion); + + // SendX5C=true must propagate the x5c chain into the JWT header. + Assert.Contains("\"x5c\"", jwtHeader, StringComparison.Ordinal); + } + + /// + /// Negative counterpart to the previous test: when SendX5C=false, the JWT header + /// must NOT contain the x5c chain (only x5t/kid). + /// + [Fact] + public async Task GetAccessTokenForApp_DstsAuthority_WithCertificateAndNoSendX5C_OmitsX5CHeaderAsync() + { + // Arrange + var tokenAcquirerFactory = InitDstsTokenAcquirerFactoryWithCertificate(sendX5C: false); + IServiceProvider serviceProvider = tokenAcquirerFactory.Build(); + var mockHttpClient = serviceProvider.GetRequiredService() as MockHttpClientFactory; + + var tokenHandler = MockHttpCreator.CreateClientCredentialTokenHandler(); + mockHttpClient!.AddMockHandler(tokenHandler); + + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act + string result = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(DstsScope); + + // Assert + Assert.Equal("Bearer header.payload.signature", result); + Assert.NotNull(tokenHandler.ActualRequestPostData); + Assert.True(tokenHandler.ActualRequestPostData.ContainsKey("client_assertion")); + + string clientAssertion = tokenHandler.ActualRequestPostData["client_assertion"]; + string jwtHeader = DecodeJwtHeader(clientAssertion); + + Assert.DoesNotContain("\"x5c\"", jwtHeader, StringComparison.Ordinal); + } + + /// + /// Negative test: configuring a dSTS-style URL via the single + /// option (instead of the + /// canonical Instance + TenantId pair) must throw a clear, actionable + /// with a message that points users to the + /// correct configuration shape — rather than letting MSAL surface its opaque + /// "The DSTS authority URI should have at least 2 segments..." error later. + /// + [Fact] + public async Task DstsAuthorityViaAuthorityOption_ThrowsClearErrorAsync() + { + // Arrange + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + TokenAcquirerFactory tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.Configure(options => + { + // ⚠️ Unsupported configuration shape for dSTS: the single composite Authority URL. + // Id.Web should reject this with a clear error and tell the user to use + // Instance + TenantId instead. + options.Authority = DstsAuthorityFullUrl; + options.ClientId = NewDstsClientId(); + options.ClientCredentials = new[] + { + new CredentialDescription + { + SourceType = CredentialSource.ClientSecret, + ClientSecret = "someSecret", + }, + }; + }); + tokenAcquirerFactory.Services.AddSingleton(); + + IServiceProvider serviceProvider = tokenAcquirerFactory.Build(); + IAuthorizationHeaderProvider authorizationHeaderProvider = + serviceProvider.GetRequiredService(); + + // Act & Assert + var ex = await Assert.ThrowsAsync( + async () => await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(DstsScope)); + + // The error message must mention both the unsupported option and the canonical fix + // so the developer can act on it without having to dig into Id.Web internals. + Assert.Contains("Authority", ex.Message, StringComparison.Ordinal); + Assert.Contains("Instance", ex.Message, StringComparison.Ordinal); + Assert.Contains("TenantId", ex.Message, StringComparison.Ordinal); + Assert.Contains("dSTS", ex.Message, StringComparison.Ordinal); + } + + // ---- helpers ---- + + /// + /// Builds a configured for vanilla dSTS using the + /// canonical + + /// configuration shape, with + /// a client-secret credential. This is the configuration shape dSTS users MUST use — + /// the single-Authority shape is rejected by MergedOptions.ParseAuthorityIfNecessary. + /// + private static TokenAcquirerFactory InitDstsTokenAcquirerFactoryWithSecret(string? clientId = null) + { + string effectiveClientId = clientId ?? DefaultDstsClientId; + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + TokenAcquirerFactory tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.Configure(options => + { + // Canonical dSTS configuration: Instance and TenantId are set separately. + // This routes through MSAL.WithAuthority() (dSTS-compatible) rather than + // MSAL.WithOidcAuthority() (vanilla OIDC / CIAM only, NOT dSTS-compatible). + options.Instance = DstsInstance; // "https://{host}/dstsv2" + options.TenantId = DstsTenantId; + options.ClientId = effectiveClientId; + options.ClientCredentials = new[] + { + new CredentialDescription + { + SourceType = CredentialSource.ClientSecret, + ClientSecret = "someSecret", + }, + }; + }); + + tokenAcquirerFactory.Services.AddSingleton(); + + return tokenAcquirerFactory; + } + + /// + /// Builds a configured for vanilla dSTS with a + /// (self-signed) certificate credential, using the canonical + /// Instance + TenantId configuration shape. The mock HTTP handler does not + /// validate the certificate, so a self-signed cert is sufficient for unit tests. + /// + private static TokenAcquirerFactory InitDstsTokenAcquirerFactoryWithCertificate(bool sendX5C, string? clientId = null) + { + string effectiveClientId = clientId ?? NewDstsClientId(); + TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest(); + TokenAcquirerFactory tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance(); + tokenAcquirerFactory.Services.Configure(options => + { + options.Instance = DstsInstance; + options.TenantId = DstsTenantId; + options.ClientId = effectiveClientId; + options.SendX5C = sendX5C; + options.ClientCredentials = new[] + { + CertificateDescription.FromCertificate(CreateTestCertificate()), + }; + }); + + tokenAcquirerFactory.Services.AddSingleton(); + + return tokenAcquirerFactory; + } + + /// + /// Creates a transient self-signed certificate for unit tests. The mock HTTP handler does + /// not perform any cryptographic validation against dSTS, so a throwaway cert is fine. + /// + private static X509Certificate2 CreateTestCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + "CN=DstsUnitTest", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + } + + /// + /// Decodes the (base64url-encoded) header of a JWT and returns it as a UTF-8 JSON string. + /// + private static string DecodeJwtHeader(string jwt) + { + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return string.Empty; + } + + string base64 = parts[0].Replace('-', '+').Replace('_', '/'); + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + + return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(base64)); + } + } +} \ No newline at end of file diff --git a/tests/Microsoft.Identity.Web.Test/MergedOptionsAuthorityConflictTests.cs b/tests/Microsoft.Identity.Web.Test/MergedOptionsAuthorityConflictTests.cs index c9baed5db..9694e2d0e 100644 --- a/tests/Microsoft.Identity.Web.Test/MergedOptionsAuthorityConflictTests.cs +++ b/tests/Microsoft.Identity.Web.Test/MergedOptionsAuthorityConflictTests.cs @@ -82,7 +82,7 @@ public void ParseAuthorityIfNecessary_AuthorityAndInstanceAndTenantId_LogsWarnin } [Fact] - public void ParseAuthorityIfNecessary_AuthorityOnly_NoWarning() + public void ParseAuthorityIfNecessary_AuthorityOnly_LogsAuthorityUsedHint() { // Arrange var mergedOptions = new MergedOptions @@ -94,8 +94,18 @@ public void ParseAuthorityIfNecessary_AuthorityOnly_NoWarning() // Act MergedOptions.ParseAuthorityIfNecessary(mergedOptions, _testLogger); - // Assert - No warning should be logged, authority should be parsed - Assert.Empty(_testLogger.LogMessages); + // Assert + // Whenever the single-string 'Authority' option is being used to derive Instance/TenantId, + // Id.Web emits a warning hinting that first-party (1P) callers + // (e.g. MISE) should configure Instance + TenantId separately instead. Third-party (3P) + // callers using CIAM / ADFS / generic OIDC can ignore the warning. + // The Authority must still be parsed into Instance + TenantId for the legitimate 3P case. + Assert.Single(_testLogger.LogMessages); + Assert.Contains("Authority", _testLogger.LogMessages[0], StringComparison.OrdinalIgnoreCase); + Assert.Contains("Instance", _testLogger.LogMessages[0], StringComparison.OrdinalIgnoreCase); + Assert.Contains("TenantId", _testLogger.LogMessages[0], StringComparison.OrdinalIgnoreCase); + Assert.Contains("1P", _testLogger.LogMessages[0], StringComparison.OrdinalIgnoreCase); + Assert.Equal(LogLevel.Warning, _testLogger.LogLevel); Assert.Equal("https://login.microsoftonline.com", mergedOptions.Instance); Assert.Equal("common", mergedOptions.TenantId); }