I recently switched to Auth0 as an OAuth2 provider, and was a little surprised to find how little data was stored in the bearer token. I’d previously shoved pretty much everything in there: profile and roles as well. Some of you may be snickering since you already know better. Access tokens should be short. But we still need all the user claims. This is available on the Auth0 server as /userinfo. The access token provided will also have access to the /userinfo endpoint, which contains the profile and claims that we are looking for. If you do the obvious thing, and load the claims and profile on demand when receiving the access token, you’ll find out something else about Auth0 – they rate limit the userinfo endpoint.
I put the following singleton into all of my web APIs and microservices:
public class ClaimsHolder
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, object>> _claims = new();
public void AddClaim(string userid, string name, object value)
{
_claims.AddOrUpdate(userid,
k =>
{
var v = new ConcurrentDictionary<string, object>();
v[name] = value;
return v;
},
(k, v) =>
{
v[name] = value;
return v;
});
}
public IList<Claim> this[string userid]
{
get
{
return _claims.GetOrAdd(userid, new ConcurrentDictionary<string, object>())
.Select(c => new Claim(c.Key, c.Value.ToString() ?? throw new Exception("null claim??")))
.ToList();
}
set
{
_claims.AddOrUpdate(userid,
k => new ConcurrentDictionary<string, object>(),
(k, v) =>
{
foreach (var claim in value)
v.GetOrAdd(claim.Type, claim.Value);
return v;
});
foreach (var claim in value)
{
_claims[userid].AddOrUpdate(claim.Type,
k =>
{
var v = new ConcurrentDictionary<string, object>();
v[claim.Type] = claim.Value;
return v;
},
(k, v) => claim.Value);
}
}
}
}
A web API can use this claims holder as follows. First, add JWT bearer authentication to your Program.cs similar to the following:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, c =>
{
c.Authority = $"https://{auth0Domain}";
c.TokenValidationParameters = new()
{
ValidAudience = auth0Audience,
ValidIssuer = $"https://{auth0Domain}"
};
c.Events = new()
{
OnTokenValidated = async context =>
{
if (context.SecurityToken is not JwtSecurityToken accessToken) return;
token.Value = accessToken.RawData;
if (context.Principal?.Identity is ClaimsIdentity identity)
{
var userid = identity.Claims.Single(c => c.Type == ClaimTypes.NameIdentifier).Value;
var claims = claimsHolder[userid ?? throw new Exception("null user!")];
if (!claims.Any())
{
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken.RawData}");
claims = (await
httpClient.GetFromJsonAsync<Dictionary<string, object>>(
$"https://{auth0Domain}/userinfo")
)?.Select(x =>
new Claim(x.Key, x.Value?.ToString() ?? throw new Exception("null claim??"))).ToList();
if (claims != null)
foreach (var claim in claims.ToList())
claimsHolder.AddClaim(userid, claim.Type, claim.Value);
}
identity.AddClaims(claims ?? Enumerable.Empty<Claim>().ToList());
identity.AddClaim(new Claim("access_token", accessToken.RawData));
}
}
};
});
What we end up with, then, is a wrapper around a ConcurrentDictionary that holds a per-user ConcurrentDictionary containing all of the claims. This provides a convenience singleton to minimize the amount of times the user info must be retrieved from Auth0. There is a problem with this code, however, in that the claims are not refreshed when the token is refreshed. We should allow the access token to work until the end of its lifetime using the cached claims, but once the token is refreshed we should refresh the cached claims. I don’t currently know how to do this, however. For many low-security scenarios, the code above would work as-is; the claims just don’t change that often. Still, this is a problem that must be solved eventually.