Move configuration options from Core/DataAccess/DataSync/ExcelIO to dedicated Options folders within each project for better organization. Update all references and tests accordingly.
43 KiB
Encrypted Login Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Consolidate login models into Core and implement RSA-OAEP encryption for login credentials between Blazor WASM client and API.
Architecture: Client fetches server's RSA public key at runtime, encrypts login credentials with RSA-OAEP, sends encrypted payload to API. Server decrypts with private key (auto-generated on first startup, persisted to file). Shared models in Core eliminate duplication.
Tech Stack: .NET 10, RSA-OAEP (SHA-256), Blazor.SubtleCrypto (Web Crypto API wrapper), xUnit, NSubstitute, Shouldly
Task 1: Add Shared Auth Models to Core
Files:
- Create:
NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs - Create:
NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs - Create:
NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs - Create:
NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs
Step 1: Create Auth folder and LoginModel
// NEW/src/JdeScoping.Core/Models/Auth/LoginModel.cs
using System.ComponentModel.DataAnnotations;
namespace JdeScoping.Core.Models.Auth;
/// <summary>
/// Login credentials model shared by Client and API.
/// </summary>
public class LoginModel
{
[Required(ErrorMessage = "Username is required")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "Password is required")]
public string Password { get; set; } = string.Empty;
}
Step 2: Create LoginResultModel
// NEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.cs
namespace JdeScoping.Core.Models.Auth;
/// <summary>
/// Result returned from login API endpoint.
/// </summary>
/// <param name="Success">Whether authentication succeeded</param>
/// <param name="ErrorMessage">Error message if failed</param>
/// <param name="User">User info if successful</param>
public record LoginResultModel(
bool Success,
string? ErrorMessage,
UserInfo? User);
Step 3: Create EncryptedLoginRequest
// NEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.cs
namespace JdeScoping.Core.Models.Auth;
/// <summary>
/// Encrypted login payload sent from client to API.
/// </summary>
/// <param name="EncryptedData">Base64-encoded RSA-encrypted JSON of LoginModel</param>
public record EncryptedLoginRequest(string EncryptedData);
Step 4: Create PublicKeyResponse
// NEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.cs
namespace JdeScoping.Core.Models.Auth;
/// <summary>
/// Server's RSA public key for client-side encryption.
/// </summary>
/// <param name="PublicKeyPem">PEM-encoded RSA public key</param>
public record PublicKeyResponse(string PublicKeyPem);
Step 5: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Core/JdeScoping.Core.csproj
Expected: Build succeeded
Step 6: Commit
git add NEW/src/JdeScoping.Core/Models/Auth/
git commit -m "feat(core): add shared auth models for encrypted login"
Task 2: Add IRsaKeyService Interface to Core
Files:
- Create:
NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs
Step 1: Create the interface
// NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs
namespace JdeScoping.Core.Interfaces;
/// <summary>
/// RSA key management for login encryption.
/// </summary>
public interface IRsaKeyService
{
/// <summary>
/// Gets the server's public key in PEM format.
/// </summary>
string GetPublicKeyPem();
/// <summary>
/// Decrypts RSA-OAEP encrypted data.
/// </summary>
/// <param name="ciphertext">Encrypted bytes</param>
/// <returns>Decrypted plaintext bytes</returns>
byte[] Decrypt(byte[] ciphertext);
}
Step 2: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Core/JdeScoping.Core.csproj
Expected: Build succeeded
Step 3: Commit
git add NEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.cs
git commit -m "feat(core): add IRsaKeyService interface"
Task 3: Implement RsaKeyService with Tests (TDD)
Files:
- Create:
NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs - Create:
NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs
Step 1: Write failing test for GetPublicKeyPem
// NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs
using JdeScoping.Core.Interfaces;
using JdeScoping.Infrastructure.Security;
using Shouldly;
namespace JdeScoping.Infrastructure.Tests.Security;
public class RsaKeyServiceTests : IDisposable
{
private readonly string _testKeyPath;
public RsaKeyServiceTests()
{
_testKeyPath = Path.Combine(Path.GetTempPath(), $"test-rsa-key-{Guid.NewGuid()}.bin");
}
public void Dispose()
{
if (File.Exists(_testKeyPath))
File.Delete(_testKeyPath);
}
[Fact]
public void GetPublicKeyPem_ReturnsValidPemFormat()
{
// Arrange
var service = new RsaKeyService(_testKeyPath);
// Act
var pem = service.GetPublicKeyPem();
// Assert
pem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
pem.ShouldEndWith("-----END PUBLIC KEY-----");
}
[Fact]
public void Constructor_WhenKeyFileMissing_GeneratesAndPersistsKey()
{
// Arrange - ensure file doesn't exist
File.Exists(_testKeyPath).ShouldBeFalse();
// Act
_ = new RsaKeyService(_testKeyPath);
// Assert
File.Exists(_testKeyPath).ShouldBeTrue();
new FileInfo(_testKeyPath).Length.ShouldBeGreaterThan(0);
}
[Fact]
public void Constructor_WhenKeyFileExists_LoadsSameKey()
{
// Arrange - create service to generate key
var service1 = new RsaKeyService(_testKeyPath);
var publicKey1 = service1.GetPublicKeyPem();
// Act - create new service instance
var service2 = new RsaKeyService(_testKeyPath);
var publicKey2 = service2.GetPublicKeyPem();
// Assert - same key loaded
publicKey2.ShouldBe(publicKey1);
}
[Fact]
public void Decrypt_WithValidCiphertext_ReturnsPlaintext()
{
// Arrange
var service = new RsaKeyService(_testKeyPath);
var plaintext = "Hello, World!"u8.ToArray();
// Encrypt using public key (simulating what client does)
using var rsa = System.Security.Cryptography.RSA.Create();
rsa.ImportFromPem(service.GetPublicKeyPem());
var ciphertext = rsa.Encrypt(plaintext, System.Security.Cryptography.RSAEncryptionPadding.OaepSHA256);
// Act
var decrypted = service.Decrypt(ciphertext);
// Assert
decrypted.ShouldBe(plaintext);
}
[Fact]
public void Decrypt_WithInvalidCiphertext_ThrowsCryptographicException()
{
// Arrange
var service = new RsaKeyService(_testKeyPath);
var invalidCiphertext = new byte[] { 1, 2, 3, 4, 5 };
// Act & Assert
Should.Throw<System.Security.Cryptography.CryptographicException>(
() => service.Decrypt(invalidCiphertext));
}
}
Step 2: Run tests to verify they fail
Run: dotnet test NEW/tests/JdeScoping.Infrastructure.Tests --filter "FullyQualifiedName~RsaKeyServiceTests" --verbosity normal
Expected: FAIL - RsaKeyService class not found
Step 3: Implement RsaKeyService
// NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs
using System.Security.Cryptography;
using JdeScoping.Core.Interfaces;
namespace JdeScoping.Infrastructure.Security;
/// <summary>
/// RSA key service that auto-generates and persists keys.
/// </summary>
public class RsaKeyService : IRsaKeyService, IDisposable
{
private readonly RSA _rsa;
/// <summary>
/// Creates a new RSA key service.
/// </summary>
/// <param name="keyFilePath">Path to persist the private key</param>
public RsaKeyService(string keyFilePath)
{
_rsa = RSA.Create(2048);
if (File.Exists(keyFilePath))
{
var keyBytes = File.ReadAllBytes(keyFilePath);
_rsa.ImportRSAPrivateKey(keyBytes, out _);
}
else
{
var privateKey = _rsa.ExportRSAPrivateKey();
var directory = Path.GetDirectoryName(keyFilePath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllBytes(keyFilePath, privateKey);
}
}
/// <inheritdoc />
public string GetPublicKeyPem()
=> _rsa.ExportSubjectPublicKeyInfoPem();
/// <inheritdoc />
public byte[] Decrypt(byte[] ciphertext)
=> _rsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
public void Dispose()
{
_rsa.Dispose();
GC.SuppressFinalize(this);
}
}
Step 4: Run tests to verify they pass
Run: dotnet test NEW/tests/JdeScoping.Infrastructure.Tests --filter "FullyQualifiedName~RsaKeyServiceTests" --verbosity normal
Expected: All 5 tests pass
Step 5: Commit
git add NEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.cs
git add NEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.cs
git commit -m "feat(infrastructure): implement RsaKeyService with tests"
Task 4: Add RsaKeyOptions and DI Registration
Files:
- Create:
NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs - Modify:
NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs
Step 1: Create RsaKeyOptions
// NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs
namespace JdeScoping.Core.Options;
/// <summary>
/// Configuration options for RSA key service.
/// </summary>
public class RsaKeyOptions
{
public const string SectionName = "RsaKey";
/// <summary>
/// Path to store the RSA private key file.
/// Defaults to "data/rsa-key.bin" relative to app directory.
/// </summary>
public string KeyFilePath { get; set; } = "data/rsa-key.bin";
}
Step 2: Update Infrastructure DependencyInjection
Add to NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs after existing using statements:
using JdeScoping.Infrastructure.Security;
Add inside AddInfrastructure method, after the auth service registration (around line 60):
// Register RSA key service for login encryption
services.Configure<RsaKeyOptions>(
configuration.GetSection(RsaKeyOptions.SectionName));
var rsaKeyOptions = configuration
.GetSection(RsaKeyOptions.SectionName)
.Get<RsaKeyOptions>() ?? new RsaKeyOptions();
var keyPath = Path.IsPathRooted(rsaKeyOptions.KeyFilePath)
? rsaKeyOptions.KeyFilePath
: Path.Combine(AppContext.BaseDirectory, rsaKeyOptions.KeyFilePath);
services.AddSingleton<IRsaKeyService>(new RsaKeyService(keyPath));
Step 3: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj
Expected: Build succeeded
Step 4: Commit
git add NEW/src/JdeScoping.Core/Options/RsaKeyOptions.cs
git add NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs
git commit -m "feat(infrastructure): register RsaKeyService in DI"
Task 5: Update AuthController to Use Encrypted Login
Files:
- Modify:
NEW/src/JdeScoping.Api/Controllers/AuthController.cs - Delete:
NEW/src/JdeScoping.Api/Models/LoginRequest.cs
Step 1: Update AuthController imports and constructor
Replace the using statements at the top of AuthController.cs:
using System.Security.Claims;
using System.Text.Json;
using JdeScoping.Api.Extensions;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Auth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
Update the constructor to inject IRsaKeyService:
public class AuthController : ApiControllerBase
{
private readonly IAuthService _authService;
private readonly IRsaKeyService _rsaKeyService;
private readonly ILogger<AuthController> _logger;
public AuthController(
IAuthService authService,
IRsaKeyService rsaKeyService,
ILogger<AuthController> logger)
{
_authService = authService;
_rsaKeyService = rsaKeyService;
_logger = logger;
}
Step 2: Add GetPublicKey endpoint
Add after the constructor:
/// <summary>
/// Gets the server's RSA public key for encrypting login credentials.
/// </summary>
[HttpGet("public-key")]
[AllowAnonymous]
[ProducesResponseType(typeof(PublicKeyResponse), StatusCodes.Status200OK)]
public ActionResult<PublicKeyResponse> GetPublicKey()
{
var publicKeyPem = _rsaKeyService.GetPublicKeyPem();
return Ok(new PublicKeyResponse(publicKeyPem));
}
Step 3: Update Login endpoint to accept encrypted payload
Replace the existing Login method:
/// <summary>
/// Authenticates a user with encrypted credentials and creates a session cookie.
/// </summary>
/// <param name="request">Encrypted login credentials</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Login result with user info on success</returns>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResultModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(LoginResultModel), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<LoginResultModel>> Login(
[FromBody] EncryptedLoginRequest request,
CancellationToken ct)
{
LoginModel loginModel;
try
{
var ciphertext = Convert.FromBase64String(request.EncryptedData);
var plaintext = _rsaKeyService.Decrypt(ciphertext);
loginModel = JsonSerializer.Deserialize<LoginModel>(plaintext)
?? throw new InvalidOperationException("Deserialization returned null");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to decrypt login request");
return BadRequest(new LoginResultModel(false, "Invalid encrypted payload", null));
}
var result = await _authService.AuthenticateAsync(
loginModel.Username, loginModel.Password, ct);
if (!result.Success)
{
_logger.LogWarning("Failed login attempt for user {Username}", loginModel.Username);
return Unauthorized(new LoginResultModel(false, result.ErrorMessage, null));
}
// Sign out existing session
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Create claims identity from user info
var identity = ClaimsExtensions.FromUserInfo(result.User!);
var principal = new ClaimsPrincipal(identity);
// Sign in with non-persistent cookie
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties { IsPersistent = false });
_logger.LogInformation("User {Username} logged in successfully", loginModel.Username);
return Ok(new LoginResultModel(true, null, result.User));
}
Step 4: Delete old LoginRequest model
Delete file: NEW/src/JdeScoping.Api/Models/LoginRequest.cs
Step 5: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Api/JdeScoping.Api.csproj
Expected: Build succeeded
Step 6: Commit
git add NEW/src/JdeScoping.Api/Controllers/AuthController.cs
git rm NEW/src/JdeScoping.Api/Models/LoginRequest.cs
git commit -m "feat(api): update AuthController for encrypted login"
Task 6: Update AuthController Tests
Files:
- Modify:
NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs
Step 1: Update imports and add RSA helper
Replace the entire test file:
// NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using JdeScoping.Api.Controllers;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Auth;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace JdeScoping.Api.Tests.Controllers;
public class AuthControllerTests
{
private readonly IAuthService _authService;
private readonly IRsaKeyService _rsaKeyService;
private readonly ILogger<AuthController> _logger;
private readonly AuthController _controller;
private readonly RSA _testRsa;
public AuthControllerTests()
{
_authService = Substitute.For<IAuthService>();
_rsaKeyService = Substitute.For<IRsaKeyService>();
_logger = Substitute.For<ILogger<AuthController>>();
// Setup test RSA key pair
_testRsa = RSA.Create(2048);
var publicKeyPem = _testRsa.ExportSubjectPublicKeyInfoPem();
_rsaKeyService.GetPublicKeyPem().Returns(publicKeyPem);
_rsaKeyService.Decrypt(Arg.Any<byte[]>())
.Returns(callInfo =>
{
var ciphertext = callInfo.Arg<byte[]>();
return _testRsa.Decrypt(ciphertext, RSAEncryptionPadding.OaepSHA256);
});
_controller = new AuthController(_authService, _rsaKeyService, _logger);
}
private EncryptedLoginRequest EncryptLoginModel(LoginModel model)
{
var json = JsonSerializer.Serialize(model);
var plaintext = Encoding.UTF8.GetBytes(json);
var ciphertext = _testRsa.Encrypt(plaintext, RSAEncryptionPadding.OaepSHA256);
return new EncryptedLoginRequest(Convert.ToBase64String(ciphertext));
}
[Fact]
public void GetPublicKey_ReturnsPublicKeyPem()
{
// Act
var result = _controller.GetPublicKey();
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var response = okResult.Value.ShouldBeOfType<PublicKeyResponse>();
response.PublicKeyPem.ShouldStartWith("-----BEGIN PUBLIC KEY-----");
}
[Fact]
public async Task Login_WithValidCredentials_ReturnsLoginResultWithUser()
{
// Arrange
var loginModel = new LoginModel { Username = "testuser", Password = "password123" };
var request = EncryptLoginModel(loginModel);
var user = new UserInfo
{
Dn = "CN=testuser,DC=example,DC=com",
Username = "testuser",
FirstName = "Test",
LastName = "User",
EmailAddress = "test@example.com",
Title = "Developer"
};
_authService.AuthenticateAsync("testuser", "password123", Arg.Any<CancellationToken>())
.Returns(new AuthResult(true, user, null));
var httpContext = CreateMockHttpContext();
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = await _controller.Login(request, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var loginResult = okResult.Value.ShouldBeOfType<LoginResultModel>();
loginResult.Success.ShouldBeTrue();
loginResult.User.ShouldNotBeNull();
loginResult.User.Username.ShouldBe("testuser");
}
[Fact]
public async Task Login_WithInvalidCredentials_Returns401WithError()
{
// Arrange
var loginModel = new LoginModel { Username = "testuser", Password = "wrongpassword" };
var request = EncryptLoginModel(loginModel);
_authService.AuthenticateAsync("testuser", "wrongpassword", Arg.Any<CancellationToken>())
.Returns(new AuthResult(false, null, "Incorrect username or password"));
var httpContext = CreateMockHttpContext();
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = await _controller.Login(request, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<UnauthorizedObjectResult>();
var unauthorizedResult = (UnauthorizedObjectResult)result.Result!;
var loginResult = unauthorizedResult.Value.ShouldBeOfType<LoginResultModel>();
loginResult.Success.ShouldBeFalse();
loginResult.ErrorMessage.ShouldBe("Incorrect username or password");
}
[Fact]
public async Task Login_WithInvalidEncryptedData_ReturnsBadRequest()
{
// Arrange
var request = new EncryptedLoginRequest("not-valid-base64!!!");
var httpContext = CreateMockHttpContext();
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = await _controller.Login(request, CancellationToken.None);
// Assert
result.Result.ShouldBeOfType<BadRequestObjectResult>();
var badRequestResult = (BadRequestObjectResult)result.Result!;
var loginResult = badRequestResult.Value.ShouldBeOfType<LoginResultModel>();
loginResult.Success.ShouldBeFalse();
loginResult.ErrorMessage.ShouldBe("Invalid encrypted payload");
}
[Fact]
public async Task Logout_ClearsAuthentication()
{
// Arrange
var httpContext = CreateAuthenticatedHttpContext("testuser");
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = await _controller.Logout();
// Assert
result.ShouldBeOfType<OkResult>();
}
[Fact]
public void GetCurrentUser_WhenAuthenticated_ReturnsUserInfo()
{
// Arrange
var claims = new List<Claim>
{
new(ClaimTypes.Name, "testuser"),
new(ClaimTypes.GivenName, "Test"),
new(ClaimTypes.Surname, "User"),
new(ClaimTypes.Email, "test@example.com"),
new("title", "Developer"),
new("dn", "CN=testuser,DC=example,DC=com")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext { User = principal };
_controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
// Act
var result = _controller.GetCurrentUser();
// Assert
result.Result.ShouldBeOfType<OkObjectResult>();
var okResult = (OkObjectResult)result.Result!;
var user = okResult.Value.ShouldBeOfType<UserInfo>();
user.Username.ShouldBe("testuser");
}
private static HttpContext CreateMockHttpContext()
{
var authServiceMock = Substitute.For<IAuthenticationService>();
authServiceMock.SignOutAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<AuthenticationProperties>())
.Returns(Task.CompletedTask);
authServiceMock.SignInAsync(Arg.Any<HttpContext>(), Arg.Any<string>(), Arg.Any<ClaimsPrincipal>(), Arg.Any<AuthenticationProperties>())
.Returns(Task.CompletedTask);
var serviceProvider = Substitute.For<IServiceProvider>();
serviceProvider.GetService(typeof(IAuthenticationService)).Returns(authServiceMock);
var httpContext = new DefaultHttpContext
{
RequestServices = serviceProvider
};
return httpContext;
}
private static HttpContext CreateAuthenticatedHttpContext(string username)
{
var httpContext = CreateMockHttpContext();
var claims = new List<Claim>
{
new(ClaimTypes.Name, username),
new("dn", $"CN={username},DC=example,DC=com")
};
var identity = new ClaimsIdentity(claims, "Test");
httpContext.User = new ClaimsPrincipal(identity);
return httpContext;
}
}
Step 2: Run tests to verify they pass
Run: dotnet test NEW/tests/JdeScoping.Api.Tests --filter "FullyQualifiedName~AuthControllerTests" --verbosity normal
Expected: All 6 tests pass
Step 3: Commit
git add NEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.cs
git commit -m "test(api): update AuthController tests for encrypted login"
Task 7: Add Blazor.SubtleCrypto Package to Client
Files:
- Modify:
NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
Step 1: Add package reference
Run: dotnet add NEW/src/JdeScoping.Client/JdeScoping.Client.csproj package Blazor.SubtleCrypto
Step 2: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
Expected: Build succeeded
Step 3: Commit
git add NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
git commit -m "chore(client): add Blazor.SubtleCrypto package"
Task 8: Create ICryptoService and Implementation
Files:
- Create:
NEW/src/JdeScoping.Client/Services/ICryptoService.cs - Create:
NEW/src/JdeScoping.Client/Services/CryptoService.cs
Step 1: Create ICryptoService interface
// NEW/src/JdeScoping.Client/Services/ICryptoService.cs
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for encrypting data using server's RSA public key.
/// </summary>
public interface ICryptoService
{
/// <summary>
/// Encrypts login credentials for transmission to server.
/// </summary>
/// <param name="model">Login credentials to encrypt</param>
/// <returns>Base64-encoded encrypted data</returns>
Task<string> EncryptLoginAsync(LoginModel model);
}
Step 2: Create CryptoService implementation
// NEW/src/JdeScoping.Client/Services/CryptoService.cs
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Blazor.SubtleCrypto;
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Client.Services;
/// <summary>
/// Encrypts login credentials using Web Crypto API via Blazor.SubtleCrypto.
/// </summary>
public class CryptoService : ICryptoService
{
private readonly HttpClient _httpClient;
private readonly ICryptoService _cryptoProvider;
private CryptoKey? _serverPublicKey;
private readonly SemaphoreSlim _keyLock = new(1, 1);
public CryptoService(HttpClient httpClient, ICryptoService cryptoProvider)
{
_httpClient = httpClient;
_cryptoProvider = cryptoProvider;
}
public async Task<string> EncryptLoginAsync(LoginModel model)
{
var publicKey = await GetOrFetchPublicKeyAsync();
var json = JsonSerializer.Serialize(model);
var plaintext = Encoding.UTF8.GetBytes(json);
var encrypted = await _cryptoProvider.EncryptAsync(
plaintext,
publicKey,
new EncryptParams { Name = "RSA-OAEP" });
return Convert.ToBase64String(encrypted);
}
private async Task<CryptoKey> GetOrFetchPublicKeyAsync()
{
if (_serverPublicKey is not null)
return _serverPublicKey;
await _keyLock.WaitAsync();
try
{
if (_serverPublicKey is not null)
return _serverPublicKey;
var response = await _httpClient.GetFromJsonAsync<PublicKeyResponse>("api/auth/public-key")
?? throw new InvalidOperationException("Failed to fetch public key");
_serverPublicKey = await _cryptoProvider.ImportKeyAsync(
"spki",
Convert.FromBase64String(ExtractBase64FromPem(response.PublicKeyPem)),
new Algorithm { Name = "RSA-OAEP", Hash = "SHA-256" },
false,
new[] { "encrypt" });
return _serverPublicKey;
}
finally
{
_keyLock.Release();
}
}
private static string ExtractBase64FromPem(string pem)
{
return pem
.Replace("-----BEGIN PUBLIC KEY-----", "")
.Replace("-----END PUBLIC KEY-----", "")
.Replace("\n", "")
.Replace("\r", "")
.Trim();
}
}
Step 3: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
Expected: Build succeeded (may have warnings about Blazor.SubtleCrypto API - we'll fix in next step if needed)
Step 4: Commit
git add NEW/src/JdeScoping.Client/Services/ICryptoService.cs
git add NEW/src/JdeScoping.Client/Services/CryptoService.cs
git commit -m "feat(client): add CryptoService for login encryption"
Task 9: Update Client AuthService and IAuthService
Files:
- Modify:
NEW/src/JdeScoping.Client/Services/IAuthService.cs - Modify:
NEW/src/JdeScoping.Client/Services/AuthService.cs - Delete:
NEW/src/JdeScoping.Client/Models/LoginModel.cs
Step 1: Update IAuthService to use shared models
Replace the entire file:
// NEW/src/JdeScoping.Client/Services/IAuthService.cs
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for authentication operations.
/// </summary>
public interface IAuthService
{
/// <summary>
/// Attempts to log in with the provided credentials (encrypted).
/// </summary>
Task<LoginResultModel> LoginAsync(LoginModel model);
/// <summary>
/// Logs out the current user.
/// </summary>
Task LogoutAsync();
}
Step 2: Update AuthService to use encrypted login
Replace the entire file:
// NEW/src/JdeScoping.Client/Services/AuthService.cs
using System.Net.Http.Json;
using JdeScoping.Client.Auth;
using JdeScoping.Core.Models.Auth;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles authentication via encrypted API calls with cookie-based auth.
/// </summary>
public class AuthService : IAuthService
{
private readonly HttpClient _httpClient;
private readonly ICryptoService _cryptoService;
private readonly AuthStateProvider _authStateProvider;
public AuthService(
HttpClient httpClient,
ICryptoService cryptoService,
AuthStateProvider authStateProvider)
{
_httpClient = httpClient;
_cryptoService = cryptoService;
_authStateProvider = authStateProvider;
}
public async Task<LoginResultModel> LoginAsync(LoginModel model)
{
try
{
// Encrypt credentials
var encryptedData = await _cryptoService.EncryptLoginAsync(model);
var request = new EncryptedLoginRequest(encryptedData);
// Send encrypted request
var response = await _httpClient.PostAsJsonAsync("api/auth/login", request);
var result = await response.Content.ReadFromJsonAsync<LoginResultModel>();
if (result is null)
{
return new LoginResultModel(false, "Invalid response from server", null);
}
if (result.Success && result.User is not null)
{
// Notify auth state provider of the login
var userViewModel = new UserInfoViewModel
{
Username = result.User.Username,
FirstName = result.User.FirstName,
LastName = result.User.LastName,
EmailAddress = result.User.EmailAddress,
Title = result.User.Title,
Dn = result.User.Dn
};
await _authStateProvider.MarkUserAsAuthenticated(userViewModel);
}
return result;
}
catch (Exception ex)
{
return new LoginResultModel(false, $"Login failed: {ex.Message}", null);
}
}
public async Task LogoutAsync()
{
try
{
await _httpClient.PostAsync("api/auth/logout", null);
}
catch
{
// Even if logout API fails, clear local state
}
await _authStateProvider.LogoutAsync();
}
}
Step 3: Delete old LoginModel
Delete file: NEW/src/JdeScoping.Client/Models/LoginModel.cs
Step 4: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
Expected: Build succeeded
Step 5: Commit
git add NEW/src/JdeScoping.Client/Services/IAuthService.cs
git add NEW/src/JdeScoping.Client/Services/AuthService.cs
git rm NEW/src/JdeScoping.Client/Models/LoginModel.cs
git commit -m "feat(client): update AuthService to use encrypted login"
Task 10: Update Login.razor to Use Shared Model
Files:
- Modify:
NEW/src/JdeScoping.Client/Pages/Login.razor
Step 1: Update the using statement and result handling
Change line 1 and add using at the top:
@page "/login"
@using JdeScoping.Core.Models.Auth
@inject IAuthService AuthService
@inject NavigationManager NavigationManager
Update the HandleLoginAsync method (in the @code block) to use LoginResultModel:
private async Task HandleLoginAsync()
{
_isLoading = true;
_errorMessage = null;
try
{
var result = await AuthService.LoginAsync(_loginModel);
if (result.Success)
{
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
NavigationManager.NavigateTo(returnUrl);
}
else
{
_errorMessage = result.ErrorMessage ?? "Login failed. Please check your credentials.";
}
}
catch (Exception ex)
{
_errorMessage = $"An error occurred: {ex.Message}";
}
finally
{
_isLoading = false;
}
}
Step 2: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
Expected: Build succeeded
Step 3: Commit
git add NEW/src/JdeScoping.Client/Pages/Login.razor
git commit -m "feat(client): update Login.razor to use shared LoginModel"
Task 11: Register CryptoService in Client DI
Files:
- Modify:
NEW/src/JdeScoping.Client/Program.cs
Step 1: Add CryptoService registration
Add after line 7 (after existing using statements):
using Blazor.SubtleCrypto;
Add after line 26 (after AuthStateProvider registration, before IAuthService):
// Crypto service for login encryption
builder.Services.AddSubtleCrypto();
builder.Services.AddScoped<ICryptoService, CryptoService>();
Update IAuthService registration (line 28) - it should still work as AuthService now takes ICryptoService.
Step 2: Verify build succeeds
Run: dotnet build NEW/src/JdeScoping.Client/JdeScoping.Client.csproj
Expected: Build succeeded
Step 3: Commit
git add NEW/src/JdeScoping.Client/Program.cs
git commit -m "chore(client): register CryptoService in DI"
Task 12: Add Client CryptoService Tests
Files:
- Create:
NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs - Modify:
NEW/tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csproj - Delete:
NEW/tests/JdeScoping.Client.Tests/Placeholder.cs
Step 1: Update Client.Tests csproj to add required packages
Add to ItemGroup with PackageReferences:
<PackageReference Include="Blazor.SubtleCrypto" Version="..." />
<PackageReference Include="RichardSzalay.MockHttp" Version="7.0.0" />
Also add project reference to Core:
<ItemGroup>
<ProjectReference Include="..\..\src\JdeScoping.Client\JdeScoping.Client.csproj" />
<ProjectReference Include="..\..\src\JdeScoping.Core\JdeScoping.Core.csproj" />
</ItemGroup>
Step 2: Create CryptoServiceTests
Note: Testing Blazor.SubtleCrypto directly is challenging because it requires browser APIs. We'll create integration-style tests that verify the service behavior with mocks.
// NEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using JdeScoping.Client.Services;
using JdeScoping.Core.Models.Auth;
using NSubstitute;
using RichardSzalay.MockHttp;
using Shouldly;
namespace JdeScoping.Client.Tests.Services;
public class CryptoServiceTests
{
[Fact]
public async Task EncryptLoginAsync_FetchesPublicKeyOnce()
{
// Arrange
using var rsa = RSA.Create(2048);
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
var mockHttp = new MockHttpMessageHandler();
// Setup public key endpoint - should only be called once
var keyRequest = mockHttp.Expect("/api/auth/public-key")
.Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKeyPem)));
var httpClient = new HttpClient(mockHttp) { BaseAddress = new Uri("http://localhost/") };
// Create a mock crypto provider that just returns dummy encrypted data
var cryptoProvider = Substitute.For<Blazor.SubtleCrypto.ICryptoService>();
cryptoProvider.ImportKeyAsync(
Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<Blazor.SubtleCrypto.Algorithm>(),
Arg.Any<bool>(), Arg.Any<string[]>())
.Returns(new Blazor.SubtleCrypto.CryptoKey());
cryptoProvider.EncryptAsync(Arg.Any<byte[]>(), Arg.Any<Blazor.SubtleCrypto.CryptoKey>(), Arg.Any<Blazor.SubtleCrypto.EncryptParams>())
.Returns(new byte[] { 1, 2, 3, 4 });
var service = new CryptoService(httpClient, cryptoProvider);
var loginModel = new LoginModel { Username = "test", Password = "pass" };
// Act - call twice
await service.EncryptLoginAsync(loginModel);
await service.EncryptLoginAsync(loginModel);
// Assert - public key fetched only once
mockHttp.GetMatchCount(keyRequest).ShouldBe(1);
}
[Fact]
public async Task EncryptLoginAsync_ReturnsBase64String()
{
// Arrange
using var rsa = RSA.Create(2048);
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
var mockHttp = new MockHttpMessageHandler();
mockHttp.When("/api/auth/public-key")
.Respond("application/json", JsonSerializer.Serialize(new PublicKeyResponse(publicKeyPem)));
var httpClient = new HttpClient(mockHttp) { BaseAddress = new Uri("http://localhost/") };
var cryptoProvider = Substitute.For<Blazor.SubtleCrypto.ICryptoService>();
cryptoProvider.ImportKeyAsync(
Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<Blazor.SubtleCrypto.Algorithm>(),
Arg.Any<bool>(), Arg.Any<string[]>())
.Returns(new Blazor.SubtleCrypto.CryptoKey());
cryptoProvider.EncryptAsync(Arg.Any<byte[]>(), Arg.Any<Blazor.SubtleCrypto.CryptoKey>(), Arg.Any<Blazor.SubtleCrypto.EncryptParams>())
.Returns(new byte[] { 1, 2, 3, 4 });
var service = new CryptoService(httpClient, cryptoProvider);
var loginModel = new LoginModel { Username = "test", Password = "pass" };
// Act
var result = await service.EncryptLoginAsync(loginModel);
// Assert - should be valid base64
var decoded = Convert.FromBase64String(result);
decoded.ShouldNotBeEmpty();
}
}
Step 3: Delete placeholder file
Delete: NEW/tests/JdeScoping.Client.Tests/Placeholder.cs
Step 4: Run tests to verify they pass
Run: dotnet test NEW/tests/JdeScoping.Client.Tests --verbosity normal
Expected: All tests pass
Step 5: Commit
git add NEW/tests/JdeScoping.Client.Tests/
git rm NEW/tests/JdeScoping.Client.Tests/Placeholder.cs
git commit -m "test(client): add CryptoService tests"
Task 13: Update Integration Tests
Files:
- Modify:
NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs
Step 1: Update integration tests for encrypted login
The integration tests need to use encrypted login requests. Update the test file to encrypt credentials before sending.
Read the current file first to understand its structure, then update to use RSA encryption similar to the unit tests.
Step 2: Run all tests
Run: dotnet test NEW/tests --verbosity normal
Expected: All tests pass
Step 3: Commit
git add NEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs
git commit -m "test(integration): update auth tests for encrypted login"
Task 14: Final Verification
Step 1: Build entire solution
Run: dotnet build NEW/JdeScoping.sln
Expected: Build succeeded with 0 errors
Step 2: Run all tests
Run: dotnet test NEW/JdeScoping.sln --verbosity normal
Expected: All tests pass
Step 3: Verify deleted files are gone
Confirm these files no longer exist:
NEW/src/JdeScoping.Api/Models/LoginRequest.csNEW/src/JdeScoping.Client/Models/LoginModel.cs
Step 4: Final commit
git add -A
git commit -m "feat: complete encrypted login implementation"
Summary
Files Created:
NEW/src/JdeScoping.Core/Models/Auth/LoginModel.csNEW/src/JdeScoping.Core/Models/Auth/LoginResultModel.csNEW/src/JdeScoping.Core/Models/Auth/EncryptedLoginRequest.csNEW/src/JdeScoping.Core/Models/Auth/PublicKeyResponse.csNEW/src/JdeScoping.Core/Interfaces/IRsaKeyService.csNEW/src/JdeScoping.Core/Options/RsaKeyOptions.csNEW/src/JdeScoping.Infrastructure/Security/RsaKeyService.csNEW/src/JdeScoping.Client/Services/ICryptoService.csNEW/src/JdeScoping.Client/Services/CryptoService.csNEW/tests/JdeScoping.Infrastructure.Tests/Security/RsaKeyServiceTests.csNEW/tests/JdeScoping.Client.Tests/Services/CryptoServiceTests.cs
Files Modified:
NEW/src/JdeScoping.Infrastructure/DependencyInjection.csNEW/src/JdeScoping.Api/Controllers/AuthController.csNEW/src/JdeScoping.Client/Services/IAuthService.csNEW/src/JdeScoping.Client/Services/AuthService.csNEW/src/JdeScoping.Client/Pages/Login.razorNEW/src/JdeScoping.Client/Program.csNEW/src/JdeScoping.Client/JdeScoping.Client.csprojNEW/tests/JdeScoping.Api.Tests/Controllers/AuthControllerTests.csNEW/tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csprojNEW/tests/JdeScoping.Api.IntegrationTests/AuthenticationTests.cs
Files Deleted:
NEW/src/JdeScoping.Api/Models/LoginRequest.csNEW/src/JdeScoping.Client/Models/LoginModel.csNEW/tests/JdeScoping.Client.Tests/Placeholder.cs