diff --git a/Identity.sln b/Identity.sln
index 6570345..350643f 100644
--- a/Identity.sln
+++ b/Identity.sln
@@ -28,7 +28,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{BCB8A692
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Identity.Domain.UnitTests", "tests\Logitar.Identity.Domain.UnitTests\Logitar.Identity.Domain.UnitTests.csproj", "{9A27A378-14E7-4D3C-B847-27D0D4EFA8F9}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Identity.EFCore.SqlServer.IntegrationTests", "tests\Logitar.Identity.EFCore.SqlServer.IntegrationTests\Logitar.Identity.EFCore.SqlServer.IntegrationTests.csproj", "{FA9AB722-026B-4842-B888-E9824568CBC1}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Identity.EFCore.SqlServer.IntegrationTests", "tests\Logitar.Identity.EFCore.SqlServer.IntegrationTests\Logitar.Identity.EFCore.SqlServer.IntegrationTests.csproj", "{FA9AB722-026B-4842-B888-E9824568CBC1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Identity.Infrastructure.UnitTests", "tests\Logitar.Identity.Infrastructure.UnitTests\Logitar.Identity.Infrastructure.UnitTests.csproj", "{04C669C6-0B63-45A1-8F0F-16A7E7FC023E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Identity.Tests", "tests\Logitar.Identity.Tests\Logitar.Identity.Tests.csproj", "{D0781AC3-5827-4DAE-BBAD-481634FF3C0F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -64,6 +68,14 @@ Global
{FA9AB722-026B-4842-B888-E9824568CBC1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FA9AB722-026B-4842-B888-E9824568CBC1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FA9AB722-026B-4842-B888-E9824568CBC1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {04C669C6-0B63-45A1-8F0F-16A7E7FC023E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {04C669C6-0B63-45A1-8F0F-16A7E7FC023E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {04C669C6-0B63-45A1-8F0F-16A7E7FC023E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {04C669C6-0B63-45A1-8F0F-16A7E7FC023E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D0781AC3-5827-4DAE-BBAD-481634FF3C0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D0781AC3-5827-4DAE-BBAD-481634FF3C0F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D0781AC3-5827-4DAE-BBAD-481634FF3C0F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D0781AC3-5827-4DAE-BBAD-481634FF3C0F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -71,6 +83,8 @@ Global
GlobalSection(NestedProjects) = preSolution
{9A27A378-14E7-4D3C-B847-27D0D4EFA8F9} = {BCB8A692-DF88-4E50-91A7-AD91E466559C}
{FA9AB722-026B-4842-B888-E9824568CBC1} = {BCB8A692-DF88-4E50-91A7-AD91E466559C}
+ {04C669C6-0B63-45A1-8F0F-16A7E7FC023E} = {BCB8A692-DF88-4E50-91A7-AD91E466559C}
+ {D0781AC3-5827-4DAE-BBAD-481634FF3C0F} = {BCB8A692-DF88-4E50-91A7-AD91E466559C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {45FD7647-C5AB-4CE1-A93C-59A73FDD2196}
diff --git a/docker-compose.yml b/docker-compose.yml
index 4a5bb9c..68e62c6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,7 +14,7 @@ services:
context: .
dockerfile: /src/Logitar.Identity.Demo/Dockerfile
image: identity_demo
- container_name: Logitar.Identity.Demo
+ container_name: Logitar.Identity_demo
depends_on:
- identity_mssql
restart: unless-stopped
diff --git a/src/Logitar.Identity.Domain/Logitar.Identity.Domain.csproj b/src/Logitar.Identity.Domain/Logitar.Identity.Domain.csproj
index 8a3d54b..1016c5a 100644
--- a/src/Logitar.Identity.Domain/Logitar.Identity.Domain.csproj
+++ b/src/Logitar.Identity.Domain/Logitar.Identity.Domain.csproj
@@ -47,16 +47,18 @@
-
+
+
+
diff --git a/src/Logitar.Identity.Domain/Tokens/CreateTokenOptions.cs b/src/Logitar.Identity.Domain/Tokens/CreateTokenOptions.cs
new file mode 100644
index 0000000..950fc78
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/CreateTokenOptions.cs
@@ -0,0 +1,40 @@
+using Microsoft.IdentityModel.Tokens;
+
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// Represents token creation options.
+///
+public record CreateTokenOptions
+{
+ ///
+ /// Gets or sets the token type. This defaults to 'JWT'.
+ ///
+ public string Type { get; set; } = "JWT";
+ ///
+ /// Gets or sets the signing algorithm. This defaults to 'HS256'.
+ ///
+ public string SigningAlgorithm { get; set; } = SecurityAlgorithms.HmacSha256;
+
+ ///
+ /// Gets or sets the token audience.
+ ///
+ public string? Audience { get; set; }
+ ///
+ /// Gets or sets the token issuer.
+ ///
+ public string? Issuer { get; set; }
+
+ ///
+ /// Gets or sets the token expiration date and time. Unspecified date time kinds will be treated as UTC.
+ ///
+ public DateTime? Expires { get; set; }
+ ///
+ /// Gets or sets the date and time when the token was issued. Unspecified date time kinds will be treated as UTC.
+ ///
+ public DateTime? IssuedAt { get; set; }
+ ///
+ /// Gets or sets the date and time from when the token is valid. Unspecified date time kinds will be treated as UTC.
+ ///
+ public DateTime? NotBefore { get; set; }
+}
diff --git a/src/Logitar.Identity.Domain/Tokens/CreateTokenParameters.cs b/src/Logitar.Identity.Domain/Tokens/CreateTokenParameters.cs
new file mode 100644
index 0000000..c94435f
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/CreateTokenParameters.cs
@@ -0,0 +1,54 @@
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// Represents token creation parameters.
+///
+public record CreateTokenParameters : CreateTokenOptions
+{
+ ///
+ /// Gets or sets the token subject.
+ ///
+ public ClaimsIdentity Subject { get; set; }
+ ///
+ /// Gets or sets the signing secret.
+ ///
+ public string Secret { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public CreateTokenParameters() : this(new(), string.Empty)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token subject.
+ /// The signing secret.
+ public CreateTokenParameters(ClaimsIdentity subject, string secret)
+ {
+ Subject = subject;
+ Secret = secret;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token subject.
+ /// The signing secret.
+ /// The token creation options.
+ public CreateTokenParameters(ClaimsIdentity subject, string secret, CreateTokenOptions? options) : this(subject, secret)
+ {
+ if (options != null)
+ {
+ Type = options.Type;
+ SigningAlgorithm = options.SigningAlgorithm;
+ Audience = options.Audience;
+ Issuer = options.Issuer;
+ Expires = options.Expires;
+ IssuedAt = options.IssuedAt;
+ NotBefore = options.NotBefore;
+ }
+ }
+}
diff --git a/src/Logitar.Identity.Domain/Tokens/CreatedToken.cs b/src/Logitar.Identity.Domain/Tokens/CreatedToken.cs
new file mode 100644
index 0000000..7260b9c
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/CreatedToken.cs
@@ -0,0 +1,29 @@
+using Microsoft.IdentityModel.Tokens;
+
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// Represents a created token.
+///
+public record CreatedToken
+{
+ ///
+ /// Gets the created security token.
+ ///
+ public SecurityToken SecurityToken { get; }
+ ///
+ /// Gets a string representation of the created token.
+ ///
+ public string TokenString { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The created security token.
+ /// A string representation of the created token.
+ public CreatedToken(SecurityToken securityToken, string tokenString)
+ {
+ SecurityToken = securityToken;
+ TokenString = tokenString;
+ }
+}
diff --git a/src/Logitar.Identity.Domain/Tokens/ITokenBlacklist.cs b/src/Logitar.Identity.Domain/Tokens/ITokenBlacklist.cs
new file mode 100644
index 0000000..4ba6d44
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/ITokenBlacklist.cs
@@ -0,0 +1,36 @@
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// Defines a token blacklist.
+///
+public interface ITokenBlacklist
+{
+ ///
+ /// Blacklists the specified list of token identifiers.
+ ///
+ /// The token identifiers to blacklist.
+ /// The cancellation token.
+ /// The asynchronous operation.
+ Task BlacklistAsync(IEnumerable tokenIds, CancellationToken cancellationToken = default);
+ ///
+ /// Blacklists the specified list of token identifiers.
+ ///
+ /// The token identifiers to blacklist.
+ /// The expiration date and time of the token.
+ /// The cancellation token.
+ /// The asynchronous operation.
+ Task BlacklistAsync(IEnumerable tokenIds, DateTime? expiresOn, CancellationToken cancellationToken = default);
+ ///
+ /// Returns the blacklisted token identifiers from the specified list of token identifiers.
+ ///
+ /// The list of token identifiers.
+ /// The cancellation token.
+ /// The list of blacklisted token identifiers.
+ Task> GetBlacklistedAsync(IEnumerable tokenIds, CancellationToken cancellationToken = default);
+ ///
+ /// Removes expired token identifiers from the blacklist.
+ ///
+ /// The cancellation token.
+ /// The asynchronous operation.
+ Task PurgeAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Logitar.Identity.Domain/Tokens/ITokenManager.cs b/src/Logitar.Identity.Domain/Tokens/ITokenManager.cs
new file mode 100644
index 0000000..2d63f63
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/ITokenManager.cs
@@ -0,0 +1,57 @@
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// Defines methods to manage tokens.
+///
+public interface ITokenManager
+{
+ ///
+ /// Creates a token for the specified subject, using the specified signing secret.
+ ///
+ /// The subject of the token.
+ /// The signing secret.
+ /// The cancellation token.
+ /// The created token.
+ Task CreateAsync(ClaimsIdentity subject, string secret, CancellationToken cancellationToken = default);
+ ///
+ /// Creates a token for the specified subject, using the specified signing secret and creation options.
+ ///
+ /// The subject of the token.
+ /// The signing secret.
+ /// The creation options.
+ /// The cancellation token.
+ /// The created token.
+ Task CreateAsync(ClaimsIdentity subject, string secret, CreateTokenOptions? options, CancellationToken cancellationToken = default);
+ ///
+ /// Creates a token with the specified parameters.
+ ///
+ /// The creation parameters.
+ /// The cancellation token.
+ /// The created token.
+ Task CreateAsync(CreateTokenParameters parameters, CancellationToken cancellationToken = default);
+
+ ///
+ /// Validates a token using the specified signing secret.
+ ///
+ /// The token to validate.
+ /// The signing secret.
+ /// The cancellation token.
+ /// The validated token.
+ Task ValidateAsync(string token, string secret, CancellationToken cancellationToken = default);
+ ///
+ /// Validates a token using the specified signing secret and validation options.
+ ///
+ /// The token to validate.
+ /// The signing secret.
+ /// The validation options.
+ /// The cancellation token.
+ /// The validated token.
+ Task ValidateAsync(string token, string secret, ValidateTokenOptions? options, CancellationToken cancellationToken = default);
+ ///
+ /// Validates a token with the specified parameters.
+ ///
+ /// The validation parameters.
+ /// The cancellation token.
+ /// The validated token.
+ Task ValidateAsync(ValidateTokenParameters parameters, CancellationToken cancellationToken = default);
+}
diff --git a/src/Logitar.Identity.Domain/Tokens/SecurityTokenBlacklistedException.cs b/src/Logitar.Identity.Domain/Tokens/SecurityTokenBlacklistedException.cs
new file mode 100644
index 0000000..d88884b
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/SecurityTokenBlacklistedException.cs
@@ -0,0 +1,46 @@
+using Microsoft.IdentityModel.Tokens;
+
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// The exception raised when a validated security token is blacklisted.
+///
+public class SecurityTokenBlacklistedException : SecurityTokenValidationException
+{
+ ///
+ /// A generic error message for this exception.
+ ///
+ public const string ErrorMessage = "The security token is blacklisted.";
+
+ ///
+ /// Gets or sets the list of blacklisted token identifiers.
+ ///
+ public IEnumerable BlacklistedIds
+ {
+ get => (IEnumerable)Data[nameof(BlacklistedIds)]!;
+ private set => Data[nameof(BlacklistedIds)] = value;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The list of the blacklisted token identifiers.
+ public SecurityTokenBlacklistedException(IEnumerable blacklistedIds) : base(BuildMessage(blacklistedIds))
+ {
+ BlacklistedIds = blacklistedIds;
+ }
+
+ private static string BuildMessage(IEnumerable blacklistedIds)
+ {
+ StringBuilder message = new();
+
+ message.AppendLine(ErrorMessage);
+ message.AppendLine("BlacklistedIds:");
+ foreach (string blacklistedId in blacklistedIds)
+ {
+ message.Append(" - ").Append(blacklistedId).AppendLine();
+ }
+
+ return message.ToString();
+ }
+}
diff --git a/src/Logitar.Identity.Domain/Tokens/ValidateTokenOptions.cs b/src/Logitar.Identity.Domain/Tokens/ValidateTokenOptions.cs
new file mode 100644
index 0000000..d8abbf5
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/ValidateTokenOptions.cs
@@ -0,0 +1,26 @@
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// Represents token validation options.
+///
+public record ValidateTokenOptions
+{
+ ///
+ /// Gets or sets the list of valid token types.
+ ///
+ public List ValidTypes { get; set; } = [];
+
+ ///
+ /// Gets or sets the list of valid audiences.
+ ///
+ public List ValidAudiences { get; set; } = [];
+ ///
+ /// Gets or sets the list of valid audiences.
+ ///
+ public List ValidIssuers { get; set; } = [];
+
+ ///
+ /// Gets or sets a value indicating whether or not to blacklist the token identifiers if the token is valid. This should be set to true for one-time use tokens.
+ ///
+ public bool Consume { get; set; }
+}
diff --git a/src/Logitar.Identity.Domain/Tokens/ValidateTokenParameters.cs b/src/Logitar.Identity.Domain/Tokens/ValidateTokenParameters.cs
new file mode 100644
index 0000000..6718ff8
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/ValidateTokenParameters.cs
@@ -0,0 +1,51 @@
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// Represents token validation parameters.
+///
+public record ValidateTokenParameters : ValidateTokenOptions
+{
+ ///
+ /// Gets or sets the token to validate.
+ ///
+ public string Token { get; set; }
+ ///
+ /// Gets or sets the signing secret.
+ ///
+ public string Secret { get; set; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ValidateTokenParameters() : this(string.Empty, string.Empty)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token to validate.
+ /// The signing secret.
+ public ValidateTokenParameters(string token, string secret)
+ {
+ Token = token;
+ Secret = secret;
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token to validate.
+ /// The signing secret.
+ /// The token validation options.
+ public ValidateTokenParameters(string token, string secret, ValidateTokenOptions? options) : this(token, secret)
+ {
+ if (options != null)
+ {
+ ValidTypes.AddRange(options.ValidTypes);
+ ValidAudiences.AddRange(options.ValidAudiences);
+ ValidIssuers.AddRange(options.ValidIssuers);
+ Consume = options.Consume;
+ }
+ }
+}
diff --git a/src/Logitar.Identity.Domain/Tokens/ValidatedToken.cs b/src/Logitar.Identity.Domain/Tokens/ValidatedToken.cs
new file mode 100644
index 0000000..d0fbae5
--- /dev/null
+++ b/src/Logitar.Identity.Domain/Tokens/ValidatedToken.cs
@@ -0,0 +1,29 @@
+using Microsoft.IdentityModel.Tokens;
+
+namespace Logitar.Identity.Domain.Tokens;
+
+///
+/// Represents a validated token.
+///
+public record ValidatedToken
+{
+ ///
+ /// Gets the validated claims principal.
+ ///
+ public ClaimsPrincipal ClaimsPrincipal { get; }
+ ///
+ /// Gets the validated security token.
+ ///
+ public SecurityToken SecurityToken { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The validated claims principal.
+ /// The validated security token.
+ public ValidatedToken(ClaimsPrincipal claimsPrincipal, SecurityToken securityToken)
+ {
+ ClaimsPrincipal = claimsPrincipal;
+ SecurityToken = securityToken;
+ }
+}
diff --git a/src/Logitar.Identity.EntityFrameworkCore.Relational/Configurations/BlacklistedTokenConfiguration.cs b/src/Logitar.Identity.EntityFrameworkCore.Relational/Configurations/BlacklistedTokenConfiguration.cs
new file mode 100644
index 0000000..a824aa6
--- /dev/null
+++ b/src/Logitar.Identity.EntityFrameworkCore.Relational/Configurations/BlacklistedTokenConfiguration.cs
@@ -0,0 +1,19 @@
+using Logitar.Identity.EntityFrameworkCore.Relational.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace Logitar.Identity.EntityFrameworkCore.Relational.Configurations;
+
+public class BlacklistedTokenConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable(nameof(IdentityContext.TokenBlacklist));
+ builder.HasKey(x => x.BlacklistedTokenId);
+
+ builder.HasIndex(x => x.TokenId).IsUnique();
+ builder.HasIndex(x => x.ExpiresOn);
+
+ builder.Property(x => x.TokenId).HasMaxLength(byte.MaxValue);
+ }
+}
diff --git a/src/Logitar.Identity.EntityFrameworkCore.Relational/DependencyInjectionExtensions.cs b/src/Logitar.Identity.EntityFrameworkCore.Relational/DependencyInjectionExtensions.cs
index 94803c9..3b0d484 100644
--- a/src/Logitar.Identity.EntityFrameworkCore.Relational/DependencyInjectionExtensions.cs
+++ b/src/Logitar.Identity.EntityFrameworkCore.Relational/DependencyInjectionExtensions.cs
@@ -3,12 +3,14 @@
using Logitar.Identity.Domain.ApiKeys;
using Logitar.Identity.Domain.Roles;
using Logitar.Identity.Domain.Sessions;
+using Logitar.Identity.Domain.Tokens;
using Logitar.Identity.Domain.Users;
using Logitar.Identity.EntityFrameworkCore.Relational.Handlers.ApiKeys;
using Logitar.Identity.EntityFrameworkCore.Relational.Handlers.Roles;
using Logitar.Identity.EntityFrameworkCore.Relational.Handlers.Sessions;
using Logitar.Identity.EntityFrameworkCore.Relational.Handlers.Users;
using Logitar.Identity.EntityFrameworkCore.Relational.Repositories;
+using Logitar.Identity.EntityFrameworkCore.Relational.Tokens;
using Logitar.Identity.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
@@ -24,7 +26,8 @@ public static IServiceCollection AddLogitarIdentityWithEntityFrameworkCoreRelati
.AddLogitarIdentityInfrastructure()
.AddMediatR(config => config.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()))
.AddRepositories()
- .AddTransient();
+ .AddTransient()
+ .AddTransient();
}
private static IServiceCollection AddEventHandlers(this IServiceCollection services)
diff --git a/src/Logitar.Identity.EntityFrameworkCore.Relational/Entities/BlacklistedTokenEntity.cs b/src/Logitar.Identity.EntityFrameworkCore.Relational/Entities/BlacklistedTokenEntity.cs
new file mode 100644
index 0000000..5a9a096
--- /dev/null
+++ b/src/Logitar.Identity.EntityFrameworkCore.Relational/Entities/BlacklistedTokenEntity.cs
@@ -0,0 +1,19 @@
+namespace Logitar.Identity.EntityFrameworkCore.Relational.Entities;
+
+public class BlacklistedTokenEntity
+{
+ public int BlacklistedTokenId { get; private set; }
+
+ public string TokenId { get; private set; }
+
+ public DateTime? ExpiresOn { get; set; }
+
+ public BlacklistedTokenEntity(string tokenId)
+ {
+ TokenId = tokenId;
+ }
+
+ private BlacklistedTokenEntity() : this(string.Empty)
+ {
+ }
+}
diff --git a/src/Logitar.Identity.EntityFrameworkCore.Relational/IdentityContext.cs b/src/Logitar.Identity.EntityFrameworkCore.Relational/IdentityContext.cs
index 60ae39d..e9f3cc7 100644
--- a/src/Logitar.Identity.EntityFrameworkCore.Relational/IdentityContext.cs
+++ b/src/Logitar.Identity.EntityFrameworkCore.Relational/IdentityContext.cs
@@ -15,6 +15,7 @@ public IdentityContext(DbContextOptions options) : base(options
public DbSet CustomAttributes { get; private set; }
public DbSet Roles { get; private set; }
public DbSet Sessions { get; private set; }
+ public DbSet TokenBlacklist { get; private set; }
public DbSet UserIdentifiers { get; private set; }
public DbSet UserRoles { get; private set; }
public DbSet Users { get; private set; }
diff --git a/src/Logitar.Identity.EntityFrameworkCore.Relational/IdentityDb.cs b/src/Logitar.Identity.EntityFrameworkCore.Relational/IdentityDb.cs
index 803ca47..a98c671 100644
--- a/src/Logitar.Identity.EntityFrameworkCore.Relational/IdentityDb.cs
+++ b/src/Logitar.Identity.EntityFrameworkCore.Relational/IdentityDb.cs
@@ -97,6 +97,15 @@ public static class Sessions
public static readonly ColumnId Version = new(nameof(SessionEntity.Version), Table);
}
+ public static class TokenBlacklist
+ {
+ public static readonly TableId Table = new(nameof(IdentityContext.TokenBlacklist));
+
+ public static readonly ColumnId BlacklistedTokenId = new(nameof(BlacklistedTokenEntity.BlacklistedTokenId), Table);
+ public static readonly ColumnId Expires = new(nameof(BlacklistedTokenEntity.ExpiresOn), Table);
+ public static readonly ColumnId TokenId = new(nameof(BlacklistedTokenEntity.TokenId), Table);
+ }
+
public static class UserIdentifiers
{
public static readonly TableId Table = new(nameof(IdentityContext.UserIdentifiers));
diff --git a/src/Logitar.Identity.EntityFrameworkCore.Relational/Tokens/TokenBlacklist.cs b/src/Logitar.Identity.EntityFrameworkCore.Relational/Tokens/TokenBlacklist.cs
new file mode 100644
index 0000000..fc1e812
--- /dev/null
+++ b/src/Logitar.Identity.EntityFrameworkCore.Relational/Tokens/TokenBlacklist.cs
@@ -0,0 +1,101 @@
+using Logitar.Identity.Domain.Tokens;
+using Logitar.Identity.EntityFrameworkCore.Relational.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace Logitar.Identity.EntityFrameworkCore.Relational.Tokens;
+
+///
+/// Implements a token blacklist.
+///
+public class TokenBlacklist : ITokenBlacklist
+{
+ ///
+ /// Gets the Identity database context.
+ ///
+ protected virtual IdentityContext Context { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Identity database context.
+ public TokenBlacklist(IdentityContext context)
+ {
+ Context = context;
+ }
+
+ ///
+ /// Blacklists the specified list of token identifiers.
+ ///
+ /// The token identifiers to blacklist.
+ /// The cancellation token.
+ /// The asynchronous operation.
+ public virtual async Task BlacklistAsync(IEnumerable tokenIds, CancellationToken cancellationToken)
+ {
+ await BlacklistAsync(tokenIds, expiresOn: null, cancellationToken);
+ }
+ ///
+ /// Blacklists the specified list of token identifiers.
+ ///
+ /// The token identifiers to blacklist.
+ /// The expiration date and time of the token. If the kind is unspecified, the expiration will be treated as UTC.
+ /// The cancellation token.
+ /// The asynchronous operation.
+ public virtual async Task BlacklistAsync(IEnumerable tokenIds, DateTime? expiresOn, CancellationToken cancellationToken)
+ {
+ expiresOn = expiresOn?.ToUniversalTime();
+
+ Dictionary entities = await Context.TokenBlacklist
+ .Where(x => tokenIds.Contains(x.TokenId))
+ .ToDictionaryAsync(x => x.TokenId, x => x, cancellationToken);
+
+ foreach (string tokenId in tokenIds)
+ {
+ if (!entities.TryGetValue(tokenId, out BlacklistedTokenEntity? entity))
+ {
+ entity = new(tokenId);
+ entities[tokenId] = entity;
+
+ Context.TokenBlacklist.Add(entity);
+ }
+
+ entity.ExpiresOn = expiresOn;
+ }
+
+ await Context.SaveChangesAsync(cancellationToken);
+ }
+
+ ///
+ /// Returns the blacklisted token identifiers from the specified list of token identifiers.
+ ///
+ /// The list of token identifiers.
+ /// The cancellation token.
+ /// The list of blacklisted token identifiers.
+ public virtual async Task> GetBlacklistedAsync(IEnumerable tokenIds, CancellationToken cancellationToken)
+ {
+ DateTime now = DateTime.UtcNow;
+
+ string[] blacklistedTokenIds = await Context.TokenBlacklist.AsNoTracking()
+ .Where(x => tokenIds.Contains(x.TokenId) && (x.ExpiresOn == null || x.ExpiresOn > now))
+ .Select(x => x.TokenId)
+ .ToArrayAsync(cancellationToken);
+
+ return blacklistedTokenIds;
+ }
+
+ ///
+ /// Removes expired token identifiers from the blacklist.
+ ///
+ /// The cancellation token.
+ /// The asynchronous operation.
+ public virtual async Task PurgeAsync(CancellationToken cancellationToken)
+ {
+ DateTime now = DateTime.UtcNow;
+
+ BlacklistedTokenEntity[] expiredEntities = await Context.TokenBlacklist
+ .Where(x => x.ExpiresOn != null && x.ExpiresOn <= now)
+ .ToArrayAsync(cancellationToken);
+
+ Context.TokenBlacklist.RemoveRange(expiredEntities);
+ await Context.SaveChangesAsync(cancellationToken);
+ }
+}
diff --git a/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/20240121052425_CreateTokenBlacklistTable.Designer.cs b/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/20240121052425_CreateTokenBlacklistTable.Designer.cs
new file mode 100644
index 0000000..d6ccdd6
--- /dev/null
+++ b/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/20240121052425_CreateTokenBlacklistTable.Designer.cs
@@ -0,0 +1,841 @@
+//
+using System;
+using Logitar.Identity.EntityFrameworkCore.Relational;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Logitar.Identity.EntityFrameworkCore.SqlServer.Migrations
+{
+ [DbContext(typeof(IdentityContext))]
+ [Migration("20240121052425_CreateTokenBlacklistTable")]
+ partial class CreateTokenBlacklistTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.ActorEntity", b =>
+ {
+ b.Property("ActorId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ActorId"));
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("EmailAddress")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("Id")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("IsDeleted")
+ .HasColumnType("bit");
+
+ b.Property("PictureUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("nvarchar(2048)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.HasKey("ActorId");
+
+ b.HasIndex("DisplayName");
+
+ b.HasIndex("EmailAddress");
+
+ b.HasIndex("Id")
+ .IsUnique();
+
+ b.HasIndex("IsDeleted");
+
+ b.HasIndex("Type");
+
+ b.ToTable("Actors", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.ApiKeyEntity", b =>
+ {
+ b.Property("ApiKeyId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("ApiKeyId"));
+
+ b.Property("AggregateId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("AuthenticatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("CreatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("CustomAttributesSerialized")
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("CustomAttributes");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("ExpiresOn")
+ .HasColumnType("datetime2");
+
+ b.Property("SecretHash")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("TenantId")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UpdatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("Version")
+ .HasColumnType("bigint");
+
+ b.HasKey("ApiKeyId");
+
+ b.HasIndex("AggregateId")
+ .IsUnique();
+
+ b.HasIndex("AuthenticatedOn");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("CreatedOn");
+
+ b.HasIndex("DisplayName");
+
+ b.HasIndex("ExpiresOn");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("UpdatedBy");
+
+ b.HasIndex("UpdatedOn");
+
+ b.HasIndex("Version");
+
+ b.ToTable("ApiKeys", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.ApiKeyRoleEntity", b =>
+ {
+ b.Property("ApiKeyId")
+ .HasColumnType("int");
+
+ b.Property("RoleId")
+ .HasColumnType("int");
+
+ b.HasKey("ApiKeyId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("ApiKeyRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.BlacklistedTokenEntity", b =>
+ {
+ b.Property("BlacklistedTokenId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("BlacklistedTokenId"));
+
+ b.Property("ExpiresOn")
+ .HasColumnType("datetime2");
+
+ b.Property("TokenId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.HasKey("BlacklistedTokenId");
+
+ b.HasIndex("ExpiresOn");
+
+ b.HasIndex("TokenId")
+ .IsUnique();
+
+ b.ToTable("TokenBlacklist", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.CustomAttributeEntity", b =>
+ {
+ b.Property("CustomAttributeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CustomAttributeId"));
+
+ b.Property("EntityId")
+ .HasColumnType("int");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ValueShortened")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.HasKey("CustomAttributeId");
+
+ b.HasIndex("Key");
+
+ b.HasIndex("ValueShortened");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.HasIndex("EntityType", "EntityId", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomAttributes", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.RoleEntity", b =>
+ {
+ b.Property("RoleId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("RoleId"));
+
+ b.Property("AggregateId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("CreatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("CustomAttributesSerialized")
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("CustomAttributes");
+
+ b.Property("Description")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("TenantId")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UniqueName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UniqueNameNormalized")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UpdatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("Version")
+ .HasColumnType("bigint");
+
+ b.HasKey("RoleId");
+
+ b.HasIndex("AggregateId")
+ .IsUnique();
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("CreatedOn");
+
+ b.HasIndex("DisplayName");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("UniqueName");
+
+ b.HasIndex("UpdatedBy");
+
+ b.HasIndex("UpdatedOn");
+
+ b.HasIndex("Version");
+
+ b.HasIndex("TenantId", "UniqueNameNormalized")
+ .IsUnique()
+ .HasFilter("[TenantId] IS NOT NULL");
+
+ b.ToTable("Roles", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.SessionEntity", b =>
+ {
+ b.Property("SessionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SessionId"));
+
+ b.Property("AggregateId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("CreatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("CustomAttributesSerialized")
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("CustomAttributes");
+
+ b.Property("IsActive")
+ .HasColumnType("bit");
+
+ b.Property("IsPersistent")
+ .HasColumnType("bit");
+
+ b.Property("SecretHash")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("SignedOutBy")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("SignedOutOn")
+ .HasColumnType("datetime2");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UpdatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.Property("Version")
+ .HasColumnType("bigint");
+
+ b.HasKey("SessionId");
+
+ b.HasIndex("AggregateId")
+ .IsUnique();
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("CreatedOn");
+
+ b.HasIndex("IsActive");
+
+ b.HasIndex("IsPersistent");
+
+ b.HasIndex("SignedOutBy");
+
+ b.HasIndex("SignedOutOn");
+
+ b.HasIndex("UpdatedBy");
+
+ b.HasIndex("UpdatedOn");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("Version");
+
+ b.ToTable("Sessions", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserEntity", b =>
+ {
+ b.Property("UserId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("UserId"));
+
+ b.Property("AddressCountry")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("AddressFormatted")
+ .HasMaxLength(1279)
+ .HasColumnType("nvarchar(1279)");
+
+ b.Property("AddressLocality")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("AddressPostalCode")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("AddressRegion")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("AddressStreet")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("AddressVerifiedBy")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("AddressVerifiedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("AggregateId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("AuthenticatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("Birthdate")
+ .HasColumnType("datetime2");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("CreatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("CustomAttributesSerialized")
+ .HasColumnType("nvarchar(max)")
+ .HasColumnName("CustomAttributes");
+
+ b.Property("DisabledBy")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("DisabledOn")
+ .HasColumnType("datetime2");
+
+ b.Property("EmailAddress")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("EmailAddressNormalized")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("EmailVerifiedBy")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("EmailVerifiedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("FirstName")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("FullName")
+ .HasMaxLength(767)
+ .HasColumnType("nvarchar(767)");
+
+ b.Property("Gender")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("HasPassword")
+ .HasColumnType("bit");
+
+ b.Property("IsAddressVerified")
+ .HasColumnType("bit");
+
+ b.Property("IsConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("IsDisabled")
+ .HasColumnType("bit");
+
+ b.Property("IsEmailVerified")
+ .HasColumnType("bit");
+
+ b.Property("IsPhoneVerified")
+ .HasColumnType("bit");
+
+ b.Property("LastName")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("Locale")
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("MiddleName")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("Nickname")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("PasswordChangedBy")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("PasswordChangedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("PasswordHash")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("PhoneCountryCode")
+ .HasMaxLength(2)
+ .HasColumnType("nvarchar(2)");
+
+ b.Property("PhoneE164Formatted")
+ .HasMaxLength(40)
+ .HasColumnType("nvarchar(40)");
+
+ b.Property("PhoneExtension")
+ .HasMaxLength(10)
+ .HasColumnType("nvarchar(10)");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(20)
+ .HasColumnType("nvarchar(20)");
+
+ b.Property("PhoneVerifiedBy")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("PhoneVerifiedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("Picture")
+ .HasMaxLength(2048)
+ .HasColumnType("nvarchar(2048)");
+
+ b.Property("Profile")
+ .HasMaxLength(2048)
+ .HasColumnType("nvarchar(2048)");
+
+ b.Property("TenantId")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("TimeZone")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("UniqueName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UniqueNameNormalized")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UpdatedOn")
+ .HasColumnType("datetime2");
+
+ b.Property("Version")
+ .HasColumnType("bigint");
+
+ b.Property("Website")
+ .HasMaxLength(2048)
+ .HasColumnType("nvarchar(2048)");
+
+ b.HasKey("UserId");
+
+ b.HasIndex("AddressCountry");
+
+ b.HasIndex("AddressFormatted");
+
+ b.HasIndex("AddressLocality");
+
+ b.HasIndex("AddressPostalCode");
+
+ b.HasIndex("AddressRegion");
+
+ b.HasIndex("AddressStreet");
+
+ b.HasIndex("AddressVerifiedBy");
+
+ b.HasIndex("AddressVerifiedOn");
+
+ b.HasIndex("AggregateId")
+ .IsUnique();
+
+ b.HasIndex("AuthenticatedOn");
+
+ b.HasIndex("Birthdate");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("CreatedOn");
+
+ b.HasIndex("DisabledBy");
+
+ b.HasIndex("DisabledOn");
+
+ b.HasIndex("EmailAddress");
+
+ b.HasIndex("EmailVerifiedBy");
+
+ b.HasIndex("EmailVerifiedOn");
+
+ b.HasIndex("FirstName");
+
+ b.HasIndex("FullName");
+
+ b.HasIndex("Gender");
+
+ b.HasIndex("HasPassword");
+
+ b.HasIndex("IsAddressVerified");
+
+ b.HasIndex("IsConfirmed");
+
+ b.HasIndex("IsDisabled");
+
+ b.HasIndex("IsEmailVerified");
+
+ b.HasIndex("IsPhoneVerified");
+
+ b.HasIndex("LastName");
+
+ b.HasIndex("Locale");
+
+ b.HasIndex("MiddleName");
+
+ b.HasIndex("Nickname");
+
+ b.HasIndex("PasswordChangedBy");
+
+ b.HasIndex("PasswordChangedOn");
+
+ b.HasIndex("PhoneCountryCode");
+
+ b.HasIndex("PhoneE164Formatted");
+
+ b.HasIndex("PhoneExtension");
+
+ b.HasIndex("PhoneNumber");
+
+ b.HasIndex("PhoneVerifiedBy");
+
+ b.HasIndex("PhoneVerifiedOn");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("TimeZone");
+
+ b.HasIndex("UniqueName");
+
+ b.HasIndex("UpdatedBy");
+
+ b.HasIndex("UpdatedOn");
+
+ b.HasIndex("Version");
+
+ b.HasIndex("TenantId", "EmailAddressNormalized");
+
+ b.HasIndex("TenantId", "UniqueNameNormalized")
+ .IsUnique()
+ .HasFilter("[TenantId] IS NOT NULL");
+
+ b.ToTable("Users", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserIdentifierEntity", b =>
+ {
+ b.Property("UserIdentifierId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("UserIdentifierId"));
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("TenantId")
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.HasKey("UserIdentifierId");
+
+ b.HasIndex("Key");
+
+ b.HasIndex("Value");
+
+ b.HasIndex("UserId", "Key")
+ .IsUnique();
+
+ b.HasIndex("TenantId", "Key", "Value")
+ .IsUnique()
+ .HasFilter("[TenantId] IS NOT NULL");
+
+ b.ToTable("UserIdentifiers", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserRoleEntity", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.Property("RoleId")
+ .HasColumnType("int");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("UserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.ApiKeyRoleEntity", b =>
+ {
+ b.HasOne("Logitar.Identity.EntityFrameworkCore.Relational.Entities.ApiKeyEntity", null)
+ .WithMany()
+ .HasForeignKey("ApiKeyId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Logitar.Identity.EntityFrameworkCore.Relational.Entities.RoleEntity", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.SessionEntity", b =>
+ {
+ b.HasOne("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserEntity", "User")
+ .WithMany("Sessions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserIdentifierEntity", b =>
+ {
+ b.HasOne("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserEntity", "User")
+ .WithMany("Identifiers")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserRoleEntity", b =>
+ {
+ b.HasOne("Logitar.Identity.EntityFrameworkCore.Relational.Entities.RoleEntity", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserEntity", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.UserEntity", b =>
+ {
+ b.Navigation("Identifiers");
+
+ b.Navigation("Sessions");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/20240121052425_CreateTokenBlacklistTable.cs b/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/20240121052425_CreateTokenBlacklistTable.cs
new file mode 100644
index 0000000..c871427
--- /dev/null
+++ b/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/20240121052425_CreateTokenBlacklistTable.cs
@@ -0,0 +1,47 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Logitar.Identity.EntityFrameworkCore.SqlServer.Migrations
+{
+ ///
+ public partial class CreateTokenBlacklistTable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "TokenBlacklist",
+ columns: table => new
+ {
+ BlacklistedTokenId = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ TokenId = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false),
+ ExpiresOn = table.Column(type: "datetime2", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_TokenBlacklist", x => x.BlacklistedTokenId);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_TokenBlacklist_ExpiresOn",
+ table: "TokenBlacklist",
+ column: "ExpiresOn");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_TokenBlacklist_TokenId",
+ table: "TokenBlacklist",
+ column: "TokenId",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "TokenBlacklist");
+ }
+ }
+}
diff --git a/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/IdentityContextModelSnapshot.cs b/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/IdentityContextModelSnapshot.cs
index 8308ef4..35fb7f4 100644
--- a/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/IdentityContextModelSnapshot.cs
+++ b/src/Logitar.Identity.EntityFrameworkCore.SqlServer/Migrations/IdentityContextModelSnapshot.cs
@@ -172,6 +172,32 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("ApiKeyRoles", (string)null);
});
+ modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.BlacklistedTokenEntity", b =>
+ {
+ b.Property("BlacklistedTokenId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("BlacklistedTokenId"));
+
+ b.Property("ExpiresOn")
+ .HasColumnType("datetime2");
+
+ b.Property("TokenId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.HasKey("BlacklistedTokenId");
+
+ b.HasIndex("ExpiresOn");
+
+ b.HasIndex("TokenId")
+ .IsUnique();
+
+ b.ToTable("TokenBlacklist", (string)null);
+ });
+
modelBuilder.Entity("Logitar.Identity.EntityFrameworkCore.Relational.Entities.CustomAttributeEntity", b =>
{
b.Property("CustomAttributeId")
diff --git a/src/Logitar.Identity.Infrastructure/DependencyInjectionExtensions.cs b/src/Logitar.Identity.Infrastructure/DependencyInjectionExtensions.cs
index 3aefcc8..d4a850f 100644
--- a/src/Logitar.Identity.Infrastructure/DependencyInjectionExtensions.cs
+++ b/src/Logitar.Identity.Infrastructure/DependencyInjectionExtensions.cs
@@ -1,9 +1,11 @@
using Logitar.EventSourcing.Infrastructure;
using Logitar.Identity.Domain;
using Logitar.Identity.Domain.Passwords;
+using Logitar.Identity.Domain.Tokens;
using Logitar.Identity.Infrastructure.Converters;
using Logitar.Identity.Infrastructure.Passwords;
using Logitar.Identity.Infrastructure.Passwords.Pbkdf2;
+using Logitar.Identity.Infrastructure.Tokens;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -24,7 +26,8 @@ public static IServiceCollection AddLogitarIdentityInfrastructure(this IServiceC
{
IConfiguration configuration = serviceProvider.GetRequiredService();
return configuration.GetSection("Pbkdf2").Get() ?? new();
- });
+ })
+ .AddTransient();
}
private static IServiceCollection AddPasswordStrategies(this IServiceCollection services)
diff --git a/src/Logitar.Identity.Infrastructure/Logitar.Identity.Infrastructure.csproj b/src/Logitar.Identity.Infrastructure/Logitar.Identity.Infrastructure.csproj
index 4f6651a..0667041 100644
--- a/src/Logitar.Identity.Infrastructure/Logitar.Identity.Infrastructure.csproj
+++ b/src/Logitar.Identity.Infrastructure/Logitar.Identity.Infrastructure.csproj
@@ -46,6 +46,7 @@
+
@@ -53,7 +54,10 @@
+
+
+
diff --git a/src/Logitar.Identity.Infrastructure/Tokens/JsonWebTokenManager.cs b/src/Logitar.Identity.Infrastructure/Tokens/JsonWebTokenManager.cs
new file mode 100644
index 0000000..96fd0ea
--- /dev/null
+++ b/src/Logitar.Identity.Infrastructure/Tokens/JsonWebTokenManager.cs
@@ -0,0 +1,167 @@
+using Logitar.Identity.Domain.Tokens;
+using Logitar.Security.Claims;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Logitar.Identity.Infrastructure.Tokens;
+
+///
+/// Implements methods to manage JSON Web tokens.
+///
+public class JsonWebTokenManager : ITokenManager
+{
+ ///
+ /// Gets the token blacklist.
+ ///
+ protected virtual ITokenBlacklist TokenBlacklist { get; }
+ ///
+ /// Gets the JSON Web token handler.
+ ///
+ protected virtual JwtSecurityTokenHandler TokenHandler { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token blacklist.
+ public JsonWebTokenManager(ITokenBlacklist tokenBlacklist) : this(tokenBlacklist, new())
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The token blacklist.
+ /// The JSON Web token handler.
+ public JsonWebTokenManager(ITokenBlacklist tokenBlacklist, JwtSecurityTokenHandler tokenHandler)
+ {
+ TokenBlacklist = tokenBlacklist;
+ TokenHandler = tokenHandler;
+ TokenHandler.InboundClaimTypeMap.Clear();
+ }
+
+ ///
+ /// Creates a token for the specified subject, using the specified signing secret.
+ ///
+ /// The subject of the token.
+ /// The signing secret.
+ /// The cancellation token.
+ /// The created token.
+ public virtual async Task CreateAsync(ClaimsIdentity subject, string secret, CancellationToken cancellationToken)
+ {
+ return await CreateAsync(subject, secret, options: null, cancellationToken);
+ }
+ ///
+ /// Creates a token for the specified subject, using the specified signing secret and creation options.
+ ///
+ /// The subject of the token.
+ /// The signing secret.
+ /// The creation options.
+ /// The cancellation token.
+ /// The created token.
+ public virtual async Task CreateAsync(ClaimsIdentity subject, string secret, CreateTokenOptions? options, CancellationToken cancellationToken)
+ {
+ return await CreateAsync(new CreateTokenParameters(subject, secret, options), cancellationToken);
+ }
+ ///
+ /// Creates a token with the specified parameters.
+ ///
+ /// The creation parameters.
+ /// The cancellation token.
+ /// The created token.
+ public virtual Task CreateAsync(CreateTokenParameters parameters, CancellationToken cancellationToken)
+ {
+ SigningCredentials signingCredentials = new(GetSecurityKey(parameters.Secret), parameters.SigningAlgorithm);
+
+ SecurityTokenDescriptor tokenDescriptor = new()
+ {
+ Audience = parameters.Audience,
+ Expires = parameters.Expires?.ToUniversalTime(),
+ IssuedAt = parameters.IssuedAt?.ToUniversalTime(),
+ Issuer = parameters.Issuer,
+ NotBefore = parameters.NotBefore?.ToUniversalTime(),
+ SigningCredentials = signingCredentials,
+ Subject = parameters.Subject,
+ TokenType = parameters.Type
+ };
+
+ SecurityToken securityToken = TokenHandler.CreateToken(tokenDescriptor);
+ string tokenString = TokenHandler.WriteToken(securityToken);
+
+ CreatedToken createdToken = new(securityToken, tokenString);
+ return Task.FromResult(createdToken);
+ }
+
+ ///
+ /// Validates a token using the specified signing secret.
+ ///
+ /// The token to validate.
+ /// The signing secret.
+ /// The cancellation token.
+ /// The validated token.
+ public virtual async Task ValidateAsync(string token, string secret, CancellationToken cancellationToken)
+ {
+ return await ValidateAsync(token, secret, options: null, cancellationToken);
+ }
+ ///
+ /// Validates a token using the specified signing secret and validation options.
+ ///
+ /// The token to validate.
+ /// The signing secret.
+ /// The validation options.
+ /// The cancellation token.
+ /// The validated token.
+ public virtual async Task ValidateAsync(string token, string secret, ValidateTokenOptions? options, CancellationToken cancellationToken)
+ {
+ return await ValidateAsync(new ValidateTokenParameters(token, secret, options), cancellationToken);
+ }
+ ///
+ /// Validates a token with the specified parameters.
+ ///
+ /// The validation parameters.
+ /// The cancellation token.
+ /// The validated token.
+ public virtual async Task ValidateAsync(ValidateTokenParameters parameters, CancellationToken cancellationToken)
+ {
+ TokenValidationParameters validationParameters = new()
+ {
+ IssuerSigningKey = GetSecurityKey(parameters.Secret),
+ ValidAudiences = parameters.ValidAudiences,
+ ValidIssuers = parameters.ValidIssuers,
+ ValidateAudience = parameters.ValidAudiences.Count > 0,
+ ValidateIssuer = parameters.ValidIssuers.Count > 0,
+ ValidateIssuerSigningKey = true
+ };
+ if (parameters.ValidTypes.Count > 0)
+ {
+ validationParameters.ValidTypes = parameters.ValidTypes;
+ }
+
+ ClaimsPrincipal claimsPrincipal = TokenHandler.ValidateToken(parameters.Token, validationParameters, out SecurityToken securityToken);
+
+ HashSet tokenIds = claimsPrincipal.FindAll(Rfc7519ClaimNames.TokenId).Select(claim => claim.Value).ToHashSet();
+ if (tokenIds.Count > 0)
+ {
+ IEnumerable blacklistedIds = await TokenBlacklist.GetBlacklistedAsync(tokenIds, cancellationToken);
+ if (blacklistedIds.Any())
+ {
+ throw new SecurityTokenBlacklistedException(blacklistedIds);
+ }
+ }
+
+ if (parameters.Consume)
+ {
+ Claim? expiresClaim = claimsPrincipal.FindAll(Rfc7519ClaimNames.ExpirationTime).OrderBy(x => x.Value).FirstOrDefault();
+ DateTime? expiresOn = expiresClaim == null ? null : ClaimHelper.ExtractDateTime(expiresClaim).Add(validationParameters.ClockSkew);
+
+ await TokenBlacklist.BlacklistAsync(tokenIds, expiresOn, cancellationToken);
+ }
+
+ return new ValidatedToken(claimsPrincipal, securityToken);
+ }
+
+ ///
+ /// Creates a symmetric security key from the specified secret string.
+ ///
+ /// The secret string.
+ /// The symmetric security key.
+ protected virtual SymmetricSecurityKey GetSecurityKey(string secret) => new(Encoding.ASCII.GetBytes(secret));
+}
diff --git a/tests/Logitar.Identity.Domain.UnitTests/Logitar.Identity.Domain.UnitTests.csproj b/tests/Logitar.Identity.Domain.UnitTests/Logitar.Identity.Domain.UnitTests.csproj
index 2c4c195..150dcc7 100644
--- a/tests/Logitar.Identity.Domain.UnitTests/Logitar.Identity.Domain.UnitTests.csproj
+++ b/tests/Logitar.Identity.Domain.UnitTests/Logitar.Identity.Domain.UnitTests.csproj
@@ -12,11 +12,10 @@
-
-
-
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -29,11 +28,14 @@
+
+
+
diff --git a/tests/Logitar.Identity.Domain.UnitTests/Tokens/CreateTokenParametersTests.cs b/tests/Logitar.Identity.Domain.UnitTests/Tokens/CreateTokenParametersTests.cs
new file mode 100644
index 0000000..07928aa
--- /dev/null
+++ b/tests/Logitar.Identity.Domain.UnitTests/Tokens/CreateTokenParametersTests.cs
@@ -0,0 +1,43 @@
+namespace Logitar.Identity.Domain.Tokens;
+
+[Trait(Traits.Category, Categories.Unit)]
+public class CreateTokenParametersTests
+{
+ private const string Secret = "S3cR3+!*";
+
+ [Fact(DisplayName = "ctor: it should construct the correct parameters with options.")]
+ public void ctor_it_should_construct_the_correct_parameters_with_options()
+ {
+ ClaimsIdentity subject = new();
+
+ DateTime now = DateTime.UtcNow;
+ CreateTokenOptions options = new()
+ {
+ Audience = "Audience",
+ Issuer = "Issuer",
+ IssuedAt = now,
+ NotBefore = now.AddMinutes(1),
+ Expires = now.AddHours(1)
+ };
+
+ CreateTokenParameters parameters = new(subject, Secret, options);
+ Assert.Same(subject, parameters.Subject);
+ Assert.Equal(Secret, parameters.Secret);
+ Assert.Equal(options.Type, parameters.Type);
+ Assert.Equal(options.SigningAlgorithm, parameters.SigningAlgorithm);
+ Assert.Equal(options.Audience, parameters.Audience);
+ Assert.Equal(options.Issuer, parameters.Issuer);
+ Assert.Equal(options.Expires, parameters.Expires);
+ Assert.Equal(options.IssuedAt, parameters.IssuedAt);
+ Assert.Equal(options.NotBefore, parameters.NotBefore);
+ }
+
+ [Fact(DisplayName = "ctor: it should construct the correct parameters.")]
+ public void ctor_it_should_construct_the_correct_parameters()
+ {
+ ClaimsIdentity subject = new();
+ CreateTokenParameters parameters = new(subject, Secret);
+ Assert.Same(subject, parameters.Subject);
+ Assert.Equal(Secret, parameters.Secret);
+ }
+}
diff --git a/tests/Logitar.Identity.Domain.UnitTests/Tokens/CreatedTokenTests.cs b/tests/Logitar.Identity.Domain.UnitTests/Tokens/CreatedTokenTests.cs
new file mode 100644
index 0000000..5264562
--- /dev/null
+++ b/tests/Logitar.Identity.Domain.UnitTests/Tokens/CreatedTokenTests.cs
@@ -0,0 +1,21 @@
+using Microsoft.IdentityModel.Tokens;
+
+namespace Logitar.Identity.Domain.Tokens;
+
+[Trait(Traits.Category, Categories.Unit)]
+public class CreatedTokenTests
+{
+ private readonly JwtSecurityTokenHandler _tokenHandler = new();
+
+ [Fact(DisplayName = "ctor: it should construct the correct instance.")]
+ public void ctor_it_should_construct_the_correct_instance()
+ {
+ SecurityTokenDescriptor tokenDescriptor = new();
+ SecurityToken securityToken = _tokenHandler.CreateToken(tokenDescriptor);
+ string tokenString = _tokenHandler.WriteToken(securityToken);
+
+ CreatedToken createdToken = new(securityToken, tokenString);
+ Assert.Same(securityToken, createdToken.SecurityToken);
+ Assert.Equal(tokenString, createdToken.TokenString);
+ }
+}
diff --git a/tests/Logitar.Identity.Domain.UnitTests/Tokens/ValidateTokenParametersTests.cs b/tests/Logitar.Identity.Domain.UnitTests/Tokens/ValidateTokenParametersTests.cs
new file mode 100644
index 0000000..6f321a6
--- /dev/null
+++ b/tests/Logitar.Identity.Domain.UnitTests/Tokens/ValidateTokenParametersTests.cs
@@ -0,0 +1,38 @@
+namespace Logitar.Identity.Domain.Tokens;
+
+[Trait(Traits.Category, Categories.Unit)]
+public class ValidateTokenParametersTests
+{
+ private const string Secret = "S3cR3+!*";
+ private const string Token = "token";
+
+ [Fact(DisplayName = "ctor: it should construct the correct parameters with options.")]
+ public void ctor_it_should_construct_the_correct_parameters_with_options()
+ {
+ DateTime now = DateTime.UtcNow;
+ ValidateTokenOptions options = new()
+ {
+ ValidTypes = ["ID+JWT"],
+ ValidAudiences = ["Audience"],
+ ValidIssuers = ["Issuer"],
+ Consume = true
+ };
+
+ ValidateTokenParameters parameters = new(Token, Secret, options);
+ Assert.Equal(Token, parameters.Token);
+ Assert.Equal(Secret, parameters.Secret);
+ Assert.Equal(options.ValidTypes, parameters.ValidTypes);
+ Assert.Equal(options.ValidAudiences, parameters.ValidAudiences);
+ Assert.Equal(options.ValidIssuers, parameters.ValidIssuers);
+ Assert.Equal(options.Consume, parameters.Consume);
+ }
+
+ [Fact(DisplayName = "ctor: it should construct the correct parameters.")]
+ public void ctor_it_should_construct_the_correct_parameters()
+ {
+ ClaimsIdentity subject = new();
+ ValidateTokenParameters parameters = new(Token, Secret);
+ Assert.Equal(Token, parameters.Token);
+ Assert.Equal(Secret, parameters.Secret);
+ }
+}
diff --git a/tests/Logitar.Identity.Domain.UnitTests/Tokens/ValidatedTokenTests.cs b/tests/Logitar.Identity.Domain.UnitTests/Tokens/ValidatedTokenTests.cs
new file mode 100644
index 0000000..713bf41
--- /dev/null
+++ b/tests/Logitar.Identity.Domain.UnitTests/Tokens/ValidatedTokenTests.cs
@@ -0,0 +1,21 @@
+using Microsoft.IdentityModel.Tokens;
+
+namespace Logitar.Identity.Domain.Tokens;
+
+[Trait(Traits.Category, Categories.Unit)]
+public class ValidatedTokenTests
+{
+ private readonly JwtSecurityTokenHandler _tokenHandler = new();
+
+ [Fact(DisplayName = "ctor: it should construct the correct instance.")]
+ public void ctor_it_should_construct_the_correct_instance()
+ {
+ ClaimsPrincipal claimsPrincipal = new();
+ SecurityTokenDescriptor tokenDescriptor = new();
+ SecurityToken securityToken = _tokenHandler.CreateToken(tokenDescriptor);
+
+ ValidatedToken validatedToken = new(claimsPrincipal, securityToken);
+ Assert.Same(claimsPrincipal, validatedToken.ClaimsPrincipal);
+ Assert.Same(securityToken, validatedToken.SecurityToken);
+ }
+}
diff --git a/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Logitar.Identity.EFCore.SqlServer.IntegrationTests.csproj b/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Logitar.Identity.EFCore.SqlServer.IntegrationTests.csproj
index 968ecce..e09018a 100644
--- a/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Logitar.Identity.EFCore.SqlServer.IntegrationTests.csproj
+++ b/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Logitar.Identity.EFCore.SqlServer.IntegrationTests.csproj
@@ -18,7 +18,7 @@
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -31,7 +31,7 @@
-
+
diff --git a/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Repositories/UserRepositoryTests.cs b/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Repositories/UserRepositoryTests.cs
index ea29f53..3ec71ad 100644
--- a/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Repositories/UserRepositoryTests.cs
+++ b/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Repositories/UserRepositoryTests.cs
@@ -3,7 +3,6 @@
using Logitar.Data.SqlServer;
using Logitar.EventSourcing;
using Logitar.EventSourcing.EntityFrameworkCore.Relational;
-using Logitar.Identity.Domain;
using Logitar.Identity.Domain.Passwords;
using Logitar.Identity.Domain.Roles;
using Logitar.Identity.Domain.Sessions;
diff --git a/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Tokens/TokenBlacklistTests.cs b/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Tokens/TokenBlacklistTests.cs
new file mode 100644
index 0000000..dc43d9c
--- /dev/null
+++ b/tests/Logitar.Identity.EFCore.SqlServer.IntegrationTests/Tokens/TokenBlacklistTests.cs
@@ -0,0 +1,142 @@
+using Logitar.Data;
+using Logitar.Data.SqlServer;
+using Logitar.EventSourcing.EntityFrameworkCore.Relational;
+using Logitar.Identity.Domain.Tokens;
+using Logitar.Identity.EntityFrameworkCore.Relational;
+using Logitar.Identity.EntityFrameworkCore.Relational.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Logitar.Identity.EntityFrameworkCore.SqlServer.Tokens;
+
+[Trait(Traits.Category, Categories.Integration)]
+public class TokenBlacklistTests : IAsyncLifetime
+{
+ private readonly EventContext _eventContext;
+ private readonly IdentityContext _identityContext;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ITokenBlacklist _tokenBlacklist;
+
+ public TokenBlacklistTests()
+ {
+ IConfiguration configuration = new ConfigurationBuilder()
+ .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
+ .Build();
+
+ string connectionString = (configuration.GetValue("SQLCONNSTR_Identity") ?? string.Empty)
+ .Replace("{Database}", nameof(TokenBlacklistTests));
+
+ _serviceProvider = new ServiceCollection()
+ .AddSingleton(configuration)
+ .AddLogitarIdentityWithEntityFrameworkCoreSqlServer(connectionString)
+ .BuildServiceProvider();
+
+ _eventContext = _serviceProvider.GetRequiredService();
+ _identityContext = _serviceProvider.GetRequiredService();
+ _tokenBlacklist = _serviceProvider.GetRequiredService();
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _eventContext.Database.MigrateAsync();
+ await _identityContext.Database.MigrateAsync();
+
+ TableId[] tables = [IdentityDb.TokenBlacklist.Table];
+ foreach (TableId table in tables)
+ {
+ ICommand command = SqlServerDeleteBuilder.From(table).Build();
+ await _identityContext.Database.ExecuteSqlRawAsync(command.Text, command.Parameters.ToArray());
+ }
+ }
+
+ [Fact(DisplayName = "BlacklistAsync: it should blacklist identifiers with expiration.")]
+ public async Task BlacklistAsync_it_should_blacklist_identifiers_with_expiration()
+ {
+ string duplicate = Guid.NewGuid().ToString();
+ string[] tokenIds = [Guid.NewGuid().ToString(), duplicate, duplicate];
+ DateTime expiresOn = DateTime.UtcNow.AddHours(1);
+
+ await _tokenBlacklist.BlacklistAsync(tokenIds, expiresOn);
+ Dictionary entities = await _identityContext.TokenBlacklist.AsNoTracking()
+ .ToDictionaryAsync(x => x.TokenId, x => x);
+ Assert.Equal(2, entities.Count);
+ foreach (string tokenId in tokenIds)
+ {
+ Assert.True(entities.ContainsKey(tokenId));
+ Assert.Equal(expiresOn, entities[tokenId].ExpiresOn);
+ }
+ }
+
+ [Fact(DisplayName = "BlacklistAsync: it should blacklist identifiers without expiration.")]
+ public async Task BlacklistAsync_it_should_blacklist_identifiers_without_expiration()
+ {
+ string duplicate = Guid.NewGuid().ToString();
+ string[] tokenIds = [Guid.NewGuid().ToString(), duplicate, duplicate];
+ Dictionary entities;
+
+ await _tokenBlacklist.BlacklistAsync(tokenIds);
+ entities = await _identityContext.TokenBlacklist.AsNoTracking().ToDictionaryAsync(x => x.TokenId, x => x);
+ foreach (string tokenId in tokenIds)
+ {
+ Assert.True(entities.ContainsKey(tokenId));
+ Assert.Null(entities[tokenId].ExpiresOn);
+ }
+
+ await _tokenBlacklist.BlacklistAsync(tokenIds, expiresOn: null);
+ entities = await _identityContext.TokenBlacklist.AsNoTracking().ToDictionaryAsync(x => x.TokenId, x => x);
+ foreach (string tokenId in tokenIds)
+ {
+ Assert.True(entities.ContainsKey(tokenId));
+ Assert.Null(entities[tokenId].ExpiresOn);
+ }
+ }
+
+ [Fact(DisplayName = "GetBlacklistedAsync: it should return empty when no ID is blacklisted.")]
+ public async Task GetBlacklistedAsync_it_should_return_empty_when_no_Id_is_blacklisted()
+ {
+ string[] tokenIds = [Guid.NewGuid().ToString(), Guid.NewGuid().ToString()];
+ IEnumerable blacklistedIds = await _tokenBlacklist.GetBlacklistedAsync(tokenIds);
+ Assert.Empty(blacklistedIds);
+ }
+
+ [Fact(DisplayName = "GetBlacklistedAsync: it should return only the blacklisted IDs.")]
+ public async Task GetBlacklistedAsync_it_should_return_only_the_blacklisted_Ids()
+ {
+ string blacklistedId = Guid.NewGuid().ToString();
+ string otherId = Guid.NewGuid().ToString();
+
+ BlacklistedTokenEntity entity = new(blacklistedId);
+ _identityContext.TokenBlacklist.Add(entity);
+ await _identityContext.SaveChangesAsync();
+
+ string[] tokenIds = [blacklistedId, otherId];
+ IEnumerable blacklistedIds = await _tokenBlacklist.GetBlacklistedAsync(tokenIds);
+ Assert.Equal([blacklistedId], blacklistedIds);
+ }
+
+ [Fact(DisplayName = "PurgeAsync: it should remove only expired items.")]
+ public async Task PurgeAsync_it_should_remove_only_expired_items()
+ {
+ BlacklistedTokenEntity expired = new("expired")
+ {
+ ExpiresOn = DateTime.Now.AddDays(-1)
+ };
+ BlacklistedTokenEntity notExpired = new("notExpired")
+ {
+ ExpiresOn = DateTime.Now.AddDays(1)
+ };
+ BlacklistedTokenEntity noExpiration = new("noExpiration");
+ _identityContext.TokenBlacklist.AddRange(expired, notExpired, noExpiration);
+ await _identityContext.SaveChangesAsync();
+
+ await _tokenBlacklist.PurgeAsync();
+
+ HashSet entities = [.. (await _identityContext.TokenBlacklist.AsNoTracking().Select(x => x.TokenId).ToArrayAsync())];
+ Assert.Equal(2, entities.Count);
+ Assert.Contains(notExpired.TokenId, entities);
+ Assert.Contains(noExpiration.TokenId, entities);
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+}
diff --git a/tests/Logitar.Identity.Infrastructure.UnitTests/Logitar.Identity.Infrastructure.UnitTests.csproj b/tests/Logitar.Identity.Infrastructure.UnitTests/Logitar.Identity.Infrastructure.UnitTests.csproj
new file mode 100644
index 0000000..a09471b
--- /dev/null
+++ b/tests/Logitar.Identity.Infrastructure.UnitTests/Logitar.Identity.Infrastructure.UnitTests.csproj
@@ -0,0 +1,39 @@
+
+
+
+ net8.0
+ enable
+ enable
+ Nullable
+
+ false
+ true
+ Logitar.Identity.Infrastructure
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Logitar.Identity.Infrastructure.UnitTests/Tokens/JsonWebTokenManagerTests.cs b/tests/Logitar.Identity.Infrastructure.UnitTests/Tokens/JsonWebTokenManagerTests.cs
new file mode 100644
index 0000000..9a72665
--- /dev/null
+++ b/tests/Logitar.Identity.Infrastructure.UnitTests/Tokens/JsonWebTokenManagerTests.cs
@@ -0,0 +1,306 @@
+using Bogus;
+using Logitar.Identity.Domain.Tokens;
+using Logitar.Security.Claims;
+using Logitar.Security.Cryptography;
+using Microsoft.IdentityModel.Tokens;
+using Moq;
+
+namespace Logitar.Identity.Infrastructure.Tokens;
+
+[Trait(Traits.Category, Categories.Unit)]
+public class JsonWebTokenManagerTests
+{
+ private readonly CancellationToken _cancellationToken = default;
+ private readonly Faker _faker = new();
+ private readonly string _tokenId = $"TokenId:{Guid.NewGuid()}";
+ private readonly ClaimsIdentity _subject = new();
+ private readonly string _secret = RandomStringGenerator.GetString(256 / 8);
+
+ private readonly Mock _tokenBlacklist = new();
+ private readonly JwtSecurityTokenHandler _tokenHandler = new();
+ private readonly JsonWebTokenManager _tokenManager;
+
+ public JsonWebTokenManagerTests()
+ {
+ DateTime now = DateTime.Now;
+ _subject.AddClaim(new(Rfc7519ClaimNames.Subject, $"UserId:{Guid.NewGuid()}"));
+ _subject.AddClaim(new(Rfc7519ClaimNames.Username, _faker.Person.UserName));
+ _subject.AddClaim(new(Rfc7519ClaimNames.EmailAddress, _faker.Person.Email));
+ _subject.AddClaim(new(Rfc7519ClaimNames.IsEmailVerified, bool.TrueString.ToLower()));
+ _subject.AddClaim(new(Rfc7519ClaimNames.PhoneNumber, _faker.Person.Phone));
+ _subject.AddClaim(new(Rfc7519ClaimNames.IsPhoneVerified, bool.FalseString.ToLower()));
+ _subject.AddClaim(new(Rfc7519ClaimNames.FirstName, _faker.Person.FirstName));
+ _subject.AddClaim(new(Rfc7519ClaimNames.LastName, _faker.Person.LastName));
+ _subject.AddClaim(new(Rfc7519ClaimNames.FullName, _faker.Person.FullName));
+ _subject.AddClaim(new(Rfc7519ClaimNames.Gender, _faker.Person.Gender.ToString().ToLower()));
+ _subject.AddClaim(new(Rfc7519ClaimNames.Locale, _faker.Locale));
+ _subject.AddClaim(new(Rfc7519ClaimNames.Picture, _faker.Person.Avatar));
+ _subject.AddClaim(new(Rfc7519ClaimNames.Roles, "manage_users manage_sessions"));
+ _subject.AddClaim(new(Rfc7519ClaimNames.SessionId, $"SessionId:{Guid.NewGuid()}"));
+ _subject.AddClaim(new(Rfc7519ClaimNames.TokenId, _tokenId));
+ _subject.AddClaim(ClaimHelper.Create(Rfc7519ClaimNames.Birthdate, _faker.Person.DateOfBirth));
+ _subject.AddClaim(ClaimHelper.Create(Rfc7519ClaimNames.UpdatedAt, now));
+ _subject.AddClaim(ClaimHelper.Create(Rfc7519ClaimNames.AuthenticationTime, now));
+
+ _tokenManager = new(_tokenBlacklist.Object, _tokenHandler);
+ }
+
+ [Fact(DisplayName = "CreateAsync: it should create the correct token from parameters.")]
+ public async Task CreateAsync_it_should_create_the_correct_token_from_parameters()
+ {
+ DateTime now = DateTime.Now;
+ CreateTokenParameters parameters = new(_subject, _secret)
+ {
+ Type = "ID+JWT",
+ Audience = "test_audience",
+ Issuer = "test_issuer",
+ Expires = now.AddMinutes(15),
+ IssuedAt = now.AddSeconds(-15).ToUniversalTime(),
+ NotBefore = new(now.Ticks, DateTimeKind.Unspecified)
+ };
+ CreatedToken createdToken = await _tokenManager.CreateAsync(parameters, _cancellationToken);
+ AssertIsValid(createdToken, parameters);
+ }
+
+ [Fact(DisplayName = "CreateAsync: it should create the correct token with options.")]
+ public async Task CreateAsync_it_should_create_the_correct_token_with_options()
+ {
+ DateTime now = DateTime.Now;
+ CreateTokenOptions options = new()
+ {
+ Type = "ID+JWT",
+ Audience = "test_audience",
+ Issuer = "test_issuer",
+ Expires = now.AddMinutes(15),
+ IssuedAt = now.AddSeconds(-15).ToUniversalTime(),
+ NotBefore = new(now.Ticks, DateTimeKind.Unspecified)
+ };
+ CreatedToken createdToken = await _tokenManager.CreateAsync(_subject, _secret, options, _cancellationToken);
+ AssertIsValid(createdToken, options);
+ }
+
+ [Fact(DisplayName = "CreateAsync: it should create the correct token without options.")]
+ public async Task CreateAsync_it_should_create_the_correct_token_without_options()
+ {
+ CreatedToken createdToken;
+
+ createdToken = await _tokenManager.CreateAsync(_subject, _secret, _cancellationToken);
+ AssertIsValid(createdToken);
+
+ createdToken = await _tokenManager.CreateAsync(_subject, _secret, options: null, _cancellationToken);
+ AssertIsValid(createdToken);
+ }
+
+ [Fact(DisplayName = "ctor: it should construct the correct instance.")]
+ public void ctor_it_should_construct_the_correct_instance()
+ {
+ Assert.Empty(_tokenHandler.InboundClaimTypeMap);
+ }
+
+ [Fact(DisplayName = "ValidateAsync: it should blacklist token identifiers when consuming.")]
+ public async Task ValidateAsync_it_should_blacklist_token_identifiers_when_consuming()
+ {
+ SecurityTokenDescriptor tokenDescriptor = new()
+ {
+ SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secret)), SecurityAlgorithms.HmacSha256),
+ Subject = _subject,
+ };
+ SecurityToken securityToken = _tokenHandler.CreateToken(tokenDescriptor);
+ string token = _tokenHandler.WriteToken(securityToken);
+
+ ValidateTokenParameters parameters = new(token, _secret)
+ {
+ Consume = true
+ };
+ ValidatedToken validatedToken = await _tokenManager.ValidateAsync(parameters, _cancellationToken);
+ AssertIsValid(validatedToken);
+
+ DateTime expiresOn = securityToken.ValidTo.AddMinutes(5); // NOTE(fpion): default TokenValidationParameters.ClockSkew
+ string[] tokenIds = [_tokenId];
+ _tokenBlacklist.Verify(x => x.GetBlacklistedAsync(It.Is>(y => y.SequenceEqual(tokenIds)), _cancellationToken), Times.Once);
+ _tokenBlacklist.Verify(x => x.BlacklistAsync(tokenIds, expiresOn, It.IsAny()), Times.Once);
+ }
+
+ [Fact(DisplayName = "ValidateAsync: it should throw SecurityTokenBlacklistedException when an identifier is blacklisted.")]
+ public async Task ValidateAsync_it_should_throw_SecurityTokenBlacklistedException_when_an_identifier_is_blacklisted()
+ {
+ string[] tokenIds = [_tokenId];
+
+ _tokenBlacklist.Setup(x => x.GetBlacklistedAsync(tokenIds, _cancellationToken)).ReturnsAsync(tokenIds);
+
+ SecurityTokenDescriptor tokenDescriptor = new()
+ {
+ SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secret)), SecurityAlgorithms.HmacSha256),
+ Subject = _subject,
+ };
+ SecurityToken securityToken = _tokenHandler.CreateToken(tokenDescriptor);
+ string token = _tokenHandler.WriteToken(securityToken);
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await _tokenManager.ValidateAsync(token, _secret, _cancellationToken)
+ );
+ Assert.Equal(tokenIds, exception.BlacklistedIds);
+
+ _tokenBlacklist.Verify(x => x.BlacklistAsync(It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ [Fact(DisplayName = "ValidateAsync: it should validate a token from parameters.")]
+ public async Task ValidateAsync_it_should_validate_a_token_from_parameters()
+ {
+ SecurityTokenDescriptor tokenDescriptor = new()
+ {
+ Audience = "Audience",
+ Issuer = "Issuer",
+ SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secret)), SecurityAlgorithms.HmacSha256),
+ Subject = _subject,
+ TokenType = "ID+JWT"
+ };
+ SecurityToken securityToken = _tokenHandler.CreateToken(tokenDescriptor);
+ string token = _tokenHandler.WriteToken(securityToken);
+
+ ValidateTokenParameters parameters = new(token, _secret)
+ {
+ ValidTypes = ["ID+JWT"],
+ ValidAudiences = ["Audience"],
+ ValidIssuers = ["Issuer"],
+ Consume = false
+ };
+ ValidatedToken validatedToken = await _tokenManager.ValidateAsync(parameters, _cancellationToken);
+ AssertIsValid(validatedToken);
+
+ _tokenBlacklist.Verify(x => x.GetBlacklistedAsync(It.Is>(y => y.SequenceEqual(new[] { _tokenId })), _cancellationToken), Times.Once);
+ _tokenBlacklist.Verify(x => x.BlacklistAsync(It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ [Fact(DisplayName = "ValidateAsync: it should validate a token with options.")]
+ public async Task ValidateAsync_it_should_validate_a_token_with_options()
+ {
+ SecurityTokenDescriptor tokenDescriptor = new()
+ {
+ Audience = "Audience",
+ Issuer = "Issuer",
+ SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secret)), SecurityAlgorithms.HmacSha256),
+ Subject = _subject,
+ TokenType = "ID+JWT"
+ };
+ SecurityToken securityToken = _tokenHandler.CreateToken(tokenDescriptor);
+ string token = _tokenHandler.WriteToken(securityToken);
+
+ ValidateTokenOptions options = new()
+ {
+ ValidTypes = ["ID+JWT"],
+ ValidAudiences = ["Audience"],
+ ValidIssuers = ["Issuer"],
+ Consume = false
+ };
+ ValidatedToken validatedToken = await _tokenManager.ValidateAsync(token, _secret, options, _cancellationToken);
+ AssertIsValid(validatedToken);
+
+ _tokenBlacklist.Verify(x => x.GetBlacklistedAsync(It.Is>(y => y.SequenceEqual(new[] { _tokenId })), _cancellationToken), Times.Once);
+ _tokenBlacklist.Verify(x => x.BlacklistAsync(It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ [Fact(DisplayName = "ValidateAsync: it should validate a token without options.")]
+ public async Task ValidateAsync_it_should_validate_a_token_without_options()
+ {
+ SecurityTokenDescriptor tokenDescriptor = new()
+ {
+ SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secret)), SecurityAlgorithms.HmacSha256),
+ Subject = _subject
+ };
+ SecurityToken securityToken = _tokenHandler.CreateToken(tokenDescriptor);
+ string token = _tokenHandler.WriteToken(securityToken);
+
+ ValidatedToken validatedToken;
+
+ validatedToken = await _tokenManager.ValidateAsync(token, _secret, _cancellationToken);
+ AssertIsValid(validatedToken);
+
+ validatedToken = await _tokenManager.ValidateAsync(token, _secret, options: null, _cancellationToken);
+ AssertIsValid(validatedToken);
+
+ _tokenBlacklist.Verify(x => x.GetBlacklistedAsync(It.Is>(y => y.SequenceEqual(new[] { _tokenId })), _cancellationToken), Times.Exactly(2));
+ _tokenBlacklist.Verify(x => x.BlacklistAsync(It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ private void AssertIsValid(CreatedToken createdToken, CreateTokenOptions? options = null)
+ {
+ TokenValidationParameters validationParameters = new()
+ {
+ ClockSkew = TimeSpan.FromSeconds(0),
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secret)),
+ ValidateAudience = false,
+ ValidateIssuer = false,
+ ValidateIssuerSigningKey = true,
+ ValidateLifetime = true
+ };
+
+ if (options != null)
+ {
+ validationParameters.ValidTypes = [options.Type];
+
+ if (options.Audience != null)
+ {
+ validationParameters.ValidAudience = options.Audience;
+ validationParameters.ValidateAudience = true;
+ }
+ if (options.Issuer != null)
+ {
+ validationParameters.ValidIssuer = options.Issuer;
+ validationParameters.ValidateIssuer = true;
+ }
+ }
+
+ ClaimsPrincipal principal = _tokenHandler.ValidateToken(createdToken.TokenString, validationParameters, out _);
+
+ Dictionary claims = principal.Claims.ToDictionary(x => x.Type, x => x.Value);
+ foreach (Claim claim in _subject.Claims)
+ {
+ Assert.True(claims.ContainsKey(claim.Type));
+ Assert.Equal(claim.Value, claims[claim.Type]);
+ }
+
+ if (options != null)
+ {
+ if (options.Audience != null)
+ {
+ Assert.True(claims.ContainsKey(Rfc7519ClaimNames.Audience));
+ Assert.Equal(options.Audience, claims[Rfc7519ClaimNames.Audience]);
+ }
+ if (options.Issuer != null)
+ {
+ Assert.True(claims.ContainsKey(Rfc7519ClaimNames.Issuer));
+ Assert.Equal(options.Issuer, claims[Rfc7519ClaimNames.Issuer]);
+ }
+
+ if (options.Expires.HasValue)
+ {
+ Claim claim = ClaimHelper.Create(Rfc7519ClaimNames.ExpirationTime, options.Expires.Value);
+ Assert.True(claims.ContainsKey(claim.Type));
+ Assert.Equal(claim.Value, claims[claim.Type]);
+ }
+ if (options.IssuedAt.HasValue)
+ {
+ Claim claim = ClaimHelper.Create(Rfc7519ClaimNames.IssuedAt, options.IssuedAt.Value);
+ Assert.True(claims.ContainsKey(claim.Type));
+ Assert.Equal(claim.Value, claims[claim.Type]);
+ }
+ if (options.NotBefore.HasValue)
+ {
+ Claim claim = ClaimHelper.Create(Rfc7519ClaimNames.NotBefore, options.NotBefore.Value);
+ Assert.True(claims.ContainsKey(claim.Type));
+ Assert.Equal(claim.Value, claims[claim.Type]);
+ }
+ }
+ }
+
+ private void AssertIsValid(ValidatedToken validatedToken)
+ {
+ Dictionary claims = validatedToken.ClaimsPrincipal.Claims.ToDictionary(x => x.Type, x => x.Value);
+ foreach (Claim claim in _subject.Claims)
+ {
+ Assert.True(claims.ContainsKey(claim.Type));
+ Assert.Equal(claim.Value, claims[claim.Type]);
+ }
+ }
+}
diff --git a/tests/Logitar.Identity.Domain.UnitTests/Categories.cs b/tests/Logitar.Identity.Tests/Categories.cs
similarity index 78%
rename from tests/Logitar.Identity.Domain.UnitTests/Categories.cs
rename to tests/Logitar.Identity.Tests/Categories.cs
index b6f5a2b..182f101 100644
--- a/tests/Logitar.Identity.Domain.UnitTests/Categories.cs
+++ b/tests/Logitar.Identity.Tests/Categories.cs
@@ -1,4 +1,4 @@
-namespace Logitar.Identity.Domain;
+namespace Logitar.Identity;
public static class Categories
{
diff --git a/tests/Logitar.Identity.Tests/Logitar.Identity.Tests.csproj b/tests/Logitar.Identity.Tests/Logitar.Identity.Tests.csproj
new file mode 100644
index 0000000..8e8e7ee
--- /dev/null
+++ b/tests/Logitar.Identity.Tests/Logitar.Identity.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+ Nullable
+
+ false
+ true
+ Logitar.Identity
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
diff --git a/tests/Logitar.Identity.Domain.UnitTests/Traits.cs b/tests/Logitar.Identity.Tests/Traits.cs
similarity index 68%
rename from tests/Logitar.Identity.Domain.UnitTests/Traits.cs
rename to tests/Logitar.Identity.Tests/Traits.cs
index 1eca285..deb626b 100644
--- a/tests/Logitar.Identity.Domain.UnitTests/Traits.cs
+++ b/tests/Logitar.Identity.Tests/Traits.cs
@@ -1,4 +1,4 @@
-namespace Logitar.Identity.Domain;
+namespace Logitar.Identity;
public static class Traits
{