Protect user information from the IdP
There are several challenges when requesting many scopes when a user (or system) is logging in:
- The ticket can easily become a bit large, or especially the cookie can become large. It might sound strange, but it’s important to consider that when the cookie is saved, the user’s claims are stored, and often id_token, access_token, and possibly a refresh_token are also stored. This means that each claim can potentially exist two to three times in the cookie, and the size can quickly grow.
- The ticket can contain sensitive information, such as personal information obtained when requesting profile, email, and similar scopes. Where will the ticket be stored? If it is to be sent to a user’s device to be included in API calls, there are not many great ways to protect the ticket and thus the sensitive information.
What can be done to reduce the size and increase security?
Avoid, if possible, sending any token to the client
Since there are no really good ways to securely protect a token on the client, the safest method is not to send any token to the client at all. Today, the recommended approach is to use cookies from the client because cookies can be securely managed (set them to HttpOnly and Strict) in an effective manner.
The challenge with cookies arises when making calls to an API with a different address than the website because the cookie is only valid on the website itself. If the client needs to make requests to an API at a different address, there are two alternatives:
- Send the token to the client, but preferably use a reference token. However, try to avoid this option if possible.
- Transform your web solution into an API gateway for the underlying API. Ensure that all API calls are routed back to the website, acting as an API gateway. For calls that need to proceed to an underlying API, forward them accordingly. When forwarding the request, append the access token associated with the logged-in user. It is not necessary to write many lines of code for this if you use a tool like YARP or Ocelot (you can also read more here).
Use a reference token instead of a JWT for your access token
An access_token can be of two types: 1) a JWT or 2) a reference token. The type an application receives is configured in the Identity Provider (IdP). A JWT contains all claims and some additional information, while a reference token is just a reference to the actual ticket. However, the real ticket must be fetched by the API from the IdP. The advantage of a reference token is that it is very small, and even if someone obtains the ticket, it contains no information by itself. The drawback of reference tokens is that the API must be able to handle this type of token, and typically, one would want the API to handle both JWT and reference tokens. This is well-documented, and there is some existing code that already supports it (you can start reading after the first bullet list, which is roughly in the middle of the blog post).
Request fewer scopes
Never request more scopes than absolutely necessary is a good general rule, but there is support in the Identity Provider (IdP) to complement with additional scopes from the server/API afterward. Besides the advantage of reducing the ticket size and containing less sensitive information, it also means that each application doesn’t need to know in advance which scopes each API it calls requires. The most crucial aspect is perhaps that it allows an API to evolve over time without requiring changes to calling applications.
Therefore, we recommend only fetching openid, the API scopes for the APIs to be called, and possibly the offline scope if a refresh token is needed. If you choose to proceed this way, both the web application and the API will need logic to fetch additional scopes. Here is example code for retrieving claims for additional scopes:
public Task<List<Claim>> GetAuthorizationAsync(ClaimsPrincipal user, List<string> scopes)
{
if (!user.Identity.IsAuthenticated)
return Task.FromResult(new List<Claim>());
const string formEncoded = "application/x-www-form-urlencoded";
var userId = user.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userId)) //Ingen autentierad anvÀndare, Ätminstone inte nÄgon som vi kan hÀmta behörigheter, profile eller nÄgot annat för
return Task.FromResult(new List<Claim>());
var cacheKey = CreateCacheKey(userId, scopes);
return _cache.GetOrCreateAsync(cacheKey, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.CacheLength);
var clientId = _options.ApiName;
var clientSecret = _options.ApiSecret;
var arguments = new List<KeyValuePair<string, string>>
{
new KeyValuePair<string, string>("client_id", clientId),
new KeyValuePair<string, string>("client_secret", clientSecret),
new KeyValuePair<string, string>("user_id", userId),
new KeyValuePair<string, string>("scopes", string.Join(" ", scopes))
};
using (var content = new FormUrlEncodedContent(arguments))
using (var client = new HttpClient())
{
content.Headers.ContentType = new MediaTypeHeaderValue(formEncoded) { CharSet = "UTF-8" };
client.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", formEncoded);
using (var response = await client.PostAsync(_baseUri + "api/client/authorization", content).ConfigureAwait(false))
{
response.EnsureSuccessStatusCode();
using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
using (var reader = new StreamReader(stream))
{
stream.Position = 0;
var serverClaims = Serializer.Deserialize<List<Claim>>(new JsonTextReader(reader));
return serverClaims;
}
}
}
});
}
ApiName and ApiSecret are obtained when an API is configured in the Identity Provider (IdP). If it’s a web application, ClientId and ClientSecret can be sent instead. In the code above, _baseUri needs to be set to the URL of the Auth server.
This function can then be called from, for example, a ClaimsTransformation class.
This logic is also necessary when using the Client Credentials flow (External systems) to make calls to your APIs. Fundamentally, a ticket for a Client cannot contain any personal claims since it represents a system/application. However, the IdP has specific support called External systems, allowing an API not to distinguish between a user or a calling system, simplifying many aspects.
In IRM.AspNetCore.Authorization.Client, there is ready-to-use support that is easy to configure, achieving exactly this.
Store the cookie on the server instead of the client
In the call to AddCookie, there is the possibility to configure a SessionStore that allows saving the content of the cookie on the server instead, making the cookie itself just a reference. Here’s an example using IMemoryCache, but it can certainly be switched to IDistributedCache or some other type of storage.
public class MemoryCacheTicketStore : ITicketStore
{
private readonly IMemoryCache _cache;
private const string CacheKeyPrefix = "MemoryCacheTicketStore-";
public MemoryCacheTicketStore(IMemoryCache cache)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
}
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
string key = await _backingTicketStore.StoreAsync(ticket);
AddToCache(key, ticket);
return key;
}
public async Task RenewAsync(string key, AuthenticationTicket ticket)
{
AddToCache(key, ticket);
}
private void AddToCache(string key, AuthenticationTicket ticket)
{
var options = new MemoryCacheEntryOptions
{
Priority = CacheItemPriority.NeverRemove
};
var expiresUtc = ticket.Properties.ExpiresUtc ?? DateTimeOffset.UtcNow.AddMinutes(20);
options.SetAbsoluteExpiration(expiresUtc);
_cache.Set(key, ticket, options);
}
public async Task<AuthenticationTicket> RetrieveAsync(string key)
{
_cache.TryGetValue(key, out AuthenticationTicket ticket);
return ticket;
}
public async Task RemoveAsync(string key)
{
_cache.Remove(key);
}
}
In IRM.AspNetCore.Authentication, there is ready-made support for using the cache as a session store:
AddCookie("Cookies", options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.IsEssential = true;
}).AddCacheSessionStore();
Here, a primarily distributed cache will be used if configured, but the support falls back to MemoryCache if it is not available.