Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update the Dantooine sample to detect expired tokens and support token refreshing #332

Merged
merged 1 commit into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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