Skip to content

Commit

Permalink
Update the Dantooine sample to detect expired tokens and support toke…
Browse files Browse the repository at this point in the history
…n refreshing
  • Loading branch information
kevinchalet committed Aug 8, 2024
1 parent 0d2fb7c commit ee86345
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 22 deletions.
8 changes: 5 additions & 3 deletions samples/Dantooine/Dantooine.Server/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ public void ConfigureServices(IServiceCollection services)
// Mark the "email", "profile" and "roles" scopes as supported scopes.
options.RegisterScopes(Scopes.Email, Scopes.Profile, Scopes.Roles);

// Note: this sample only uses the authorization code flow but you can enable
// the other flows if you need to support implicit, password or client credentials.
options.AllowAuthorizationCodeFlow();
// Note: this sample only uses the authorization code and refresh token
// flows but you can enable the other flows if you need to support
// implicit, password or client credentials.
options.AllowAuthorizationCodeFlow()
.AllowRefreshTokenFlow();

// Register the signing and encryption credentials.
options.AddDevelopmentEncryptionCertificate()
Expand Down
1 change: 1 addition & 0 deletions samples/Dantooine/Dantooine.Server/Worker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor
Permissions.Endpoints.Logout,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.GrantTypes.RefreshToken,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Email,
Permissions.Scopes.Profile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,12 @@ public async Task<ActionResult> LogInCallback()
// To make cookies less heavy, tokens that are not used are filtered out before creating the cookie.
properties.StoreTokens(result.Properties.GetTokens().Where(token => token.Name is
// Preserve the access, identity and refresh tokens returned in the token response, if available.
OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or
OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken or
//
// The expiration date of the access token is also preserved to later determine
// whether the access token is expired and proactively refresh tokens if necessary.
OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or
OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessTokenExpirationDate or
OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken or
OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken));

// Ask the default sign-in handler to return a new cookie and redirect the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using OpenIddict.Client;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants;
using static OpenIddict.Client.OpenIddictClientModels;

namespace Dantooine.WebAssembly.Server.Helpers
{
internal sealed class TokenRefreshingDelegatingHandler(
OpenIddictClientService service, HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler)
{
private readonly OpenIddictClientService _service = service ?? throw new ArgumentNullException(nameof(service));

protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// If an access token expiration date was returned by the authorization server and stored
// in the authentication cookie, use it to determine whether the token is about to expire.
// If it's not, try to use it: if the resource server returns a 401 error response, try
// to refresh the tokens before replaying the request with the new access token attached.
var date = GetBackchannelAccessTokenExpirationDate(request.Options);
if (date is null || DateTimeOffset.UtcNow <= date?.AddMinutes(-5))
{
request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Bearer, GetBackchannelAccessToken(request.Options));

var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode is not HttpStatusCode.Unauthorized)
{
return response;
}

// Note: this handler can be called concurrently for the same user if multiple HTTP
// requests are processed in parallel: while this results in multiple refresh token
// requests being sent concurrently, this is something OpenIddict allows during a short
// period of time (called refresh token reuse leeway and set to 30 seconds by default).
var result = await _service.AuthenticateWithRefreshTokenAsync(new RefreshTokenAuthenticationRequest
{
CancellationToken = cancellationToken,
DisableUserinfo = true,
RefreshToken = GetRefreshToken(request.Options)
});

request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Bearer, result.AccessToken);

return new TokenRefreshingHttpResponseMessage(result, await base.SendAsync(request, cancellationToken));
}

// Otherwise, don't bother using the existing access token and refresh tokens immediately.
else
{
var result = await _service.AuthenticateWithRefreshTokenAsync(new RefreshTokenAuthenticationRequest
{
CancellationToken = cancellationToken,
DisableUserinfo = true,
RefreshToken = GetRefreshToken(request.Options)
});

request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Bearer, result.AccessToken);

return new TokenRefreshingHttpResponseMessage(result, await base.SendAsync(request, cancellationToken));
}

static string GetBackchannelAccessToken(HttpRequestOptions options) =>
options.TryGetValue(new(Tokens.BackchannelAccessToken), out string token) ? token :
throw new InvalidOperationException("The access token couldn't be found in the request options.");

static DateTimeOffset? GetBackchannelAccessTokenExpirationDate(HttpRequestOptions options) =>
options.TryGetValue(new(Tokens.BackchannelAccessTokenExpirationDate), out string token) &&
DateTimeOffset.TryParse(token, CultureInfo.InvariantCulture, out DateTimeOffset date) ? date : null;

static string GetRefreshToken(HttpRequestOptions options) =>
options.TryGetValue(new(Tokens.RefreshToken), out string token) ? token :
throw new InvalidOperationException("The refresh token couldn't be found in the request options.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Net.Http;
using OpenIddict.Client;
using Yarp.ReverseProxy.Forwarder;

namespace Dantooine.WebAssembly.Server.Helpers
{
internal sealed class TokenRefreshingForwarderHttpClientFactory(OpenIddictClientService service) : ForwarderHttpClientFactory
{
private readonly OpenIddictClientService _service = service ?? throw new ArgumentNullException(nameof(service));

protected override HttpMessageHandler WrapHandler(ForwarderHttpClientContext context, HttpMessageHandler handler)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(handler);

return new TokenRefreshingDelegatingHandler(_service, handler);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System;
using System.Net.Http;
using static OpenIddict.Client.OpenIddictClientModels;

namespace Dantooine.WebAssembly.Server.Helpers
{
internal sealed class TokenRefreshingHttpResponseMessage : HttpResponseMessage
{
public TokenRefreshingHttpResponseMessage(RefreshTokenAuthenticationResult result, HttpResponseMessage response)
{
ArgumentNullException.ThrowIfNull(response);
ArgumentNullException.ThrowIfNull(result);

RefreshTokenAuthenticationResult = result;

Content = response.Content;
StatusCode = response.StatusCode;
Version = response.Version;

foreach (var header in response.Headers)
{
Headers.Add(header.Key, header.Value);
}
}

public RefreshTokenAuthenticationResult RefreshTokenAuthenticationResult { get; }
}
}
94 changes: 77 additions & 17 deletions samples/Dantooine/Dantooine.WebAssembly.Server/Startup.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Globalization;
using System.IO;
using System.Net.Http.Headers;
using Dantooine.WebAssembly.Server.Helpers;
using Dantooine.WebAssembly.Server.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
Expand All @@ -10,12 +11,15 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using OpenIddict.Client;
using Quartz;
using Yarp.ReverseProxy.Forwarder;
using Yarp.ReverseProxy.Transforms;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Client.AspNetCore.OpenIddictClientAspNetCoreConstants;
using static OpenIddict.Client.OpenIddictClientModels;

namespace Dantooine.WebAssembly.Server;

Expand Down Expand Up @@ -96,8 +100,10 @@ public void ConfigureServices(IServiceCollection services)
// Register the OpenIddict client components.
.AddClient(options =>
{
// Note: this sample uses the code flow, but you can enable the other flows if necessary.
options.AllowAuthorizationCodeFlow();
// Note: this sample uses the authorization code and refresh token
// flows, but you can enable the other flows if necessary.
options.AllowAuthorizationCodeFlow()
.AllowRefreshTokenFlow();

// Register the signing and encryption credentials used to protect
// sensitive data like the state tokens produced by OpenIddict.
Expand All @@ -123,7 +129,7 @@ public void ConfigureServices(IServiceCollection services)

ClientId = "blazorcodeflowpkceclient",
ClientSecret = "codeflow_pkce_client_secret",
Scopes = { Scopes.Profile, "api1" },
Scopes = { Scopes.OfflineAccess, Scopes.Profile, "api1" },

// Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint
// URI per provider, unless all the registered providers support returning a special "iss"
Expand All @@ -147,20 +153,74 @@ public void ConfigureServices(IServiceCollection services)

services.AddReverseProxy()
.LoadFromConfig(Configuration.GetSection("ReverseProxy"))
.AddTransforms(builder => builder.AddRequestTransform(async context =>
.AddTransforms(builder =>
{
// Attach the access token retrieved from the authentication cookie.
//
// Note: in a real world application, the expiration date of the access token
// should be checked before sending a request to avoid getting a 401 response.
// Once expired, a new access token could be retrieved using the OAuth 2.0
// refresh token grant (which could be done transparently).
var token = await context.HttpContext.GetTokenAsync(
scheme: CookieAuthenticationDefaults.AuthenticationScheme,
tokenName: Tokens.BackchannelAccessToken);

context.ProxyRequest.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Bearer, token);
}));
builder.AddRequestTransform(async context =>
{
// Attach the access token, access token expiration date and refresh token resolved from the authentication
// cookie to the request options so they can later be resolved from the delegating handler and attached
// to the request message or used to refresh the tokens if the server returned a 401 error response.
//
// Alternatively, the user tokens could be stored in a database or a distributed cache.

var result = await context.HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

context.ProxyRequest.Options.Set(
key : new(Tokens.BackchannelAccessToken),
value: result.Properties.GetTokenValue(Tokens.BackchannelAccessToken));

context.ProxyRequest.Options.Set(
key : new(Tokens.BackchannelAccessTokenExpirationDate),
value: result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate));

context.ProxyRequest.Options.Set(
key : new(Tokens.RefreshToken),
value: result.Properties.GetTokenValue(Tokens.RefreshToken));
});

builder.AddResponseTransform(async context =>
{
// If tokens were refreshed during the request handling (e.g due to the stored access token being
// expired or a 401 error response being returned by the resource server), extract and attach them
// to the authentication cookie that will be returned to the browser: doing that is essential as
// OpenIddict uses rolling refresh tokens: if the refresh token wasn't replaced, future refresh
// token requests would end up being rejected as they would be treated as replayed requests.

if (context.ProxyResponse is not TokenRefreshingHttpResponseMessage {
RefreshTokenAuthenticationResult: RefreshTokenAuthenticationResult } response)
{
return;
}

var result = await context.HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

// Override the tokens using the values returned in the token response.
var properties = result.Properties.Clone();
properties.UpdateTokenValue(Tokens.BackchannelAccessToken, response.RefreshTokenAuthenticationResult.AccessToken);

properties.UpdateTokenValue(Tokens.BackchannelAccessTokenExpirationDate,
response.RefreshTokenAuthenticationResult.AccessTokenExpirationDate?.ToString(CultureInfo.InvariantCulture));

// Note: if no refresh token was returned, preserve the refresh token initially returned.
if (!string.IsNullOrEmpty(response.RefreshTokenAuthenticationResult.RefreshToken))
{
properties.UpdateTokenValue(Tokens.RefreshToken, response.RefreshTokenAuthenticationResult.RefreshToken);
}

// Remove the redirect URI from the authentication properties
// to prevent the cookies handler from genering a 302 response.
properties.RedirectUri = null;

// Note: this event handler can be called concurrently for the same user if multiple HTTP
// responses are returned in parallel: in this case, the browser will always store the latest
// cookie received and the refresh tokens stored in the other cookies will be discarded.
await context.HttpContext.SignInAsync(result.Ticket.AuthenticationScheme, result.Principal, properties);
});
});

// Replace the default HTTP client factory used by YARP by an instance able to inject the HTTP delegating
// handler that will be used to attach the access tokens to HTTP requests or refresh tokens if necessary.
services.Replace(ServiceDescriptor.Singleton<IForwarderHttpClientFactory, TokenRefreshingForwarderHttpClientFactory>());

// Register the worker responsible for creating the database used to store tokens.
// Note: in a real world application, this step should be part of a setup script.
Expand Down

0 comments on commit ee86345

Please sign in to comment.