diff --git a/Directory.Build.targets b/Directory.Build.targets index 1ff00b31..51c5ac51 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -107,6 +107,7 @@ $(DefineConstants);SUPPORTS_AUTHENTICATION_HANDLER_SELECTION_FALLBACK + $(DefineConstants);SUPPORTS_BULK_DBSET_OPERATIONS internal static class OpenIddictHelpers { + /// + /// Generates a sequence of non-overlapping adjacent buffers over the source sequence. + /// + /// The source sequence element type. + /// The source sequence. + /// The number of elements for allocated buffers. + /// A sequence of buffers containing source sequence elements. + public static IEnumerable> Buffer(this IEnumerable source, int count) + { + List? buffer = null; + + foreach (var element in source) + { + buffer ??= []; + buffer.Add(element); + + if (buffer.Count == count) + { + yield return buffer; + + buffer = null; + } + } + + if (buffer is not null) + { + yield return buffer; + } + } + /// /// Finds the first base type that matches the specified generic type definition. /// diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs index a51e983f..23bacf25 100644 --- a/src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs @@ -391,10 +391,8 @@ public interface IOpenIddictAuthorizationManager /// /// The date before which authorizations are not pruned. /// The that can be used to abort the operation. - /// - /// A that can be used to monitor the asynchronous operation. - /// - ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default); + /// The number of authorizations that were removed. + ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default); /// /// Tries to revoke an authorization. diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs index af039c7e..1866a21c 100644 --- a/src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs @@ -406,10 +406,16 @@ public interface IOpenIddictTokenManager /// /// The date before which tokens are not pruned. /// The that can be used to abort the operation. - /// - /// A that can be used to monitor the asynchronous operation. - /// - ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default); + /// The number of tokens that were removed. + ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default); + + /// + /// Revokes all the tokens associated with the specified authorization identifier. + /// + /// The authorization identifier associated with the tokens. + /// The that can be used to abort the operation. + /// The number of tokens associated with the specified authorization that were marked as revoked. + ValueTask RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken = default); /// /// Tries to redeem a token. diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 9fc60a72..e8d130a8 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -2739,6 +2739,12 @@ This may indicate that the hashed entry is corrupted or malformed. The request was rejected because the '{Method}' client authentication method that was used by the client application is not enabled in the server options. + + {Count} tokens associated with the authorization '{Identifier}' were revoked to prevent a potential token replay attack. + + + An error occurred while trying to revoke the tokens associated with the authorization '{Identifier}'. + https://documentation.openiddict.com/errors/{0} diff --git a/src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs b/src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs index d766517c..bb5e122b 100644 --- a/src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs @@ -276,8 +276,8 @@ public interface IOpenIddictAuthorizationStore where TAuthorizat /// /// The date before which authorizations are not pruned. /// The that can be used to abort the operation. - /// A that can be used to monitor the asynchronous operation. - ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken); + /// The number of authorizations that were removed. + ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken); /// /// Sets the application identifier associated with an authorization. diff --git a/src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs b/src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs index 0cbfd1cb..dfb6af73 100644 --- a/src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs +++ b/src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs @@ -323,8 +323,16 @@ public interface IOpenIddictTokenStore where TToken : class /// /// The date before which tokens are not pruned. /// The that can be used to abort the operation. - /// A that can be used to monitor the asynchronous operation. - ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken); + /// The number of tokens that were removed. + ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken); + + /// + /// Revokes all the tokens associated with the specified authorization identifier. + /// + /// The authorization identifier associated with the tokens. + /// The that can be used to abort the operation. + /// The number of tokens associated with the specified authorization that were marked as revoked. + ValueTask RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken); /// /// Sets the application identifier associated with a token. diff --git a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs index 7cd65946..b3f5cfb3 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs @@ -1020,10 +1020,8 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori /// /// The date before which authorizations are not pruned. /// The that can be used to abort the operation. - /// - /// A that can be used to monitor the asynchronous operation. - /// - public virtual ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default) + /// The number of authorizations that were removed. + public virtual ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default) => Store.PruneAsync(threshold, cancellationToken); /// @@ -1332,7 +1330,7 @@ public class OpenIddictAuthorizationManager : IOpenIddictAuthori => PopulateAsync((TAuthorization) authorization, descriptor, cancellationToken); /// - ValueTask IOpenIddictAuthorizationManager.PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + ValueTask IOpenIddictAuthorizationManager.PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) => PruneAsync(threshold, cancellationToken); /// diff --git a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs index a0ebd3cd..26a8cb6f 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs @@ -1051,11 +1051,26 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok /// /// The date before which tokens are not pruned. /// The that can be used to abort the operation. - /// - /// A that can be used to monitor the asynchronous operation. - /// - public virtual ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default) + /// The number of tokens that were removed. + public virtual ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default) => Store.PruneAsync(threshold, cancellationToken); + + /// + /// Revokes all the tokens associated with the specified authorization identifier. + /// + /// The authorization identifier associated with the tokens. + /// The that can be used to abort the operation. + /// The number of tokens associated with the specified authorization that were marked as revoked. + public virtual ValueTask RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0195), nameof(identifier)); + } + + return Store.RevokeByAuthorizationIdAsync(identifier, cancellationToken); + } + /// /// Tries to redeem a token. /// @@ -1479,9 +1494,13 @@ public class OpenIddictTokenManager : IOpenIddictTokenManager where TTok => PopulateAsync((TToken) token, descriptor, cancellationToken); /// - ValueTask IOpenIddictTokenManager.PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + ValueTask IOpenIddictTokenManager.PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) => PruneAsync(threshold, cancellationToken); + /// + ValueTask IOpenIddictTokenManager.RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken) + => RevokeByAuthorizationIdAsync(identifier, cancellationToken); + /// ValueTask IOpenIddictTokenManager.TryRedeemAsync(object token, CancellationToken cancellationToken) => TryRedeemAsync((TToken) token, cancellationToken); diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs index 15567df7..771c1ae9 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs @@ -591,7 +591,7 @@ public class OpenIddictEntityFrameworkAuthorizationStore - public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) { // Note: Entity Framework 6.x doesn't support set-based deletes, which prevents removing // entities in a single command without having to retrieve and materialize them first. @@ -599,6 +599,8 @@ public class OpenIddictEntityFrameworkAuthorizationStore? exceptions = null; + var result = 0L; + DbContextTransaction? CreateTransaction() { // Note: relational providers like Sqlite are known to lack proper support @@ -662,13 +664,19 @@ public class OpenIddictEntityFrameworkAuthorizationStore diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs index f5628ed4..d48e6ff8 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs @@ -574,7 +574,7 @@ public class OpenIddictEntityFrameworkTokenStore - public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) { // Note: Entity Framework 6.x doesn't support set-based deletes, which prevents removing // entities in a single command without having to retrieve and materialize them first. @@ -582,6 +582,8 @@ public class OpenIddictEntityFrameworkTokenStore? exceptions = null; + var result = 0L; + DbContextTransaction? CreateTransaction() { // Note: relational providers like Sqlite are known to lack proper support @@ -642,13 +644,66 @@ public class OpenIddictEntityFrameworkTokenStore + public virtual async ValueTask RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0195), nameof(identifier)); + } + + var key = ConvertIdentifierFromString(identifier); + + List? exceptions = null; + + var result = 0L; + + foreach (var token in await (from token in Tokens + where token.Authorization!.Id!.Equals(key) + select token).ToListAsync(cancellationToken)) + { + token.Status = Statuses.Revoked; + + try + { + await Context.SaveChangesAsync(cancellationToken); + } + + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + Context.Entry(token).State = EntityState.Unchanged; + + exceptions ??= []; + exceptions.Add(exception); + + continue; } + + result++; } if (exceptions is not null) { throw new AggregateException(SR.GetResourceString(SR.ID0249), exceptions); } + + return result; } /// diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs index ed741dcc..052b0009 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs @@ -47,6 +47,16 @@ public sealed class OpenIddictEntityFrameworkCoreBuilder return this; } + /// + /// Prevents the Entity Framework Core stores from using bulk operations. + /// + /// + /// Note: bulk operations are only supported when targeting .NET 7.0 and higher. + /// + /// The instance. + public OpenIddictEntityFrameworkCoreBuilder DisableBulkOperations() + => Configure(options => options.DisableBulkOperations = true); + /// /// Configures OpenIddict to use the default OpenIddict /// Entity Framework Core entities, with the specified key type. diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs index 60f179b9..3151486c 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs @@ -4,10 +4,13 @@ * the license and the contributors participating to this project. */ +using System; +using System.Data; using System.Runtime.CompilerServices; using Microsoft.Extensions.DependencyInjection; using OpenIddict.EntityFrameworkCore; using OpenIddict.EntityFrameworkCore.Models; +using OpenIddict.Extensions; namespace Microsoft.EntityFrameworkCore; @@ -224,4 +227,40 @@ public static class OpenIddictEntityFrameworkCoreHelpers #endif } } + + /// + /// Tries to create a new with the specified . + /// + /// The Entity Framework Core context. + /// The desired level of isolation. + /// The that can be used to abort the operation. + /// The if it could be created, otherwise. + internal static async ValueTask CreateTransactionAsync( + this DbContext context, IsolationLevel level, CancellationToken cancellationToken) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // 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(level, cancellationToken); + } + + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + return null; + } + } + + return null; + } } diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreOptions.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreOptions.cs index 7bcdbef6..24a37b46 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreOptions.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreOptions.cs @@ -18,4 +18,12 @@ public sealed class OpenIddictEntityFrameworkCoreOptions /// an exception is thrown at runtime when trying to use the stores. /// public Type? DbContextType { get; set; } + + /// + /// Gets or sets a boolean indicating whether bulk operations should be disabled. + /// + /// + /// Note: bulk operations are only supported when targeting .NET 7.0 and higher. + /// + public bool DisableBulkOperations { get; set; } } diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs index ba747c10..02fa0de1 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs @@ -9,6 +9,7 @@ using System.ComponentModel; using System.Data; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Net; using System.Runtime.CompilerServices; using System.Text; using System.Text.Encodings.Web; @@ -17,7 +18,6 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using OpenIddict.EntityFrameworkCore.Models; -using OpenIddict.Extensions; using static OpenIddict.Abstractions.OpenIddictExceptions; namespace OpenIddict.EntityFrameworkCore; @@ -153,101 +153,129 @@ public class OpenIddictEntityFrameworkCoreApplicationStore CreateTransactionAsync() +#if SUPPORTS_BULK_DBSET_OPERATIONS + if (!Options.CurrentValue.DisableBulkOperations) { - // 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) + var strategy = Context.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => { + // 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 Context.CreateTransactionAsync(IsolationLevel.Serializable, cancellationToken); + + // Remove all the tokens associated with the application. + await (from token in Tokens + where token.Application!.Id!.Equals(application.Id) + select token).ExecuteDeleteAsync(cancellationToken); + + // Remove all the authorizations associated with the application and + // the tokens attached to these implicit or explicit authorizations. + await (from authorization in Authorizations + where authorization.Application!.Id!.Equals(application.Id) + select authorization).ExecuteDeleteAsync(cancellationToken); + + // Note: calling DbContext.SaveChangesAsync() is not necessary + // with bulk delete operations as they are executed immediately. + + Context.Remove(application); + try { - return await Context.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); } - catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + catch (DbUpdateConcurrencyException exception) { - return null; - } - } + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + Context.Entry(application).State = EntityState.Unchanged; - return null; + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); + } + }); } - // 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).AsTracking() - join element in Applications.AsTracking() on authorization.Application!.Id equals element.Id - where element.Id!.Equals(application.Id) - select authorization).ToListAsync(cancellationToken); - - // 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.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); - - // 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(); - - // Remove all the authorizations associated with the application and - // the tokens attached to these implicit or explicit authorizations. - var authorizations = await ListAuthorizationsAsync(); - foreach (var authorization in authorizations) - { - foreach (var token in authorization.Tokens) + else +#endif + { + // 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).AsTracking() + join element in Applications.AsTracking() on authorization.Application!.Id equals element.Id + where element.Id!.Equals(application.Id) + select authorization).ToListAsync(cancellationToken); + + // 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.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); + + var strategy = Context.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => { - Context.Remove(token); - } + // 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 Context.CreateTransactionAsync(IsolationLevel.Serializable, cancellationToken); + + // Remove all the authorizations associated with the application and + // the tokens attached to these implicit or explicit authorizations. + var authorizations = await ListAuthorizationsAsync(); + foreach (var authorization in authorizations) + { + foreach (var token in authorization.Tokens) + { + Context.Remove(token); + } - Context.Remove(authorization); - } + Context.Remove(authorization); + } - // Remove all the tokens associated with the application. - var tokens = await ListTokensAsync(); - foreach (var token in tokens) - { - Context.Remove(token); - } + // Remove all the tokens associated with the application. + var tokens = await ListTokensAsync(); + foreach (var token in tokens) + { + Context.Remove(token); + } - Context.Remove(application); + Context.Remove(application); - try - { - await Context.SaveChangesAsync(cancellationToken); - transaction?.Commit(); - } + try + { + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } - catch (DbUpdateConcurrencyException exception) - { - // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. - Context.Entry(application).State = EntityState.Unchanged; + catch (DbUpdateConcurrencyException exception) + { + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + Context.Entry(application).State = EntityState.Unchanged; - foreach (var authorization in authorizations) - { - Context.Entry(authorization).State = EntityState.Unchanged; - } + foreach (var authorization in authorizations) + { + Context.Entry(authorization).State = EntityState.Unchanged; + } - foreach (var token in tokens) - { - Context.Entry(token).State = EntityState.Unchanged; - } + foreach (var token in tokens) + { + Context.Entry(token).State = EntityState.Unchanged; + } - throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0239), exception); + } + }); } } diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs index 31e21d66..69edb1b0 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs @@ -150,71 +150,93 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore 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) +#if SUPPORTS_BULK_DBSET_OPERATIONS + if (!Options.CurrentValue.DisableBulkOperations) + { + var strategy = Context.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => { + // 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 Context.CreateTransactionAsync(IsolationLevel.Serializable, cancellationToken); + + // Remove all the tokens associated with the authorization. + await (from token in Tokens.AsTracking() + where token.Authorization!.Id!.Equals(authorization.Id) + select token).ExecuteDeleteAsync(cancellationToken); + + // Note: calling DbContext.SaveChangesAsync() is not necessary + // with bulk delete operations as they are executed immediately. + + Context.Remove(authorization); + try { - return await Context.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); } - catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + catch (DbUpdateConcurrencyException exception) { - return null; - } - } + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + Context.Entry(authorization).State = EntityState.Unchanged; - return null; + throw new ConcurrencyException(SR.GetResourceString(SR.ID0241), exception); + } + }); } - // 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. + else +#endif + { + // 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.AsTracking() - join element in Authorizations.AsTracking() on token.Authorization!.Id equals element.Id - where element.Id!.Equals(authorization.Id) - select token).ToListAsync(cancellationToken); + Task> ListTokensAsync() + => (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); - // 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(); + var strategy = Context.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + // 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 Context.CreateTransactionAsync(IsolationLevel.Serializable, cancellationToken); + + // Remove all the tokens associated with the authorization. + var tokens = await ListTokensAsync(); + foreach (var token in tokens) + { + Context.Remove(token); + } - // Remove all the tokens associated with the authorization. - var tokens = await ListTokensAsync(); - foreach (var token in tokens) - { - Context.Remove(token); - } + Context.Remove(authorization); - Context.Remove(authorization); + try + { + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } - try - { - await Context.SaveChangesAsync(cancellationToken); - transaction?.Commit(); - } + catch (DbUpdateConcurrencyException exception) + { + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + Context.Entry(authorization).State = EntityState.Unchanged; - catch (DbUpdateConcurrencyException exception) - { - // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. - Context.Entry(authorization).State = EntityState.Unchanged; + foreach (var token in tokens) + { + Context.Entry(token).State = EntityState.Unchanged; + } - foreach (var token in tokens) - { - Context.Entry(token).State = EntityState.Unchanged; - } - - throw new ConcurrencyException(SR.GetResourceString(SR.ID0241), exception); + throw new ConcurrencyException(SR.GetResourceString(SR.ID0241), exception); + } + }); } } @@ -659,39 +681,17 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore - public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) { - // Note: Entity Framework Core doesn't support set-based deletes, which prevents removing - // entities in a single command without having to retrieve and materialize them first. - // To work around this limitation, entities are manually listed and deleted using a batch logic. - List? exceptions = null; - async ValueTask 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) - { - // Note: relational providers like Sqlite are known to lack proper support - // for repeatable read transactions. To ensure this method can be safely used - // with such providers, the database transaction is created in a try/catch block. - try - { - return await Context.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken); - } + var result = 0L; - catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) - { - return null; - } - } - - return null; - } + // Note: the Oracle MySQL provider doesn't support DateTimeOffset and is unable + // to create a SQL query with an expression calling DateTimeOffset.UtcDateTime. + // To work around this limitation, the threshold represented as a DateTimeOffset + // instance is manually converted to a UTC DateTime instance outside the query. + var date = threshold.UtcDateTime; // Note: to avoid sending too many queries, the maximum number of elements // that can be removed by a single call to PruneAsync() is deliberately limited. @@ -699,47 +699,87 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore authorization.Tokens).AsTracking() - where authorization.CreationDate < date - where authorization.Status != Statuses.Valid || - (authorization.Type == AuthorizationTypes.AdHoc && !authorization.Tokens.Any()) - orderby authorization.Id - select authorization).Take(1_000).ToListAsync(cancellationToken); - - if (authorizations.Count is 0) +#if SUPPORTS_BULK_DBSET_OPERATIONS + if (!Options.CurrentValue.DisableBulkOperations) { - 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); + try + { + var count = await + (from authorization in Authorizations + where authorization.CreationDate < date + where authorization.Status != Statuses.Valid || + (authorization.Type == AuthorizationTypes.AdHoc && !authorization.Tokens.Any()) + orderby authorization.Id + select authorization).Take(1_000).ExecuteDeleteAsync(cancellationToken); + + if (count is 0) + { + break; + } + + // Note: calling DbContext.SaveChangesAsync() is not necessary + // with bulk delete operations as they are executed immediately. + + result += count; + } - try - { - await Context.SaveChangesAsync(cancellationToken); - transaction?.Commit(); + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + exceptions ??= new List(capacity: 1); + exceptions.Add(exception); + } } - catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + else +#endif { - exceptions ??= []; - exceptions.Add(exception); + var strategy = Context.Database.CreateExecutionStrategy(); + var count = await strategy.ExecuteAsync(async () => + { + // To prevent concurrency exceptions from being thrown if an entry is modified + // after it was retrieved from the database, the following logic is executed in + // a repeatable read transaction, that will put a lock on the retrieved entries + // and thus prevent them from being concurrently modified outside this block. + using var transaction = await Context.CreateTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken); + + var authorizations = await + (from authorization in Authorizations.Include(authorization => authorization.Tokens).AsTracking() + where authorization.CreationDate < date + where authorization.Status != Statuses.Valid || + (authorization.Type == AuthorizationTypes.AdHoc && !authorization.Tokens.Any()) + orderby authorization.Id + select authorization).Take(1_000).ToListAsync(cancellationToken); + + if (authorizations.Count is not 0) + { + // 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); + + try + { + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } + + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + exceptions ??= []; + exceptions.Add(exception); + } + } + + return authorizations.Count; + }); + + if (count is 0) + { + break; + } + + result += count; } } @@ -747,6 +787,8 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs index 89673ca9..cf5920e8 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs @@ -624,91 +624,172 @@ public class OpenIddictEntityFrameworkCoreTokenStore - public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) { - // Note: Entity Framework Core doesn't support set-based deletes, which prevents removing - // entities in a single command without having to retrieve and materialize them first. - // To work around this limitation, entities are manually listed and deleted using a batch logic. - List? exceptions = null; - async ValueTask CreateTransactionAsync() + var result = 0L; + + // Note: the Oracle MySQL provider doesn't support DateTimeOffset and is unable + // to create a SQL query with an expression calling DateTimeOffset.UtcDateTime. + // To work around this limitation, the threshold represented as a DateTimeOffset + // instance is manually converted to a UTC DateTime instance outside the query. + var date = threshold.UtcDateTime; + + // Note: to avoid sending too many queries, the maximum number of elements + // that can be removed by a single call to PruneAsync() is deliberately limited. + for (var index = 0; index < 1_000; index++) { - // 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) + cancellationToken.ThrowIfCancellationRequested(); + +#if SUPPORTS_BULK_DBSET_OPERATIONS + if (!Options.CurrentValue.DisableBulkOperations) { - // Note: relational providers like Sqlite are known to lack proper support - // for repeatable read transactions. To ensure this method can be safely used - // with such providers, the database transaction is created in a try/catch block. try { - return await Context.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken); + var count = await + (from token in Tokens + where token.CreationDate < date + where (token.Status != Statuses.Inactive && token.Status != Statuses.Valid) || + (token.Authorization != null && token.Authorization.Status != Statuses.Valid) || + token.ExpirationDate < DateTime.UtcNow + orderby token.Id + select token).Take(1_000).ExecuteDeleteAsync(cancellationToken); + + if (count is 0) + { + break; + } + + // Note: calling DbContext.SaveChangesAsync() is not necessary + // with bulk delete operations as they are executed immediately. + + result += count; } catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) { - return null; + exceptions ??= new List(capacity: 1); + exceptions.Add(exception); } } - return null; + else +#endif + { + var strategy = Context.Database.CreateExecutionStrategy(); + var count = await strategy.ExecuteAsync(async () => + { + // To prevent concurrency exceptions from being thrown if an entry is modified + // after it was retrieved from the database, the following logic is executed in + // a repeatable read transaction, that will put a lock on the retrieved entries + // and thus prevent them from being concurrently modified outside this block. + using var transaction = await Context.CreateTransactionAsync(IsolationLevel.RepeatableRead, cancellationToken); + + var tokens = await + (from token in Tokens.AsTracking() + where token.CreationDate < date + where (token.Status != Statuses.Inactive && token.Status != Statuses.Valid) || + (token.Authorization != null && token.Authorization.Status != Statuses.Valid) || + token.ExpirationDate < DateTime.UtcNow + orderby token.Id + select token).Take(1_000).ToListAsync(cancellationToken); + + if (tokens.Count is not 0) + { + Context.RemoveRange(tokens); + + try + { + await Context.SaveChangesAsync(cancellationToken); + transaction?.Commit(); + } + + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + exceptions ??= []; + exceptions.Add(exception); + } + } + + return tokens.Count; + }); + + if (count is 0) + { + break; + } + + result += count; + } } - // Note: to avoid sending too many queries, the maximum number of elements - // that can be removed by a single call to PruneAsync() is deliberately limited. - for (var index = 0; index < 1_000; index++) + if (exceptions is not null) { - cancellationToken.ThrowIfCancellationRequested(); + throw new AggregateException(SR.GetResourceString(SR.ID0249), exceptions); + } - // To prevent concurrency exceptions from being thrown if an entry is modified - // after it was retrieved from the database, the following logic is executed in - // a repeatable read transaction, that will put a lock on the retrieved entries - // and thus prevent them from being concurrently modified outside this block. - using var transaction = await CreateTransactionAsync(); - - // Note: the Oracle MySQL provider doesn't support DateTimeOffset and is unable - // to create a SQL query with an expression calling DateTimeOffset.UtcDateTime. - // To work around this limitation, the threshold represented as a DateTimeOffset - // instance is manually converted to a UTC DateTime instance outside the query. - var date = threshold.UtcDateTime; - - var tokens = await - (from token in Tokens.AsTracking() - where token.CreationDate < date - where (token.Status != Statuses.Inactive && token.Status != Statuses.Valid) || - (token.Authorization != null && token.Authorization.Status != Statuses.Valid) || - token.ExpirationDate < DateTime.UtcNow - orderby token.Id - select token).Take(1_000).ToListAsync(cancellationToken); - - if (tokens.Count is 0) - { - break; - } + return result; + } + + /// + public virtual async ValueTask RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0195), nameof(identifier)); + } + + var key = ConvertIdentifierFromString(identifier); - Context.RemoveRange(tokens); +#if SUPPORTS_BULK_DBSET_OPERATIONS + if (!Options.CurrentValue.DisableBulkOperations) + { + return await ( + from token in Tokens + where token.Authorization!.Id!.Equals(key) + select token).ExecuteUpdateAsync(entity => entity.SetProperty( + token => token.Status, Statuses.Revoked), cancellationToken); + + // Note: calling DbContext.SaveChangesAsync() is not necessary + // with bulk update operations as they are executed immediately. + } +#endif + List? exceptions = null; + + var result = 0L; + + foreach (var token in await (from token in Tokens + where token.Authorization!.Id!.Equals(key) + select token).ToListAsync(cancellationToken)) + { + token.Status = Statuses.Revoked; try { await Context.SaveChangesAsync(cancellationToken); - transaction?.Commit(); } catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) { + // Reset the state of the entity to prevents future calls to SaveChangesAsync() from failing. + Context.Entry(token).State = EntityState.Unchanged; + exceptions ??= []; exceptions.Add(exception); + + continue; } + + result++; } if (exceptions is not null) { throw new AggregateException(SR.GetResourceString(SR.ID0249), exceptions); } + + return result; } /// diff --git a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs index bd6bb46f..233d1804 100644 --- a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs +++ b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs @@ -10,6 +10,7 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.Extensions.Options; +using OpenIddict.Extensions; using OpenIddict.MongoDb.Models; using static OpenIddict.Abstractions.OpenIddictExceptions; @@ -518,11 +519,13 @@ public class OpenIddictMongoDbAuthorizationStore : IOpenIddictAu } /// - public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) { var database = await Context.GetDatabaseAsync(cancellationToken); var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + var result = 0L; + // Note: directly deleting the resulting set of an aggregate query is not supported by MongoDB. // To work around this limitation, the authorization identifiers are stored in an intermediate // list and delete requests are sent to remove the documents corresponding to these identifiers. @@ -538,33 +541,12 @@ public class OpenIddictMongoDbAuthorizationStore : IOpenIddictAu // Note: to avoid generating delete requests with very large filters, a buffer is used here and the // maximum number of elements that can be removed by a single call to PruneAsync() is deliberately limited. - foreach (var buffer in Buffer(identifiers.Take(1_000_000), 1_000)) + foreach (var buffer in identifiers.Take(1_000_000).Buffer(1_000)) { - await collection.DeleteManyAsync(authorization => buffer.Contains(authorization.Id), cancellationToken); + result += (await collection.DeleteManyAsync(authorization => buffer.Contains(authorization.Id), cancellationToken)).DeletedCount; } - static IEnumerable> Buffer(IEnumerable source, int count) - { - List? buffer = null; - - foreach (var element in source) - { - buffer ??= []; - buffer.Add(element); - - if (buffer.Count == count) - { - yield return buffer; - - buffer = null; - } - } - - if (buffer is not null) - { - yield return buffer; - } - } + return result; } /// diff --git a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs index 6310090a..70858b3b 100644 --- a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs +++ b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs @@ -10,6 +10,7 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using Microsoft.Extensions.Options; +using OpenIddict.Extensions; using OpenIddict.MongoDb.Models; using static OpenIddict.Abstractions.OpenIddictExceptions; @@ -554,11 +555,13 @@ public class OpenIddictMongoDbTokenStore : IOpenIddictTokenStore } /// - public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) { var database = await Context.GetDatabaseAsync(cancellationToken); var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + var result = 0L; + // Note: directly deleting the resulting set of an aggregate query is not supported by MongoDB. // To work around this limitation, the token identifiers are stored in an intermediate list // and delete requests are sent to remove the documents corresponding to these identifiers. @@ -575,33 +578,30 @@ public class OpenIddictMongoDbTokenStore : IOpenIddictTokenStore // Note: to avoid generating delete requests with very large filters, a buffer is used here and the // maximum number of elements that can be removed by a single call to PruneAsync() is deliberately limited. - foreach (var buffer in Buffer(identifiers.Take(1_000_000), 1_000)) + foreach (var buffer in identifiers.Take(1_000_000).Buffer(1_000)) { - await collection.DeleteManyAsync(token => buffer.Contains(token.Id), cancellationToken); + result += (await collection.DeleteManyAsync(token => buffer.Contains(token.Id), cancellationToken)).DeletedCount; } - static IEnumerable> Buffer(IEnumerable source, int count) - { - List? buffer = null; - - foreach (var element in source) - { - buffer ??= []; - buffer.Add(element); + return result; + } - if (buffer.Count == count) - { - yield return buffer; + /// + public virtual async ValueTask RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0195), nameof(identifier)); + } - buffer = null; - } - } + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); - if (buffer is not null) - { - yield return buffer; - } - } + return (await collection.UpdateManyAsync( + filter : token => token.AuthorizationId == ObjectId.Parse(identifier), + update : Builders.Update.Set(token => token.Status, Statuses.Revoked), + options : null, + cancellationToken: cancellationToken)).MatchedCount; } /// diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs index f7863f76..350eed97 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs @@ -962,7 +962,8 @@ public static partial class OpenIddictServerHandlers throw new InvalidOperationException(SR.GetResourceString(SR.ID0021)); // If the token is already marked as redeemed, this may indicate that it was compromised. - // In this case, revoke the entire chain of tokens associated with the authorization. + // In this case, revoke the entire chain of tokens associated with the authorization, if one was attached to the token. + // // Special logic is used to avoid revoking refresh tokens already marked as redeemed to allow for a small leeway. // Note: the authorization itself is not revoked to allow the legitimate client to start a new flow. // See https://tools.ietf.org/html/rfc6749#section-10.5 for more information. @@ -970,6 +971,26 @@ public static partial class OpenIddictServerHandlers { if (!context.Principal.HasTokenType(TokenTypeHints.RefreshToken) || !await IsReusableAsync(token)) { + if (!string.IsNullOrEmpty(context.AuthorizationId)) + { + long? count = null; + + try + { + count = await _tokenManager.RevokeByAuthorizationIdAsync(context.AuthorizationId); + } + + catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception)) + { + context.Logger.LogWarning(exception, SR.GetResourceString(SR.ID6229), context.AuthorizationId); + } + + if (count is not null) + { + context.Logger.LogWarning(SR.GetResourceString(SR.ID6228), count, context.AuthorizationId); + } + } + context.Logger.LogInformation(SR.GetResourceString(SR.ID6002), context.TokenId); context.Reject( @@ -996,9 +1017,6 @@ public static partial class OpenIddictServerHandlers _ => SR.FormatID8000(SR.ID2013) }); - // Revoke all the token entries associated with the authorization. - await TryRevokeChainAsync(context.AuthorizationId); - return; } @@ -1079,34 +1097,6 @@ public static partial class OpenIddictServerHandlers return false; } - - async ValueTask TryRevokeChainAsync(string? identifier) - { - if (string.IsNullOrEmpty(identifier)) - { - return; - } - - // Revoke all the token entries associated with the authorization, - // including the redeemed token that was used in the token request. - - // Note: the tokens are deliberately buffered before being marked - // as revoked to prevent issues with providers that try to reuse the - // connection opened to iterate the tokens instead of opening a new one. - // - // See https://github.com/openiddict/openiddict-core/issues/1658 for more information. - List tokens = new(capacity: 1); - - await foreach (var token in _tokenManager.FindByAuthorizationIdAsync(identifier)) - { - tokens.Add(token); - } - - for (var index = 0; index < tokens.Count; index++) - { - await _tokenManager.TryRevokeAsync(tokens[index]); - } - } } } diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs index 3e5ede62..e6ca8ca9 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs @@ -2908,9 +2908,7 @@ public abstract partial class OpenIddictServerIntegrationTests Mock.Get(manager).Verify(manager => manager.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.AtLeastOnce()); Mock.Get(manager).Verify(manager => manager.HasStatusAsync(tokens[0], Statuses.Redeemed, It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[0], It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[1], It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[2], It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.RevokeByAuthorizationIdAsync("18D15F73-BE2B-6867-DC01-B3C1E8AFDED0", It.IsAny()), Times.Once()); } [Fact] @@ -3001,9 +2999,7 @@ public abstract partial class OpenIddictServerIntegrationTests Mock.Get(manager).Verify(manager => manager.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny()), Times.AtLeastOnce()); Mock.Get(manager).Verify(manager => manager.HasStatusAsync(tokens[0], Statuses.Redeemed, It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[0], It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[1], It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[2], It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.RevokeByAuthorizationIdAsync("18D15F73-BE2B-6867-DC01-B3C1E8AFDED0", It.IsAny()), Times.Once()); } [Fact] @@ -3094,9 +3090,7 @@ public abstract partial class OpenIddictServerIntegrationTests Mock.Get(manager).Verify(manager => manager.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny()), Times.AtLeastOnce()); Mock.Get(manager).Verify(manager => manager.HasStatusAsync(tokens[0], Statuses.Redeemed, It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[0], It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[1], It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[2], It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.RevokeByAuthorizationIdAsync("18D15F73-BE2B-6867-DC01-B3C1E8AFDED0", It.IsAny()), Times.Once()); } [Fact]