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 {