Skip to content

Commit 4141e0e

Browse files
committed
Implemented the JWT Token refresh logic through the OpenIdConnectHandler
1 parent bb7c393 commit 4141e0e

File tree

5 files changed

+177
-1
lines changed

5 files changed

+177
-1
lines changed

src/Security/Authentication/OpenIdConnect/src/Events/OpenIdConnectEvents.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Microsoft.AspNetCore.Authentication.OpenIdConnect.Events;
5+
46
namespace Microsoft.AspNetCore.Authentication.OpenIdConnect;
57

68
/// <summary>
@@ -66,6 +68,16 @@ public class OpenIdConnectEvents : RemoteAuthenticationEvents
6668
/// </summary>
6769
public Func<PushedAuthorizationContext, Task> OnPushAuthorization { get; set; } = context => Task.CompletedTask;
6870

71+
/// <summary>
72+
/// Invoked when the the token needs to be refreshed.
73+
/// </summary>
74+
public Func<TokenRefreshContext, Task> OnTokenRefreshing { get; set; } = context => Task.CompletedTask;
75+
76+
/// <summary>
77+
/// Invoked immedaitely after the ticket has been refreshed.
78+
/// </summary>
79+
public Func<TokenRefreshContext, Task> OnTokenRefreshed { get; set; } = context => Task.CompletedTask;
80+
6981
/// <summary>
7082
/// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed.
7183
/// </summary>
@@ -125,4 +137,8 @@ public class OpenIdConnectEvents : RemoteAuthenticationEvents
125137
/// <param name="context"></param>
126138
/// <returns></returns>
127139
public virtual Task PushAuthorization(PushedAuthorizationContext context) => OnPushAuthorization(context);
140+
141+
public virtual Task TokenRefreshing(TokenRefreshContext context) => OnTokenRefreshing(context);
142+
143+
public virtual Task TokenRefreshed(TokenRefreshContext context) => OnTokenRefreshed(context);
128144
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Security.Claims;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace Microsoft.AspNetCore.Authentication.OpenIdConnect.Events;
8+
9+
/// <summary>
10+
/// Represents a context for the TokenRefresh and TokenRefreshing events.
11+
/// </summary>
12+
public class TokenRefreshContext : RemoteAuthenticationContext<OpenIdConnectOptions>
13+
{
14+
/// <summary>
15+
/// Gets or sets a value indicating whether the token should be refreshed by the OpenIdConnectHandler or not.
16+
/// </summary>
17+
/// <remarks>
18+
/// The default value of this property is `true`, which indicates,
19+
/// that the OpenIdConnectHandler should be responsible for refreshing the token.
20+
/// However, custom handler can be registered for the <see cref="OpenIdConnectEvents.OnTokenRefreshing"/> event,
21+
/// which may take the responsibility for updating the token. In that case,
22+
/// the handler should set the <see cref="ShouldRefresh"/> to `false` to indicate that the token has already
23+
/// been refreshed and the <see cref="OpenIdConnectHandler"/> shouldn't try to refresh it.
24+
/// </remarks>
25+
public bool ShouldRefresh { get; set; } = true;
26+
27+
/// <summary>
28+
/// Creates a <see cref="TokenValidatedContext"/>
29+
/// </summary>
30+
/// <inheritdoc />
31+
public TokenRefreshContext(HttpContext context, AuthenticationScheme scheme, OpenIdConnectOptions options, ClaimsPrincipal principal, AuthenticationProperties properties)
32+
: base(context, scheme, options, properties)
33+
=> Principal = principal;
34+
35+
/// <summary>
36+
/// Called to replace the claims principal. The supplied principal will replace the value of the
37+
/// Principal property, which determines the identity of the authenticated request.
38+
/// </summary>
39+
/// <param name="principal">The <see cref="ClaimsPrincipal"/> used as the replacement</param>
40+
public void ReplacePrincipal(ClaimsPrincipal principal) => Principal = principal;
41+
}

src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
<ItemGroup>
1212
<Reference Include="Microsoft.AspNetCore.Authentication.OAuth" />
13+
<Reference Include="Microsoft.AspNetCore.Authentication.Cookies" />
1314
<Reference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
1415
</ItemGroup>
1516

src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
using System.Text;
1212
using System.Text.Encodings.Web;
1313
using System.Text.Json;
14+
using Microsoft.AspNetCore.Authentication.Cookies;
1415
using Microsoft.AspNetCore.Authentication.OAuth;
1516
using Microsoft.AspNetCore.Http;
1617
using Microsoft.AspNetCore.WebUtilities;
18+
using Microsoft.Extensions.DependencyInjection;
1719
using Microsoft.Extensions.Logging;
1820
using Microsoft.Extensions.Options;
1921
using Microsoft.Extensions.Primitives;
@@ -1531,4 +1533,113 @@ private OpenIdConnectProtocolException CreateOpenIdConnectProtocolException(Open
15311533
ex.Data["error_uri"] = errorUri;
15321534
return ex;
15331535
}
1536+
1537+
/// <summary>
1538+
/// Handles the `ValidatePrincipal event fired from the underlying CookieAuthenticationHandler.
1539+
/// This is used for refreshing OIDC auth. token if needed.
1540+
/// </summary>
1541+
/// <param name="context">The CookieValidatePrincipalContext passed as part of the event.</param>
1542+
internal static async Task ValidatePrincipal(CookieValidatePrincipalContext context)
1543+
{
1544+
var authHandlerProvider = context.HttpContext.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
1545+
var handler = await authHandlerProvider.GetHandlerAsync(context.HttpContext, context.Scheme.Name);
1546+
if (handler is OpenIdConnectHandler oidcHandler)
1547+
{
1548+
await oidcHandler.HandleValidatePrincipalAsync(context);
1549+
}
1550+
}
1551+
1552+
private async Task HandleValidatePrincipalAsync(CookieValidatePrincipalContext validateContext)
1553+
{
1554+
var accessTokenExpirationText = validateContext.Properties.GetTokenValue("expires_at");
1555+
if (!DateTimeOffset.TryParse(accessTokenExpirationText, out var accessTokenExpiration))
1556+
{
1557+
return;
1558+
}
1559+
1560+
var oidcOptions = this.OptionsMonitor.Get(validateContext.Scheme.Name);
1561+
var now = oidcOptions.TimeProvider!.GetUtcNow();
1562+
if (now + TimeSpan.FromMinutes(5) < accessTokenExpiration)
1563+
{
1564+
return;
1565+
}
1566+
1567+
var tokenRefreshContext = new Events.TokenRefreshContext(Context, Scheme, oidcOptions, validateContext.Principal!, validateContext.Properties);
1568+
await Options.Events.TokenRefreshing(tokenRefreshContext);
1569+
if (tokenRefreshContext.ShouldRefresh)
1570+
{
1571+
var oidcConfiguration = await oidcOptions.ConfigurationManager!.GetConfigurationAsync(validateContext.HttpContext.RequestAborted);
1572+
var tokenEndpoint = oidcConfiguration.TokenEndpoint ?? throw new InvalidOperationException("Cannot refresh cookie. TokenEndpoint missing!");
1573+
1574+
using var refreshResponse = await oidcOptions.Backchannel.PostAsync(tokenEndpoint,
1575+
new FormUrlEncodedContent(new Dictionary<string, string?>()
1576+
{
1577+
["grant_type"] = "refresh_token",
1578+
["client_id"] = oidcOptions.ClientId,
1579+
["client_secret"] = oidcOptions.ClientSecret,
1580+
["scope"] = string.Join(" ", oidcOptions.Scope),
1581+
["refresh_token"] = validateContext.Properties.GetTokenValue("refresh_token"),
1582+
}));
1583+
1584+
if (!refreshResponse.IsSuccessStatusCode)
1585+
{
1586+
validateContext.RejectPrincipal();
1587+
return;
1588+
}
1589+
1590+
var refreshJson = await refreshResponse.Content.ReadAsStringAsync();
1591+
var message = new OpenIdConnectMessage(refreshJson);
1592+
1593+
var validationParameters = oidcOptions.TokenValidationParameters.Clone();
1594+
if (oidcOptions.ConfigurationManager is BaseConfigurationManager baseConfigurationManager)
1595+
{
1596+
validationParameters.ConfigurationManager = baseConfigurationManager;
1597+
}
1598+
else
1599+
{
1600+
validationParameters.ValidIssuer = oidcConfiguration.Issuer;
1601+
validationParameters.IssuerSigningKeys = oidcConfiguration.SigningKeys;
1602+
}
1603+
1604+
var validationResult = await oidcOptions.TokenHandler.ValidateTokenAsync(message.IdToken, validationParameters);
1605+
1606+
if (!validationResult.IsValid)
1607+
{
1608+
validateContext.RejectPrincipal();
1609+
return;
1610+
}
1611+
1612+
var validatedIdToken = JwtSecurityTokenConverter.Convert(validationResult.SecurityToken as JsonWebToken);
1613+
validatedIdToken.Payload["nonce"] = null;
1614+
Options.ProtocolValidator.ValidateTokenResponse(new()
1615+
{
1616+
ProtocolMessage = message,
1617+
ClientId = oidcOptions.ClientId,
1618+
ValidatedIdToken = validatedIdToken,
1619+
});
1620+
1621+
var principal = new ClaimsPrincipal(validationResult.ClaimsIdentity);
1622+
validateContext.ReplacePrincipal(principal);
1623+
tokenRefreshContext.ReplacePrincipal(principal);
1624+
1625+
var expiresIn = int.Parse(message.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture);
1626+
var expiresAt = now + TimeSpan.FromSeconds(expiresIn);
1627+
validateContext.Properties.StoreTokens([
1628+
new() { Name = "access_token", Value = message.AccessToken },
1629+
new() { Name = "id_token", Value = message.IdToken },
1630+
new() { Name = "refresh_token", Value = message.RefreshToken },
1631+
new() { Name = "token_type", Value = message.TokenType },
1632+
new() { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) },
1633+
]);
1634+
}
1635+
else
1636+
{
1637+
// a handler for the `OpenIdConnectOptions.Events.TokenRefreshing` event has updated the principal,
1638+
// so we need to pass that down through the validateContext.
1639+
validateContext.ReplacePrincipal(tokenRefreshContext.Principal);
1640+
}
1641+
1642+
validateContext.ShouldRenew = true;
1643+
await Options.Events.TokenRefreshed(tokenRefreshContext);
1644+
}
15341645
}

src/Security/Authentication/OpenIdConnect/src/OpenIdConnectPostConfigureOptions.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Net.Http;
55
using System.Text;
6+
using Microsoft.AspNetCore.Authentication.Cookies;
67
using Microsoft.AspNetCore.DataProtection;
78
using Microsoft.Extensions.Options;
89
using Microsoft.IdentityModel.Protocols;
@@ -16,14 +17,18 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect;
1617
public class OpenIdConnectPostConfigureOptions : IPostConfigureOptions<OpenIdConnectOptions>
1718
{
1819
private readonly IDataProtectionProvider _dp;
20+
private readonly CookieAuthenticationOptions _cookieAuthenticationOptions;
21+
private readonly IAuthenticationHandlerProvider _handlerProvider;
1922

2023
/// <summary>
2124
/// Initializes a new instance of <see cref="OpenIdConnectPostConfigureOptions"/>.
2225
/// </summary>
2326
/// <param name="dataProtection">The <see cref="IDataProtectionProvider"/>.</param>
24-
public OpenIdConnectPostConfigureOptions(IDataProtectionProvider dataProtection)
27+
public OpenIdConnectPostConfigureOptions(IDataProtectionProvider dataProtection, CookieAuthenticationOptions cookieAuthenticationOptions, IAuthenticationHandlerProvider handlerProvider)
2528
{
2629
_dp = dataProtection;
30+
_cookieAuthenticationOptions = cookieAuthenticationOptions;
31+
_handlerProvider = handlerProvider;
2732
}
2833

2934
/// <summary>
@@ -105,6 +110,8 @@ public void PostConfigure(string? name, OpenIdConnectOptions options)
105110
};
106111
}
107112
}
113+
114+
_cookieAuthenticationOptions?.Events.OnValidatePrincipal = OpenIdConnectHandler.ValidatePrincipal;
108115
}
109116

110117
private sealed class StringSerializer : IDataSerializer<string>

0 commit comments

Comments
 (0)