2
votes

I'm trying to follow the example validation code in https://azure.microsoft.com/en-us/resources/samples/active-directory-dotnet-webapi-manual-jwt-validation/

(REALLY the code in https://github.com/Azure-Samples/active-directory-dotnet-webapi-manual-jwt-validation/blob/master/TodoListService-ManualJwt/Global.asax.cs#L136)

I then attempt to validate using:

TokenValidationParameters validationParams = new TokenValidationParameters
{
    // We accept both the App Id URI and the AppId of this service application
    ValidAudiences = new[] { kADConfiguration_.Audience, kADConfiguration_.ClientId },

    // Supports both the Azure AD V1 and V2 endpoint
    ValidIssuers = new[] { _issuer, $"{_issuer}/v2.0" },
    ValidateIssuer = true,  // set to false and works, set to true it fails

    IssuerSigningKeys = validationInfo.Item2
};
Microsoft.IdentityModel.Tokens.SecurityToken v;
System.Security.Claims.ClaimsPrincipal answer = handler.ValidateToken(authorizationHeader.Substring(kBearer_.Length), validationParams, out v);

I can see that the issuer in the token differs (just the hostname part) from the issuer in the https://login.microsoftonline.com/efa3038a-575b-42ea-8ba1-483cf7f0bdb6/.well-known/openid-configuration

But I cannot tell why, or what I'm doing wrong.

I haven't yet found any useful documentation on what parameters to pass to the validation process (for example https://docs.microsoft.com/en-us/dotnet/api/system.identitymodel.tokens.jwt.jwtsecuritytokenhandler.validatetoken?view=azure-dotnet just says "validationParameters Contains validation parameters", and https://docs.microsoft.com/en-us/dotnet/api/microsoft.identitymodel.tokens.tokenvalidationparameters.validissuers?view=azure-dotnet#Microsoft_IdentityModel_Tokens_TokenValidationParameters_ValidIssuers which says "contains valid issuers that will be used to check against the token's issuer."

2
note - I found that changing the logic from that sample call to ValidIssues to also include the authority works: ValidIssuers = new[] { validationInfo.Item1, $"{validationInfo.Item1}/v2.0", kAuthority_, $"{kAuthority_}/v2.0", }, - lewis

2 Answers

6
votes

The sample you're currently looking at is a little old and explaining with Azure AD v1.0 endpoint as reference. The issuer value you are seeing in token is correct, because you have acquired that token from Azure AD v2.0 endpoint. The OpenID discovery document URL you're using to find the valid issuer is not correct. More explanation in further sections.

I should also briefly mention that in most cases, explicitly validating the token manually like the sample you're following explains is a bit of heavy lifting which isn't really needed. I don't want to stray off from your orginal question hence I'm just keeping some pointers on this part at the end of my answer, but do take a look to see if it makes sense for your case.

More details on Access Tokens acquired from Azure AD v1.0 and v2.0 endpoints

Please look at this Microsoft Documentation - Access Tokens Reference - Sample Tokens

OpenID Discovery Document URL for your tenant

For openid configuration you should be looking at Azure AD v2.0 endpoint, and you will see the correct issuer value there. Specifically for your tenant (as shared in question) correct URL to use will be

https://login.microsoftonline.com/efa3038a-575b-42ea-8ba1-483cf7f0bdb6/v2.0/.well-known/openid-configuration

The value for OpenID Discovery document that you're currently looking at, is only applicable for tokens acquired from v1.0 endpoint.

How to find the correct OpenID Discovery Document URL from Azure Portal

For v2.0 Endpoint, go the preview experience, as shown below. Azure Portal > Azure Active Directory > App Registrations (Preview) > Endpoints

enter image description here

For v1.0 Endpoint, go to old experience (about to go away). Azure Portal > Azure Active Directory > App Registrations > Endpoints

enter image description here


Like I said initially, for most applications manual token validation is generally not needed.

In case of single tenant applications, generally you just keep ValidateIssuer = truefor theTokenValidationParameters`

In case of Multi-Tenant applications, there can be a few cases..

  • If you know your issuers before hand, you can still set ValidateIssuer=True and set the list of ValidIssuers.. ValidIssuers = new List<string>()...

  • If valid issuers for your application are dynamic or if you want to write some logic to gather that list, you can write an implementation for TokenValidationParameters.IssuerValidator which has your custom logic. You just need to set a delegate that will be used to validate the issuer.

  • In other cases, where you still want to write your own custom logic, then explicit validation like the sample you're following makes sense.

Please see a related SO Thread here.

1
votes

Though the above marked answer was helpful, it was not directly fully an answer. In case anyone has this trouble, here is a snippet of code which can be used to address the above:

...

private static AppSettings.ADConfiguration kADConfiguration_ = AppSettings.PeekObj<AppSettings.AuthorizationConfiguration>(AppSettings.kFieldname_Authorization).ADConfiguration;

private static string kAuthority_ = String.Format(CultureInfo.InvariantCulture, kADConfiguration_.AADInstance, kADConfiguration_.Tenant);

private static string _issuer = string.Empty;
private static ICollection<SecurityKey> _signingKeys = null;
private static DateTime _stsMetadataRetrievalTime = DateTime.MinValue;

private static Tuple<string, ICollection<SecurityKey>> AssureSigningKeysAndIssuerUpToDate_()
{
    /*
     *  This logic is based on https://github.com/Azure-Samples/active-directory-dotnet-webapi-manual-jwt-validation/blob/master/TodoListService-ManualJwt/Global.asax.cs#L136 ,
     *     as amended in https://stackguides.com/questions/55840510/validating-the-issuer-token-has-issuer-https-login-microsoftonline-com-xv2-0
     */

    // The issuer and signingKeys are cached for 24 hours. They are updated if any of the conditions in the if condition is true.
    if (DateTime.UtcNow.Subtract(_stsMetadataRetrievalTime).TotalHours > 24
    || string.IsNullOrEmpty(_issuer)
    || _signingKeys == null)
    {
        // Get tenant information that's used to validate incoming jwt tokens
        string stsDiscoveryEndpoint = $"{kAuthority_}/v2.0/.well-known/openid-configuration";
        var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(stsDiscoveryEndpoint, new OpenIdConnectConfigurationRetriever());
        OpenIdConnectConfiguration config = null;
        Task.Run(async () => config = await configManager.GetConfigurationAsync()).Wait();
        _issuer = config.Issuer;
        _signingKeys = config.SigningKeys;
        _stsMetadataRetrievalTime = DateTime.UtcNow;
    }
    return new Tuple<string, ICollection<SecurityKey>>(_issuer, _signingKeys);
}

...

public String GetUserFromAuthHeader(String authorizationHeader)
{
    const String kBearer_ = "Bearer ";
    if (authorizationHeader.StartsWith(kBearer_))
    {
        System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler();

        Tuple<string, ICollection<SecurityKey>> issuerAndSigningKeys = AssureSigningKeysAndIssuerUpToDate_();
        TokenValidationParameters validationParams = new TokenValidationParameters
        {
            // We accept both the App Id URI and the AppId of this service application
            ValidAudiences = new[] { kADConfiguration_.Audience, kADConfiguration_.ClientId },
            ValidIssuers = new[] { issuerAndSigningKeys.Item1, $"{issuerAndSigningKeys.Item1}/v2.0" },
            IssuerSigningKeys = issuerAndSigningKeys.Item2
        };

        SecurityToken v;
        System.Security.Claims.ClaimsPrincipal answer = handler.ValidateToken(authorizationHeader.Substring(kBearer_.Length), validationParams, out v);

        //DEBUG - var jwt = new Microsoft.IdentityModel.JsonWebTokens.JsonWebToken(authorizationHeader.Substring(kBearer_.Length));
        var claims = answer.Claims;
        foreach (System.Security.Claims.Claim c in claims)
        {
            if (c.Type == "preferred_username")
            {
                return c.Value;
            }
        }
    }
    return null;
}