Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,7 +28,7 @@ public static async Task<OidcMetadata> GetOidcAsync(
}

await s_lockOidcRetrieval.WaitAsync().ConfigureAwait(false);

Uri oidcMetadataEndpoint = null;
try
{
Expand All @@ -44,9 +45,13 @@ public static async Task<OidcMetadata> 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}");

Expand All @@ -70,6 +75,62 @@ public static async Task<OidcMetadata> GetOidcAsync(
}
}

/// <summary>
/// Validates that the issuer in the OIDC metadata matches the authority.
/// </summary>
/// <param name="authority">The authority URL.</param>
/// <param name="issuer">The issuer from the OIDC metadata - the single source of truth.</param>
/// <exception cref="MsalServiceException">Thrown when issuer validation fails.</exception>
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-like scenarios
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()
{
Expand Down
2 changes: 2 additions & 0 deletions src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,27 +111,26 @@ 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
.Create(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
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,46 @@ public async Task BadOidcResponse_ThrowsException_Async(string badOidcResponseTy
}
}

[TestMethod]
public async Task OidcIssuerValidation_ThrowsForNonMatchingIssuer_Async()
{
using (var httpManager = new MockHttpManager())
{
string authority = "https://demo.duendesoftware.com";
string wrongIssuer = "https://wrong.issuer.com";

IConfidentialClientApplication app = ConfidentialClientApplicationBuilder
.Create(TestConstants.ClientId)
.WithHttpManager(httpManager)
.WithOidcAuthority(authority)
.WithClientSecret(TestConstants.ClientSecret)
.Build();

// Create OIDC document with non-matching issuer
string validOidcDocumentWithWrongIssuer = TestConstants.GenericOidcResponse.Replace(
$"\"issuer\":\"{authority}\"",
$"\"issuer\":\"{wrongIssuer}\"");

// Mock OIDC endpoint response
httpManager.AddMockHandler(new MockHttpMessageHandler
{
ExpectedMethod = HttpMethod.Get,
ExpectedUrl = authority + "/" + Constants.WellKnownOpenIdConfigurationPath,
ResponseMessage = MockHelpers.CreateSuccessResponseMessage(validOidcDocumentWithWrongIssuer)
});

var ex = await AssertException.TaskThrowsAsync<MsalServiceException>(() =>
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,
Expand Down