Claude-skill-registry add-ravendb-identity-store
Implement ASP.NET Identity user and refresh token stores backed by RavenDB
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/add-ravendb-identity-store" ~/.claude/skills/majiayu000-claude-skill-registry-add-ravendb-identity-store && rm -rf "$T"
manifest:
skills/data/add-ravendb-identity-store/SKILL.mdsource content
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/
// 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<string> 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
public class RavenDbUserStore : IUserStore<ApplicationUser>, IUserPasswordStore<ApplicationUser>, IUserRoleStore<ApplicationUser>, IUserEmailStore<ApplicationUser> { private readonly IAsyncDocumentSession _session; public RavenDbUserStore(IAsyncDocumentSession session) { _session = session; } // IUserStore public async Task<IdentityResult> 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<ApplicationUser?> FindByIdAsync( string userId, CancellationToken ct) { return await _session.Query<ApplicationUser>() .FirstOrDefaultAsync(u => u.UserId == userId, ct); } public async Task<ApplicationUser?> FindByNameAsync( string normalizedUserName, CancellationToken ct) { return await _session.Query<ApplicationUser>() .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedUserName, ct); } public async Task<IdentityResult> UpdateAsync( ApplicationUser user, CancellationToken ct) { await _session.SaveChangesAsync(ct); return IdentityResult.Success; } public async Task<IdentityResult> DeleteAsync( ApplicationUser user, CancellationToken ct) { _session.Delete(user); await _session.SaveChangesAsync(ct); return IdentityResult.Success; } public Task<string> GetUserIdAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult(user.UserId); public Task<string?> GetUserNameAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult<string?>(user.Email); public Task SetUserNameAsync( ApplicationUser user, string? userName, CancellationToken ct) { user.Email = userName!; return Task.CompletedTask; } public Task<string?> GetNormalizedUserNameAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult<string?>(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<string?> GetPasswordHashAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult<string?>(user.PasswordHash); public Task<bool> 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<IList<string>> GetRolesAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult<IList<string>>(user.Roles); public Task<bool> IsInRoleAsync( ApplicationUser user, string roleName, CancellationToken ct) => Task.FromResult(user.Roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)); public async Task<IList<ApplicationUser>> GetUsersInRoleAsync( string roleName, CancellationToken ct) { return await _session.Query<ApplicationUser>() .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<string?> GetEmailAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult<string?>(user.Email); public Task<bool> 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<ApplicationUser?> FindByEmailAsync( string normalizedEmail, CancellationToken ct) { return await _session.Query<ApplicationUser>() .FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct); } public Task<string?> GetNormalizedEmailAsync( ApplicationUser user, CancellationToken ct) => Task.FromResult<string?>(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
public interface IRefreshTokenRepository { Task<RefreshToken> CreateAsync(string userId, string tokenHash, DateTime expiresAt, string? deviceId, CancellationToken ct); Task<RefreshToken?> FindByHashAsync(string tokenHash, CancellationToken ct); Task RevokeAsync(string tokenId, CancellationToken ct); Task RevokeAllForUserAsync(string userId, CancellationToken ct); Task<int> 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<RefreshToken> 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<RefreshToken?> FindByHashAsync( string tokenHash, CancellationToken ct) { return await _session.Query<RefreshToken>() .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<RefreshToken>(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<RefreshToken>() .Where(t => t.UserId == userId && !t.IsRevoked) .ToListAsync(ct); foreach (var token in tokens) token.IsRevoked = true; await _session.SaveChangesAsync(ct); } public async Task<int> GetActiveCountForUserAsync( string userId, CancellationToken ct) { return await _session.Query<RefreshToken>() .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<RefreshToken>() .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
// Register RavenDB session (per request) builder.Services.AddScoped(sp => { var store = sp.GetRequiredService<IDocumentStore>(); return store.OpenAsyncSession(); }); // Register identity stores builder.Services.AddScoped<IUserStore<ApplicationUser>, RavenDbUserStore>(); builder.Services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>(); // Configure Identity (without Entity Framework) builder.Services.AddIdentityCore<ApplicationUser>(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<IdentityRole>() .AddUserStore<RavenDbUserStore>() .AddDefaultTokenProviders();
5. Create RavenDB Indexes
Location:
src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/RavenDb/Indexes/
// Users_ByEmail.cs public class Users_ByEmail : AbstractIndexCreationTask<ApplicationUser> { public Users_ByEmail() { Map = users => from user in users select new { user.NormalizedEmail }; } } // RefreshTokens_ByUserAndHash.cs public class RefreshTokens_ByUserAndHash : AbstractIndexCreationTask<RefreshToken> { public RefreshTokens_ByUserAndHash() { Map = tokens => from token in tokens select new { token.UserId, token.TokenHash, token.IsRevoked, token.ExpiresAt }; } }
Required NuGet Packages
dotnet add package Microsoft.AspNetCore.Identity dotnet add package Microsoft.Extensions.Identity.Core dotnet add package Ulid
RavenDB Collections
| Collection | Document Type | Purpose |
|---|---|---|
| | User accounts and credentials |
| | Hashed refresh tokens |
Testing
[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"); }