Skip to content

Add issuer validation when making call to OIDC endpoint #5358

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 16, 2025
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,63 @@ 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 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()
{
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 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 =
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,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<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
Loading