diff --git a/samples/Dantooine/Dantooine.Server/Startup.cs b/samples/Dantooine/Dantooine.Server/Startup.cs index 4108d3c9..4eb841af 100644 --- a/samples/Dantooine/Dantooine.Server/Startup.cs +++ b/samples/Dantooine/Dantooine.Server/Startup.cs @@ -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() diff --git a/samples/Dantooine/Dantooine.Server/Worker.cs b/samples/Dantooine/Dantooine.Server/Worker.cs index a227b1e4..ee2bb1e5 100644 --- a/samples/Dantooine/Dantooine.Server/Worker.cs +++ b/samples/Dantooine/Dantooine.Server/Worker.cs @@ -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, diff --git a/samples/Dantooine/Dantooine.WebAssembly.Server/Controllers/AuthenticationController.cs b/samples/Dantooine/Dantooine.WebAssembly.Server/Controllers/AuthenticationController.cs index 4aa45ab6..b3c345d4 100644 --- a/samples/Dantooine/Dantooine.WebAssembly.Server/Controllers/AuthenticationController.cs +++ b/samples/Dantooine/Dantooine.WebAssembly.Server/Controllers/AuthenticationController.cs @@ -152,8 +152,12 @@ public async Task 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 diff --git a/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingDelegatingHandler.cs b/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingDelegatingHandler.cs new file mode 100644 index 00000000..5c21f32c --- /dev/null +++ b/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingDelegatingHandler.cs @@ -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 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."); + } + } +} diff --git a/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingForwarderHttpClientFactory.cs b/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingForwarderHttpClientFactory.cs new file mode 100644 index 00000000..bfa60227 --- /dev/null +++ b/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingForwarderHttpClientFactory.cs @@ -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); + } + } +} diff --git a/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingHttpResponseMessage.cs b/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingHttpResponseMessage.cs new file mode 100644 index 00000000..5892a1f3 --- /dev/null +++ b/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingHttpResponseMessage.cs @@ -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; } + } +} diff --git a/samples/Dantooine/Dantooine.WebAssembly.Server/Startup.cs b/samples/Dantooine/Dantooine.WebAssembly.Server/Startup.cs index d25b2f5f..10881f72 100644 --- a/samples/Dantooine/Dantooine.WebAssembly.Server/Startup.cs +++ b/samples/Dantooine/Dantooine.WebAssembly.Server/Startup.cs @@ -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; @@ -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; @@ -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. @@ -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" @@ -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()); // 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.