using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using OpenIddict.Server; using OpenIddict.Validation; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; namespace Umbraco.Cms.Api.Common.DependencyInjection; internal sealed class HideBackOfficeTokensHandler : IOpenIddictServerHandler, IOpenIddictServerHandler, IOpenIddictValidationHandler, INotificationHandler { private const string RedactedTokenValue = "[redacted]"; private const string AccessTokenCookieKey = "__Host-umbAccessToken"; private const string RefreshTokenCookieKey = "__Host-umbRefreshToken"; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IDataProtectionProvider _dataProtectionProvider; private readonly BackOfficeTokenCookieSettings _backOfficeTokenCookieSettings; private readonly GlobalSettings _globalSettings; public HideBackOfficeTokensHandler( IHttpContextAccessor httpContextAccessor, IDataProtectionProvider dataProtectionProvider, IOptions backOfficeTokenCookieSettings, IOptions globalSettings) { _httpContextAccessor = httpContextAccessor; _dataProtectionProvider = dataProtectionProvider; _backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value; _globalSettings = globalSettings.Value; } /// /// This is invoked when tokens (access and refresh tokens) are issued to a client. For the back-office client, /// we will intercept the response, write the tokens from the response into HTTP-only cookies, and redact the /// tokens from the response, so they are not exposed to the client. /// public ValueTask HandleAsync(OpenIddictServerEvents.ApplyTokenResponseContext context) { if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice) { // Only ever handle the back-office client. return ValueTask.CompletedTask; } HttpContext httpContext = GetHttpContext(); if (context.Response.AccessToken is not null) { SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken); context.Response.AccessToken = RedactedTokenValue; } if (context.Response.RefreshToken is not null) { SetCookie(httpContext, RefreshTokenCookieKey, context.Response.RefreshToken); context.Response.RefreshToken = RedactedTokenValue; } return ValueTask.CompletedTask; } /// /// This is invoked when requesting new tokens. /// public ValueTask HandleAsync(OpenIddictServerEvents.ExtractTokenRequestContext context) { if (context.Request?.ClientId != Constants.OAuthClientIds.BackOffice) { // Only ever handle the back-office client. return ValueTask.CompletedTask; } // For the back-office client, this only happens when a refresh token is being exchanged for a new access token. if (context.Request.RefreshToken == RedactedTokenValue && TryGetCookie(RefreshTokenCookieKey, out var refreshToken)) { context.Request.RefreshToken = refreshToken; } else { // If we got here, either the refresh token was not redacted, or nothing was found in the refresh token cookie. // If OpenIddict found a refresh token, it could be an old token that is potentially still valid. For security // reasons, we cannot accept that; at this point, we expect the refresh tokens to be explicitly redacted. context.Request.RefreshToken = null; } return ValueTask.CompletedTask; } /// /// This is invoked when extracting the auth context for a client request. /// public ValueTask HandleAsync(OpenIddictValidationEvents.ProcessAuthenticationContext context) { // For the back-office client, this only happens when an access token is sent to the API. if (context.AccessToken != RedactedTokenValue) { return ValueTask.CompletedTask; } if (TryGetCookie(AccessTokenCookieKey, out var accessToken)) { context.AccessToken = accessToken; } return ValueTask.CompletedTask; } public void Handle(UserLogoutSuccessNotification notification) { HttpContext? context = _httpContextAccessor.HttpContext; if (context is null) { // For some reason there is no ambient HTTP context, so we can't clean up the cookies. // This is OK, because the tokens in the cookies have already been revoked at user sign-out, // so the cookie clean-up is mostly cosmetic. return; } context.Response.Cookies.Delete(AccessTokenCookieKey); context.Response.Cookies.Delete(RefreshTokenCookieKey); } private HttpContext GetHttpContext() => _httpContextAccessor.GetRequiredHttpContext(); private void SetCookie(HttpContext httpContext, string key, string value) { var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider); var cookieOptions = new CookieOptions { // Prevent the client-side scripts from accessing the cookie. HttpOnly = true, // Mark the cookie as essential to the application, to enforce it despite any // data collection consent options. This aligns with how ASP.NET Core Identity // does when writing cookies for cookie authentication. IsEssential = true, // Cookie path must be root for optimal security. Path = "/", // For optimal security, the cooke must be secure. However, Umbraco allows for running development // environments over HTTP, so we need to take that into account here. // Thus, we will make the cookie secure if: // - HTTPS is explicitly enabled by config (default for production environments), or // - The current request is over HTTPS (meaning the environment supports it regardless of config). Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps, // SameSite is configurable (see BackOfficeTokenCookieSettings for defaults): SameSite = ParseSameSiteMode(_backOfficeTokenCookieSettings.SameSite), }; httpContext.Response.Cookies.Delete(key, cookieOptions); httpContext.Response.Cookies.Append(key, cookieValue, cookieOptions); } private bool TryGetCookie(string key, [NotNullWhen(true)] out string? value) { if (GetHttpContext().Request.Cookies.TryGetValue(key, out var cookieValue)) { value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider); return true; } value = null; return false; } private static SameSiteMode ParseSameSiteMode(string sameSiteMode) => Enum.TryParse(sameSiteMode, ignoreCase: true, out SameSiteMode result) ? result : throw new ArgumentException($"The provided {nameof(sameSiteMode)} value could not be parsed into as SameSiteMode value.", nameof(sameSiteMode)); }