diff --git a/src/Security/Authentication/OpenIdConnect/src/Events/OpenIdConnectEvents.cs b/src/Security/Authentication/OpenIdConnect/src/Events/OpenIdConnectEvents.cs index 2a66006bd2dd..5b51c579262b 100644 --- a/src/Security/Authentication/OpenIdConnect/src/Events/OpenIdConnectEvents.cs +++ b/src/Security/Authentication/OpenIdConnect/src/Events/OpenIdConnectEvents.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Authentication.OpenIdConnect.Events; + namespace Microsoft.AspNetCore.Authentication.OpenIdConnect; /// @@ -66,6 +68,16 @@ public class OpenIdConnectEvents : RemoteAuthenticationEvents /// public Func OnPushAuthorization { get; set; } = context => Task.CompletedTask; + /// + /// Invoked when the the token needs to be refreshed. + /// + public Func OnTokenRefreshing { get; set; } = context => Task.CompletedTask; + + /// + /// Invoked immedaitely after the ticket has been refreshed. + /// + public Func OnTokenRefreshed { get; set; } = context => Task.CompletedTask; + /// /// Invoked if exceptions are thrown during request processing. The exceptions will be re-thrown after this event unless suppressed. /// @@ -125,4 +137,8 @@ public class OpenIdConnectEvents : RemoteAuthenticationEvents /// /// public virtual Task PushAuthorization(PushedAuthorizationContext context) => OnPushAuthorization(context); + + public virtual Task TokenRefreshing(TokenRefreshContext context) => OnTokenRefreshing(context); + + public virtual Task TokenRefreshed(TokenRefreshContext context) => OnTokenRefreshed(context); } diff --git a/src/Security/Authentication/OpenIdConnect/src/Events/TokenRefreshContext.cs b/src/Security/Authentication/OpenIdConnect/src/Events/TokenRefreshContext.cs new file mode 100644 index 000000000000..ed19e63d68b2 --- /dev/null +++ b/src/Security/Authentication/OpenIdConnect/src/Events/TokenRefreshContext.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Authentication.OpenIdConnect.Events; + +/// +/// Represents a context for the TokenRefresh and TokenRefreshing events. +/// +public class TokenRefreshContext : RemoteAuthenticationContext +{ + /// + /// Gets or sets a value indicating whether the token should be refreshed by the OpenIdConnectHandler or not. + /// + /// + /// The default value of this property is `true`, which indicates, + /// that the OpenIdConnectHandler should be responsible for refreshing the token. + /// However, custom handler can be registered for the event, + /// which may take the responsibility for updating the token. In that case, + /// the handler should set the to `false` to indicate that the token has already + /// been refreshed and the shouldn't try to refresh it. + /// + public bool ShouldRefresh { get; set; } = true; + + /// + /// Creates a + /// + /// + public TokenRefreshContext(HttpContext context, AuthenticationScheme scheme, OpenIdConnectOptions options, ClaimsPrincipal principal, AuthenticationProperties properties) + : base(context, scheme, options, properties) + => Principal = principal; + + /// + /// Called to replace the claims principal. The supplied principal will replace the value of the + /// Principal property, which determines the identity of the authenticated request. + /// + /// The used as the replacement + public void ReplacePrincipal(ClaimsPrincipal principal) => Principal = principal; +} diff --git a/src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj b/src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj index 68b81587a9a0..930cadaf8b1f 100644 --- a/src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj +++ b/src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs index 716126163e04..73a0475cf663 100644 --- a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs +++ b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs @@ -11,9 +11,11 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -1531,4 +1533,113 @@ private OpenIdConnectProtocolException CreateOpenIdConnectProtocolException(Open ex.Data["error_uri"] = errorUri; return ex; } + + /// + /// Handles the `ValidatePrincipal event fired from the underlying CookieAuthenticationHandler. + /// This is used for refreshing OIDC auth. token if needed. + /// + /// The CookieValidatePrincipalContext passed as part of the event. + internal static async Task ValidatePrincipal(CookieValidatePrincipalContext context) + { + var authHandlerProvider = context.HttpContext.RequestServices.GetRequiredService(); + var handler = await authHandlerProvider.GetHandlerAsync(context.HttpContext, context.Scheme.Name); + if (handler is OpenIdConnectHandler oidcHandler) + { + await oidcHandler.HandleValidatePrincipalAsync(context); + } + } + + private async Task HandleValidatePrincipalAsync(CookieValidatePrincipalContext validateContext) + { + var accessTokenExpirationText = validateContext.Properties.GetTokenValue("expires_at"); + if (!DateTimeOffset.TryParse(accessTokenExpirationText, out var accessTokenExpiration)) + { + return; + } + + var oidcOptions = this.OptionsMonitor.Get(validateContext.Scheme.Name); + var now = oidcOptions.TimeProvider!.GetUtcNow(); + if (now + TimeSpan.FromMinutes(5) < accessTokenExpiration) + { + return; + } + + var tokenRefreshContext = new Events.TokenRefreshContext(Context, Scheme, oidcOptions, validateContext.Principal!, validateContext.Properties); + await Options.Events.TokenRefreshing(tokenRefreshContext); + if (tokenRefreshContext.ShouldRefresh) + { + var oidcConfiguration = await oidcOptions.ConfigurationManager!.GetConfigurationAsync(validateContext.HttpContext.RequestAborted); + var tokenEndpoint = oidcConfiguration.TokenEndpoint ?? throw new InvalidOperationException("Cannot refresh cookie. TokenEndpoint missing!"); + + using var refreshResponse = await oidcOptions.Backchannel.PostAsync(tokenEndpoint, + new FormUrlEncodedContent(new Dictionary() + { + ["grant_type"] = "refresh_token", + ["client_id"] = oidcOptions.ClientId, + ["client_secret"] = oidcOptions.ClientSecret, + ["scope"] = string.Join(" ", oidcOptions.Scope), + ["refresh_token"] = validateContext.Properties.GetTokenValue("refresh_token"), + })); + + if (!refreshResponse.IsSuccessStatusCode) + { + validateContext.RejectPrincipal(); + return; + } + + var refreshJson = await refreshResponse.Content.ReadAsStringAsync(); + var message = new OpenIdConnectMessage(refreshJson); + + var validationParameters = oidcOptions.TokenValidationParameters.Clone(); + if (oidcOptions.ConfigurationManager is BaseConfigurationManager baseConfigurationManager) + { + validationParameters.ConfigurationManager = baseConfigurationManager; + } + else + { + validationParameters.ValidIssuer = oidcConfiguration.Issuer; + validationParameters.IssuerSigningKeys = oidcConfiguration.SigningKeys; + } + + var validationResult = await oidcOptions.TokenHandler.ValidateTokenAsync(message.IdToken, validationParameters); + + if (!validationResult.IsValid) + { + validateContext.RejectPrincipal(); + return; + } + + var validatedIdToken = JwtSecurityTokenConverter.Convert(validationResult.SecurityToken as JsonWebToken); + validatedIdToken.Payload["nonce"] = null; + Options.ProtocolValidator.ValidateTokenResponse(new() + { + ProtocolMessage = message, + ClientId = oidcOptions.ClientId, + ValidatedIdToken = validatedIdToken, + }); + + var principal = new ClaimsPrincipal(validationResult.ClaimsIdentity); + validateContext.ReplacePrincipal(principal); + tokenRefreshContext.ReplacePrincipal(principal); + + var expiresIn = int.Parse(message.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture); + var expiresAt = now + TimeSpan.FromSeconds(expiresIn); + validateContext.Properties.StoreTokens([ + new() { Name = "access_token", Value = message.AccessToken }, + new() { Name = "id_token", Value = message.IdToken }, + new() { Name = "refresh_token", Value = message.RefreshToken }, + new() { Name = "token_type", Value = message.TokenType }, + new() { Name = "expires_at", Value = expiresAt.ToString("o", CultureInfo.InvariantCulture) }, + ]); + } + else + { + // a handler for the `OpenIdConnectOptions.Events.TokenRefreshing` event has updated the principal, + // so we need to pass that down through the validateContext. + validateContext.ReplacePrincipal(tokenRefreshContext.Principal); + } + + validateContext.ShouldRenew = true; + await Options.Events.TokenRefreshed(tokenRefreshContext); + } } diff --git a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectPostConfigureOptions.cs b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectPostConfigureOptions.cs index 4a75bd64a565..1738e4cecb3b 100644 --- a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectPostConfigureOptions.cs +++ b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectPostConfigureOptions.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Text; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols; @@ -16,14 +17,18 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect; public class OpenIdConnectPostConfigureOptions : IPostConfigureOptions { private readonly IDataProtectionProvider _dp; + private readonly CookieAuthenticationOptions _cookieAuthenticationOptions; + private readonly IAuthenticationHandlerProvider _handlerProvider; /// /// Initializes a new instance of . /// /// The . - public OpenIdConnectPostConfigureOptions(IDataProtectionProvider dataProtection) + public OpenIdConnectPostConfigureOptions(IDataProtectionProvider dataProtection, CookieAuthenticationOptions cookieAuthenticationOptions, IAuthenticationHandlerProvider handlerProvider) { _dp = dataProtection; + _cookieAuthenticationOptions = cookieAuthenticationOptions; + _handlerProvider = handlerProvider; } /// @@ -105,6 +110,8 @@ public void PostConfigure(string? name, OpenIdConnectOptions options) }; } } + + _cookieAuthenticationOptions?.Events.OnValidatePrincipal = OpenIdConnectHandler.ValidatePrincipal; } private sealed class StringSerializer : IDataSerializer