From 89d487e449908c645a5bf94540ef442996cbb652 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Thu, 13 Nov 2025 10:57:18 +0100 Subject: [PATCH 01/13] Picker Data Source: update getConfigValue with alias-based type safety (#20802) * Improve type safety in getConfigValue function * Refactor config typing in example document picker * Update index.ts * add unit tests --- .../example-document-picker-data-source.ts | 12 +++--- .../get-config-value.test.ts | 38 +++++++++++++++++++ .../core/utils/config-collection/index.ts | 16 ++++---- 3 files changed, 54 insertions(+), 12 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/utils/config-collection/get-config-value.test.ts diff --git a/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-document-picker-data-source.ts b/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-document-picker-data-source.ts index 47cfbdc043..3ba6c2d2ef 100644 --- a/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-document-picker-data-source.ts +++ b/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-document-picker-data-source.ts @@ -19,7 +19,9 @@ import type { UmbTreeChildrenOfRequestArgs, UmbTreeRootItemsRequestArgs, } from '@umbraco-cms/backoffice/tree'; -import { getConfigValue, type UmbConfigCollectionModel } from '@umbraco-cms/backoffice/utils'; +import { getConfigValue } from '@umbraco-cms/backoffice/utils'; + +type ExampleDocumentPickerConfigCollectionModel = Array<{ alias: 'filter'; value: string }>; export class ExampleDocumentPickerPropertyEditorDataSource extends UmbControllerBase @@ -30,17 +32,17 @@ export class ExampleDocumentPickerPropertyEditorDataSource #tree = new UmbDocumentTreeRepository(this); #item = new UmbDocumentItemRepository(this); #search = new UmbDocumentSearchRepository(this); - #config: UmbConfigCollectionModel = []; + #config: ExampleDocumentPickerConfigCollectionModel = []; treePickableFilter: (treeItem: UmbDocumentTreeItemModel) => boolean = (treeItem) => !!treeItem.unique; - setConfig(config: UmbConfigCollectionModel) { + setConfig(config: ExampleDocumentPickerConfigCollectionModel) { // TODO: add examples for all config options this.#config = config; this.#applyPickableFilterFromConfig(); } - getConfig(): UmbConfigCollectionModel { + getConfig(): ExampleDocumentPickerConfigCollectionModel { return this.#config; } @@ -72,7 +74,7 @@ export class ExampleDocumentPickerPropertyEditorDataSource } #getAllowedDocumentTypesConfig() { - const filterString = getConfigValue(this.#config, 'filter'); + const filterString = getConfigValue(this.#config, 'filter'); const filterArray = filterString ? filterString.split(',') : []; const allowedContentTypes: UmbDocumentSearchRequestArgs['allowedContentTypes'] = filterArray.map( (unique: string) => ({ diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/config-collection/get-config-value.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/config-collection/get-config-value.test.ts new file mode 100644 index 0000000000..9c510c09b3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/config-collection/get-config-value.test.ts @@ -0,0 +1,38 @@ +import { expect } from '@open-wc/testing'; +import { getConfigValue } from './index.js'; + +describe('getConfigValue', () => { + it('should return the value for a matching alias', () => { + const config = [ + { alias: 'foo', value: 123 }, + { alias: 'bar', value: 'hello' }, + ]; + const result = getConfigValue(config, 'foo'); + expect(result).to.equal(123); + }); + + it('should return undefined if alias is not found', () => { + const config = [ + { alias: 'foo', value: 123 }, + { alias: 'bar', value: 'hello' }, + ]; + const result = getConfigValue(config, 'baz'); + expect(result).to.be.undefined; + }); + + it('should return undefined if config is undefined', () => { + const result = getConfigValue(undefined, 'foo'); + expect(result).to.be.undefined; + }); + + it('should work with different value types', () => { + const config = [ + { alias: 'num', value: 42 }, + { alias: 'str', value: 'test' }, + { alias: 'obj', value: { a: 1 } }, + ]; + expect(getConfigValue(config, 'num')).to.equal(42); + expect(getConfigValue(config, 'str')).to.equal('test'); + expect(getConfigValue(config, 'obj')).to.deep.equal({ a: 1 }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/config-collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/config-collection/index.ts index b4f03f1155..a2de3f4899 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/config-collection/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/config-collection/index.ts @@ -1,12 +1,14 @@ -import type { UmbConfigCollectionModel } from './types.js'; +import type { UmbConfigCollectionEntryModel } from './types.js'; /** * Get a value from a config collection by its alias. - * @param {UmbConfigCollectionModel | undefined} config - The config collection to get the value from. - * @param {string} alias - The alias of the value to get. - * @returns {T | undefined} The value with the specified alias, or undefined if not found or if the config is undefined. + * @param config - The config collection to get the value from. + * @param alias - The alias of the config entry to get the value for. + * @returns The value of the config entry with the specified alias, or undefined if not found. */ -export function getConfigValue(config: UmbConfigCollectionModel | undefined, alias: string): T | undefined { - const entry = config?.find((entry) => entry.alias === alias); - return entry?.value as T | undefined; +export function getConfigValue( + config: T[] | undefined, + alias: K, +) { + return config?.find((entry) => entry.alias === alias)?.value as Extract['value'] | undefined; } From 20180c3180f25bc788ed6a5f11150ced74e7c9f9 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Thu, 13 Nov 2025 12:16:59 +0100 Subject: [PATCH 02/13] Collection Item Picker: support icon colors (#20787) Update icon usage in collection menu and example data Replaces with in the default collection menu item element to support colors. Also updates example picker data source items to showcase color support. --- .../example-custom-picker-collection-data-source.ts | 10 +++++----- .../default/default-collection-menu-item.element.ts | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-custom-picker-collection-data-source.ts b/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-custom-picker-collection-data-source.ts index 34328f20b7..e0b5b84d7a 100644 --- a/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-custom-picker-collection-data-source.ts +++ b/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-custom-picker-collection-data-source.ts @@ -53,35 +53,35 @@ const customItems: Array = [ unique: '1', entityType: 'example', name: 'Example 1', - icon: 'icon-shape-triangle', + icon: 'icon-shape-triangle yellow', isPickable: true, }, { unique: '2', entityType: 'example', name: 'Example 2', - icon: 'icon-shape-triangle', + icon: 'icon-shape-triangle yellow', isPickable: true, }, { unique: '3', entityType: 'example', name: 'Example 3', - icon: 'icon-shape-triangle', + icon: 'icon-shape-triangle yellow', isPickable: true, }, { unique: '4', entityType: 'example', name: 'Example 4', - icon: 'icon-shape-triangle', + icon: 'icon-shape-triangle yellow', isPickable: false, }, { unique: '5', entityType: 'example', name: 'Example 5', - icon: 'icon-shape-triangle', + icon: 'icon-shape-triangle yellow', isPickable: true, }, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.element.ts index 3dfed63ced..fc30554826 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.element.ts @@ -61,9 +61,7 @@ export class UmbDefaultCollectionMenuItemElement extends UmbLitElement { ?selected=${this._isSelected} @selected=${() => this.#api?.select()} @deselected=${() => this.#api?.deselect()}> - ${item.icon - ? html`` - : html``} + `; } From c48103cd30d4945934720b704aa4321f07005ded Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:16:23 +0100 Subject: [PATCH 03/13] bump lockfile --- src/Umbraco.Web.UI.Client/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index f570a7800b..0fba41feef 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@umbraco-cms/backoffice", - "version": "17.0.0-rc2", + "version": "17.0.0-rc3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@umbraco-cms/backoffice", - "version": "17.0.0-rc2", + "version": "17.0.0-rc3", "license": "MIT", "workspaces": [ "./src/packages/*", From e850c131e5a5729e046cc8b1a804292e0e1abe20 Mon Sep 17 00:00:00 2001 From: NillasKA Date: Fri, 14 Nov 2025 13:31:06 +0100 Subject: [PATCH 04/13] Changing news dashboard URL to a new custom hostname --- .../Services/NewsDashboard/NewsDashboardService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs b/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs index 2d05e20df3..6dfe7fde54 100644 --- a/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs @@ -46,7 +46,7 @@ public class NewsDashboardService : INewsDashboardService /// public async Task GetItemsAsync() { - const string BaseUrl = "https://umbraco-dashboard-news.euwest01.umbraco.io"; + const string BaseUrl = "https://news-dashboard.umbraco.com"; const string Path = "/api/News"; var version = _umbracoVersion.SemanticVersion.ToSemanticStringWithoutBuild(); From 342cb171eb6a59102ca16525017bf5e51c14ffef Mon Sep 17 00:00:00 2001 From: NillasKA Date: Fri, 14 Nov 2025 13:36:26 +0100 Subject: [PATCH 05/13] Revert "Changing news dashboard URL to a new custom hostname" This reverts commit e850c131e5a5729e046cc8b1a804292e0e1abe20. --- .../Services/NewsDashboard/NewsDashboardService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs b/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs index 6dfe7fde54..2d05e20df3 100644 --- a/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs @@ -46,7 +46,7 @@ public class NewsDashboardService : INewsDashboardService /// public async Task GetItemsAsync() { - const string BaseUrl = "https://news-dashboard.umbraco.com"; + const string BaseUrl = "https://umbraco-dashboard-news.euwest01.umbraco.io"; const string Path = "/api/News"; var version = _umbracoVersion.SemanticVersion.ToSemanticStringWithoutBuild(); From 2a609e1ecac1fb89ae85707edb4abe00ee2cca10 Mon Sep 17 00:00:00 2001 From: Mole Date: Fri, 14 Nov 2025 14:14:28 +0100 Subject: [PATCH 06/13] Load Balancing: Clear request cache in cache version accessor on cache version update to prevent unnecessary cache roll forwards (#20831) * Clear request cache on version update * Update src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Cache/IRepositoryCacheVersionAccessor.cs | 11 +++++++++-- .../Cache/RepositoryCacheVersionService.cs | 1 + .../Cache/RepositoryCacheVersionAccessor.cs | 12 ++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs b/src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs index 61ae733fe1..af304dc19e 100644 --- a/src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs +++ b/src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs @@ -20,7 +20,14 @@ public interface IRepositoryCacheVersionAccessor /// /// The cache version if found, or if the version doesn't exist or the request is a client-side request. /// - public Task GetAsync(string cacheKey); + Task GetAsync(string cacheKey); + + /// + /// Notifies of a version change on a given cache key. + /// + /// Key of the changed version. + void VersionChanged(string cacheKey) + { } /// /// Notifies the accessor that caches have been synchronized. @@ -29,5 +36,5 @@ public interface IRepositoryCacheVersionAccessor /// This method is called after cache synchronization to temporarily bypass version checking, /// preventing recursive sync attempts while repositories reload data from the database. /// - public void CachesSynced(); + void CachesSynced(); } diff --git a/src/Umbraco.Core/Cache/RepositoryCacheVersionService.cs b/src/Umbraco.Core/Cache/RepositoryCacheVersionService.cs index 541e9f032e..09269f92e1 100644 --- a/src/Umbraco.Core/Cache/RepositoryCacheVersionService.cs +++ b/src/Umbraco.Core/Cache/RepositoryCacheVersionService.cs @@ -88,6 +88,7 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService _logger.LogDebug("Setting cache for {EntityType} to version {Version}", typeof(TEntity).Name, newVersion); await _repositoryCacheVersionRepository.SaveAsync(new RepositoryCacheVersion { Identifier = cacheKey, Version = newVersion.ToString() }); _cacheVersions[cacheKey] = newVersion; + _repositoryCacheVersionAccessor.VersionChanged(cacheKey); scope.Complete(); } diff --git a/src/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessor.cs b/src/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessor.cs index b96e2546e1..4485c80834 100644 --- a/src/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessor.cs +++ b/src/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessor.cs @@ -77,5 +77,17 @@ public class RepositoryCacheVersionAccessor : IRepositoryCacheVersionAccessor return databaseVersion; } + + /// + public void VersionChanged(string cacheKey) + { + var removed = _requestCache.Remove(cacheKey); + if (removed is false) + { + _logger.LogDebug("Cache version for key {CacheKey} wasn't removed from request cache, possibly missing HTTP context", cacheKey); + } + } + + /// public void CachesSynced() => _requestCache.ClearOfType(); } From 06c566c0748edb997bf658395eb42c9867b60774 Mon Sep 17 00:00:00 2001 From: Nicklas Kramer Date: Fri, 14 Nov 2025 14:33:35 +0100 Subject: [PATCH 07/13] News Dashboard: Replacing old hostname, with new custom one. (#20837) Changing hostname of dashboard to a new custom one. --- .../Services/NewsDashboard/NewsDashboardService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs b/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs index 2d05e20df3..6dfe7fde54 100644 --- a/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/NewsDashboard/NewsDashboardService.cs @@ -46,7 +46,7 @@ public class NewsDashboardService : INewsDashboardService /// public async Task GetItemsAsync() { - const string BaseUrl = "https://umbraco-dashboard-news.euwest01.umbraco.io"; + const string BaseUrl = "https://news-dashboard.umbraco.com"; const string Path = "/api/News"; var version = _umbracoVersion.SemanticVersion.ToSemanticStringWithoutBuild(); From 0600df4211a03b17effa38e694d4120132237230 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Fri, 14 Nov 2025 17:09:46 +0100 Subject: [PATCH 08/13] Fix `EntityContainer.GetUdi()` for member type containers (#20840) Fix EntityContainer.GetUdi() for member type containers --- src/Umbraco.Core/Extensions/UdiGetterExtensions.cs | 8 ++++++-- src/Umbraco.Core/Models/EntityContainer.cs | 4 ++-- .../Umbraco.Core/Extensions/UdiGetterExtensionsTests.cs | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs index a5adc0de2a..763dde8410 100644 --- a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs +++ b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs @@ -64,6 +64,10 @@ public static class UdiGetterExtensions { entityType = Constants.UdiEntityType.DataTypeContainer; } + else if (entity.ContainedObjectType == Constants.ObjectTypes.DocumentBlueprint) + { + entityType = Constants.UdiEntityType.DocumentBlueprintContainer; + } else if (entity.ContainedObjectType == Constants.ObjectTypes.DocumentType) { entityType = Constants.UdiEntityType.DocumentTypeContainer; @@ -72,9 +76,9 @@ public static class UdiGetterExtensions { entityType = Constants.UdiEntityType.MediaTypeContainer; } - else if (entity.ContainedObjectType == Constants.ObjectTypes.DocumentBlueprint) + else if (entity.ContainedObjectType == Constants.ObjectTypes.MemberType) { - entityType = Constants.UdiEntityType.DocumentBlueprintContainer; + entityType = Constants.UdiEntityType.MemberTypeContainer; } else { diff --git a/src/Umbraco.Core/Models/EntityContainer.cs b/src/Umbraco.Core/Models/EntityContainer.cs index b6770913a6..02cefdc795 100644 --- a/src/Umbraco.Core/Models/EntityContainer.cs +++ b/src/Umbraco.Core/Models/EntityContainer.cs @@ -10,10 +10,10 @@ public sealed class EntityContainer : TreeEntityBase, IUmbracoEntity private static readonly Dictionary ObjectTypeMap = new() { { Constants.ObjectTypes.DataType, Constants.ObjectTypes.DataTypeContainer }, + { Constants.ObjectTypes.DocumentBlueprint, Constants.ObjectTypes.DocumentBlueprintContainer }, { Constants.ObjectTypes.DocumentType, Constants.ObjectTypes.DocumentTypeContainer }, { Constants.ObjectTypes.MediaType, Constants.ObjectTypes.MediaTypeContainer }, { Constants.ObjectTypes.MemberType, Constants.ObjectTypes.MemberTypeContainer }, - { Constants.ObjectTypes.DocumentBlueprint, Constants.ObjectTypes.DocumentBlueprintContainer }, }; /// @@ -83,7 +83,7 @@ public sealed class EntityContainer : TreeEntityBase, IUmbracoEntity public static Guid GetContainedObjectType(Guid containerObjectType) { Guid contained = ObjectTypeMap.FirstOrDefault(x => x.Value == containerObjectType).Key; - if (contained == null) + if (contained == default) { throw new ArgumentException("Not a container object type.", nameof(containerObjectType)); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/UdiGetterExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/UdiGetterExtensionsTests.cs index 476fefec66..0dd3568175 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/UdiGetterExtensionsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Extensions/UdiGetterExtensionsTests.cs @@ -16,9 +16,10 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions; public class UdiGetterExtensionsTests { [TestCase(Constants.ObjectTypes.Strings.DataType, "6ad82c70-685c-4e04-9b36-d81bd779d16f", "umb://data-type-container/6ad82c70685c4e049b36d81bd779d16f")] + [TestCase(Constants.ObjectTypes.Strings.DocumentBlueprint, "6ad82c70-685c-4e04-9b36-d81bd779d16f", "umb://document-blueprint-container/6ad82c70685c4e049b36d81bd779d16f")] [TestCase(Constants.ObjectTypes.Strings.DocumentType, "6ad82c70-685c-4e04-9b36-d81bd779d16f", "umb://document-type-container/6ad82c70685c4e049b36d81bd779d16f")] [TestCase(Constants.ObjectTypes.Strings.MediaType, "6ad82c70-685c-4e04-9b36-d81bd779d16f", "umb://media-type-container/6ad82c70685c4e049b36d81bd779d16f")] - [TestCase(Constants.ObjectTypes.Strings.DocumentBlueprint, "6ad82c70-685c-4e04-9b36-d81bd779d16f", "umb://document-blueprint-container/6ad82c70685c4e049b36d81bd779d16f")] + [TestCase(Constants.ObjectTypes.Strings.MemberType, "6ad82c70-685c-4e04-9b36-d81bd779d16f", "umb://member-type-container/6ad82c70685c4e049b36d81bd779d16f")] public void GetUdiForEntityContainer(Guid containedObjectType, Guid key, string expected) { EntityContainer entity = new EntityContainer(containedObjectType) From ba7d550a74278fe365f1da6a558e50dd29633505 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 14 Nov 2025 17:10:57 +0100 Subject: [PATCH 09/13] 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 --- .github/BUILD.md | 12 +- .github/copilot-instructions.md | 7 +- .vscode/launch.json | 4 +- .../HideBackOfficeTokensHandler.cs | 187 ++++++++++++++++++ .../UmbracoBuilderAuthExtensions.cs | 24 +++ .../Models/BackOfficeTokenCookieSettings.cs | 22 +++ src/Umbraco.Core/Constants-Configuration.cs | 1 + .../UmbracoBuilder.Configuration.cs | 3 +- .../Migrations/Upgrade/UmbracoPlan.cs | 1 + .../InvalidateBackofficeUserAccess.cs | 15 ++ src/Umbraco.Web.UI.Client/.github/README.md | 11 +- .../src/external/openid/src/xhr.ts | 1 + .../src/packages/core/auth/auth-flow.ts | 2 + .../src/packages/core/http-client/index.ts | 15 +- .../try-execute/tryXhrRequest.function.ts | 1 + .../src/packages/core/resources/types.ts | 1 + .../document-permission.server.data.ts | 1 + .../package-lock.json | 8 +- .../Umbraco.Tests.AcceptanceTest/package.json | 2 +- .../tests/DefaultConfig/Users/User.spec.ts | 3 +- 20 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs create mode 100644 src/Umbraco.Core/Configuration/Models/BackOfficeTokenCookieSettings.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/InvalidateBackofficeUserAccess.cs diff --git a/.github/BUILD.md b/.github/BUILD.md index f42b7d1270..012afcae86 100644 --- a/.github/BUILD.md +++ b/.github/BUILD.md @@ -37,7 +37,7 @@ In order to work with the Umbraco source code locally, first make sure you have ### Familiarizing yourself with the code -Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes. +Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes. There are two web projects in the solution with client-side assets based on TypeScript, `Umbraco.Web.UI.Client` and `Umbraco.Web.UI.Login`. @@ -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. ``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b438a0027b..adc56be7fc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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` diff --git a/.vscode/launch.json b/.vscode/launch.json index ef4677989e..d70e70c5a0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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" diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs new file mode 100644 index 0000000000..8d1dbd040e --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs @@ -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, + 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)); +} diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs index 82cc61dc18..0acda6dde2 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs @@ -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().SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1); }); + + options.AddEventHandler(configuration => + { + configuration + .UseSingletonHandler() + .SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse.Descriptor.Order - 1); + }); + options.AddEventHandler(configuration => + { + configuration + .UseSingletonHandler() + .SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest.Descriptor.Order + 1); + }); }) // Register the OpenIddict validation components. @@ -137,9 +151,19 @@ public static class UmbracoBuilderAuthExtensions { configuration.UseSingletonHandler().SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1); }); + + options.AddEventHandler(configuration => + { + configuration + .UseSingletonHandler() + // 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(); builder.Services.ConfigureOptions(); + + builder.AddNotificationHandler(); } } diff --git a/src/Umbraco.Core/Configuration/Models/BackOfficeTokenCookieSettings.cs b/src/Umbraco.Core/Configuration/Models/BackOfficeTokenCookieSettings.cs new file mode 100644 index 0000000000..e7632d4270 --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/BackOfficeTokenCookieSettings.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; + +namespace Umbraco.Cms.Core.Configuration.Models; + +/// +/// Typed configuration options for back-office token cookie settings. +/// +[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"; + + /// + /// Gets or sets a value indicating whether the cookie SameSite configuration. + /// + /// + /// Valid values are "Unspecified", "None", "Lax" and "Strict" (default). + /// + [DefaultValue(StaticSameSite)] + public string SameSite { get; set; } = StaticSameSite; +} diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs index 8ac24c71f4..02236f2cf9 100644 --- a/src/Umbraco.Core/Constants-Configuration.cs +++ b/src/Umbraco.Core/Constants-Configuration.cs @@ -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 { diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 84462dda98..e56a4cef27 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -88,7 +88,8 @@ public static partial class UmbracoBuilderExtensions .AddUmbracoOptions() .AddUmbracoOptions() .AddUmbracoOptions() - .AddUmbracoOptions(); + .AddUmbracoOptions() + .AddUmbracoOptions(); // Configure connection string and ensure it's updated when the configuration changes builder.Services.AddSingleton, ConfigureConnectionStrings>(); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 7a781b889a..599f8367e4 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -139,5 +139,6 @@ public class UmbracoPlan : MigrationPlan To("{263075BF-F18A-480D-92B4-4947D2EAB772}"); To("26179D88-58CE-4C92-B4A4-3CBA6E7188AC"); To("{8B2C830A-4FFB-4433-8337-8649B0BF52C8}"); + To("{1C38D589-26BB-4A46-9ABE-E4A0DF548A87}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/InvalidateBackofficeUserAccess.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/InvalidateBackofficeUserAccess.cs new file mode 100644 index 0000000000..fdb111df1a --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/InvalidateBackofficeUserAccess.cs @@ -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; + } +} diff --git a/src/Umbraco.Web.UI.Client/.github/README.md b/src/Umbraco.Web.UI.Client/.github/README.md index cf76fd68ef..fc94ba5835 100644 --- a/src/Umbraco.Web.UI.Client/.github/README.md +++ b/src/Umbraco.Web.UI.Client/.github/README.md @@ -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. diff --git a/src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts b/src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts index fc32dbf8cb..0ef5981a5b 100644 --- a/src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts +++ b/src/Umbraco.Web.UI.Client/src/external/openid/src/xhr.ts @@ -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') { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts index fc6f5d1ab2..7a9d0bbf2e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts @@ -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', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/http-client/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/http-client/index.ts index 450649ecb2..26ee2b570c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/http-client/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/http-client/index.ts @@ -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 }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryXhrRequest.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryXhrRequest.function.ts index 2be9e84fa7..dce562bc3a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryXhrRequest.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryXhrRequest.function.ts @@ -44,6 +44,7 @@ function createXhrRequest(options: XhrRequestOptions): UmbCancelablePromise(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) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts index 60e415e0d5..887f09c180 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts @@ -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); headers?: Record; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/repository/document-permission.server.data.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/repository/document-permission.server.data.ts index 4d2cde0ffc..ae14cd16a9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/repository/document-permission.server.data.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/repository/document-permission.server.data.ts @@ -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', }, diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 08bccb8177..442b1fbb3f 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -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", diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index 0f160e4733..1677bfc276 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -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" diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/User.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/User.spec.ts index 0ede3be26b..c8024ba19c 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/User.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Users/User.spec.ts @@ -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]); From 1fedaa3d8fdc40b60d32c43e6ec435a1575cf39c Mon Sep 17 00:00:00 2001 From: Warren Buckley Date: Fri, 14 Nov 2025 20:25:19 +0000 Subject: [PATCH 10/13] Removes npm commands from MSBuild of the CSPROJ for umbraco-extension dotnet new template (#20839) * Removes npm commands from the MSBuild of the CSPROJ of the umbraco-extension dotnet new template Was agreed by the community package team to remove this, as this DX can cause more issues than actually help users in our opinion * Removed the unused value - good catch by Copilot --- .../UmbracoExtension/Umbraco.Extension.csproj | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/templates/UmbracoExtension/Umbraco.Extension.csproj b/templates/UmbracoExtension/Umbraco.Extension.csproj index 4bc359eaa8..1e515aebbf 100644 --- a/templates/UmbracoExtension/Umbraco.Extension.csproj +++ b/templates/UmbracoExtension/Umbraco.Extension.csproj @@ -26,8 +26,6 @@ - - @@ -39,19 +37,4 @@ - - - - - - - - - - - <_ClientAssetsBuildOutput Include="wwwroot\App_Plugins\**" /> - - - - From 23fc355e619f5677db1cc38d7ab0c7718db892fe Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 17 Nov 2025 09:50:31 +0100 Subject: [PATCH 11/13] Redact back-office PKCE codes from the server (#20847) * Redact back-office PKCE codes from the server * Update src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs --------- Co-authored-by: Andy Butland --- .../HideBackOfficeTokensHandler.cs | 57 ++++++++++++++++--- .../UmbracoBuilderAuthExtensions.cs | 6 ++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs index 8d1dbd040e..725d613183 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs @@ -15,6 +15,7 @@ namespace Umbraco.Cms.Api.Common.DependencyInjection; internal sealed class HideBackOfficeTokensHandler : IOpenIddictServerHandler, + IOpenIddictServerHandler, IOpenIddictServerHandler, IOpenIddictValidationHandler, INotificationHandler @@ -22,6 +23,7 @@ internal sealed class HideBackOfficeTokensHandler private const string RedactedTokenValue = "[redacted]"; private const string AccessTokenCookieKey = "__Host-umbAccessToken"; private const string RefreshTokenCookieKey = "__Host-umbRefreshToken"; + private const string PkceCodeCookieKey = "__Host-umbPkceCode"; private readonly IHttpContextAccessor _httpContextAccessor; private readonly IDataProtectionProvider _dataProtectionProvider; @@ -70,6 +72,28 @@ internal sealed class HideBackOfficeTokensHandler return ValueTask.CompletedTask; } + /// + /// This is invoked when a PKCE code is issued to the client. For the back-office client, we will intercept the + /// response, write the PKCE code from the response into a HTTP-only cookie, and redact the code from the response, + /// so it's not exposed to the client. + /// + public ValueTask HandleAsync(OpenIddictServerEvents.ApplyAuthorizationResponseContext context) + { + if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice) + { + // Only ever handle the back-office client. + return ValueTask.CompletedTask; + } + + if (context.Response.Code is not null) + { + SetCookie(GetHttpContext(), PkceCodeCookieKey, context.Response.Code); + context.Response.Code = RedactedTokenValue; + } + + return ValueTask.CompletedTask; + } + /// /// This is invoked when requesting new tokens. /// @@ -81,7 +105,23 @@ internal sealed class HideBackOfficeTokensHandler return ValueTask.CompletedTask; } - // For the back-office client, this only happens when a refresh token is being exchanged for a new access token. + // Handle when the PKCE code is being exchanged for an access token. + if (context.Request.Code == RedactedTokenValue + && TryGetCookie(PkceCodeCookieKey, out var code)) + { + context.Request.Code = code; + + // We won't need the PKCE cookie after this, let's remove it. + RemoveCookie(GetHttpContext(), PkceCodeCookieKey); + } + else + { + // PCKE codes should always be redacted. If we got here, someone might be trying to pass another PKCE + // code. For security reasons, explicitly discard the code (if any) to be on the safe side. + context.Request.Code = null; + } + + // Handle when a refresh token is being exchanged for a new access token. if (context.Request.RefreshToken == RedactedTokenValue && TryGetCookie(RefreshTokenCookieKey, out var refreshToken)) { @@ -95,7 +135,6 @@ internal sealed class HideBackOfficeTokensHandler context.Request.RefreshToken = null; } - return ValueTask.CompletedTask; } @@ -140,7 +179,15 @@ internal sealed class HideBackOfficeTokensHandler { var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider); - var cookieOptions = new CookieOptions + RemoveCookie(httpContext, key); + httpContext.Response.Cookies.Append(key, cookieValue, GetCookieOptions(httpContext)); + } + + private void RemoveCookie(HttpContext httpContext, string key) + => httpContext.Response.Cookies.Delete(key, GetCookieOptions(httpContext)); + + private CookieOptions GetCookieOptions(HttpContext httpContext) => + new() { // Prevent the client-side scripts from accessing the cookie. HttpOnly = true, @@ -164,10 +211,6 @@ internal sealed class HideBackOfficeTokensHandler 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)) diff --git a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs index 0acda6dde2..302930f169 100644 --- a/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs +++ b/src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs @@ -121,6 +121,12 @@ public static class UmbracoBuilderAuthExtensions .UseSingletonHandler() .SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse.Descriptor.Order - 1); }); + options.AddEventHandler(configuration => + { + configuration + .UseSingletonHandler() + .SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.Authentication.ProcessQueryResponse.Descriptor.Order - 1); + }); options.AddEventHandler(configuration => { configuration From 08990b91479d788ed5aec9e8c0236e95d895b3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Mon, 17 Nov 2025 15:41:47 +0100 Subject: [PATCH 12/13] Document input: Find variant name (#20853) * ability to override get name * implement document name method --- .../core/picker-input/picker-input.context.ts | 14 +++++++++++--- .../input-document/input-document.context.ts | 10 ++++++++++ .../Umbraco.Tests.AcceptanceTest/package-lock.json | 8 ++++---- tests/Umbraco.Tests.AcceptanceTest/package.json | 2 +- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts index 2829e753db..6f7d7e30e8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts @@ -82,6 +82,12 @@ export class UmbPickerInputContext< getSelection() { return this.#itemManager.getUniques(); } + getSelectedItems() { + return this.#itemManager.getItems(); + } + getSelectedItemByUnique(unique: string) { + return this.#itemManager.getItems().find((item) => item.unique === unique); + } setSelection(selection: Array) { // Note: Currently we do not support picking root item. So we filter out null values: @@ -111,10 +117,12 @@ export class UmbPickerInputContext< this.getHostElement().dispatchEvent(new UmbChangeEvent()); } - async requestRemoveItem(unique: string) { - const item = this.#itemManager.getItems().find((item) => item.unique === unique); + protected async _requestItemName(unique: string) { + return this.getSelectedItemByUnique(unique)?.name ?? '#general_notFound'; + } - const name = item?.name ?? '#general_notFound'; + async requestRemoveItem(unique: string) { + const name = await this._requestItemName(unique); await umbConfirmModal(this, { color: 'danger', headline: `#actions_remove ${name}?`, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.context.ts index 1d815ed740..43ac95bd8b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.context.ts @@ -7,6 +7,7 @@ import { UmbPickerInputContext } from '@umbraco-cms/backoffice/picker-input'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbDocumentTypeEntityType } from '@umbraco-cms/backoffice/document-type'; import { UMB_VARIANT_CONTEXT } from '@umbraco-cms/backoffice/variant'; +import { UmbDocumentItemDataResolver } from '../../item/index.js'; interface UmbDocumentPickerInputContextOpenArgs { allowedContentTypes?: Array<{ unique: string; entityType: UmbDocumentTypeEntityType }>; @@ -56,6 +57,15 @@ export class UmbDocumentPickerInputContext extends UmbPickerInputContext< await super.openPicker(combinedPickerData); } + protected override async _requestItemName(unique: string): Promise { + const item = this.getSelectedItemByUnique(unique); + const resolver = new UmbDocumentItemDataResolver(this); + resolver.setData(item); + const name = await resolver.getName(); + this.removeUmbController(resolver); + return name ?? '#general_notFound'; + } + #pickableFilter = ( item: UmbDocumentItemModel, allowedContentTypes?: Array<{ unique: string; entityType: UmbDocumentTypeEntityType }>, diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 442b1fbb3f..eaa1a1894e 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -8,7 +8,7 @@ "hasInstallScript": true, "dependencies": { "@umbraco/json-models-builders": "^2.0.41", - "@umbraco/playwright-testhelpers": "^17.0.0-beta.11", + "@umbraco/playwright-testhelpers": "^17.0.0-beta.13", "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.11", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.0-beta.11.tgz", - "integrity": "sha512-HZMdtees5o5FLFsSRQ02BzO+Kxhm1iZop/2Sys/5MzIZkz1pbJIPUvudeK7LbbpJON5piJzI9yCyrZYaF5usiw==", + "version": "17.0.0-beta.13", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.0-beta.13.tgz", + "integrity": "sha512-a4E/uGko0TiGMvuPthfa3KMDz+UYoFzj6zqVFl2g+mKdXqRJLHh++6/YQuXlHdxdN/KSAlvF8yCYZ6+2F5HDWw==", "license": "MIT", "dependencies": { "@umbraco/json-models-builders": "2.0.41", diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index 1677bfc276..fd25862790 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@umbraco/json-models-builders": "^2.0.41", - "@umbraco/playwright-testhelpers": "^17.0.0-beta.11", + "@umbraco/playwright-testhelpers": "^17.0.0-beta.13", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" From 57c66faf40039f7e4c499683a132d03078251e48 Mon Sep 17 00:00:00 2001 From: Mole Date: Mon, 17 Nov 2025 16:26:30 +0100 Subject: [PATCH 13/13] Update microsoft packages to non rc version (#20858) * Update RC2 packages to 10.0.0 * Also update hybrid cache project --- Directory.Packages.props | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d07032a3ff..416a91cec3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,27 +12,27 @@ - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +