save progress
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Auth Security Tests AGENTS
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/__Libraries/__Tests/StellaOps.Auth.Security.Tests/`.
|
||||
- Roles: QA automation, backend engineer.
|
||||
- Focus: DPoP proof validation, nonce/replay caches, and edge-case coverage.
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/authority/architecture.md`
|
||||
- Relevant sprint files.
|
||||
|
||||
## Working Agreements
|
||||
- Keep tests deterministic (fixed time/IDs, stable ordering).
|
||||
- Avoid live network calls and nondeterministic RNG.
|
||||
- Update `docs/implplan/SPRINT_*.md` and local `TASKS.md` when starting or completing work.
|
||||
|
||||
## Testing
|
||||
- Use xUnit + FluentAssertions + TestKit.
|
||||
- Cover validator error paths, nonce stores, and replay cache semantics.
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopNonceUtilitiesTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ComputeStorageKey_NormalizesToLowerInvariant()
|
||||
{
|
||||
var key = DpopNonceUtilities.ComputeStorageKey("API", "Client-Id", "ThumbPrint");
|
||||
|
||||
Assert.Equal("dpop-nonce:api:client-id:thumbprint", key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopProofValidatorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringTyp()
|
||||
{
|
||||
var proof = BuildUnsignedToken(
|
||||
new { typ = 123, alg = "ES256" },
|
||||
new { htm = "GET", htu = "https://api.test/resource", iat = 0, jti = "1" });
|
||||
|
||||
var validator = CreateValidator();
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_header", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringAlg()
|
||||
{
|
||||
var proof = BuildUnsignedToken(
|
||||
new { typ = "dpop+jwt", alg = 55 },
|
||||
new { htm = "GET", htu = "https://api.test/resource", iat = 0, jti = "1" });
|
||||
|
||||
var validator = CreateValidator();
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_header", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringHtm()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["htm"] = 123);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_payload", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringHtu()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["htu"] = 123);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_payload", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ReturnsFailure_ForNonStringNonce()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now, payloadMutator: payload => payload["nonce"] = 999);
|
||||
|
||||
var validator = CreateValidator(now);
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"), nonce: "nonce-1");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsProofIssuedInFuture()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var issuedAt = now.AddMinutes(2);
|
||||
var (proof, _) = CreateSignedProof(issuedAt);
|
||||
|
||||
var validator = CreateValidator(now, options => options.AllowedClockSkew = TimeSpan.FromSeconds(5));
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsExpiredProofs()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var issuedAt = now.AddMinutes(-10);
|
||||
var (proof, _) = CreateSignedProof(issuedAt);
|
||||
|
||||
var validator = CreateValidator(now, options =>
|
||||
{
|
||||
options.ProofLifetime = TimeSpan.FromMinutes(1);
|
||||
options.AllowedClockSkew = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("invalid_token", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_RejectsReplayTokens()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var jwtId = "jwt-1";
|
||||
var (proof, _) = CreateSignedProof(now, jti: jwtId);
|
||||
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var replayCache = new InMemoryDpopReplayCache(timeProvider);
|
||||
var validator = CreateValidator(timeProvider, replayCache);
|
||||
|
||||
var first = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
var second = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.True(first.IsValid);
|
||||
Assert.False(second.IsValid);
|
||||
Assert.Equal("replay", second.ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidateAsync_UsesSnapshotOfOptions()
|
||||
{
|
||||
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
|
||||
var (proof, _) = CreateSignedProof(now);
|
||||
|
||||
var options = new DpopValidationOptions();
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
var validator = new DpopProofValidator(Options.Create(options), new InMemoryDpopReplayCache(timeProvider), timeProvider);
|
||||
|
||||
options.AllowedAlgorithms.Clear();
|
||||
options.AllowedAlgorithms.Add("ES512");
|
||||
|
||||
var result = await validator.ValidateAsync(proof, "GET", new Uri("https://api.test/resource"));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
private static DpopProofValidator CreateValidator(DateTimeOffset now, Action<DpopValidationOptions>? configure = null)
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(now);
|
||||
return CreateValidator(timeProvider, null, configure);
|
||||
}
|
||||
|
||||
private static DpopProofValidator CreateValidator(TimeProvider timeProvider, IDpopReplayCache? replayCache = null, Action<DpopValidationOptions>? configure = null)
|
||||
{
|
||||
var options = new DpopValidationOptions();
|
||||
configure?.Invoke(options);
|
||||
return new DpopProofValidator(Options.Create(options), replayCache, timeProvider);
|
||||
}
|
||||
|
||||
private static (string Token, JsonWebKey Jwk) CreateSignedProof(
|
||||
DateTimeOffset issuedAt,
|
||||
string? method = "GET",
|
||||
Uri? httpUri = null,
|
||||
string? nonce = null,
|
||||
string? jti = null,
|
||||
Action<JwtHeader>? headerMutator = null,
|
||||
Action<JwtPayload>? payloadMutator = null)
|
||||
{
|
||||
httpUri ??= new Uri("https://api.test/resource");
|
||||
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var securityKey = new ECDsaSecurityKey(ecdsa) { KeyId = Guid.NewGuid().ToString("N") };
|
||||
var jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey);
|
||||
|
||||
var header = new JwtHeader(new SigningCredentials(securityKey, SecurityAlgorithms.EcdsaSha256))
|
||||
{
|
||||
{ "typ", "dpop+jwt" },
|
||||
{ "jwk", jwk }
|
||||
};
|
||||
headerMutator?.Invoke(header);
|
||||
|
||||
var payload = new JwtPayload
|
||||
{
|
||||
{ "htm", method ?? "GET" },
|
||||
{ "htu", httpUri.ToString() },
|
||||
{ "iat", issuedAt.ToUnixTimeSeconds() },
|
||||
{ "jti", jti ?? Guid.NewGuid().ToString("N") }
|
||||
};
|
||||
|
||||
if (nonce is not null)
|
||||
{
|
||||
payload["nonce"] = nonce;
|
||||
}
|
||||
|
||||
payloadMutator?.Invoke(payload);
|
||||
|
||||
var token = new JwtSecurityToken(header, payload);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
return (handler.WriteToken(token), jwk);
|
||||
}
|
||||
|
||||
private static string BuildUnsignedToken(object header, object payload)
|
||||
{
|
||||
var headerJson = JsonSerializer.Serialize(header);
|
||||
var payloadJson = JsonSerializer.Serialize(payload);
|
||||
var encodedHeader = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(headerJson));
|
||||
var encodedPayload = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(payloadJson));
|
||||
return $"{encodedHeader}.{encodedPayload}.signature";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Auth.Security.Tests;
|
||||
|
||||
public class DpopReplayCacheTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InMemoryReplayCache_RejectsDuplicatesUntilExpiry()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var cache = new InMemoryDpopReplayCache(timeProvider);
|
||||
|
||||
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
|
||||
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MessagingReplayCache_RejectsDuplicatesUntilExpiry()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));
|
||||
var factory = new FakeIdempotencyStoreFactory(timeProvider);
|
||||
var cache = new MessagingDpopReplayCache(factory, timeProvider);
|
||||
|
||||
var expiresAt = timeProvider.GetUtcNow().AddMinutes(1);
|
||||
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
Assert.False(await cache.TryStoreAsync("jti-1", expiresAt));
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
Assert.True(await cache.TryStoreAsync("jti-1", timeProvider.GetUtcNow().AddMinutes(1)));
|
||||
}
|
||||
|
||||
private sealed class FakeIdempotencyStoreFactory : IIdempotencyStoreFactory
|
||||
{
|
||||
private readonly FakeIdempotencyStore store;
|
||||
|
||||
public FakeIdempotencyStoreFactory(TimeProvider timeProvider)
|
||||
{
|
||||
store = new FakeIdempotencyStore(timeProvider);
|
||||
}
|
||||
|
||||
public string ProviderName => "fake";
|
||||
|
||||
public IIdempotencyStore Create(string name) => store;
|
||||
}
|
||||
|
||||
private sealed class FakeIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly Dictionary<string, Entry> entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public FakeIdempotencyStore(TimeProvider timeProvider)
|
||||
{
|
||||
this.timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public string ProviderName => "fake";
|
||||
|
||||
public ValueTask<IdempotencyResult> TryClaimAsync(string key, string value, TimeSpan window, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (entries.TryGetValue(key, out var entry) && entry.ExpiresAt > now)
|
||||
{
|
||||
return ValueTask.FromResult(IdempotencyResult.Duplicate(entry.Value));
|
||||
}
|
||||
|
||||
entries[key] = new Entry(value, now.Add(window));
|
||||
return ValueTask.FromResult(IdempotencyResult.Claimed());
|
||||
}
|
||||
|
||||
public ValueTask<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.TryGetValue(key, out var entry) && entry.ExpiresAt > timeProvider.GetUtcNow());
|
||||
|
||||
public ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.TryGetValue(key, out var entry) && entry.ExpiresAt > timeProvider.GetUtcNow() ? entry.Value : null);
|
||||
|
||||
public ValueTask<bool> ReleaseAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> ValueTask.FromResult(entries.Remove(key));
|
||||
|
||||
public ValueTask<bool> ExtendAsync(string key, TimeSpan extension, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
entries[key] = entry with { ExpiresAt = entry.ExpiresAt.Add(extension) };
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private readonly record struct Entry(string Value, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# Auth Security Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0082-A | DONE | Test coverage for DPoP validation, nonce stores, and replay cache. |
|
||||
Reference in New Issue
Block a user