Browse Source

Introduce a faster way to revoke all the tokens associated with an authorization and use bulk operations when available

pull/1920/head 5.0.0-preview3
Kévin Chalet 2 years ago
committed by GitHub
parent
commit
61f036fd59
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      Directory.Build.targets
  2. 30
      shared/OpenIddict.Extensions/OpenIddictHelpers.cs
  3. 6
      src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs
  4. 14
      src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs
  5. 6
      src/OpenIddict.Abstractions/OpenIddictResources.resx
  6. 4
      src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs
  7. 12
      src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs
  8. 8
      src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs
  9. 29
      src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs
  10. 10
      src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs
  11. 57
      src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs
  12. 10
      src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs
  13. 39
      src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreHelpers.cs
  14. 8
      src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreOptions.cs
  15. 182
      src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs
  16. 270
      src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs
  17. 179
      src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs
  18. 32
      src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbAuthorizationStore.cs
  19. 44
      src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbTokenStore.cs
  20. 54
      src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs
  21. 12
      test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs

1
Directory.Build.targets

@ -107,6 +107,7 @@
<PropertyGroup
Condition=" ('$(TargetFrameworkIdentifier)' == '.NETCoreApp' And $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '7.0'))) ">
<DefineConstants>$(DefineConstants);SUPPORTS_AUTHENTICATION_HANDLER_SELECTION_FALLBACK</DefineConstants>
<DefineConstants>$(DefineConstants);SUPPORTS_BULK_DBSET_OPERATIONS</DefineConstants>
</PropertyGroup>
<PropertyGroup

30
shared/OpenIddict.Extensions/OpenIddictHelpers.cs

@ -14,6 +14,36 @@ namespace OpenIddict.Extensions;
/// </summary>
internal static class OpenIddictHelpers
{
/// <summary>
/// Generates a sequence of non-overlapping adjacent buffers over the source sequence.
/// </summary>
/// <typeparam name="TSource">The source sequence element type.</typeparam>
/// <param name="source">The source sequence.</param>
/// <param name="count">The number of elements for allocated buffers.</param>
/// <returns>A sequence of buffers containing source sequence elements.</returns>
public static IEnumerable<List<TSource>> Buffer<TSource>(this IEnumerable<TSource> source, int count)
{
List<TSource>? 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;
}
}
/// <summary>
/// Finds the first base type that matches the specified generic type definition.
/// </summary>

6
src/OpenIddict.Abstractions/Managers/IOpenIddictAuthorizationManager.cs

@ -391,10 +391,8 @@ public interface IOpenIddictAuthorizationManager
/// </remarks>
/// <param name="threshold">The date before which authorizations are not pruned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="ValueTask"/> that can be used to monitor the asynchronous operation.
/// </returns>
ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default);
/// <returns>The number of authorizations that were removed.</returns>
ValueTask<long> PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default);
/// <summary>
/// Tries to revoke an authorization.

14
src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs

@ -406,10 +406,16 @@ public interface IOpenIddictTokenManager
/// </summary>
/// <param name="threshold">The date before which tokens are not pruned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="ValueTask"/> that can be used to monitor the asynchronous operation.
/// </returns>
ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default);
/// <returns>The number of tokens that were removed.</returns>
ValueTask<long> PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default);
/// <summary>
/// Revokes all the tokens associated with the specified authorization identifier.
/// </summary>
/// <param name="identifier">The authorization identifier associated with the tokens.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>The number of tokens associated with the specified authorization that were marked as revoked.</returns>
ValueTask<long> RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken = default);
/// <summary>
/// Tries to redeem a token.

6
src/OpenIddict.Abstractions/OpenIddictResources.resx

@ -2739,6 +2739,12 @@ This may indicate that the hashed entry is corrupted or malformed.</value>
<data name="ID6227" xml:space="preserve">
<value>The request was rejected because the '{Method}' client authentication method that was used by the client application is not enabled in the server options.</value>
</data>
<data name="ID6228" xml:space="preserve">
<value>{Count} tokens associated with the authorization '{Identifier}' were revoked to prevent a potential token replay attack.</value>
</data>
<data name="ID6229" xml:space="preserve">
<value>An error occurred while trying to revoke the tokens associated with the authorization '{Identifier}'.</value>
</data>
<data name="ID8000" xml:space="preserve">
<value>https://documentation.openiddict.com/errors/{0}</value>
</data>

4
src/OpenIddict.Abstractions/Stores/IOpenIddictAuthorizationStore.cs

@ -276,8 +276,8 @@ public interface IOpenIddictAuthorizationStore<TAuthorization> where TAuthorizat
/// </remarks>
/// <param name="threshold">The date before which authorizations are not pruned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>A <see cref="ValueTask"/> that can be used to monitor the asynchronous operation.</returns>
ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
/// <returns>The number of authorizations that were removed.</returns>
ValueTask<long> PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
/// <summary>
/// Sets the application identifier associated with an authorization.

12
src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs

@ -323,8 +323,16 @@ public interface IOpenIddictTokenStore<TToken> where TToken : class
/// </summary>
/// <param name="threshold">The date before which tokens are not pruned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>A <see cref="ValueTask"/> that can be used to monitor the asynchronous operation.</returns>
ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
/// <returns>The number of tokens that were removed.</returns>
ValueTask<long> PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken);
/// <summary>
/// Revokes all the tokens associated with the specified authorization identifier.
/// </summary>
/// <param name="identifier">The authorization identifier associated with the tokens.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>The number of tokens associated with the specified authorization that were marked as revoked.</returns>
ValueTask<long> RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken);
/// <summary>
/// Sets the application identifier associated with a token.

8
src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs

@ -1020,10 +1020,8 @@ public class OpenIddictAuthorizationManager<TAuthorization> : IOpenIddictAuthori
/// </remarks>
/// <param name="threshold">The date before which authorizations are not pruned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="ValueTask"/> that can be used to monitor the asynchronous operation.
/// </returns>
public virtual ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default)
/// <returns>The number of authorizations that were removed.</returns>
public virtual ValueTask<long> PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default)
=> Store.PruneAsync(threshold, cancellationToken);
/// <summary>
@ -1332,7 +1330,7 @@ public class OpenIddictAuthorizationManager<TAuthorization> : IOpenIddictAuthori
=> PopulateAsync((TAuthorization) authorization, descriptor, cancellationToken);
/// <inheritdoc/>
ValueTask IOpenIddictAuthorizationManager.PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
ValueTask<long> IOpenIddictAuthorizationManager.PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
=> PruneAsync(threshold, cancellationToken);
/// <inheritdoc/>

29
src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs

@ -1051,11 +1051,26 @@ public class OpenIddictTokenManager<TToken> : IOpenIddictTokenManager where TTok
/// </summary>
/// <param name="threshold">The date before which tokens are not pruned.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="ValueTask"/> that can be used to monitor the asynchronous operation.
/// </returns>
public virtual ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default)
/// <returns>The number of tokens that were removed.</returns>
public virtual ValueTask<long> PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken = default)
=> Store.PruneAsync(threshold, cancellationToken);
/// <summary>
/// Revokes all the tokens associated with the specified authorization identifier.
/// </summary>
/// <param name="identifier">The authorization identifier associated with the tokens.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>The number of tokens associated with the specified authorization that were marked as revoked.</returns>
public virtual ValueTask<long> RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(identifier))
{
throw new ArgumentException(SR.GetResourceString(SR.ID0195), nameof(identifier));
}
return Store.RevokeByAuthorizationIdAsync(identifier, cancellationToken);
}
/// <summary>
/// Tries to redeem a token.
/// </summary>
@ -1479,9 +1494,13 @@ public class OpenIddictTokenManager<TToken> : IOpenIddictTokenManager where TTok
=> PopulateAsync((TToken) token, descriptor, cancellationToken);
/// <inheritdoc/>
ValueTask IOpenIddictTokenManager.PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
ValueTask<long> IOpenIddictTokenManager.PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
=> PruneAsync(threshold, cancellationToken);
/// <inheritdoc/>
ValueTask<long> IOpenIddictTokenManager.RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken)
=> RevokeByAuthorizationIdAsync(identifier, cancellationToken);
/// <inheritdoc/>
ValueTask<bool> IOpenIddictTokenManager.TryRedeemAsync(object token, CancellationToken cancellationToken)
=> TryRedeemAsync((TToken) token, cancellationToken);

10
src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkAuthorizationStore.cs

@ -591,7 +591,7 @@ public class OpenIddictEntityFrameworkAuthorizationStore<TAuthorization, TApplic
}
/// <inheritdoc/>
public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public virtual async ValueTask<long> 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<TAuthorization, TApplic
List<Exception>? 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<TAuthorization, TApplic
{
exceptions ??= [];
exceptions.Add(exception);
continue;
}
result += authorizations.Count;
}
if (exceptions is not null)
{
throw new AggregateException(SR.GetResourceString(SR.ID0243), exceptions);
}
return result;
}
/// <inheritdoc/>

57
src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkTokenStore.cs

@ -574,7 +574,7 @@ public class OpenIddictEntityFrameworkTokenStore<TToken, TApplication, TAuthoriz
}
/// <inheritdoc/>
public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public virtual async ValueTask<long> 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<TToken, TApplication, TAuthoriz
List<Exception>? 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<TToken, TApplication, TAuthoriz
{
exceptions ??= [];
exceptions.Add(exception);
continue;
}
result += tokens.Count;
}
if (exceptions is not null)
{
throw new AggregateException(SR.GetResourceString(SR.ID0249), exceptions);
}
return result;
}
/// <inheritdoc/>
public virtual async ValueTask<long> RevokeByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(identifier))
{
throw new ArgumentException(SR.GetResourceString(SR.ID0195), nameof(identifier));
}
var key = ConvertIdentifierFromString(identifier);
List<Exception>? 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;
}
/// <inheritdoc/>

10
src/OpenIddict.EntityFrameworkCore/OpenIddictEntityFrameworkCoreBuilder.cs

@ -47,6 +47,16 @@ public sealed class OpenIddictEntityFrameworkCoreBuilder
return this;
}
/// <summary>
/// Prevents the Entity Framework Core stores from using bulk operations.
/// </summary>
/// <remarks>
/// Note: bulk operations are only supported when targeting .NET 7.0 and higher.
/// </remarks>
/// <returns>The <see cref="OpenIddictEntityFrameworkCoreBuilder"/> instance.</returns>
public OpenIddictEntityFrameworkCoreBuilder DisableBulkOperations()
=> Configure(options => options.DisableBulkOperations = true);
/// <summary>
/// Configures OpenIddict to use the default OpenIddict
/// Entity Framework Core entities, with the specified key type.

39
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
}
}
/// <summary>
/// Tries to create a new <see cref="IDbContextTransaction"/> with the specified <paramref name="level"/>.
/// </summary>
/// <param name="context">The Entity Framework Core context.</param>
/// <param name="level">The desired level of isolation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>The <see cref="IDbContextTransaction"/> if it could be created, <see langword="null"/> otherwise.</returns>
internal static async ValueTask<IDbContextTransaction?> 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<IDbContextTransactionManager>();
if (manager is IRelationalTransactionManager)
{
try
{
return await context.Database.BeginTransactionAsync(level, cancellationToken);
}
catch (Exception exception) when (!OpenIddictHelpers.IsFatal(exception))
{
return null;
}
}
return null;
}
}

8
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.
/// </summary>
public Type? DbContextType { get; set; }
/// <summary>
/// Gets or sets a boolean indicating whether bulk operations should be disabled.
/// </summary>
/// <remarks>
/// Note: bulk operations are only supported when targeting .NET 7.0 and higher.
/// </remarks>
public bool DisableBulkOperations { get; set; }
}

182
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<TApplication, TAuthor
throw new ArgumentNullException(nameof(application));
}
async ValueTask<IDbContextTransaction?> 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<IDbContextTransactionManager>();
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<List<TAuthorization>> 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<List<TToken>> 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<List<TAuthorization>> 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<List<TToken>> 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);
}
});
}
}

270
src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreAuthorizationStore.cs

@ -150,71 +150,93 @@ public class OpenIddictEntityFrameworkCoreAuthorizationStore<TAuthorization, TAp
throw new ArgumentNullException(nameof(authorization));
}
async ValueTask<IDbContextTransaction?> 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<IDbContextTransactionManager>();
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<List<TToken>> 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<List<TToken>> 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<TAuthorization, TAp
}
/// <inheritdoc/>
public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public virtual async ValueTask<long> 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<Exception>? exceptions = null;
async ValueTask<IDbContextTransaction?> 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<IDbContextTransactionManager>();
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<TAuthorization, TAp
{
cancellationToken.ThrowIfCancellationRequested();
// 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 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 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<Exception>(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<TAuthorization, TAp
{
throw new AggregateException(SR.GetResourceString(SR.ID0243), exceptions);
}
return result;
}
/// <inheritdoc/>

179
src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreTokenStore.cs

@ -624,91 +624,172 @@ public class OpenIddictEntityFrameworkCoreTokenStore<TToken, TApplication, TAuth
}
/// <inheritdoc/>
public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public virtual async ValueTask<long> 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<Exception>? exceptions = null;
async ValueTask<IDbContextTransaction?> 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<IDbContextTransactionManager>();
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<Exception>(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;
}
/// <inheritdoc/>
public virtual async ValueTask<long> 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<Exception>? 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;
}
/// <inheritdoc/>

32
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<TAuthorization> : IOpenIddictAu
}
/// <inheritdoc/>
public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public virtual async ValueTask<long> PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
{
var database = await Context.GetDatabaseAsync(cancellationToken);
var collection = database.GetCollection<TAuthorization>(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<TAuthorization> : 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<List<TSource>> Buffer<TSource>(IEnumerable<TSource> source, int count)
{
List<TSource>? 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;
}
/// <inheritdoc/>

44
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<TToken> : IOpenIddictTokenStore<TToken>
}
/// <inheritdoc/>
public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
public virtual async ValueTask<long> PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken)
{
var database = await Context.GetDatabaseAsync(cancellationToken);
var collection = database.GetCollection<TToken>(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<TToken> : IOpenIddictTokenStore<TToken>
// 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<List<TSource>> Buffer<TSource>(IEnumerable<TSource> source, int count)
{
List<TSource>? buffer = null;
foreach (var element in source)
{
buffer ??= [];
buffer.Add(element);
return result;
}
if (buffer.Count == count)
{
yield return buffer;
/// <inheritdoc/>
public virtual async ValueTask<long> 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<TToken>(Options.CurrentValue.TokensCollectionName);
if (buffer is not null)
{
yield return buffer;
}
}
return (await collection.UpdateManyAsync(
filter : token => token.AuthorizationId == ObjectId.Parse(identifier),
update : Builders<TToken>.Update.Set(token => token.Status, Statuses.Revoked),
options : null,
cancellationToken: cancellationToken)).MatchedCount;
}
/// <inheritdoc/>

54
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<object> 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]);
}
}
}
}

12
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<CancellationToken>()), Times.AtLeastOnce());
Mock.Get(manager).Verify(manager => manager.HasStatusAsync(tokens[0], Statuses.Redeemed, It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[0], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[1], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[2], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.RevokeByAuthorizationIdAsync("18D15F73-BE2B-6867-DC01-B3C1E8AFDED0", It.IsAny<CancellationToken>()), 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<CancellationToken>()), Times.AtLeastOnce());
Mock.Get(manager).Verify(manager => manager.HasStatusAsync(tokens[0], Statuses.Redeemed, It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[0], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[1], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[2], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.RevokeByAuthorizationIdAsync("18D15F73-BE2B-6867-DC01-B3C1E8AFDED0", It.IsAny<CancellationToken>()), 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<CancellationToken>()), Times.AtLeastOnce());
Mock.Get(manager).Verify(manager => manager.HasStatusAsync(tokens[0], Statuses.Redeemed, It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[0], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[1], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.TryRevokeAsync(tokens[2], It.IsAny<CancellationToken>()), Times.Once());
Mock.Get(manager).Verify(manager => manager.RevokeByAuthorizationIdAsync("18D15F73-BE2B-6867-DC01-B3C1E8AFDED0", It.IsAny<CancellationToken>()), Times.Once());
}
[Fact]

Loading…
Cancel
Save