save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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";
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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. |