diff --git a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj index 6ecc446e46..4c14cb084b 100644 --- a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj +++ b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj @@ -7,11 +7,14 @@ Umbraco.Cms.Api.Common Umbraco.Cms.Api.Common + + + + + - - diff --git a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj index c6e8ca3a93..431f73cd32 100644 --- a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj +++ b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj b/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj index d3274a5005..d1939fda24 100644 --- a/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs b/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs index 7d5bf0dd11..f0f2416b32 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs @@ -1,4 +1,14 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.EntityFrameworkCore; +using OpenIddict.EntityFrameworkCore.Models; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; @@ -23,7 +33,7 @@ public class UmbracoEFCoreComposer : IComposer .AddCore(options => { options - .UseEntityFrameworkCore() + .UseCustomEntityFrameworkCore() // TODO Revert to this after .NET 8 Preview 7: .UseEntityFrameworkCore() .UseDbContext(); }); } @@ -54,3 +64,394 @@ public class EFCoreCreateTablesNotificationHandler : INotificationAsyncHandler : OpenIddictEntityFrameworkCoreAuthorizationStore, IOpenIddictAuthorizationStore + where TAuthorization : OpenIddictEntityFrameworkCoreAuthorization + where TApplication : OpenIddictEntityFrameworkCoreApplication + where TToken : OpenIddictEntityFrameworkCoreToken + where TContext : DbContext + where TKey : notnull, IEquatable +{ + public MyOpenIddictAuthorizationStore( + IMemoryCache cache, + TContext context, + IOptionsMonitor options) + : base(cache, context, options) + { + } + + private DbSet Applications => Context.Set(); + + public override async ValueTask GetApplicationIdAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authorization); + + // If the application is not attached to the authorization, try to load it manually. + if (authorization.Application is null) + { + var reference = Context.Entry(authorization).Reference(entry => entry.Application); + if (reference.EntityEntry.State is EntityState.Detached) + { + return null; + } + + await reference.LoadAsync(cancellationToken: cancellationToken); + } + + if (authorization.Application is null) + { + return null; + } + + return ConvertIdentifierToString(authorization.Application.Id); + } + + public override async ValueTask SetApplicationIdAsync(TAuthorization authorization, string? identifier, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authorization); + + if (!string.IsNullOrEmpty(identifier)) + { + var key = ConvertIdentifierFromString(identifier); + + authorization.Application = await Applications.AsQueryable() + .AsTracking() + .FirstOrDefaultAsync(application => application.Id!.Equals(key), cancellationToken) ?? + throw new InvalidOperationException(); + } + + else + { + // If the application is not attached to the authorization, try to load it manually. + if (authorization.Application is null) + { + var reference = Context.Entry(authorization).Reference(entry => entry.Application); + if (reference.EntityEntry.State is EntityState.Detached) + { + return; + } + + await reference.LoadAsync(cancellationToken: cancellationToken); + } + + authorization.Application = null; + } + } +} + +// TODO Revert to this after .NET 8 Preview 7 +internal class MyOpenIddictAuthorizationStoreResolver : IOpenIddictAuthorizationStoreResolver +{ + private readonly TypeResolutionCache _cache; + private readonly IOptionsMonitor _options; + private readonly IServiceProvider _provider; + + public MyOpenIddictAuthorizationStoreResolver( + TypeResolutionCache cache, + IOptionsMonitor options, + IServiceProvider provider) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + public IOpenIddictAuthorizationStore Get() where TAuthorization : class + { + var store = _provider.GetService>(); + if (store is not null) + { + return store; + } + + var type = _cache.GetOrAdd(typeof(TAuthorization), key => + { + var root = OpenIddictHelpers.FindGenericBaseType(key, typeof(OpenIddictEntityFrameworkCoreAuthorization<,,>)) ?? + throw new InvalidOperationException(); + var context = _options.CurrentValue.DbContextType ?? + throw new InvalidOperationException(); + return typeof(MyOpenIddictAuthorizationStore<,,,,>).MakeGenericType( + /* TAuthorization: */ key, + /* TApplication: */ root.GenericTypeArguments[1], + /* TToken: */ root.GenericTypeArguments[2], + /* TContext: */ context, + /* TKey: */ root.GenericTypeArguments[0]); + }); + + return (IOpenIddictAuthorizationStore)_provider.GetRequiredService(type); + } + + [SuppressMessage("Design", "CA1034:Nested types should not be visible")] + public sealed class TypeResolutionCache : ConcurrentDictionary { } +} + +// TODO Revert to this after .NET 8 Preview 7 +internal class MyOpenIddictTokenStore : OpenIddictEntityFrameworkCoreTokenStore, IOpenIddictTokenStore + where TToken : OpenIddictEntityFrameworkCoreToken + where TApplication : OpenIddictEntityFrameworkCoreApplication + where TAuthorization : OpenIddictEntityFrameworkCoreAuthorization + where TContext : DbContext + where TKey : notnull, IEquatable +{ + public MyOpenIddictTokenStore( + IMemoryCache cache, + TContext context, + IOptionsMonitor options) + : base(cache, context, options) + { + } + + private DbSet Applications => Context.Set(); + private DbSet Authorizations => Context.Set(); + + public override async ValueTask GetApplicationIdAsync(TToken token, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(token); + + // If the application is not attached to the token, try to load it manually. + if (token.Application is null) + { + var reference = Context.Entry(token).Reference(entry => entry.Application); + if (reference.EntityEntry.State is EntityState.Detached) + { + return null; + } + + await reference.LoadAsync(cancellationToken: cancellationToken); + } + + if (token.Application is null) + { + return null; + } + + return ConvertIdentifierToString(token.Application.Id); + } + + public override async ValueTask GetAuthorizationIdAsync(TToken token, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(token); + + // If the authorization is not attached to the token, try to load it manually. + if (token.Authorization is null) + { + var reference = Context.Entry(token).Reference(entry => entry.Authorization); + if (reference.EntityEntry.State is EntityState.Detached) + { + return null; + } + + await reference.LoadAsync(cancellationToken: cancellationToken); + } + + if (token.Authorization is null) + { + return null; + } + + return ConvertIdentifierToString(token.Authorization.Id); + } + + public override async ValueTask SetApplicationIdAsync(TToken token, string? identifier, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(token); + + if (!string.IsNullOrEmpty(identifier)) + { + var key = ConvertIdentifierFromString(identifier); + + // Warning: FindAsync() is deliberately not used to work around a breaking change introduced + // in Entity Framework Core 3.x (where a ValueTask instead of a Task is now returned). + token.Application = await Applications.AsQueryable() + .AsTracking() + .FirstOrDefaultAsync(application => application.Id!.Equals(key), cancellationToken) ?? + throw new InvalidOperationException(); + } + + else + { + // If the application is not attached to the token, try to load it manually. + if (token.Application is null) + { + var reference = Context.Entry(token).Reference(entry => entry.Application); + if (reference.EntityEntry.State is EntityState.Detached) + { + return; + } + + await reference.LoadAsync(cancellationToken: cancellationToken); + } + + token.Application = null; + } + } + + public override async ValueTask SetAuthorizationIdAsync(TToken token, string? identifier, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(token); + + if (!string.IsNullOrEmpty(identifier)) + { + var key = ConvertIdentifierFromString(identifier); + + // Warning: FindAsync() is deliberately not used to work around a breaking change introduced + // in Entity Framework Core 3.x (where a ValueTask instead of a Task is now returned). + token.Authorization = await Authorizations.AsQueryable() + .AsTracking() + .FirstOrDefaultAsync(authorization => authorization.Id!.Equals(key), cancellationToken) ?? + throw new InvalidOperationException(); + } + + else + { + // If the authorization is not attached to the token, try to load it manually. + if (token.Authorization is null) + { + var reference = Context.Entry(token).Reference(entry => entry.Authorization); + if (reference.EntityEntry.State is EntityState.Detached) + { + return; + } + + await reference.LoadAsync(cancellationToken: cancellationToken); + } + + token.Authorization = null; + } + } +} + +// TODO Revert to this after .NET 8 Preview 7 +internal class MyOpenIddictTokenStoreResolver : IOpenIddictTokenStoreResolver +{ + private readonly TypeResolutionCache _cache; + private readonly IOptionsMonitor _options; + private readonly IServiceProvider _provider; + + public MyOpenIddictTokenStoreResolver( + TypeResolutionCache cache, + IOptionsMonitor options, + IServiceProvider provider) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + public IOpenIddictTokenStore Get() where TToken : class + { + var store = _provider.GetService>(); + if (store is not null) + { + return store; + } + + var type = _cache.GetOrAdd(typeof(TToken), key => + { + var root = OpenIddictHelpers.FindGenericBaseType(key, typeof(OpenIddictEntityFrameworkCoreToken<,,>)) ?? + throw new InvalidOperationException(); + var context = _options.CurrentValue.DbContextType ?? + throw new InvalidOperationException(); + return typeof(MyOpenIddictTokenStore<,,,,>).MakeGenericType( + /* TToken: */ key, + /* TApplication: */ root.GenericTypeArguments[1], + /* TAuthorization: */ root.GenericTypeArguments[2], + /* TContext: */ context, + /* TKey: */ root.GenericTypeArguments[0]); + }); + + return (IOpenIddictTokenStore)_provider.GetRequiredService(type); + } + + [SuppressMessage("Design", "CA1034:Nested types should not be visible")] + public sealed class TypeResolutionCache : ConcurrentDictionary { } +} + +// TODO Revert to this after .NET 8 Preview 7 +internal static class OpenIddictHelpers +{ + public static Type FindGenericBaseType(Type type, Type definition) + => FindGenericBaseTypes(type, definition).FirstOrDefault()!; + + public static IEnumerable FindGenericBaseTypes(Type type, Type definition) + { + ArgumentNullException.ThrowIfNull(type); + + ArgumentNullException.ThrowIfNull(definition); + + if (!definition.IsGenericTypeDefinition) + { + throw new ArgumentException(null, nameof(definition)); + } + + if (definition.IsInterface) + { + foreach (var contract in type.GetInterfaces()) + { + if (!contract.IsGenericType && !contract.IsConstructedGenericType) + { + continue; + } + + if (contract.GetGenericTypeDefinition() == definition) + { + yield return contract; + } + } + } + + else + { + for (var candidate = type; candidate is not null; candidate = candidate.BaseType) + { + if (!candidate.IsGenericType && !candidate.IsConstructedGenericType) + { + continue; + } + + if (candidate.GetGenericTypeDefinition() == definition) + { + yield return candidate; + } + } + } + } +} + +// TODO Revert to this after .NET 8 Preview 7 +internal static class OpenIddictExtension +{ + public static OpenIddictEntityFrameworkCoreBuilder UseCustomEntityFrameworkCore(this OpenIddictCoreBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + // Since Entity Framework Core may be used with databases performing case-insensitive + // or culture-sensitive comparisons, ensure the additional filtering logic is enforced + // in case case-sensitive stores were registered before this extension was called. + builder.Configure(options => options.DisableAdditionalFiltering = false); + + builder.SetDefaultApplicationEntity() + .SetDefaultAuthorizationEntity() + .SetDefaultScopeEntity() + .SetDefaultTokenEntity(); + + builder.ReplaceApplicationStoreResolver() + .ReplaceAuthorizationStoreResolver() + .ReplaceScopeStoreResolver() + .ReplaceTokenStoreResolver(); + + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + + builder.Services.TryAddScoped(typeof(OpenIddictEntityFrameworkCoreApplicationStore<,,,,>)); + builder.Services.TryAddScoped(typeof(MyOpenIddictAuthorizationStore<,,,,>)); + builder.Services.TryAddScoped(typeof(OpenIddictEntityFrameworkCoreScopeStore<,,>)); + builder.Services.TryAddScoped(typeof(MyOpenIddictTokenStore<,,,,>)); + + return new OpenIddictEntityFrameworkCoreBuilder(builder.Services); + } +} diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs index 52c187dba3..d0a5299ca5 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs @@ -48,9 +48,6 @@ public static class UmbracoEFCoreServiceCollectionExtensions private static ConnectionStrings GetConnectionStringAndProviderName(IServiceProvider serviceProvider) { - string? connectionString = null; - string? providerName = null; - ConnectionStrings connectionStrings = serviceProvider.GetRequiredService>().CurrentValue; // Replace data directory diff --git a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj index 8d7a835ab1..4191bdb9ea 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj @@ -8,7 +8,6 @@ - diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj index 92d929ce12..5c37b88d10 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 6a385aeba6..69b657f062 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index c527c9efa2..a8d1a87702 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -26,7 +26,6 @@ - diff --git a/src/Umbraco.Web.UI.New/Umbraco.Web.UI.New.csproj b/src/Umbraco.Web.UI.New/Umbraco.Web.UI.New.csproj index 79cd00810b..abd41a7d6b 100644 --- a/src/Umbraco.Web.UI.New/Umbraco.Web.UI.New.csproj +++ b/src/Umbraco.Web.UI.New/Umbraco.Web.UI.New.csproj @@ -11,7 +11,7 @@ - + all diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 44a72b7380..ebb17d6046 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -24,7 +24,6 @@ - all diff --git a/templates/UmbracoPackageRcl/.template.config/template.json b/templates/UmbracoPackageRcl/.template.config/template.json index be7b1c04e8..c887e8902c 100644 --- a/templates/UmbracoPackageRcl/.template.config/template.json +++ b/templates/UmbracoPackageRcl/.template.config/template.json @@ -29,13 +29,13 @@ "datatype": "choice", "choices": [ { - "displayName": ".NET 7.0", - "description": "Target net7.0", - "choice": "net7.0" + "displayName": ".NET 8.0", + "description": "Target net8.0", + "choice": "net8.0" } ], - "defaultValue": "net7.0", - "replaces": "net7.0" + "defaultValue": "net8.0", + "replaces": "net8.0" }, "UmbracoVersion": { "displayName": "Umbraco version", diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 7d37843ce0..bb58a6f090 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -10,10 +10,10 @@ - - + + - +