using Npgsql; namespace SpaceGame.Api.Auth.Simulation; public sealed class PostgresAuthRepository(NpgsqlDataSource dataSource) : IAuthRepository { public async Task FindUserByEmailAsync(string email, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" select id, email, password_hash, created_at_utc, roles from auth_users where email = $1 """); command.Parameters.AddWithValue(email); await using var reader = await command.ExecuteReaderAsync(cancellationToken); return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null; } public async Task FindUserByIdAsync(Guid userId, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" select id, email, password_hash, created_at_utc, roles from auth_users where id = $1 """); command.Parameters.AddWithValue(userId); await using var reader = await command.ExecuteReaderAsync(cancellationToken); return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null; } public async Task CreateUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken) { var userId = Guid.NewGuid(); var createdAtUtc = DateTimeOffset.UtcNow; await using var command = dataSource.CreateCommand(""" insert into auth_users (id, email, password_hash, created_at_utc, roles) values ($1, $2, $3, $4, $5) """); command.Parameters.AddWithValue(userId); command.Parameters.AddWithValue(email); command.Parameters.AddWithValue(passwordHash); command.Parameters.AddWithValue(createdAtUtc); command.Parameters.AddWithValue(roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray()); await command.ExecuteNonQueryAsync(cancellationToken); return new UserAccount(userId, email, passwordHash, createdAtUtc, roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray()); } public async Task UpsertUserAsync(string email, string passwordHash, IReadOnlyCollection roles, CancellationToken cancellationToken) { var normalizedRoles = roles.Select(role => role.Trim().ToLowerInvariant()).Where(role => role.Length > 0).Distinct(StringComparer.Ordinal).ToArray(); var userId = Guid.NewGuid(); var createdAtUtc = DateTimeOffset.UtcNow; await using var command = dataSource.CreateCommand(""" insert into auth_users (id, email, password_hash, created_at_utc, roles) values ($1, $2, $3, $4, $5) on conflict (email) do update set password_hash = excluded.password_hash, roles = excluded.roles returning id, email, password_hash, created_at_utc, roles """); command.Parameters.AddWithValue(userId); command.Parameters.AddWithValue(email); command.Parameters.AddWithValue(passwordHash); command.Parameters.AddWithValue(createdAtUtc); command.Parameters.AddWithValue(normalizedRoles); await using var reader = await command.ExecuteReaderAsync(cancellationToken); await reader.ReadAsync(cancellationToken); return ReadUser(reader); } public async Task StoreRefreshTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" insert into auth_refresh_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc, revoked_at_utc) values ($1, $2, $3, $4, $5, null) """); command.Parameters.AddWithValue(Guid.NewGuid()); command.Parameters.AddWithValue(userId); command.Parameters.AddWithValue(tokenHash); command.Parameters.AddWithValue(DateTimeOffset.UtcNow); command.Parameters.AddWithValue(expiresAtUtc); await command.ExecuteNonQueryAsync(cancellationToken); } public async Task FindRefreshTokenAsync(string tokenHash, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" select id, user_id, token_hash, created_at_utc, expires_at_utc, revoked_at_utc from auth_refresh_tokens where token_hash = $1 """); command.Parameters.AddWithValue(tokenHash); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) { return null; } return new RefreshTokenRecord( reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetFieldValue(3), reader.GetFieldValue(4), reader.IsDBNull(5) ? null : reader.GetFieldValue(5)); } public async Task RevokeRefreshTokenAsync(Guid refreshTokenId, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" update auth_refresh_tokens set revoked_at_utc = $2 where id = $1 and revoked_at_utc is null """); command.Parameters.AddWithValue(refreshTokenId); command.Parameters.AddWithValue(DateTimeOffset.UtcNow); await command.ExecuteNonQueryAsync(cancellationToken); } public async Task RevokeAllRefreshTokensAsync(Guid userId, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" update auth_refresh_tokens set revoked_at_utc = $2 where user_id = $1 and revoked_at_utc is null """); command.Parameters.AddWithValue(userId); command.Parameters.AddWithValue(DateTimeOffset.UtcNow); await command.ExecuteNonQueryAsync(cancellationToken); } public async Task StorePasswordResetTokenAsync(Guid userId, string tokenHash, DateTimeOffset expiresAtUtc, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" insert into auth_password_reset_tokens (id, user_id, token_hash, created_at_utc, expires_at_utc, consumed_at_utc) values ($1, $2, $3, $4, $5, null) """); command.Parameters.AddWithValue(Guid.NewGuid()); command.Parameters.AddWithValue(userId); command.Parameters.AddWithValue(tokenHash); command.Parameters.AddWithValue(DateTimeOffset.UtcNow); command.Parameters.AddWithValue(expiresAtUtc); await command.ExecuteNonQueryAsync(cancellationToken); } public async Task FindPasswordResetTokenAsync(string tokenHash, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" select id, user_id, token_hash, created_at_utc, expires_at_utc, consumed_at_utc from auth_password_reset_tokens where token_hash = $1 """); command.Parameters.AddWithValue(tokenHash); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) { return null; } return new PasswordResetTokenRecord( reader.GetGuid(0), reader.GetGuid(1), reader.GetString(2), reader.GetFieldValue(3), reader.GetFieldValue(4), reader.IsDBNull(5) ? null : reader.GetFieldValue(5)); } public async Task ConsumePasswordResetTokenAsync(Guid passwordResetTokenId, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" update auth_password_reset_tokens set consumed_at_utc = $2 where id = $1 and consumed_at_utc is null """); command.Parameters.AddWithValue(passwordResetTokenId); command.Parameters.AddWithValue(DateTimeOffset.UtcNow); await command.ExecuteNonQueryAsync(cancellationToken); } public async Task UpdatePasswordHashAsync(Guid userId, string passwordHash, CancellationToken cancellationToken) { await using var command = dataSource.CreateCommand(""" update auth_users set password_hash = $2 where id = $1 """); command.Parameters.AddWithValue(userId); command.Parameters.AddWithValue(passwordHash); await command.ExecuteNonQueryAsync(cancellationToken); } private static UserAccount ReadUser(NpgsqlDataReader reader) => new( reader.GetGuid(0), reader.GetString(1), reader.GetString(2), reader.GetFieldValue(3), reader.GetFieldValue(4)); }