0

I have setup IdentityServer4 with the quickstart template to authenticate the user against Micorosft Entra ID(with FIDO2).

IdentityServer4 gets a token back and the verifikation starts in the ExternalController.Callback method. When it finnaly executes this row :

var principal = handler.ValidateToken(token, validationParameters, out SecurityToken validatedToken);

it fails with this exception :

IDX10511: Signature validation failed. Keys tried: 'Microsoft.IdentityModel.Tokens.X509SecurityKey, KeyId: 'MGLqj10VNLoXaFfpJCBpgB4JaKs', InternalId: 'MGLqj10VNLoXaFfpJCBpgB4JaKs'. , KeyId: MGLqj10VNLoXaFfpJCBpgB4JaKs Microsoft.IdentityModel.Tokens.RsaSecurityKey, KeyId: 'MGLqj10VNLoXaFfpJCBpgB4JaKs', InternalId: 'EvI8giarv1jMMohnATIJ9o5MZ_J_rThL2EGO3Upamq4'. , KeyId: MGLqj10VNLoXaFfpJCBpgB4JaKs '. Number of keys in TokenValidationParameters: '12'. Number of keys in Configuration: '0'. Matched key was in 'TokenValidationParameters'. kid: 'MGLqj10VNLoXaFfpJCBpgB4JaKs'. Exceptions caught: ''. token: 'RETRACTED'. See https://aka.ms/IDX10511 for details.

The KID seems to match just fine but it still fails without any explaination?

Any suggestion on how to solve this?

Authentication creation code in Program.cs :

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect("Microsoft Entra ID", options =>
{
    var microsoftEntraIdSettings = builder.Configuration.GetSection("MicrosoftEntraID").Get<MicrosoftEntraIDSettings>();

    options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
    options.Authority = $"https://login.microsoftonline.com/{microsoftEntraIdSettings.TenantId}/v2.0";
    options.ClientId = microsoftEntraIdSettings.ClientId;
    options.ClientSecret = microsoftEntraIdSettings.ClientSecret;
    options.ResponseType = "code";
    options.SaveTokens = true;
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.CallbackPath = "/signin-oidc";
    options.Events = new OpenIdConnectEvents
    {
        OnRemoteFailure = context =>
        {
            // Log detailed error information
            var error = context.Failure;

            context.Response.Redirect("/Home/Error?message=" + error?.Message);
            context.HandleResponse();
            return Task.CompletedTask;
        },
        OnTokenValidated = context =>
        {
            return Task.CompletedTask;
        },
        OnAuthenticationFailed = context =>
        {
            return Task.CompletedTask;
        }
    };
});

Here is some code from ExternalController :

[HttpGet]
public async Task<IActionResult> Callback()
{
    // read external identity from the temporary cookie
    var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
    if (result?.Succeeded != true)
    {
        throw new Exception("External authentication error");
    }

    var token = result.Properties.GetTokenValue("access_token");
    var validatedToken = await ValidateTokenAsync(token);
    if (validatedToken == null)
        throw new Exception("Token validation failed");

    if (_logger.IsEnabled(LogLevel.Debug))
    {
        var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
        _logger.LogDebug("External claims: {@claims}", externalClaims);
    }

    // lookup our user and external provider info
    var (user, provider, providerUserId, claims) = FindUserFromExternalProvider(result);
    if (user == null)
    {
        // this might be where you might initiate a custom workflow for user registration
        // in this sample we don't show how that would be done, as our sample implementation
        // simply auto-provisions new external user
        user = AutoProvisionUser(provider, providerUserId, claims);
    }

    // this allows us to collect any additional claims or properties
    // for the specific protocols used and store them in the local auth cookie.
    // this is typically used to store data needed for signout from those protocols.
    var additionalLocalClaims = new List<Claim>();
    var localSignInProps = new AuthenticationProperties();
    ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);

    // issue authentication cookie for user
    var isuser = new IdentityServerUser(user.SubjectId)
    {
        DisplayName = user.Username,
        IdentityProvider = provider,
        AdditionalClaims = additionalLocalClaims
    };

    await HttpContext.SignInAsync(isuser, localSignInProps);
    // delete temporary cookie used during external authentication
    await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);

    // retrieve return URL
    var returnUrl = result.Properties.Items["returnUrl"] ?? "~/";
    // check if external login is in the context of an OIDC request
    var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
    await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.SubjectId, user.Username, true, context?.Client.ClientId));

    // The client is native, so this change in how to
    // return the response is for better UX for the end user.
    if (context != null && context.IsNativeClient())
        return this.LoadingPage("Redirect", returnUrl);

    return Redirect(returnUrl);
}


private async Task<ClaimsPrincipal> ValidateTokenAsync(string token)
{
    var handler = new JwtSecurityTokenHandler();
    IdentityModelEventSource.ShowPII = true;
    IdentityModelEventSource.LogCompleteSecurityArtifact = true;
    // Fetch the OpenID Connect configuration document
    var config = await GetOpenIdConnectConfigurationAsync();
    if (config == null || !config.SigningKeys.Any())
    {
        throw new Exception("No signing keys found in configuration");
    }

    // Extract the Key ID (kid) from the token header
    var tokenKid = GetKidFromToken(token);
    _logger.LogInformation("Token kid: {TokenKid}", tokenKid);
    _logger.LogInformation("Config Signing Keys: {@Keys}", config.SigningKeys.Select(k => k.KeyId));


    // Set up token validation parameters
    var validationParameters = new TokenValidationParameters
    {
        ValidIssuer = $"https://login.microsoftonline.com/{_microsoftEntraIdSettings.TenantId}/v2.0",
        ValidAudiences = new[] { _microsoftEntraIdSettings.ClientId },
        IssuerSigningKeys = config.SigningKeys, //config.SigningKeys, // Use all keys from the configuration
        ValidateIssuerSigningKey = true,
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = false,
        ValidateTokenReplay = true,
        ClockSkew = new TimeSpan(2, 0, 0),
        // Additional logging for key retrieval
        IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
        {
            var keys = config.SigningKeys.Where(k => k.KeyId == kid).ToList();
            _logger.LogInformation("Resolved keys for kid {Kid}: {@Keys}", kid, keys);
            return keys;
        }
    };

    try
    {
        // Validate the token using the matched public key
        var principal = handler.ValidateToken(token, validationParameters, out SecurityToken validatedToken);
        _logger.LogInformation("Token validated successfully");
        return principal;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Token validation failed");
        throw;
    }
}

private async Task<OpenIdConnectConfiguration> GetOpenIdConnectConfigurationAsync()
{
    try
    {
        // Fetch the OpenID Connect configuration document from Microsoft Entra ID
        var config = await _configurationManager.GetConfigurationAsync(CancellationToken.None);
        _logger.LogInformation("Fetched OpenID Connect configuration: {@Config}", config);
        return config;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Failed to fetch OpenID Connect configuration");
        throw;
    }
}

private string GetKidFromToken(string token)
{
    var handler = new JwtSecurityTokenHandler();
    var jwtToken = handler.ReadJwtToken(token);
    return jwtToken.Header.Kid;
}

Edit (Solved) Apparently, I got a Microsoft Graph API token that is intended to be used with Microsoft services. This type of token should not and cannot be verified by the client. Instead, I followed @Rukmini's guide to get a proper API token that could then be verified in my Blazor app. The ValidationParameters in ExternalController.cs hade to be changed somewhat to match.

var validationParameters = new TokenValidationParameters
{
    ValidIssuer = $"https://sts.windows.net/{_microsoftEntraIdSettings.TenantId}/",
    ValidAudiences = new[] { $"api://{_microsoftEntraIdSettings.ClientId}" },
    IssuerSigningKeys = config.SigningKeys, //config.SigningKeys, // Use all keys from the configuration
    ValidateIssuerSigningKey = true,
    ValidateIssuer = true,
    ValidateAudience = true,
    ValidateLifetime = false,
    ValidateTokenReplay = true,
    ClockSkew = new TimeSpan(2, 0, 0),
    // Additional logging for key retrieval
    IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
    {
        var keys = config.SigningKeys.Where(k => k.KeyId == kid).ToList();
        _logger.LogInformation("Resolved keys for kid {Kid}: {@Keys}", kid, keys);
        return keys;
    }
};

As well as the scopes on the AddAuthentication in program.cs :

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect("Microsoft Entra ID", options =>
{
    var microsoftEntraIdSettings = builder.Configuration.GetSection("MicrosoftEntraID").Get<MicrosoftEntraIDSettings>();

    options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
    options.Authority = $"https://login.microsoftonline.com/{microsoftEntraIdSettings.TenantId}/v2.0";
    options.ClientId = microsoftEntraIdSettings.ClientId;
    options.ClientSecret = microsoftEntraIdSettings.ClientSecret;
    options.ResponseType = "code";
    options.SaveTokens = true;
    options.Scope.Add("api://413fef85-71c1-479a-a2b7-c7b62059a42b/BlazorToIdentityService");
    options.CallbackPath = "/signin-oidc";
    options.Events = new OpenIdConnectEvents
    {
        OnRemoteFailure = context =>
        {
            // Log detailed error information
            var error = context.Failure;

            context.Response.Redirect("/Home/Error?message=" + error?.Message);
            context.HandleResponse();
            return Task.CompletedTask;
        },
        OnTokenValidated = context =>
        {
            return Task.CompletedTask;
        },
        OnAuthenticationFailed = context =>
        {
            return Task.CompletedTask;
        }
    };
});
12
  • What is the aud of the access token? What is the scope you are passing to generate access token?
    – Rukmini
    Commented Jul 9 at 10:15
  • The aud is "00000003-0000-0000-c000-000000000000", the scp is "email openid profile" in the token. I have also added the creation of the authentiocation in the post above.
    – Banshee
    Commented Jul 9 at 10:36
  • The access token with aud 00000003-0000-0000-c000-000000000000 or graph.microsoft.com is for Microsoft Graph API shouldn't be validated as the token is not meant for the application
    – Rukmini
    Commented Jul 9 at 10:38
  • Sorry, im somewhat new to this, but are you saying that I should not verify this token? Or do I get the wrong "kind" of token issued for something else then my user and application?
    – Banshee
    Commented Jul 9 at 10:43
  • You shouldnt be validating Microsoft Graph token check this stackoverflow.com/questions/78642832/…
    – Rukmini
    Commented Jul 9 at 10:44

1 Answer 1

1

Based on your code, you are generating access token for Microsoft Graph API:

options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");

And the aud is 00000003-0000-0000-c000-000000000000, the scp is email openid profile in the token.

Note that: Only the access token meant or generated for the application must be validated.

  • The access token with aud 00000003-0000-0000-c000-000000000000 or https://graph.microsoft.com is for Microsoft Graph API shouldn't be validated.
  • Microsoft Graph API tokens use a different for signing and you cannot use the same methods to validate Microsoft Graph API tokens.
  • Hence, you need to generate token by passing a scope defined for your API in the Expose API blade of your app registration.
  • You must get an access token for your API.

When I decoded the token via for sample, even I got the same error:

enter image description here

If you want to generate access token for your Blazor app or your application, then check the below:

Go to your application -> Expose an API -> Add a scope

enter image description here

Grant API permissions for the scope you added:

Go to API permissions -> Add a permission -> APIs my organization uses -> Search you application ->Add permission

enter image description here

Now modify the code and pass scope as api://ClientID/ScopeName or api://ClientID/.default while generating the token.

options.Scope.Add("api://ClientID/ScopeName");

While validating the token pass audience as ValidAudiences = new[] { _microsoftEntraIdSettings.ClientId } pass the ClientID or api://ClientID based on the token you generate.

You can refer SO Thread by me to validate token for API.

2
  • 1
    Great! i hade to change a couple of things to get it matching(see Edit) but now it works, tank you vary much!
    – Banshee
    Commented Jul 9 at 12:29
  • Glad that I could help:)
    – Rukmini
    Commented Jul 9 at 12:29

Not the answer you're looking for? Browse other questions tagged or ask your own question.