Skip to content

Commit

Permalink
Introduce a faster way to revoke all the tokens associated with an au…
Browse files Browse the repository at this point in the history
…thorization and use bulk operations when available
  • Loading branch information
kevinchalet authored Dec 1, 2023
1 parent 1427821 commit 61f036f
Show file tree
Hide file tree
Showing 21 changed files with 655 additions and 352 deletions.
1 change: 1 addition & 0 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions shared/OpenIddict.Extensions/OpenIddictHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -391,10 +391,8 @@ IAsyncEnumerable<TResult> ListAsync<TState, TResult>(
/// </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.
Expand Down
14 changes: 10 additions & 4 deletions src/OpenIddict.Abstractions/Managers/IOpenIddictTokenManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,16 @@ IAsyncEnumerable<TResult> ListAsync<TState, TResult>(
/// </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.
Expand Down
6 changes: 6 additions & 0 deletions src/OpenIddict.Abstractions/OpenIddictResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,8 @@ IAsyncEnumerable<TResult> ListAsync<TState, TResult>(
/// </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.
Expand Down
12 changes: 10 additions & 2 deletions src/OpenIddict.Abstractions/Stores/IOpenIddictTokenStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,16 @@ IAsyncEnumerable<TResult> ListAsync<TState, TResult>(
/// </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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1020,10 +1020,8 @@ public virtual async ValueTask PopulateAsync(
/// </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>
Expand Down Expand Up @@ -1332,7 +1330,7 @@ ValueTask IOpenIddictAuthorizationManager.PopulateAsync(object authorization, Op
=> 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/>
Expand Down
29 changes: 24 additions & 5 deletions src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,11 +1051,26 @@ public virtual async ValueTask PopulateAsync(
/// </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>
Expand Down Expand Up @@ -1479,9 +1494,13 @@ ValueTask IOpenIddictTokenManager.PopulateAsync(object token, OpenIddictTokenDes
=> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -591,14 +591,16 @@ public virtual IAsyncEnumerable<TResult> ListAsync<TState, TResult>(
}

/// <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.
// To work around this limitation, entities are manually listed and deleted using a batch logic.

List<Exception>? exceptions = null;

var result = 0L;

DbContextTransaction? CreateTransaction()
{
// Note: relational providers like Sqlite are known to lack proper support
Expand Down Expand Up @@ -662,13 +664,19 @@ orderby authorization.Id
{
exceptions ??= [];
exceptions.Add(exception);

continue;
}

result += authorizations.Count;
}

if (exceptions is not null)
{
throw new AggregateException(SR.GetResourceString(SR.ID0243), exceptions);
}

return result;
}

/// <inheritdoc/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,14 +574,16 @@ public virtual IAsyncEnumerable<TResult> ListAsync<TState, TResult>(
}

/// <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.
// To work around this limitation, entities are manually listed and deleted using a batch logic.

List<Exception>? exceptions = null;

var result = 0L;

DbContextTransaction? CreateTransaction()
{
// Note: relational providers like Sqlite are known to lack proper support
Expand Down Expand Up @@ -642,13 +644,66 @@ orderby token.Id
{
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/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ public OpenIddictEntityFrameworkCoreBuilder Configure(Action<OpenIddictEntityFra
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -224,4 +227,40 @@ static async IAsyncEnumerable<T> ExecuteAsync(IQueryable<T> source, [EnumeratorC
#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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Loading

0 comments on commit 61f036f

Please sign in to comment.