Move access/refresh tokens to secure cookies (V17) (#20820)

* Move access/refresh tokens to secure cookies (#20779)

* feat: adds the `credentials: include` header to all manual requests

* feat: adds `credentials: include` as a configurable option to xhr requests (and sets it by default to true)

* feat: configures the auto-generated fetch client from hey-api to include credentials by default

* Add OpenIddict handler to hide tokens from the back-office client

* Make back-office token redaction optional (default false)

* Clear back-office token cookies on logout

* Add configuration for backoffice cookie settings

* Make cookies forcefully secure + move cookie handler enabling to the BackOfficeTokenCookieSettings

* Use the "__Host-" prefix for cookie names

* docs: adds documentation on cookie settings

* build: sets up launch profile for vscode with new cookie recommended settings

* docs: adds extra note around SameSite settings

* docs: adds extra note around SameSite settings

* Respect sites that do not use HTTPS

* Explicitly invalidate potentially valid, old refresh tokens that should no longer be used

* Removed obsolete const

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>

* Remove configuration option

* Invalidate all existing access tokens on upgrade

* docs: updates recommended settings for development

* build: removes non-existing variable

* Skip flaky test

* Bumped version of our test helpers to fix failing tests

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
This commit is contained in:
Kenn Jacobsen
2025-11-14 17:10:57 +01:00
committed by GitHub
parent 0600df4211
commit ba7d550a74
20 changed files with 307 additions and 14 deletions

10
.github/BUILD.md vendored
View File

@@ -73,13 +73,19 @@ Just be careful not to include this change in your PR.
Conversely, if you are working on front-end only, you want to build the back-end once and then run it. Before you do so, update the configuration in `appSettings.json` to add the following under `Umbraco:Cms:Security`:
```
```json
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error"
"AuthorizeCallbackErrorPathName": "/error",
"BackOfficeTokenCookie": {
"SameSite": "None"
}
```
> [!NOTE]
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `Umbraco:Cms:Security:BackOfficeTokenCookie:SameSite` setting is set to `None`.
Then run Umbraco from the command line.
```

View File

@@ -31,6 +31,8 @@ Bootstrap, build, and test the repository:
- `cd src/Umbraco.Web.UI`
- `dotnet run --no-build` -- Application runs on https://localhost:44339 and http://localhost:11000
Check out [BUILD.md](./BUILD.md) for more detailed instructions.
## Validation
- ALWAYS run through at least one complete end-to-end scenario after making changes.
@@ -103,7 +105,10 @@ For frontend-only changes:
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error"
"AuthorizeCallbackErrorPathName": "/error",
"BackOfficeTokenCookie": {
"SameSite": "None"
}
```
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
3. Run frontend dev server: `cd src/Umbraco.Web.UI.Client && npm run dev:server`

4
.vscode/launch.json vendored
View File

@@ -104,7 +104,9 @@
"UMBRACO__CMS__SECURITY__BACKOFFICEHOST": "http://localhost:5173",
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKPATHNAME": "/oauth_complete",
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKLOGOUTPATHNAME": "/logout",
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error"
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error",
"UMBRACO__CMS__SECURITY__KEEPUSERLOGGEDIN": "true",
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__SAMESITE": "None"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Umbraco.Web.UI/Views"

View File

@@ -0,0 +1,187 @@
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<OpenIddictServerEvents.ApplyTokenResponseContext>,
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractTokenRequestContext>,
IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>,
INotificationHandler<UserLogoutSuccessNotification>
{
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> backOfficeTokenCookieSettings,
IOptions<GlobalSettings> globalSettings)
{
_httpContextAccessor = httpContextAccessor;
_dataProtectionProvider = dataProtectionProvider;
_backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value;
_globalSettings = globalSettings.Value;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// This is invoked when requesting new tokens.
/// </summary>
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;
}
/// <summary>
/// This is invoked when extracting the auth context for a client request.
/// </summary>
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));
}

View File

@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Infrastructure.BackgroundJobs;
using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.DistributedJobs;
using Umbraco.Cms.Core.Notifications;
namespace Umbraco.Cms.Api.Common.DependencyInjection;
@@ -113,6 +114,19 @@ public static class UmbracoBuilderAuthExtensions
{
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
});
options.AddEventHandler<OpenIddictServerEvents.ApplyTokenResponseContext>(configuration =>
{
configuration
.UseSingletonHandler<HideBackOfficeTokensHandler>()
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse<OpenIddictServerEvents.ApplyTokenResponseContext>.Descriptor.Order - 1);
});
options.AddEventHandler<OpenIddictServerEvents.ExtractTokenRequestContext>(configuration =>
{
configuration
.UseSingletonHandler<HideBackOfficeTokensHandler>()
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractTokenRequestContext>.Descriptor.Order + 1);
});
})
// Register the OpenIddict validation components.
@@ -137,9 +151,19 @@ public static class UmbracoBuilderAuthExtensions
{
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
});
options.AddEventHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>(configuration =>
{
configuration
.UseSingletonHandler<HideBackOfficeTokensHandler>()
// IMPORTANT: the handler must be AFTER the built-in query string handler, because the client-side SignalR library sometimes appends access tokens to the query string.
.SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ExtractAccessTokenFromQueryString.Descriptor.Order + 1);
});
});
builder.Services.AddSingleton<IDistributedBackgroundJob, OpenIddictCleanupJob>();
builder.Services.ConfigureOptions<ConfigureOpenIddict>();
builder.AddNotificationHandler<UserLogoutSuccessNotification, HideBackOfficeTokensHandler>();
}
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Typed configuration options for back-office token cookie settings.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigBackOfficeTokenCookie)]
[Obsolete("This will be replaced with a different authentication scheme. Scheduled for removal in Umbraco 18.")]
public class BackOfficeTokenCookieSettings
{
private const string StaticSameSite = "Strict";
/// <summary>
/// Gets or sets a value indicating whether the cookie SameSite configuration.
/// </summary>
/// <remarks>
/// Valid values are "Unspecified", "None", "Lax" and "Strict" (default).
/// </remarks>
[DefaultValue(StaticSameSite)]
public string SameSite { get; set; } = StaticSameSite;
}

View File

@@ -67,6 +67,7 @@ public static partial class Constants
public const string ConfigWebhookPayloadType = ConfigWebhook + ":PayloadType";
public const string ConfigCache = ConfigPrefix + "Cache";
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
public const string ConfigBackOfficeTokenCookie = ConfigSecurity + ":BackOfficeTokenCookie";
public static class NamedOptions
{

View File

@@ -88,7 +88,8 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<WebhookSettings>()
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>();
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<BackOfficeTokenCookieSettings>();
// Configure connection string and ensure it's updated when the configuration changes
builder.Services.AddSingleton<IConfigureOptions<ConnectionStrings>, ConfigureConnectionStrings>();

View File

@@ -139,5 +139,6 @@ public class UmbracoPlan : MigrationPlan
To<V_17_0_0.AddDistributedJobLock>("{263075BF-F18A-480D-92B4-4947D2EAB772}");
To<V_17_0_0.AddLastSyncedTable>("26179D88-58CE-4C92-B4A4-3CBA6E7188AC");
To<V_17_0_0.EnsureDefaultMediaFolderHasDefaultCollection>("{8B2C830A-4FFB-4433-8337-8649B0BF52C8}");
To<V_17_0_0.InvalidateBackofficeUserAccess>("{1C38D589-26BB-4A46-9ABE-E4A0DF548A87}");
}
}

View File

@@ -0,0 +1,15 @@
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0;
public class InvalidateBackofficeUserAccess : AsyncMigrationBase
{
public InvalidateBackofficeUserAccess(IMigrationContext context)
: base(context)
{
}
protected override Task MigrateAsync()
{
InvalidateBackofficeUserAccess = true;
return Task.CompletedTask;
}
}

View File

@@ -26,6 +26,7 @@ If you have an existing Vite server running, you can run the task **Backoffice A
### Run a Front-end server against a local Umbraco instance
#### 1. Configure Umbraco instance
Enable the front-end server communicating with the Backend server(Umbraco instance) you need need to correct the `appsettings.json` of your project.
For code contributions use the backend project of `/src/Umbraco.Web.UI`.
@@ -38,7 +39,10 @@ Open this file in an editor: `/src/Umbraco.Web.UI/appsettings.Development.json`
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error",
"AuthorizeCallbackErrorPathName": "/error",,
"BackOfficeTokenCookie": {
"SameSite": "None"
}
},
},
}
@@ -46,10 +50,15 @@ Open this file in an editor: `/src/Umbraco.Web.UI/appsettings.Development.json`
This will override the backoffice host URL, enabling the Client to run from a different origin.
> [!NOTE]
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `Umbraco:Cms:Security:BackOfficeTokenCookie:SameSite` setting is set to `None`.
#### 2. Start Umbraco
Then start the backend server by running the command: `dotnet run` in the `/src/Umbraco.Web.UI` folder.
#### 3. Start Frontend server
Now start the frontend server by running the command: `npm run dev:server` in the `/src/Umbraco.Web.UI.Client` folder.
Finally open `http://localhost:5173` in your browser.

View File

@@ -35,6 +35,7 @@ export class FetchRequestor extends Requestor {
const requestInit: RequestInit = {};
requestInit.method = settings.method;
requestInit.mode = 'cors';
requestInit.credentials = settings.credentials ?? 'include';
if (settings.data) {
if (settings.method && settings.method.toUpperCase() === 'POST') {

View File

@@ -365,6 +365,7 @@ export class UmbAuthFlow {
const token = await this.performWithFreshTokens();
const request = new Request(this.#unlink_endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ loginProvider, providerKey }),
});
@@ -458,6 +459,7 @@ export class UmbAuthFlow {
const token = await this.performWithFreshTokens();
const request = await fetch(`${this.#link_key_endpoint}?provider=${provider}`, {
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',

View File

@@ -1 +1,14 @@
export { client as umbHttpClient } from '@umbraco-cms/backoffice/external/backend-api';
import { client } from '@umbraco-cms/backoffice/external/backend-api';
/**
* Pre-configure the client with default credentials for cookie-based authentication.
* This ensures all requests include cookies by default, which is required for
* cookie-based authentication in Umbraco 17.0+.
*
* Extensions using this client will automatically get credentials: 'include'.
*/
client.setConfig({
credentials: 'include',
});
export { client as umbHttpClient };

View File

@@ -44,6 +44,7 @@ function createXhrRequest<T>(options: XhrRequestOptions): UmbCancelablePromise<T
return new UmbCancelablePromise<T>(async (resolve, reject, onCancel) => {
const xhr = new XMLHttpRequest();
xhr.open(options.method, `${baseUrl}${options.url}`, true);
xhr.withCredentials = options.withCredentials ?? true;
// Set default headers
if (options.token) {

View File

@@ -7,6 +7,7 @@ export interface XhrRequestOptions extends UmbTryExecuteOptions {
baseUrl?: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
url: string;
withCredentials?: boolean;
body?: unknown;
token?: string | (() => undefined | string | Promise<string | undefined>);
headers?: Record<string, string>;

View File

@@ -22,6 +22,7 @@ export class UmbDocumentPermissionServerDataSource {
this.#host,
fetch(`/umbraco/management/api/v1/document/${id}/permissions`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},

View File

@@ -8,7 +8,7 @@
"hasInstallScript": true,
"dependencies": {
"@umbraco/json-models-builders": "^2.0.41",
"@umbraco/playwright-testhelpers": "^17.0.0-beta.10",
"@umbraco/playwright-testhelpers": "^17.0.0-beta.11",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.7"
@@ -67,9 +67,9 @@
}
},
"node_modules/@umbraco/playwright-testhelpers": {
"version": "17.0.0-beta.10",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.0-beta.10.tgz",
"integrity": "sha512-ePvtWK2IG/j3TIL1w7xkZR63FHM32hIjZxaxJOQ4rYNuVxBKT7TTKEvASfdwpDBFnlAN186xZRGA9KJq+Jxijg==",
"version": "17.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.0-beta.11.tgz",
"integrity": "sha512-HZMdtees5o5FLFsSRQ02BzO+Kxhm1iZop/2Sys/5MzIZkz1pbJIPUvudeK7LbbpJON5piJzI9yCyrZYaF5usiw==",
"license": "MIT",
"dependencies": {
"@umbraco/json-models-builders": "2.0.41",

View File

@@ -22,7 +22,7 @@
},
"dependencies": {
"@umbraco/json-models-builders": "^2.0.41",
"@umbraco/playwright-testhelpers": "^17.0.0-beta.10",
"@umbraco/playwright-testhelpers": "^17.0.0-beta.11",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.7"

View File

@@ -194,7 +194,8 @@ test('can add multiple content start nodes for a user', async ({umbracoApi, umbr
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
});
test('can remove a content start node from a user', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
// TODO: Look into flaky test
test.fixme('can remove a content start node from a user', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
// Arrange
const userGroup = await umbracoApi.userGroup.getByName(defaultUserGroupName);
const userId = await umbracoApi.user.createDefaultUser(nameOfTheUser, userEmail, [userGroup.id]);