Browse Source

Backport the authorizations/tokens pruning changes to OpenIddict 1.x

pull/670/head
Kévin Chalet 8 years ago
parent
commit
aa6dbef6b5
  1. 64
      src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs
  2. 64
      src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs
  3. 10
      src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs
  4. 10
      src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs
  5. 37
      src/OpenIddict.Core/Stores/OpenIddictAuthorizationStore.cs
  6. 36
      src/OpenIddict.Core/Stores/OpenIddictTokenStore.cs
  7. 83
      src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs
  8. 82
      src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs
  9. 95
      src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs
  10. 94
      src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs

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

@ -699,23 +699,6 @@ namespace OpenIddict.Core
return Store.ListAsync(query, state, cancellationToken);
}
/// <summary>
/// Lists the ad-hoc authorizations that are marked as invalid or have no
/// valid token attached and that can be safely removed from the database.
/// </summary>
/// <param name="count">The number of results to return.</param>
/// <param name="offset">The number of results to skip.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
/// </returns>
public virtual Task<ImmutableArray<TAuthorization>> ListInvalidAsync(
[CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken = default)
{
return Store.ListInvalidAsync(count, offset, cancellationToken);
}
/// <summary>
/// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached.
/// </summary>
@ -723,51 +706,8 @@ namespace OpenIddict.Core
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
public virtual async Task PruneInvalidAsync(CancellationToken cancellationToken = default)
{
IList<Exception> exceptions = null;
var authorizations = new List<TAuthorization>();
// First, start retrieving the invalid authorizations from the database.
for (var offset = 0; offset < 10_000; offset = offset + 100)
{
cancellationToken.ThrowIfCancellationRequested();
var results = await ListInvalidAsync(100, offset, cancellationToken);
if (results.IsEmpty)
{
break;
}
authorizations.AddRange(results);
}
// Then, remove the invalid authorizations one by one.
foreach (var authorization in authorizations)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await DeleteAsync(authorization, cancellationToken);
}
catch (Exception exception)
{
if (exceptions == null)
{
exceptions = new List<Exception>(capacity: 1);
}
exceptions.Add(exception);
}
}
if (exceptions != null)
{
throw new AggregateException("An error occurred while pruning authorizations.", exceptions);
}
}
public virtual Task PruneAsync(CancellationToken cancellationToken = default)
=> Store.PruneAsync(cancellationToken);
/// <summary>
/// Revokes an authorization.

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

@ -589,23 +589,6 @@ namespace OpenIddict.Core
return Store.ListAsync(query, state, cancellationToken);
}
/// <summary>
/// Lists the tokens that are marked as expired or invalid
/// and that can be safely removed from the database.
/// </summary>
/// <param name="count">The number of results to return.</param>
/// <param name="offset">The number of results to skip.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
/// </returns>
public virtual Task<ImmutableArray<TToken>> ListInvalidAsync(
[CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken = default)
{
return Store.ListInvalidAsync(count, offset, cancellationToken);
}
/// <summary>
/// Obfuscates the specified reference identifier so it can be safely stored in a database.
/// By default, this method returns a simple hashed representation computed using SHA256.
@ -637,51 +620,8 @@ namespace OpenIddict.Core
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
public virtual async Task PruneInvalidAsync(CancellationToken cancellationToken = default)
{
IList<Exception> exceptions = null;
var tokens = new List<TToken>();
// First, start retrieving the invalid tokens from the database.
for (var offset = 0; offset < 10_000; offset = offset + 100)
{
cancellationToken.ThrowIfCancellationRequested();
var results = await ListInvalidAsync(100, offset, cancellationToken);
if (results.IsEmpty)
{
break;
}
tokens.AddRange(results);
}
// Then, remove the invalid tokens one by one.
foreach (var token in tokens)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await DeleteAsync(token, cancellationToken);
}
catch (Exception exception)
{
if (exceptions == null)
{
exceptions = new List<Exception>(capacity: 1);
}
exceptions.Add(exception);
}
}
if (exceptions != null)
{
throw new AggregateException("An error occurred while pruning tokens.", exceptions);
}
}
public virtual Task PruneAsync(CancellationToken cancellationToken = default)
=> Store.PruneAsync(cancellationToken);
/// <summary>
/// Redeems a token.

10
src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs

@ -261,17 +261,13 @@ namespace OpenIddict.Core
[CanBeNull] TState state, CancellationToken cancellationToken);
/// <summary>
/// Lists the ad-hoc authorizations that are marked as invalid or have no
/// valid token attached and that can be safely removed from the database.
/// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached.
/// </summary>
/// <param name="count">The number of results to return.</param>
/// <param name="offset">The number of results to skip.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
Task<ImmutableArray<TAuthorization>> ListInvalidAsync([CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken);
Task PruneAsync(CancellationToken cancellationToken);
/// <summary>
/// Sets the application identifier associated with an authorization.

10
src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs

@ -294,17 +294,13 @@ namespace OpenIddict.Core
[CanBeNull] TState state, CancellationToken cancellationToken);
/// <summary>
/// Lists the tokens that are marked as expired or invalid
/// and that can be safely removed from the database.
/// Removes the tokens that are marked as expired or invalid.
/// </summary>
/// <param name="count">The number of results to return.</param>
/// <param name="offset">The number of results to skip.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
Task<ImmutableArray<TToken>> ListInvalidAsync([CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken);
Task PruneAsync(CancellationToken cancellationToken);
/// <summary>
/// Sets the application identifier associated with a token.

37
src/OpenIddict.Core/Stores/OpenIddictAuthorizationStore.cs

@ -499,44 +499,13 @@ namespace OpenIddict.Core
[CanBeNull] TState state, CancellationToken cancellationToken);
/// <summary>
/// Lists the ad-hoc authorizations that are marked as invalid or have no
/// valid token attached and that can be safely removed from the database.
/// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached.
/// </summary>
/// <param name="count">The number of results to return.</param>
/// <param name="offset">The number of results to skip.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
public virtual Task<ImmutableArray<TAuthorization>> ListInvalidAsync([CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken)
{
IQueryable<TAuthorization> Query(IQueryable<TAuthorization> authorizations, int? skip, int? take)
{
var query = (from authorization in authorizations
where authorization.Status != OpenIddictConstants.Statuses.Valid ||
(authorization.Type == OpenIddictConstants.AuthorizationTypes.AdHoc &&
!authorization.Tokens.Any(token => token.Status == OpenIddictConstants.Statuses.Valid))
orderby authorization.Id
select authorization).AsQueryable();
if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}
return query;
}
return ListAsync(
(authorizations, state) => Query(authorizations, state.Key, state.Value),
new KeyValuePair<int?, int?>(offset, count), cancellationToken);
}
public abstract Task PruneAsync(CancellationToken cancellationToken);
/// <summary>
/// Sets the application identifier associated with an authorization.

36
src/OpenIddict.Core/Stores/OpenIddictTokenStore.cs

@ -529,43 +529,13 @@ namespace OpenIddict.Core
[CanBeNull] TState state, CancellationToken cancellationToken);
/// <summary>
/// Lists the tokens that are marked as expired or invalid
/// and that can be safely removed from the database.
/// Removes the tokens that are marked as expired or invalid.
/// </summary>
/// <param name="count">The number of results to return.</param>
/// <param name="offset">The number of results to skip.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
public virtual Task<ImmutableArray<TToken>> ListInvalidAsync([CanBeNull] int? count, [CanBeNull] int? offset, CancellationToken cancellationToken)
{
IQueryable<TToken> Query(IQueryable<TToken> tokens, int? skip, int? take)
{
var query = (from token in tokens
where token.ExpirationDate < DateTimeOffset.UtcNow ||
token.Status != OpenIddictConstants.Statuses.Valid
orderby token.Id
select token).AsQueryable();
if (skip.HasValue)
{
query = query.Skip(skip.Value);
}
if (take.HasValue)
{
query = query.Take(take.Value);
}
return query;
}
return ListAsync(
(tokens, state) => Query(tokens, state.Key, state.Value),
new KeyValuePair<int?, int?>(offset, count), cancellationToken);
}
public abstract Task PruneAsync(CancellationToken cancellationToken);
/// <summary>
/// Sets the authorization identifier associated with a token.

83
src/OpenIddict.EntityFramework/Stores/OpenIddictAuthorizationStore.cs

@ -7,6 +7,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading;
@ -291,6 +292,88 @@ namespace OpenIddict.EntityFramework
Authorizations.Include(authorization => authorization.Application), state).ToListAsync(cancellationToken));
}
/// <summary>
/// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
public override async Task PruneAsync(CancellationToken cancellationToken = default)
{
// 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.
IList<Exception> exceptions = null;
IQueryable<TAuthorization> Query(IQueryable<TAuthorization> authorizations, int offset)
=> (from authorization in authorizations.Include(authorization => authorization.Tokens)
where authorization.Status != OpenIddictConstants.Statuses.Valid ||
(authorization.Type == OpenIddictConstants.AuthorizationTypes.AdHoc &&
!authorization.Tokens.Any(token => token.Status == OpenIddictConstants.Statuses.Valid))
orderby authorization.Id
select authorization).Skip(offset).Take(1_000);
DbContextTransaction CreateTransaction()
{
// 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 Context.Database.BeginTransaction(IsolationLevel.RepeatableRead);
}
catch
{
return null;
}
}
for (var offset = 0; offset < 100_000; offset = offset + 1_000)
{
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 = CreateTransaction())
{
var authorizations = await ListAsync((source, state) => Query(source, state), offset, cancellationToken);
if (authorizations.IsEmpty)
{
break;
}
Authorizations.RemoveRange(authorizations);
Tokens.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens));
try
{
await Context.SaveChangesAsync(cancellationToken);
transaction?.Commit();
}
catch (Exception exception)
{
if (exceptions == null)
{
exceptions = new List<Exception>(capacity: 1);
}
exceptions.Add(exception);
}
}
}
if (exceptions != null)
{
throw new AggregateException("An error occurred while pruning authorizations.", exceptions);
}
}
/// <summary>
/// Sets the application identifier associated with an authorization.
/// </summary>

82
src/OpenIddict.EntityFramework/Stores/OpenIddictTokenStore.cs

@ -5,7 +5,9 @@
*/
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading;
@ -316,6 +318,86 @@ namespace OpenIddict.EntityFramework
.Include(token => token.Authorization), state).ToListAsync(cancellationToken));
}
/// <summary>
/// Removes the tokens that are marked as expired or invalid.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
public override async Task PruneAsync(CancellationToken cancellationToken = default)
{
// 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.
IList<Exception> exceptions = null;
IQueryable<TToken> Query(IQueryable<TToken> tokens, int offset)
=> (from token in tokens
where token.ExpirationDate < DateTimeOffset.UtcNow ||
token.Status != OpenIddictConstants.Statuses.Valid
orderby token.Id
select token).Skip(offset).Take(1_000);
DbContextTransaction CreateTransaction()
{
// 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 Context.Database.BeginTransaction(IsolationLevel.RepeatableRead);
}
catch
{
return null;
}
}
for (var offset = 0; offset < 100_000; offset = offset + 1_000)
{
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 = CreateTransaction())
{
var tokens = await ListAsync((source, state) => Query(source, state), offset, cancellationToken);
if (tokens.IsEmpty)
{
break;
}
Tokens.RemoveRange(tokens);
try
{
await Context.SaveChangesAsync(cancellationToken);
transaction?.Commit();
}
catch (Exception exception)
{
if (exceptions == null)
{
exceptions = new List<Exception>(capacity: 1);
}
exceptions.Add(exception);
}
}
}
if (exceptions != null)
{
throw new AggregateException("An error occurred while pruning tokens.", exceptions);
}
}
/// <summary>
/// Sets the application identifier associated with a token.
/// </summary>

95
src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs

@ -7,11 +7,14 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Caching.Memory;
using OpenIddict.Core;
using OpenIddict.Models;
@ -405,6 +408,98 @@ namespace OpenIddict.EntityFrameworkCore
Authorizations.Include(authorization => authorization.Application), state).ToListAsync(cancellationToken));
}
/// <summary>
/// Removes the ad-hoc authorizations that are marked as invalid or have no valid token attached.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
public override async Task PruneAsync(CancellationToken cancellationToken = default)
{
// 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.
IList<Exception> exceptions = null;
IQueryable<TAuthorization> Query(IQueryable<TAuthorization> authorizations, int offset)
=> (from authorization in authorizations.Include(authorization => authorization.Tokens)
where authorization.Status != OpenIddictConstants.Statuses.Valid ||
(authorization.Type == OpenIddictConstants.AuthorizationTypes.AdHoc &&
!authorization.Tokens.Any(token => token.Status == OpenIddictConstants.Statuses.Valid))
orderby authorization.Id
select authorization).Skip(offset).Take(1_000);
async Task<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);
}
catch
{
return null;
}
}
return null;
}
for (var offset = 0; offset < 100_000; offset = offset + 1_000)
{
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())
{
var authorizations = await ListAsync((source, state) => Query(source, state), offset, cancellationToken);
if (authorizations.IsEmpty)
{
break;
}
Context.RemoveRange(authorizations);
Context.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens));
try
{
await Context.SaveChangesAsync(cancellationToken);
transaction?.Commit();
}
catch (Exception exception)
{
if (exceptions == null)
{
exceptions = new List<Exception>(capacity: 1);
}
exceptions.Add(exception);
}
}
}
if (exceptions != null)
{
throw new AggregateException("An error occurred while pruning authorizations.", exceptions);
}
}
/// <summary>
/// Sets the application identifier associated with an authorization.
/// </summary>

94
src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs

@ -5,12 +5,16 @@
*/
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Caching.Memory;
using OpenIddict.Core;
using OpenIddict.Models;
@ -306,6 +310,96 @@ namespace OpenIddict.EntityFrameworkCore
.Include(token => token.Authorization), state).ToListAsync(cancellationToken));
}
/// <summary>
/// Removes the tokens that are marked as expired or invalid.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
/// <returns>
/// A <see cref="Task"/> that can be used to monitor the asynchronous operation.
/// </returns>
public override async Task PruneAsync(CancellationToken cancellationToken = default)
{
// 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.
IList<Exception> exceptions = null;
IQueryable<TToken> Query(IQueryable<TToken> tokens, int offset)
=> (from token in tokens
where token.ExpirationDate < DateTimeOffset.UtcNow ||
token.Status != OpenIddictConstants.Statuses.Valid
orderby token.Id
select token).Skip(offset).Take(1_000);
async Task<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);
}
catch
{
return null;
}
}
return null;
}
for (var offset = 0; offset < 100_000; offset = offset + 1_000)
{
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())
{
var tokens = await ListAsync((source, state) => Query(source, state), offset, cancellationToken);
if (tokens.IsEmpty)
{
break;
}
Context.RemoveRange(tokens);
try
{
await Context.SaveChangesAsync(cancellationToken);
transaction?.Commit();
}
catch (Exception exception)
{
if (exceptions == null)
{
exceptions = new List<Exception>(capacity: 1);
}
exceptions.Add(exception);
}
}
}
if (exceptions != null)
{
throw new AggregateException("An error occurred while pruning tokens.", exceptions);
}
}
/// <summary>
/// Sets the application identifier associated with a token.
/// </summary>

Loading…
Cancel
Save