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;
}
};
});
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.
00000003-0000-0000-c000-000000000000
or https://graph.microsoft.com
is for Microsoft Graph API shouldn't be validated.When I decoded the token via for sample, even I got the same error:
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
Grant API permissions for the scope you added:
Go to API permissions -> Add a permission -> APIs my organization uses -> Search you application ->Add permission
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.