Reputation: 1
In a katana web api, I'm using:
appBuilder.UseIdentityServerBearerTokenAuthentication(
new IdentityServerBearerTokenAuthenticationOptions
{
Authority = "https://...",
ValidationMode = ValidationMode.Local,
RequiredScopes = new[] { "..." },
});
This appears to nicely find the public signing key(s) from the authority and (hopefully?) cache them, etc. Although I haven't tried it, I understand there's an equivalent for ASP.NET Core.
Now I need to do the same thing but not in a web api middleware. So I'm trying to find the code that IdentityServer3.AccessTokenValidation.IdentityServerBearerTokenValidationMiddleware uses to do this. All I can see is that it calls UseOAuthBearerAuthentication, which seems to be in Microsoft.Owin.Security.OAuth. I haven't been able to find a version of that source code that seems to match the signature.
It seems to me that under the covers, somebody is probably using System.IdentityModel.Tokens.JwtSecurityTokenHandler and putting a nice little snippet of code into the IssuerSigningKeyResolver of the TokenValidationParameters. That nice little snippet is getting the signing keys from the metadata address. Anybody know what that code is, or have a piece that works well? Obviously, I could write it but I hate to re-invent the wheel, plus mine would be un-tested.
Upvotes: 0
Views: 1468
Reputation: 934
I liked the idea of the helper class, but jon-brichoux's implementation still has a lot of code (e.g. iterating over signingTokens) you'd think would be handled by libraries. Microsoft's libraries also have a lot of solid code including refreshing the keys regularly. That implementation looks to be similar to what's recommended by Microsoft at Obtaining SecurityKeys for Validation Dynamically.
A blog post, Manually validating a JWT using .NET, is good but it hits the server and gets the metadata on every validation. Here's what I came up with that seems to work as expected.
You will need to reference the Microsoft.IdentityModel.Protocols.OpenIdConnect and Microsoft.IdentityModel.Protocols NuGet packages.
public class JwtTokenValidator
{
protected static TokenValidationParameters _validationParameters;
public JwtTokenValidator(string metadataAddress, string audience)
{
_validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataAddress, new OpenIdConnectConfigurationRetriever()),
};
}
public void ValidateToken(string token)
{
var jsonWebTokenHandler = new JsonWebTokenHandler();
var tokenValidationResult = jsonWebTokenHandler.ValidateToken(token, _validationParameters);
if (!tokenValidationResult.IsValid)
{
// Handle each exception which tokenValidationResult can contain as appropriate for your service
// Your service might need to respond with a http response instead of an exception.
if (tokenValidationResult.Exception != null)
throw tokenValidationResult.Exception;
throw new InvalidOperationException("invalid token"); // TODO: throw an application-appropriate exception
}
}
}
Usage:
// cache single instance at application initialization
JwtTokenValidator jwtTokenValidator = new JwtTokenValidator($"https:.../.well-known/openid-configuration", "[audience]");
// live check in controller or wherever
jwtTokenValidator.ValidateToken("eyJ0eX...");
Upvotes: 0
Reputation: 1
Thanks, leastprivilege. Looking deeper at your DiscoverydocumentIssuerSecurityTokenProvider
class, I found ConfigurationManager<OpenIdConnectConfiguration>
. Using that, I have come up with the following helper class for access token validation outside of OWIN middleware.
Feedback solicited!
using Microsoft.IdentityModel.Protocols;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
namespace WpfClient
{
public class AccessTokenValidator
{
protected TokenValidationParameters _accessTokenValidationParameters;
public AccessTokenValidator(string stsRoot)
{
stsRoot = stsRoot.TrimEnd('/');
var discoveryEndpoint = stsRoot + "/.well-known/openid-configuration";
var webHandler = new WebRequestHandler();
var httpClient = new HttpClient(webHandler);
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(discoveryEndpoint, httpClient);
_accessTokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = stsRoot,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (string token, SecurityToken securityToken, SecurityKeyIdentifier keyIdentifier, TokenValidationParameters validationParameters) => {
var signingTokens = configurationManager.GetConfigurationAsync().Result.JsonWebKeySet.GetSigningTokens();
foreach (var signingToken in signingTokens)
{
foreach (var clause in keyIdentifier)
{
var key = signingToken.ResolveKeyIdentifierClause(clause);
if (key != null)
{
return key;
}
}
}
return null;
},
RequireExpirationTime = true,
ValidateAudience = false, // See https://github.com/IdentityServer/IdentityServer3/issues/1365: "OAuth2 does not use the term 'audience' ... it instead uses the term 'scope' ... 'audience' and the 'aud' claim are JWT specific concepts."
ValidateLifetime = true,
};
}
public void ValidateAccessToken(string accessToken, IEnumerable<string> requiredScopes, IEnumerable<string> requiredRoles)
{
ClaimsPrincipal claimsPrincipal;
SecurityToken securityToken;
var handler = new JwtSecurityTokenHandler();
claimsPrincipal = handler.ValidateToken(accessToken, _accessTokenValidationParameters, out securityToken);
if (claimsPrincipal == null)
{
throw new NullReferenceException("ClaimsPrincipal object returned is null");
}
RequireClaims("scope", requiredScopes, claimsPrincipal);
RequireClaims("role", requiredRoles, claimsPrincipal);
}
private static void RequireClaims(string type, IEnumerable<string> requiredValues, ClaimsPrincipal claimsPrincipal)
{
if (requiredValues != null)
{
var haveClaims = claimsPrincipal.FindAll(type);
var missingRequiredValues = requiredValues.Where(s => !haveClaims.Any(c => c.Value.Equals(s, StringComparison.InvariantCultureIgnoreCase))).ToArray();
if (missingRequiredValues.Any())
{
var list = string.Join(", ", missingRequiredValues);
throw new InvalidDataException($"Missing required {type} claims: {list}");
}
}
}
}
}
Upvotes: 0