diff --git a/src/client/Microsoft.Identity.Client/Instance/Oidc/OidcMetadata.cs b/src/client/Microsoft.Identity.Client/Instance/Oidc/OidcMetadata.cs index d2aaba5530..f15dd9a188 100644 --- a/src/client/Microsoft.Identity.Client/Instance/Oidc/OidcMetadata.cs +++ b/src/client/Microsoft.Identity.Client/Instance/Oidc/OidcMetadata.cs @@ -19,6 +19,9 @@ namespace Microsoft.Identity.Client.Instance.Oidc [Preserve(AllMembers = true)] internal class OidcMetadata { + [JsonProperty("issuer")] + public string Issuer { get; set; } + [JsonProperty("token_endpoint")] public string TokenEndpoint { get; set; } diff --git a/src/client/Microsoft.Identity.Client/Instance/Oidc/OidcRetrieverWithCache.cs b/src/client/Microsoft.Identity.Client/Instance/Oidc/OidcRetrieverWithCache.cs index 8875c1194f..2e774dcddf 100644 --- a/src/client/Microsoft.Identity.Client/Instance/Oidc/OidcRetrieverWithCache.cs +++ b/src/client/Microsoft.Identity.Client/Instance/Oidc/OidcRetrieverWithCache.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client.Core; @@ -27,7 +28,7 @@ public static async Task GetOidcAsync( } await s_lockOidcRetrieval.WaitAsync().ConfigureAwait(false); - + Uri oidcMetadataEndpoint = null; try { @@ -44,9 +45,13 @@ public static async Task GetOidcAsync( builder.Path = existingPath.TrimEnd('/') + "/" + Constants.WellKnownOpenIdConfigurationPath; oidcMetadataEndpoint = builder.Uri; - var client = new OAuth2Client(requestContext.Logger, requestContext.ServiceBundle.HttpManager, null); + var client = new OAuth2Client(requestContext.Logger, requestContext.ServiceBundle.HttpManager, null); configuration = await client.DiscoverOidcMetadataAsync(oidcMetadataEndpoint, requestContext).ConfigureAwait(false); + // Validate the issuer before caching the configuration + requestContext.Logger.Verbose(() => $"[OIDC Discovery] Validating issuer: {configuration.Issuer} against authority: {authority}"); + ValidateIssuer(new Uri(authority), configuration.Issuer); + s_cache[authority] = configuration; requestContext.Logger.Verbose(() => $"[OIDC Discovery] OIDC discovery retrieved metadata from the network for {authority}"); @@ -70,6 +75,63 @@ public static async Task GetOidcAsync( } } + /// + /// Validates that the issuer in the OIDC metadata matches the authority. + /// + /// The authority URL. + /// The issuer from the OIDC metadata - the single source of truth. + /// Thrown when issuer validation fails. + private static void ValidateIssuer(Uri authority, string issuer) + { + // Normalize both URLs to handle trailing slash differences + string normalizedAuthority = authority.AbsoluteUri.TrimEnd('/'); + string normalizedIssuer = issuer?.TrimEnd('/'); + + // Primary validation: check if normalized authority starts with normalized issuer (case-insensitive comparison) + if (normalizedAuthority.StartsWith(normalizedIssuer, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // Extract tenant for CIAM scenarios. In a CIAM scenario the issuer is expected to have "{tenant}.ciamlogin.com" + // as the host, even when using a custom domain. + string tenant = null; + try + { + tenant = AuthorityInfo.GetFirstPathSegment(authority); + } + catch (InvalidOperationException) + { + // If no path segments exist, try to extract from hostname (first part) + var hostParts = authority.Host.Split('.'); + tenant = hostParts.Length > 0 ? hostParts[0] : null; + } + + // If tenant extraction failed or returned empty, validation fails + if (!string.IsNullOrEmpty(tenant)) + { + // Create a collection of valid CIAM issuer patterns for the tenant + string[] validCiamPatterns = + { + $"https://{tenant}{Constants.CiamAuthorityHostSuffix}", + $"https://{tenant}{Constants.CiamAuthorityHostSuffix}/{tenant}", + $"https://{tenant}{Constants.CiamAuthorityHostSuffix}/{tenant}/v2.0" + }; + + // Normalize and check if the issuer matches any of the valid patterns + if (validCiamPatterns.Any(pattern => + string.Equals(normalizedIssuer, pattern.TrimEnd('/'), StringComparison.OrdinalIgnoreCase))) + { + return; + } + } + + // Validation failed + throw new MsalServiceException( + MsalError.AuthorityValidationFailed, + string.Format(MsalErrorMessage.IssuerValidationFailed, authority, issuer)); + } + // For testing purposes only public static void ResetCacheForTest() { diff --git a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs index 182b96fa29..d5b61c67e8 100644 --- a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs +++ b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs @@ -149,6 +149,8 @@ public static string iOSBrokerKeySaveFailed(string keyChainResult) public const string AuthorityValidationFailed = "Authority validation failed. "; + public const string IssuerValidationFailed = "Issuer validation failed for authority: {0} . Issuer from OIDC endpoint does not match any expected pattern: {1} . "; + public const string AuthorityUriInsecure = "The authority must use HTTPS scheme. "; public const string AuthorityUriInvalidPath = diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/CiamIntegrationTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/CiamIntegrationTests.cs index c0b4e4ae2c..e00035ab9e 100644 --- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/CiamIntegrationTests.cs +++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/CiamIntegrationTests.cs @@ -111,10 +111,10 @@ public async Task ClientCredentialCiam_WithClientCredentials_ReturnsValidTokens( //Ciam CUD authority = "https://login.msidlabsciam.com/fe362aec-5d43-45d1-b730-9755e60dc3b9/v2.0/"; string ciamClient = "b244c86f-ed88-45bf-abda-6b37aa482c79"; - await RunCiamCCATest(authority, ciamClient).ConfigureAwait(false); + await RunCiamCCATest(authority, ciamClient, true).ConfigureAwait(false); } - private async Task RunCiamCCATest(string authority, string appId) + private async Task RunCiamCCATest(string authority, string appId, bool useOidcAuthority = false) { //Acquire tokens var msalConfidentialClientBuilder = ConfidentialClientApplicationBuilder @@ -122,16 +122,15 @@ private async Task RunCiamCCATest(string authority, string appId) .WithCertificate(CertificateHelper.FindCertificateByName(TestConstants.AutomationTestCertName)) .WithExperimentalFeatures(); - if (authority.Contains(Constants.CiamAuthorityHostSuffix)) + if (useOidcAuthority) { - msalConfidentialClientBuilder.WithAuthority(authority, false); + msalConfidentialClientBuilder.WithOidcAuthority(authority); } else { - msalConfidentialClientBuilder.WithOidcAuthority(authority); + msalConfidentialClientBuilder.WithAuthority(authority); } - var msalConfidentialClient = msalConfidentialClientBuilder.Build(); var result = await msalConfidentialClient @@ -217,6 +216,29 @@ public async Task OBOCiam_CustomDomain_ReturnsValidTokens() Assert.AreEqual(TokenSource.Cache, resultObo.AuthenticationResultMetadata.TokenSource); } + [TestMethod] + public async Task WithOidcAuthority_ValidatesIssuerSuccessfully() + { + //Get lab details + var labResponse = await LabUserHelper.GetLabUserDataAsync(new UserQuery() + { + FederationProvider = FederationProvider.CIAMCUD, + SignInAudience = SignInAudience.AzureAdMyOrg + }).ConfigureAwait(false); + + //Test with standard and CUD CIAM authorities + string[] authorities = + { + string.Format("https://{0}.ciamlogin.com/{1}/v2.0/", labResponse.Lab.TenantId, labResponse.Lab.TenantId), + string.Format("https://login.msidlabsciam.com/{0}/v2.0/", labResponse.Lab.TenantId) + }; + + foreach (var authority in authorities) + { + await RunCiamCCATest(authority, labResponse.App.AppId, true).ConfigureAwait(false); + } + } + private string GetCiamSecret() { KeyVaultSecretsProvider provider = new KeyVaultSecretsProvider(); diff --git a/tests/Microsoft.Identity.Test.Unit/CoreTests/InstanceTests/GenericAuthorityTests.cs b/tests/Microsoft.Identity.Test.Unit/CoreTests/InstanceTests/GenericAuthorityTests.cs index 98819354d5..9f138860d4 100644 --- a/tests/Microsoft.Identity.Test.Unit/CoreTests/InstanceTests/GenericAuthorityTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/CoreTests/InstanceTests/GenericAuthorityTests.cs @@ -333,6 +333,45 @@ public async Task BadOidcResponse_ThrowsException_Async(string badOidcResponseTy } } + [TestMethod] + public async Task OidcIssuerValidation_ThrowsForNonMatchingIssuer_Async() + { + using (var httpManager = new MockHttpManager()) + { + string wrongIssuer = "https://wrong.issuer.com"; + + IConfidentialClientApplication app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithHttpManager(httpManager) + .WithOidcAuthority(TestConstants.GenericAuthority) + .WithClientSecret(TestConstants.ClientSecret) + .Build(); + + // Create OIDC document with non-matching issuer + string validOidcDocumentWithWrongIssuer = TestConstants.GenericOidcResponse.Replace( + $"\"issuer\":\"{TestConstants.GenericAuthority}\"", + $"\"issuer\":\"{wrongIssuer}\""); + + // Mock OIDC endpoint response + httpManager.AddMockHandler(new MockHttpMessageHandler + { + ExpectedMethod = HttpMethod.Get, + ExpectedUrl = $"{TestConstants.GenericAuthority}/{Constants.WellKnownOpenIdConfigurationPath}", + ResponseMessage = MockHelpers.CreateSuccessResponseMessage(validOidcDocumentWithWrongIssuer) + }); + + var ex = await AssertException.TaskThrowsAsync(() => + app.AcquireTokenForClient(new[] { "api" }).ExecuteAsync() + ).ConfigureAwait(false); + + string expectedErrorMessage = string.Format(MsalErrorMessage.IssuerValidationFailed, app.Authority, wrongIssuer); + + Assert.AreEqual(MsalError.AuthorityValidationFailed, ex.ErrorCode); + Assert.AreEqual(expectedErrorMessage, ex.Message, + "Error message should match the expected error message."); + } + } + private static MockHttpMessageHandler CreateTokenResponseHttpHandler( string tokenEndpoint, string scopesInRequest,