diff --git a/src/Microsoft.Identity.Web.GraphServiceClientBeta/Microsoft.Identity.Web.GraphServiceClientBeta.csproj b/src/Microsoft.Identity.Web.GraphServiceClientBeta/Microsoft.Identity.Web.GraphServiceClientBeta.csproj
index 329ae2baf..65287580c 100644
--- a/src/Microsoft.Identity.Web.GraphServiceClientBeta/Microsoft.Identity.Web.GraphServiceClientBeta.csproj
+++ b/src/Microsoft.Identity.Web.GraphServiceClientBeta/Microsoft.Identity.Web.GraphServiceClientBeta.csproj
@@ -23,6 +23,7 @@
+
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);
}