--- description: Implement ASP.NET Identity user and refresh token stores backed by RavenDB --- # Add RavenDB Identity Store Skill Implement ASP.NET Identity stores backed by RavenDB for NovaTune. ## Project Context - Identity models: `src/NovaTuneApp/NovaTuneApp.ApiService/Models/Identity/` - Identity stores: `src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/Identity/` - RavenDB config: `src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/RavenDb/` ## Steps ### 1. Create Identity Models Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Models/Identity/` ```csharp // ApplicationUser.cs public class ApplicationUser { public string Id { get; set; } = null!; // RavenDB internal ID: "Users/{guid}" public string UserId { get; set; } = null!; // ULID external identifier public string Email { get; set; } = null!; public string NormalizedEmail { get; set; } = null!; public string DisplayName { get; set; } = null!; public string PasswordHash { get; set; } = null!; public UserStatus Status { get; set; } = UserStatus.Active; public List Roles { get; set; } = ["Listener"]; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? LastLoginAt { get; set; } } // UserStatus.cs public enum UserStatus { Active, Disabled, PendingDeletion } // RefreshToken.cs public class RefreshToken { public string Id { get; set; } = null!; // RavenDB: "RefreshTokens/{guid}" public string UserId { get; set; } = null!; // References ApplicationUser.UserId public string TokenHash { get; set; } = null!; // SHA-256 hash public string? DeviceIdentifier { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime ExpiresAt { get; set; } public bool IsRevoked { get; set; } } ``` ### 2. Create User Store Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/Identity/RavenDbUserStore.cs` ```csharp public class RavenDbUserStore : IUserStore, IUserPasswordStore, IUserRoleStore, IUserEmailStore { private readonly IAsyncDocumentSession _session; public RavenDbUserStore(IAsyncDocumentSession session) { _session = session; } // IUserStore public async Task CreateAsync( ApplicationUser user, CancellationToken ct) { user.UserId = Ulid.NewUlid().ToString(); await _session.StoreAsync(user, ct); await _session.SaveChangesAsync(ct); return IdentityResult.Success; } public async Task FindByIdAsync( string userId, CancellationToken ct) { return await _session.Query() .FirstOrDefaultAsync(u => u.UserId == userId, ct); } public async Task FindByNameAsync( string normalizedUserName, CancellationToken ct) { return await _session.Query() .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedUserName, ct); } public async Task UpdateAsync( ApplicationUser user, CancellationToken ct) { await _session.SaveChangesAsync(ct); return IdentityResult.Success; } public async Task DeleteAsync( ApplicationUser user, CancellationToken ct) { _session.Delete(user); await _session.SaveChangesAsync(ct); return IdentityResult.Success; } public Task GetUserIdAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(user.UserId); public Task GetUserNameAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(user.Email); public Task SetUserNameAsync( ApplicationUser user, string? userName, CancellationToken ct) { user.Email = userName!; return Task.CompletedTask; } public Task GetNormalizedUserNameAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(user.NormalizedEmail); public Task SetNormalizedUserNameAsync( ApplicationUser user, string? normalizedName, CancellationToken ct) { user.NormalizedEmail = normalizedName!; return Task.CompletedTask; } // IUserPasswordStore public Task SetPasswordHashAsync( ApplicationUser user, string? passwordHash, CancellationToken ct) { user.PasswordHash = passwordHash!; return Task.CompletedTask; } public Task GetPasswordHashAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(user.PasswordHash); public Task HasPasswordAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(!string.IsNullOrEmpty(user.PasswordHash)); // IUserRoleStore public Task AddToRoleAsync( ApplicationUser user, string roleName, CancellationToken ct) { if (!user.Roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)) user.Roles.Add(roleName); return Task.CompletedTask; } public Task RemoveFromRoleAsync( ApplicationUser user, string roleName, CancellationToken ct) { user.Roles.RemoveAll(r => r.Equals(roleName, StringComparison.OrdinalIgnoreCase)); return Task.CompletedTask; } public Task> GetRolesAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult>(user.Roles); public Task IsInRoleAsync( ApplicationUser user, string roleName, CancellationToken ct) => Task.FromResult(user.Roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)); public async Task> GetUsersInRoleAsync( string roleName, CancellationToken ct) { return await _session.Query() .Where(u => u.Roles.Contains(roleName)) .ToListAsync(ct); } // IUserEmailStore public Task SetEmailAsync( ApplicationUser user, string? email, CancellationToken ct) { user.Email = email!; return Task.CompletedTask; } public Task GetEmailAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(user.Email); public Task GetEmailConfirmedAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(true); // Email confirmation not required for MVP public Task SetEmailConfirmedAsync( ApplicationUser user, bool confirmed, CancellationToken ct) => Task.CompletedTask; public async Task FindByEmailAsync( string normalizedEmail, CancellationToken ct) { return await _session.Query() .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct); } public Task GetNormalizedEmailAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(user.NormalizedEmail); public Task SetNormalizedEmailAsync( ApplicationUser user, string? normalizedEmail, CancellationToken ct) { user.NormalizedEmail = normalizedEmail!; return Task.CompletedTask; } public void Dispose() { } } ``` ### 3. Create Refresh Token Repository Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/Identity/RefreshTokenRepository.cs` ```csharp public interface IRefreshTokenRepository { Task CreateAsync(string userId, string tokenHash, DateTime expiresAt, string? deviceId, CancellationToken ct); Task FindByHashAsync(string tokenHash, CancellationToken ct); Task RevokeAsync(string tokenId, CancellationToken ct); Task RevokeAllForUserAsync(string userId, CancellationToken ct); Task GetActiveCountForUserAsync(string userId, CancellationToken ct); Task RevokeOldestForUserAsync(string userId, CancellationToken ct); } public class RefreshTokenRepository : IRefreshTokenRepository { private readonly IAsyncDocumentSession _session; public RefreshTokenRepository(IAsyncDocumentSession session) { _session = session; } public async Task CreateAsync( string userId, string tokenHash, DateTime expiresAt, string? deviceId, CancellationToken ct) { var token = new RefreshToken { UserId = userId, TokenHash = tokenHash, ExpiresAt = expiresAt, DeviceIdentifier = deviceId }; await _session.StoreAsync(token, ct); await _session.SaveChangesAsync(ct); return token; } public async Task FindByHashAsync( string tokenHash, CancellationToken ct) { return await _session.Query() .FirstOrDefaultAsync(t => t.TokenHash == tokenHash && !t.IsRevoked && t.ExpiresAt > DateTime.UtcNow, ct); } public async Task RevokeAsync(string tokenId, CancellationToken ct) { var token = await _session.LoadAsync(tokenId, ct); if (token != null) { token.IsRevoked = true; await _session.SaveChangesAsync(ct); } } public async Task RevokeAllForUserAsync(string userId, CancellationToken ct) { var tokens = await _session.Query() .Where(t => t.UserId == userId && !t.IsRevoked) .ToListAsync(ct); foreach (var token in tokens) token.IsRevoked = true; await _session.SaveChangesAsync(ct); } public async Task GetActiveCountForUserAsync( string userId, CancellationToken ct) { return await _session.Query() .CountAsync(t => t.UserId == userId && !t.IsRevoked && t.ExpiresAt > DateTime.UtcNow, ct); } public async Task RevokeOldestForUserAsync(string userId, CancellationToken ct) { var oldest = await _session.Query() .Where(t => t.UserId == userId && !t.IsRevoked) .OrderBy(t => t.CreatedAt) .FirstOrDefaultAsync(ct); if (oldest != null) { oldest.IsRevoked = true; await _session.SaveChangesAsync(ct); } } } ``` ### 4. Register Identity Services in Program.cs ```csharp // Register RavenDB session (per request) builder.Services.AddScoped(sp => { var store = sp.GetRequiredService(); return store.OpenAsyncSession(); }); // Register identity stores builder.Services.AddScoped, RavenDbUserStore>(); builder.Services.AddScoped(); // Configure Identity (without Entity Framework) builder.Services.AddIdentityCore(options => { options.Password.RequireDigit = false; options.Password.RequireLowercase = false; options.Password.RequireUppercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequiredLength = 1; // Non-empty per requirements options.User.RequireUniqueEmail = true; }) .AddRoles() .AddUserStore() .AddDefaultTokenProviders(); ``` ### 5. Create RavenDB Indexes Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/RavenDb/Indexes/` ```csharp // Users_ByEmail.cs public class Users_ByEmail : AbstractIndexCreationTask { public Users_ByEmail() { Map = users => from user in users select new { user.NormalizedEmail }; } } // RefreshTokens_ByUserAndHash.cs public class RefreshTokens_ByUserAndHash : AbstractIndexCreationTask { public RefreshTokens_ByUserAndHash() { Map = tokens => from token in tokens select new { token.UserId, token.TokenHash, token.IsRevoked, token.ExpiresAt }; } } ``` ## Required NuGet Packages ```bash dotnet add package Microsoft.AspNetCore.Identity dotnet add package Microsoft.Extensions.Identity.Core dotnet add package Ulid ``` ## RavenDB Collections | Collection | Document Type | Purpose | |------------|---------------|---------| | `Users` | `ApplicationUser` | User accounts and credentials | | `RefreshTokens` | `RefreshToken` | Hashed refresh tokens | ## Testing ```csharp [Fact] public async Task CreateAsync_GeneratesUlid() { var user = new ApplicationUser { Email = "test@example.com" }; var result = await _userStore.CreateAsync(user, CancellationToken.None); result.Succeeded.Should().BeTrue(); user.UserId.Should().NotBeNullOrEmpty(); Ulid.TryParse(user.UserId, out _).Should().BeTrue(); } [Fact] public async Task FindByEmailAsync_ReturnsUser_WhenExists() { var user = await _userStore.FindByEmailAsync("TEST@EXAMPLE.COM", CancellationToken.None); user.Should().NotBeNull(); user!.Email.Should().Be("test@example.com"); } ```