In this episode, we look at the backend for frontend, and the changes required for it to handle the users authentication, redirection to the identity provider (the IdentityServer4 powered auth service), the inclusion of an access token when making API calls, the refresh of said token and handling CSRF tokens.
For the walk-through you can check out the next video, but if you prefer a quick read, skip to the written synthesis.
The playlist for the whole series is here.
Intro
In the past couple of episodes, we saw how to integrate IdentityServer4 into our auth service, then prepared the group management API to make use of the access tokens (particularly, JWT) it gets on each request to authenticate and authorize the user.
In this episode, we'll start adapting the frontend, namely its back for front component, to delegate authentication to the auth service, then include the user's access token in each request to the group management API.
Given we'll use cookies to authenticate each SPA (single page application) request to the BFF, we'll also include support for cross-site request forgery tokens.
Configure the BFF to authenticate/authorize the user
We've seen some ways to implement authentication in ASP.NET Core, and we're going to see yet another one with the OpenID Connect integration.
The gist is the same as we've seen in the previous post on the group management API - we'll use AddAuthentication
with some specific OpenID Connect configurations in Startup.ConfigureServices
and call app.UseAuthentication
in the request handling pipeline definition.
AddAuthentication
has a bit more configurations this time around, so let's jump right into it.
AddAuthentication
Let's start by looking at the code, then go through it step by step.
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies", options =>
{
options.EventsType = typeof(CustomCookieAuthenticationEvents);
})
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = "Cookies";
options.Authority = "https://localhost:5005";
options.ClientId = "WebFrontend";
options.ClientSecret = "secret";
options.ResponseType = OidcConstants.ResponseTypes.Code;
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("GroupManagement");
options.Scope.Add(OidcConstants.StandardScopes.OfflineAccess);
options.Events.OnRedirectToIdentityProvider = context =>
{
if (!context.HttpContext.Request.Path.StartsWithSegments("/auth/login"))
{
context.HttpContext.Response.StatusCode = 401;
context.HandleResponse();
}
return Task.CompletedTask;
};
});
// ...
}
The actual call to AddAuthentication
doesn't to that much, configuring the default authentication scheme and challenge scheme. After this call, on the returned AuthenticationBuilder
instance we configure the schemes to match with what we configured.
The Cookies
authentication scheme is configured with AddCookie
and even though not a lot is going on, it's an important bit. We'll see how this works in more detail in one of the next sections, but setting the options.EventsType
allows us to provide an alternative implementation of the CookieAuthenticationEvents
class. By doing this we can, as the name implies, define what happens at certain moments of the cookie authentication flow. In this case, as we'll see later, we're hooking up to an event in order to ensure the tokens we have in our possession (namely access and refresh tokens) are still valid and the user can continue to use the app without re-authenticating.
The challenge scheme, named oidc
, is configured with the call to AddOpenIdConnect
. A lot of this should look familiar, as it matches what we configured in the auth service, when we looked at adding IdentityServer4
to it (in episode 022).
We start by setting the SignInScheme
, which is responsible for persisting the user info we'll obtain from the authentication process.
Then we set the Authority
with the auth service endpoint, plus ClientId
, ClientSecret
and ResponseType
with the same values we set when configuring the client in the auth service.
SaveTokens = true
means the tokens will be stored in the cookie. By default it's false
, to keep the cookie smaller, so in the future we probably should extract it to some server side session, but it works ok for now.
Setting GetClaimsFromUserInfoEndpoint
to true
, means that after the OpenID Connect flow is complete and the tokens retrieved, an additional request is made to the user info endpoint to fetch additional user claims.
We add some extra scopes (besides the default OpenID Connect ones), GroupManagement
and offline_access
so we can access the group management API and use the refresh token.
Finally, we're registering on the OnRedirectToIdentityProvider
event. As this application will be used as an API by the SPA and not as a full-on MVC application, it doesn't make sense to redirect API requests to the auth service when the user isn't logged in, but rather responding with a 401 (unauthorized). To achieve this, in the event handler we're providing, all unauthenticated requests that go to an endpoint that's not /auth/login
, we set the status code to 401 and mark the response has handled, otherwise letting it proceed as usual.
Requiring the user to be authenticated
We already saw in the previous episode how to ensure all requests to an API are authenticated, so this will be exactly the same, but I'll put it here for reference anyway 🙂.
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services
.AddMvc(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
// ...
})
// ...
}
As we saw previously, we go to our AddMvc
call, and in the configuration lambda we create and add an AuthorizeFilter
with the policies we need, in this case for the user to be authenticated.
Logging in
As you might have noticed, when we registered on the OnRedirectToIdentityProvider
for the OpenID Connect authentication challenge scheme, we made it respond with a 401 for all requests except for the /auth/login
route. That's because we'll use this route to put a login link in the SPA.
In summary, the login strategy will be the following:
- We expose an
/auth/info
endpoint that the SPA can invoke. If it gets a 401, the SPA shows the link for logging in, otherwise it makes use of the returned information. - We expose an
/auth/login
endpoint, with the sole purpose of redirecting to the auth service and then, when the authentication is completed, redirect to the desired SPA url.
Let's see the AuthController
's code, developed to implement the aforementioned login strategy.
Features/Auth/AuthController.cs
[Route("auth")]
public class AuthController : ControllerBase
{
// ...
[HttpGet]
[Route("info")]
public ActionResult<AuthInfoModel> GetInfo()
{
// CSRF token stuff we'll see later ...
return new AuthInfoModel
{
Name = User.FindFirst("name").Value
};
}
[HttpGet]
[Route("login")]
public IActionResult Login([FromQuery] string returnUrl)
{
/*
* No need to do anything here, as the auth middleware will take care of redirecting to IdentityServer4.
* When the user is authenticated and gets back here, we can redirect to the desired url.
*/
return Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
}
}
The /auth/info
endpoint simply returns some of the current user's information (plus some CSRF token logic we'll see later). It returning 401 on unauthenticated users is handled automatically by the framework, as we saw when we added the authorization filter and transformed the redirect in a 401 in the authentication challenge scheme.
The /auth/login
endpoint, gets a return url, so it can redirect back the user to the correct place in the SPA. Again, the handling of the unauthenticated users, redirecting to the auth service, is done automatically by the framework.
Support cross-site request forgery token
To add support for cross-site request forgery token, we have a wee bit more work than what we saw in previous episodes with MVC and Razor Pages. That's because since we're using a SPA instead of Razor generated HTML, so we got some more manual work to do.
Let's begin with Startup.ConfigureServices
. In here we need to make a couple of changes: add a filter to validate the presence of an anti-forgery token in all requests that should have it (so, all except GET
, HEAD
, OPTIONS
, and TRACE
) and add helper anti-forgery services.
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services
.AddMvc(options =>
{
// ...
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
})
// ...
services.AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN");
}
In the lambda that configures MVC, we can see the new filter being added to validate the anti-forgery tokens.
After the filter, we can see the configuration of the anti-forgery services, with the call to the AddAntiforgery
extension method. The HeaderName
property we're setting, is the name of the header that the SPA will send with the anti-forgery token, so it can be validated server side. If we didn't set this, the server would only try to find the token in the submitted form data, which is the typical behavior in MVC/Razor Pages applications.
Now for the SPA to be able to set the header, it needs to get it from somewhere. That's where we get back to the AuthController
. In the GetInfo
action, we'll generate the anti-forgery token and set a cookie with it, so that the SPA is able to use it.
Features/Auth/AuthController.cs
[Route("auth")]
public class AuthController : ControllerBase
{
private readonly IAntiforgery _antiForgery;
public AuthController(IAntiforgery antiForgery)
{
_antiForgery = antiForgery;
}
[HttpGet]
[Route("info")]
public ActionResult<AuthInfoModel> GetInfo()
{
var tokens = _antiForgery.GetAndStoreTokens(HttpContext);
HttpContext.Response.Cookies.Append(
"XSRF-TOKEN",
tokens.RequestToken,
new CookieOptions() {HttpOnly = false}); //allow JS to grab the cookie to put it in the request header
return new AuthInfoModel
{
Name = User.FindFirst("name").Value
};
}
// ...
}
We now get a dependency in the constructor, IAntiforgery
which allows us to generate the anti-forgery token. We make use of it in the GetInfo
action, where we can see that besides generating the token, this service also stores it so it can validated when the client application sends it back in the header.
For the SPA to get the token, we're adding the cookie and setting its HttpOnly
property to false
, otherwise the browser wouldn't allow for the application to access it.
Provide the access token on API calls
Probably the first thing that comes to mind when we think that we must send the access token in each request we'll make to the API, is going to the place we're making the requests and add the header. This would certainly work, but we can use a cleaner solution, which is very reusable: create a class inheriting from DelegatingHandler
and configure the HttpClient
s we want to use it.
A DelegatingHandler
allows us to intercept the HttpClient
's requests, so we can add some logic before, after or even instead of the request. In this case, we want to add the access token header before sending the request. The following class AccessTokenHttpMessageHandler
implements this logic.
AuthTokenHelpers/AccessTokenHttpMessageHandler.cs
public class AccessTokenHttpMessageHandler : DelegatingHandler
{
private readonly IHttpContextAccessor _httpContextAccessor;
public AccessTokenHttpMessageHandler(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var accessToken = await _httpContextAccessor.HttpContext.GetTokenAsync("access_token");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return await base.SendAsync(request, cancellationToken);
}
}
Not a lot going on, but let's go through it quickly. We're simply overriding the SendAsync
method, where we can implement the logic we require. We have an IHttpContextAccessor
instance injected, that we use in SendAsync
to grab the access token from the HttpContext
(remember we stored it in the cookies, and can fetch it in this fashion).
With the token in hand, we can add it to the request headers, then pass it on to the base implementation of SendAsync
, letting the rest of the request flow as normal.
Now we just need to make a couple of changes in Startup.ConfigureServices
, to use the handler: register it in the dependency injection container and configure it with the desired HttpClient
s.
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// ...
services
.AddTransient<AccessTokenHttpMessageHandler>()
.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
//...
services
.AddHttpClient<GroupsController>(/*...*/)
// ...
.AddHttpMessageHandler<AccessTokenHttpMessageHandler>();
//...
}
As you can see, not a lot to do, we just need to remember to do it 😛. Register the AccessTokenHttpMessageHandler
with the DI container, while also registering the IHttpContextAccessor
, as it's not there by default in ASP.NET Core. After that, we can make use of the handler, by calling the AddHttpMessageHandler
extension method on the IHttpClientBuilder
.
Note: a better way to register the
IHttpContextAccessor
is to call theAddHttpContextAccessor
extension method, but I didn't know about it when I originally wrote this code 🙂.
Refresh the access token
Now the final thing we need to do to wrap this up, is to make sure we refresh the access token when its expired (or about to).
To handle this, as I briefly mentioned, we have an alternative implementation of CookieAuthenticationEvents
and some extra helper classes. This means that in each request received, we'll check for the validity of the access token, and if it's expired we refresh it.
Another possible approach to checking the validity of the access token, would be to do it in the HttpClient
's delegating handler we implemented. The main difference is, with the cookie authentication events, we make this check once per request to the BFF, while if we do it in the delegating handler, we do it once per request to the backing API. Both have pros and cons, but it seemed like a good opportunity to check out more ways in which we can hook up to the framework's extensibility points, so we'll go with this one 😉.
Let's start with the CustomCookieAuthenticationEvents
class. When inheriting from CookieAuthenticationEvents
, we have a bunch of methods we can override. In this case, we want to override ValidatePrincipal
- if the access token is valid or we are able to refresh it, we can let it flow as normal (with maybe some tweaks), if not, we reject the principal and force a logout, so the user needs to re-authenticate.
AuthTokenHelpers/CustomCookieAuthenticationEvents.cs
public class CustomCookieAuthenticationEvents : CookieAuthenticationEvents
{
private readonly ITokenRefresher _tokenRefresher;
public CustomCookieAuthenticationEvents(ITokenRefresher tokenRefresher)
{
_tokenRefresher = tokenRefresher;
}
public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
{
var result = await _tokenRefresher.TryRefreshTokenIfRequiredAsync(
context.Properties.GetTokenValue("refresh_token"),
context.Properties.GetTokenValue("expires_at"),
CancellationToken.None);
if (!result.IsSuccessResult)
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
else if (result.TokensRenewed)
{
context.Properties.UpdateTokenValue("access_token", result.AccessToken);
context.Properties.UpdateTokenValue("refresh_token", result.RefreshToken);
context.Properties.UpdateTokenValue("expires_at", result.ExpiresAt);
context.ShouldRenew = true;
}
}
}
There are still a couple of missing pieces, but we're able to understand what's going on in the CustomCookieAuthenticationEvents
anyway. We get an instance of ITokenRefresher
in the constructor, which we'll see in a bit in more detail, but as the name implies, takes care of refreshing (if needed) an access token. If the token refresh fails, we reject the principal and force logout. If the token is refreshed, then we need to update the values that are stored in the cookie and renew the cookie for the user (hence the context.ShouldRenew = true
). Another scenario is that the token might still be valid, so we don't need to do anything (hence no specific code for that scenario).
Before looking at the ITokenRefresher
and its implementation, let's see the TokenRefreshResult
class which is returned by TryRefreshTokenIfRequiredAsync
.
AuthTokenHelpers/TokenRefreshResult.cs
public class TokenRefreshResult
{
private static readonly TokenRefreshResult NoRefreshNeededResult =
new TokenRefreshResult(true, false, null, null, null);
private static readonly TokenRefreshResult FailedResult =
new TokenRefreshResult(false, false, null, null, null);
protected TokenRefreshResult(
bool isSuccessResult,
bool tokensRenewed,
string accessToken,
string refreshToken,
string expiresAt)
{
IsSuccessResult = isSuccessResult;
TokensRenewed = tokensRenewed;
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresAt = expiresAt;
}
public bool IsSuccessResult { get; }
public bool TokensRenewed { get; }
public string AccessToken { get; }
public string RefreshToken { get; }
public string ExpiresAt { get; }
public static TokenRefreshResult Success(
string accessToken,
string refreshToken,
string expiresAt)
{
return new TokenRefreshResult(true, true, accessToken, refreshToken, expiresAt);
}
public static TokenRefreshResult Failed() => FailedResult;
public static TokenRefreshResult NoRefreshNeeded() => NoRefreshNeededResult;
}
As we can see, besides having the necessary properties to check the results in the CustomCookieAuthenticationEvents
class, we have some helper methods that the ITokenRefresher
implementation can use to achieve nicer readability.
The ITokenRefresher
interface exposes a single method, TryRefreshTokenIfRequiredAsync
which... does what the name says 😛
AuthTokenHelpers/ITokenRefresher.cs
/// <summary>
/// Provides an easy way to ensure the user's access token is up to date.
/// </summary>
public interface ITokenRefresher
{
/// <summary>
/// Tries to refresh the current user's access token if required.
/// </summary>
/// <param name="refreshToken">The current refresh token.</param>
/// <param name="expiresAt">The current token expiration information.</param>
/// <param name="ct">The async cancellation token.</param>
/// <returns><code>True</code> if refresh is not needed or executed successfully, <code>False</code> otherwise.</returns>
Task<TokenRefreshResult> TryRefreshTokenIfRequiredAsync(
string refreshToken,
string expiresAt,
CancellationToken ct);
}
Now let's see its implementation.
AuthTokenHelpers/TokenRefresher.cs
/// <inheritdoc />
public class TokenRefresher : ITokenRefresher
{
private static readonly TimeSpan TokenRefreshThreshold = TimeSpan.FromSeconds(30);
private readonly HttpClient _httpClient;
private readonly IDiscoveryCache _discoveryCache;
private readonly ILogger<TokenRefresher> _logger;
public TokenRefresher(
HttpClient httpClient,
IDiscoveryCache discoveryCache,
ILogger<TokenRefresher> logger)
{
_httpClient = httpClient;
_discoveryCache = discoveryCache;
_logger = logger;
}
/// <inheritdoc />
public async Task<TokenRefreshResult> TryRefreshTokenIfRequiredAsync(
string refreshToken,
string expiresAt,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(refreshToken))
{
return TokenRefreshResult.Failed();
}
if (!DateTime.TryParse(expiresAt, out var expiresAtDate) || expiresAtDate >= GetRefreshThreshold())
{
return TokenRefreshResult.NoRefreshNeeded();
}
var discovered = await _discoveryCache.GetAsync();
var tokenResult = await _httpClient.RequestRefreshTokenAsync(
new RefreshTokenRequest
{
Address = discovered.TokenEndpoint,
ClientId = "WebFrontend",
ClientSecret = "secret",
RefreshToken = refreshToken
}, ct);
if (tokenResult.IsError)
{
_logger.LogDebug(
"Unable to refresh token, reason: {refreshTokenErrorDescription}",
tokenResult.ErrorDescription);
return TokenRefreshResult.Failed();
}
var newAccessToken = tokenResult.AccessToken;
var newRefreshToken = tokenResult.RefreshToken;
var newExpiresAt = CalculateNewExpiresAt(tokenResult.ExpiresIn);
return TokenRefreshResult.Success(newAccessToken, newRefreshToken, newExpiresAt);
}
private static string CalculateNewExpiresAt(int expiresIn)
{
// TODO: abstract usages of DateTime to ease unit tests
return (DateTime.UtcNow + TimeSpan.FromSeconds(expiresIn)).ToString("o", CultureInfo.InvariantCulture);
}
private static DateTime GetRefreshThreshold()
{
// TODO: abstract usages of DateTime to ease unit tests
return DateTime.UtcNow + TokenRefreshThreshold;
}
}
The code isn't super complex, but let's go though it carefully, as there are some interesting bits in it.
Taking a look at the constructor, 2 of the dependencies injected are well known by now, but the other isn't. IDiscoveryCache
is a helper provided in the IdentityModel
NuGet package, allowing us to get the info from the discovery endpoint of the OpenID Connect provider (and cache it) so we can use it in other requests to the provider, in this case, for token refresh.
Getting into the TryRefreshTokenIfRequiredAsync
method implementation, we have:
- Check if we have a refresh token, if not, it's a fail.
- Check if the token expiration date is below a threshold, if it isn't we can return a
NoRefreshNeeded
immediately. - Fetch the provider information with the
_discoveryCache
. - Make a request to refresh the token, using the info we configured in
Startup
class as well (yes it should come from configuration and not be hardcoded here 🙃). Note that theRequestRefreshTokenAsync
is an extension method from theIdentityModel
NuGet package, simplifying the creation of the refresh token request. - If the token refresh was successful, return success with the required information, otherwise return a failure.
And that should do it for the token refresh logic. It's not a lot, but a good chunk of code nonetheless. Maybe we can put it in a shared package if we need to reuse it in other components of the PlayBall application.
PS: don't forget to register all the required services in the DI container.
Outro
Wrapping up, we've seen how to delegate the user authentication to the auth service, while getting and maintaining the tokens to access the backing APIs (right now, only the group management API). We also saw how to get the CSRF token infrastructure ready to work with a SPA, instead of a the usual server side rendering with Razor. In the next episode, we'll wrap up the sub-series, by seeing the changes made to the frontend to play well with the BFF and integrate everything we developed so far.
As a reminder (and I'll probably repeat myself in the next episode), all of this is just one of (probably) many possible ways of tackling this problem, so if you have a different idea and think it's better, go for it 🙂 (and let me know so I can improve mine).
Links in the post:
- Cross-Site Request Forgery (CSRF)
- Episode 023 - Integrating IdentityServer4 - Part 3 - API - ASP.NET Core: From 0 to overkill
The source code for this sub-series of posts is scattered across a bunch of repositories in the "Coding Militia: ASP.NET Core - From 0 to overkill" organization, tagged as episode021
.
Sharing and feedback always appreciated!
Thanks for stopping by, cyaz!
Top comments (0)