diff --git a/src/OpenIddict.EntityFramework/OpenIddictExtensions.cs b/src/OpenIddict.EntityFramework/OpenIddictExtensions.cs index 431e5c7c..5e2cc25d 100644 --- a/src/OpenIddict.EntityFramework/OpenIddictExtensions.cs +++ b/src/OpenIddict.EntityFramework/OpenIddictExtensions.cs @@ -236,7 +236,8 @@ namespace Microsoft.Extensions.DependencyInjection builder.Entity() .HasMany(application => application.Tokens) .WithOptional(token => token.Authorization) - .Map(association => association.MapKey("AuthorizationId")); + .Map(association => association.MapKey("AuthorizationId")) + .WillCascadeOnDelete(); builder.Entity() .ToTable("OpenIddictAuthorizations"); diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictApplicationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictApplicationStore.cs index 6a1dc4f3..46ef278d 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictApplicationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictApplicationStore.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Data; using System.Data.Entity; using System.Linq; using System.Threading; @@ -161,6 +162,19 @@ namespace OpenIddict.EntityFramework throw new ArgumentNullException(nameof(application)); } + DbContextTransaction CreateTransaction() + { + try + { + return Context.Database.BeginTransaction(IsolationLevel.Serializable); + } + + catch + { + return null; + } + } + Task> ListAuthorizationsAsync() => (from authorization in Authorizations.Include(authorization => authorization.Tokens) where authorization.Application.Id.Equals(application.Id) @@ -168,30 +182,38 @@ namespace OpenIddict.EntityFramework Task> ListTokensAsync() => (from token in Tokens + where token.Authorization == null where token.Application.Id.Equals(application.Id) select token).ToListAsync(cancellationToken); - // Remove all the authorizations associated with the application and - // the tokens attached to these implicit or explicit authorizations. - foreach (var authorization in await ListAuthorizationsAsync()) + // To prevent an SQL exception from being thrown if a new associated entity is + // created after the existing entries have been listed, the following logic is + // executed in a serializable transaction, that will lock the affected tables. + using (var transaction = CreateTransaction()) { - foreach (var token in authorization.Tokens) + // Remove all the authorizations associated with the application and + // the tokens attached to these implicit or explicit authorizations. + foreach (var authorization in await ListAuthorizationsAsync()) + { + foreach (var token in authorization.Tokens) + { + Tokens.Remove(token); + } + + Authorizations.Remove(authorization); + } + + // Remove all the tokens associated with the application. + foreach (var token in await ListTokensAsync()) { Tokens.Remove(token); } - Authorizations.Remove(authorization); - } + Applications.Remove(application); - // Remove all the tokens associated with the application. - foreach (var token in await ListTokensAsync()) - { - Tokens.Remove(token); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); } - - Applications.Remove(application); - - await Context.SaveChangesAsync(cancellationToken); } /// diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs index 00b4cef1..1a494d88 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs @@ -162,20 +162,40 @@ namespace OpenIddict.EntityFramework throw new ArgumentNullException(nameof(authorization)); } + DbContextTransaction CreateTransaction() + { + try + { + return Context.Database.BeginTransaction(IsolationLevel.Serializable); + } + + catch + { + return null; + } + } + Task> ListTokensAsync() => (from token in Tokens where token.Authorization.Id.Equals(authorization.Id) select token).ToListAsync(cancellationToken); - // Remove all the tokens associated with the application. - foreach (var token in await ListTokensAsync()) + // To prevent an SQL exception from being thrown if a new associated entity is + // created after the existing entries have been listed, the following logic is + // executed in a serializable transaction, that will lock the affected tables. + using (var transaction = CreateTransaction()) { - Tokens.Remove(token); - } + // Remove all the tokens associated with the authorization. + foreach (var token in await ListTokensAsync()) + { + Tokens.Remove(token); + } - Authorizations.Remove(authorization); + Authorizations.Remove(authorization); - await Context.SaveChangesAsync(cancellationToken); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } } /// @@ -353,6 +373,10 @@ namespace OpenIddict.EntityFramework break; } + // Note: new tokens may be attached after the authorizations were retrieved + // from the database since the transaction level is deliberately limited to + // repeatable read instead of serializable for performance reasons). In this + // case, the operation will fail, which is considered an acceptable risk. Authorizations.RemoveRange(authorizations); Tokens.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens)); @@ -389,7 +413,8 @@ namespace OpenIddict.EntityFramework /// /// A that can be used to monitor the asynchronous operation. /// - public override async Task SetApplicationIdAsync([NotNull] TAuthorization authorization, [CanBeNull] string identifier, CancellationToken cancellationToken) + public override async Task SetApplicationIdAsync([NotNull] TAuthorization authorization, + [CanBeNull] string identifier, CancellationToken cancellationToken) { if (authorization == null) { diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs index 32cdd988..7b8bcb1e 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs @@ -419,7 +419,8 @@ namespace OpenIddict.EntityFramework /// /// A that can be used to monitor the asynchronous operation. /// - public override async Task SetApplicationIdAsync([NotNull] TToken token, [CanBeNull] string identifier, CancellationToken cancellationToken) + public override async Task SetApplicationIdAsync([NotNull] TToken token, + [CanBeNull] string identifier, CancellationToken cancellationToken) { if (token == null) { @@ -464,7 +465,8 @@ namespace OpenIddict.EntityFramework /// /// A that can be used to monitor the asynchronous operation. /// - public override async Task SetAuthorizationIdAsync([NotNull] TToken token, [CanBeNull] string identifier, CancellationToken cancellationToken) + public override async Task SetAuthorizationIdAsync([NotNull] TToken token, + [CanBeNull] string identifier, CancellationToken cancellationToken) { if (token == null) { diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs index 732a1531..27b1944d 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs @@ -268,7 +268,8 @@ namespace Microsoft.Extensions.DependencyInjection entity.HasMany(authorization => authorization.Tokens) .WithOne(token => token.Authorization) .HasForeignKey("AuthorizationId") - .IsRequired(required: false); + .IsRequired(required: false) + .OnDelete(DeleteBehavior.Cascade); entity.ToTable("OpenIddictAuthorizations"); }); diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs index 4e26e1c5..26fcbb16 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs @@ -7,11 +7,14 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Data; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Caching.Memory; using OpenIddict.Core; using OpenIddict.Models; @@ -161,14 +164,37 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(application)); } + async Task CreateTransactionAsync() + { + // Note: transactions that specify an explicit isolation level are only supported by + // relational providers and trying to use them with a different provider results in + // an invalid operation exception being thrown at runtime. To prevent that, a manual + // check is made to ensure the underlying transaction manager is relational. + var manager = Context.Database.GetService(); + if (manager is IRelationalTransactionManager) + { + try + { + return await Context.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + } + + catch + { + return null; + } + } + + return null; + } + // Note: due to a bug in Entity Framework Core's query visitor, the authorizations can't be // filtered using authorization.Application.Id.Equals(key). To work around this issue, // this local method uses an explicit join before applying the equality check. // See https://github.com/openiddict/openiddict-core/issues/499 for more information. Task> ListAuthorizationsAsync() - => (from authorization in Authorizations.Include(authorization => authorization.Tokens) - join element in Applications on authorization.Application.Id equals element.Id + => (from authorization in Authorizations.Include(authorization => authorization.Tokens).AsTracking() + join element in Applications.AsTracking() on authorization.Application.Id equals element.Id where element.Id.Equals(application.Id) select authorization).ToListAsync(cancellationToken); @@ -178,32 +204,40 @@ namespace OpenIddict.EntityFrameworkCore // See https://github.com/openiddict/openiddict-core/issues/499 for more information. Task> ListTokensAsync() - => (from token in Tokens - join element in Applications on token.Application.Id equals element.Id + => (from token in Tokens.AsTracking() + where token.Authorization == null + join element in Applications.AsTracking() on token.Application.Id equals element.Id where element.Id.Equals(application.Id) select token).ToListAsync(cancellationToken); - // Remove all the authorizations associated with the application and - // the tokens attached to these implicit or explicit authorizations. - foreach (var authorization in await ListAuthorizationsAsync()) + // To prevent an SQL exception from being thrown if a new associated entity is + // created after the existing entries have been listed, the following logic is + // executed in a serializable transaction, that will lock the affected tables. + using (var transaction = await CreateTransactionAsync()) { - foreach (var token in authorization.Tokens) + // Remove all the authorizations associated with the application and + // the tokens attached to these implicit or explicit authorizations. + foreach (var authorization in await ListAuthorizationsAsync()) + { + foreach (var token in authorization.Tokens) + { + Context.Remove(token); + } + + Context.Remove(authorization); + } + + // Remove all the tokens associated with the application. + foreach (var token in await ListTokensAsync()) { Context.Remove(token); } - Context.Remove(authorization); - } + Context.Remove(application); - // Remove all the tokens associated with the application. - foreach (var token in await ListTokensAsync()) - { - Context.Remove(token); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); } - - Context.Remove(application); - - await Context.SaveChangesAsync(cancellationToken); } /// @@ -227,7 +261,7 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(query)); } - return query(Applications, state).FirstOrDefaultAsync(cancellationToken); + return query(Applications.AsTracking(), state).FirstOrDefaultAsync(cancellationToken); } /// @@ -251,7 +285,7 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(query)); } - return ImmutableArray.CreateRange(await query(Applications, state).ToListAsync(cancellationToken)); + return ImmutableArray.CreateRange(await query(Applications.AsTracking(), state).ToListAsync(cancellationToken)); } /// diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs index 51b9caf9..356bb7d5 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs @@ -164,26 +164,56 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(authorization)); } + async Task CreateTransactionAsync() + { + // Note: transactions that specify an explicit isolation level are only supported by + // relational providers and trying to use them with a different provider results in + // an invalid operation exception being thrown at runtime. To prevent that, a manual + // check is made to ensure the underlying transaction manager is relational. + var manager = Context.Database.GetService(); + if (manager is IRelationalTransactionManager) + { + try + { + return await Context.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + } + + catch + { + return null; + } + } + + return null; + } + // Note: due to a bug in Entity Framework Core's query visitor, the tokens can't be // filtered using token.Application.Id.Equals(key). To work around this issue, // this local method uses an explicit join before applying the equality check. // See https://github.com/openiddict/openiddict-core/issues/499 for more information. Task> ListTokensAsync() - => (from token in Tokens - join element in Authorizations on token.Authorization.Id equals element.Id + => (from token in Tokens.AsTracking() + join element in Authorizations.AsTracking() on token.Authorization.Id equals element.Id where element.Id.Equals(authorization.Id) select token).ToListAsync(cancellationToken); - // Remove all the tokens associated with the application. - foreach (var token in await ListTokensAsync()) + // To prevent an SQL exception from being thrown if a new associated entity is + // created after the existing entries have been listed, the following logic is + // executed in a serializable transaction, that will lock the affected tables. + using (var transaction = await CreateTransactionAsync()) { - Context.Remove(token); - } + // Remove all the tokens associated with the authorization. + foreach (var token in await ListTokensAsync()) + { + Context.Remove(token); + } - Context.Remove(authorization); + Context.Remove(authorization); - await Context.SaveChangesAsync(cancellationToken); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } } /// @@ -217,9 +247,9 @@ namespace OpenIddict.EntityFrameworkCore IQueryable Query(IQueryable authorizations, IQueryable applications, TKey key, string principal) - => from authorization in authorizations.Include(authorization => authorization.Application) + => from authorization in authorizations.Include(authorization => authorization.Application).AsTracking() where authorization.Subject == principal - join application in applications on authorization.Application.Id equals application.Id + join application in applications.AsTracking() on authorization.Application.Id equals application.Id where application.Id.Equals(key) select authorization; @@ -264,10 +294,9 @@ namespace OpenIddict.EntityFrameworkCore IQueryable Query(IQueryable authorizations, IQueryable applications, TKey key, string principal, string state) - => from authorization in authorizations.Include(authorization => authorization.Application) - where authorization.Subject == principal && - authorization.Status == state - join application in applications on authorization.Application.Id equals application.Id + => from authorization in authorizations.Include(authorization => authorization.Application).AsTracking() + where authorization.Subject == principal && authorization.Status == state + join application in applications.AsTracking() on authorization.Application.Id equals application.Id where application.Id.Equals(key) select authorization; @@ -318,11 +347,11 @@ namespace OpenIddict.EntityFrameworkCore IQueryable Query(IQueryable authorizations, IQueryable applications, TKey key, string principal, string state, string kind) - => from authorization in authorizations.Include(authorization => authorization.Application) + => from authorization in authorizations.Include(authorization => authorization.Application).AsTracking() where authorization.Subject == principal && authorization.Status == state && authorization.Type == kind - join application in applications on authorization.Application.Id equals application.Id + join application in applications.AsTracking() on authorization.Application.Id equals application.Id where application.Id.Equals(key) select authorization; @@ -380,7 +409,9 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(query)); } - return query(Authorizations.Include(authorization => authorization.Application), state).FirstOrDefaultAsync(cancellationToken); + return query( + Authorizations.Include(authorization => authorization.Application) + .AsTracking(), state).FirstOrDefaultAsync(cancellationToken); } /// @@ -405,7 +436,8 @@ namespace OpenIddict.EntityFrameworkCore } return ImmutableArray.CreateRange(await query( - Authorizations.Include(authorization => authorization.Application), state).ToListAsync(cancellationToken)); + Authorizations.Include(authorization => authorization.Application) + .AsTracking(), state).ToListAsync(cancellationToken)); } /// @@ -424,7 +456,7 @@ namespace OpenIddict.EntityFrameworkCore IList exceptions = null; IQueryable Query(IQueryable authorizations, int offset) - => (from authorization in authorizations.Include(authorization => authorization.Tokens) + => (from authorization in authorizations.Include(authorization => authorization.Tokens).AsTracking() where authorization.Status != OpenIddictConstants.Statuses.Valid || (authorization.Type == OpenIddictConstants.AuthorizationTypes.AdHoc && !authorization.Tokens.Any(token => token.Status == OpenIddictConstants.Statuses.Valid)) @@ -473,6 +505,10 @@ namespace OpenIddict.EntityFrameworkCore break; } + // Note: new tokens may be attached after the authorizations were retrieved + // from the database since the transaction level is deliberately limited to + // repeatable read instead of serializable for performance reasons). In this + // case, the operation will fail, which is considered an acceptable risk. Context.RemoveRange(authorizations); Context.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens)); @@ -509,7 +545,8 @@ namespace OpenIddict.EntityFrameworkCore /// /// A that can be used to monitor the asynchronous operation. /// - public override async Task SetApplicationIdAsync([NotNull] TAuthorization authorization, [CanBeNull] string identifier, CancellationToken cancellationToken) + public override async Task SetApplicationIdAsync([NotNull] TAuthorization authorization, + [CanBeNull] string identifier, CancellationToken cancellationToken) { if (authorization == null) { diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictScopeStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictScopeStore.cs index 45b083e1..f2386b9a 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictScopeStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictScopeStore.cs @@ -167,7 +167,7 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(query)); } - return query(Scopes, state).FirstOrDefaultAsync(cancellationToken); + return query(Scopes.AsTracking(), state).FirstOrDefaultAsync(cancellationToken); } /// @@ -191,7 +191,7 @@ namespace OpenIddict.EntityFrameworkCore throw new ArgumentNullException(nameof(query)); } - return ImmutableArray.CreateRange(await query(Scopes, state).ToListAsync(cancellationToken)); + return ImmutableArray.CreateRange(await query(Scopes.AsTracking(), state).ToListAsync(cancellationToken)); } /// diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs index ffdc45f0..4f72ff21 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs @@ -189,8 +189,8 @@ namespace OpenIddict.EntityFrameworkCore // See https://github.com/openiddict/openiddict-core/issues/499 for more information. IQueryable Query(IQueryable applications, IQueryable tokens, TKey key) - => from token in tokens.Include(token => token.Application).Include(token => token.Authorization) - join application in applications on token.Application.Id equals application.Id + => from token in tokens.Include(token => token.Application).Include(token => token.Authorization).AsTracking() + join application in applications.AsTracking() on token.Application.Id equals application.Id where application.Id.Equals(key) select token; @@ -220,8 +220,8 @@ namespace OpenIddict.EntityFrameworkCore // See https://github.com/openiddict/openiddict-core/issues/499 for more information. IQueryable Query(IQueryable authorizations, IQueryable tokens, TKey key) - => from token in tokens.Include(token => token.Application).Include(token => token.Authorization) - join authorization in authorizations on token.Authorization.Id equals authorization.Id + => from token in tokens.Include(token => token.Application).Include(token => token.Authorization).AsTracking() + join authorization in authorizations.AsTracking() on token.Authorization.Id equals authorization.Id where authorization.Id.Equals(key) select token; @@ -281,7 +281,8 @@ namespace OpenIddict.EntityFrameworkCore return query( Tokens.Include(token => token.Application) - .Include(token => token.Authorization), state).FirstOrDefaultAsync(cancellationToken); + .Include(token => token.Authorization) + .AsTracking(), state).FirstOrDefaultAsync(cancellationToken); } /// @@ -307,7 +308,8 @@ namespace OpenIddict.EntityFrameworkCore return ImmutableArray.CreateRange(await query( Tokens.Include(token => token.Application) - .Include(token => token.Authorization), state).ToListAsync(cancellationToken)); + .Include(token => token.Authorization) + .AsTracking(), state).ToListAsync(cancellationToken)); } /// @@ -326,7 +328,7 @@ namespace OpenIddict.EntityFrameworkCore IList exceptions = null; IQueryable Query(IQueryable tokens, int offset) - => (from token in tokens + => (from token in tokens.AsTracking() where token.ExpirationDate < DateTimeOffset.UtcNow || token.Status != OpenIddictConstants.Statuses.Valid orderby token.Id @@ -409,7 +411,8 @@ namespace OpenIddict.EntityFrameworkCore /// /// A that can be used to monitor the asynchronous operation. /// - public override async Task SetApplicationIdAsync([NotNull] TToken token, [CanBeNull] string identifier, CancellationToken cancellationToken) + public override async Task SetApplicationIdAsync([NotNull] TToken token, + [CanBeNull] string identifier, CancellationToken cancellationToken) { if (token == null) { @@ -452,7 +455,8 @@ namespace OpenIddict.EntityFrameworkCore /// /// A that can be used to monitor the asynchronous operation. /// - public override async Task SetAuthorizationIdAsync([NotNull] TToken token, [CanBeNull] string identifier, CancellationToken cancellationToken) + public override async Task SetAuthorizationIdAsync([NotNull] TToken token, + [CanBeNull] string identifier, CancellationToken cancellationToken) { if (token == null) {