Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/workflows/pull-requests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
prerelease: ${{ steps.filter.outputs.prerelease }}
azure-docs: ${{ steps.filter.outputs.azure-docs }}
e2e: ${{ steps.filter.outputs.e2e }}
redis-replay: ${{ steps.filter.outputs.redis-replay }}
steps:
# The pull request files API lists at most 3,000 files, so a larger pull request could hide
# changed paths from the filter and skip checks. Fail closed instead of filtering a partial list.
Expand All @@ -46,6 +47,21 @@ jobs:
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
filters: |
redis-replay:
- "Source/AuthProxy/BearerRoutes/**"
- "Source/AuthProxy/Configuration/Dpop*.cs"
- "Source/AuthProxy/Configuration/BearerRoute*.cs"
- "Source/AuthProxy/Configuration/AuthProxy.cs"
- "Source/AuthProxy/AuthProxy.csproj"
- "Source/AuthProxy/Properties/AssemblyInfo.cs"
- "Source/AuthProxy.Specs/BearerRoutes/for_DpopProofVerifier/**"
- "Source/AuthProxy.Integration.Specs/**"
- "Directory.*"
- "global.json"
- "NuGet.config"
- "AuthProxy.slnx"
- ".github/workflows/redis-replay-specs.yml"
- ".github/workflows/pull-requests.yml"
dotnet:
- "Source/**"
- ".github/workflows/**"
Expand Down Expand Up @@ -91,6 +107,13 @@ jobs:
permissions:
contents: read

redis-replay-specs:
needs: changes
if: ${{ needs.changes.outputs.redis-replay == 'true' }}
uses: ./.github/workflows/redis-replay-specs.yml
permissions:
contents: read

# No filter: the security checks must always run (their old trigger had no paths).
security:
uses: ./.github/workflows/security.yml
Expand All @@ -108,7 +131,7 @@ jobs:
gate:
name: gate
if: ${{ always() }}
needs: [changes, dotnet-build, prerelease, azure-docs-specs, consumer-e2e, security, policy]
needs: [changes, dotnet-build, prerelease, azure-docs-specs, consumer-e2e, redis-replay-specs, security, policy]
runs-on: ${{ vars.RUNNER_GATE || 'ubuntu-latest' }}
timeout-minutes: 5
permissions: {}
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/redis-replay-specs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.

name: Redis replay specs

on:
workflow_call:

permissions:
contents: read

jobs:
redis-replay-specs:
name: Redis replay specs
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
- name: Restore integration specs
run: dotnet restore Source/AuthProxy.Integration.Specs/AuthProxy.Integration.Specs.csproj -p:RunRedisReplaySpecs=true
- name: Build integration specs
run: dotnet build Source/AuthProxy.Integration.Specs/AuthProxy.Integration.Specs.csproj -c Release --no-restore -warnaserror -p:RunRedisReplaySpecs=true
- name: Run Redis integration specs
run: dotnet test Source/AuthProxy.Integration.Specs/AuthProxy.Integration.Specs.csproj -c Release --no-build -p:RunRedisReplaySpecs=true --logger trx --results-directory TestResults/redis-replay
- name: Upload test results
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: redis-replay-results
path: TestResults/redis-replay
if-no-files-found: warn
1 change: 1 addition & 0 deletions AuthProxy.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<Project Path="Source/Composition/Composition.csproj" />
<Project Path="Source/AuthProxy.Specs/AuthProxy.Specs.csproj" />
<Project Path="Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj" />
<Project Path="Source/AuthProxy.Integration.Specs/AuthProxy.Integration.Specs.csproj" />
<Project Path="Source/TestApp/TestApp.csproj" />
</Folder>
</Solution>
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<PackageVersion Include="Roslynator.Analyzers" Version="5.0.1" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.299" />
<!-- Specifications -->
<PackageVersion Include="Testcontainers.Redis" Version="4.16.0" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.10.0" />
<PackageVersion Include="Cratis.Specifications.XUnit.v3" Version="5.0.0" />
<PackageVersion Include="xunit.v3.mtp-off" Version="4.0.2" />
Expand Down
4 changes: 3 additions & 1 deletion Documentation/configuration/dpop.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ create a new proof and `jti` when retrying a nonce challenge.

DPoP refusals are non-cacheable machine responses, never redirects or cookie
challenges. Proof/store/clock/replay failures use HTTP 401 `invalid_dpop_proof`;
valid proofs lacking scope use HTTP 403 `insufficient_scope`. DPoP challenges
valid proofs lacking scope use HTTP 403 `insufficient_scope` after replay-store
health and any required nonce are checked, without reserving the proof identifier.
Successful authorization still requires an atomic replay reservation. DPoP challenges
advertise `algs="ES256"` and do not suggest downgrading to Bearer. Logs contain
fixed reason codes only, not tokens, proofs, keys, nonces or full request URLs.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<IsPackable>false</IsPackable>
<!-- Keep root dotnet test container-free; the dedicated Redis workflow opts in. -->
<IsTestProject>false</IsTestProject>
<IsTestProject Condition="'$(RunRedisReplaySpecs)' == 'true'">true</IsTestProject>
<OutputType>Exe</OutputType>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="../AuthProxy/AuthProxy.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Cratis.Specifications.XUnit.v3" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
<PackageReference Include="Testcontainers.Redis" />
</ItemGroup>
</Project>
5 changes: 5 additions & 0 deletions Source/AuthProxy.Integration.Specs/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

global using Cratis.Specifications;
global using Xunit;
12 changes: 12 additions & 0 deletions Source/AuthProxy.Integration.Specs/RedisReplayCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Cratis.AuthProxy.Integration.Specs;

/// <summary>Shares one Redis container across serialized replay scenarios.</summary>
[CollectionDefinition(Name)]
public class RedisReplayCollection : ICollectionFixture<RedisReplayFixture>
{
/// <summary>The shared collection name.</summary>
public const string Name = "Redis DPoP replay";
}
46 changes: 46 additions & 0 deletions Source/AuthProxy.Integration.Specs/RedisReplayFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using StackExchange.Redis;
using Testcontainers.Redis;

namespace Cratis.AuthProxy.Integration.Specs;

/// <summary>Owns a disposable Redis process with persistence disabled.</summary>
public sealed class RedisReplayFixture : IAsyncLifetime
{
readonly RedisContainer _container = new RedisBuilder("redis:7.4.2-alpine")
.WithCommand("redis-server", "--save", string.Empty, "--appendonly", "no", "--maxmemory-policy", "noeviction")
.Build();
ConnectionMultiplexer _connection = null!;

/// <summary>Gets the endpoint for independent production-store connections.</summary>
public string Endpoint => _container.GetConnectionString();

/// <summary>Gets the database used to inspect replay evidence.</summary>
public IDatabase Database => _connection.GetDatabase();

/// <inheritdoc/>
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
_connection = await ConnectionMultiplexer.ConnectAsync(Endpoint);
}

/// <summary>Restarts Redis without preserving in-memory replay evidence.</summary>
/// <returns>The restart operation.</returns>
public async Task Restart()
{
await _container.StopAsync();
await _container.StartAsync();
await _connection.DisposeAsync();
_connection = await ConnectionMultiplexer.ConnectAsync(Endpoint);
}

/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
await _container.DisposeAsync();
}
}
75 changes: 75 additions & 0 deletions Source/AuthProxy.Integration.Specs/given_a_redis_replay_store.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.AuthProxy.BearerRoutes.Dpop;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using C = Cratis.AuthProxy.Configuration;

namespace Cratis.AuthProxy.Integration.Specs;

/// <summary>Isolates each replay scenario behind a unique Redis key prefix.</summary>
/// <param name="redis">The shared Redis process.</param>
public class given_a_redis_replay_store(RedisReplayFixture redis) : Specification
{
const string Origin = "https://api.example.test";
const string Thumbprint = "synthetic-thumbprint";
readonly List<RedisDpopReplayStore> _stores = [];
readonly string _prefix = "dpop-spec-" + Guid.NewGuid().ToString("N");

/// <summary>Gets the Redis process lifecycle.</summary>
protected RedisReplayFixture Redis => redis;

/// <summary>Gets the database for evidence inspection and coverage setup.</summary>
protected IDatabase Database => redis.Database;

/// <summary>Gets this scenario's coverage marker.</summary>
protected RedisKey CoverageKey => "{" + _prefix + "}:coverage";

/// <summary>Creates an independent production replay-store replica.</summary>
/// <param name="clock">The replica clock, or the system clock.</param>
/// <returns>The replay store.</returns>
protected IDpopReplayStore Replica(TimeProvider? clock = null)
{
var options = new C.AuthProxy();
options.Dpop.ReplayStore.Redis = new() { ConnectionString = redis.Endpoint, KeyPrefix = _prefix };
var store = new RedisDpopReplayStore(Options.Create(options), clock ?? TimeProvider.System);
_stores.Add(store);
return store;
}

/// <summary>Disposes a replica without changing Redis evidence.</summary>
/// <param name="store">The replica to close.</param>
protected void StopReplica(IDpopReplayStore store)
{
var instance = (RedisDpopReplayStore)store;
_stores.Remove(instance);
instance.Dispose();
}

/// <summary>Sets coverage age relative to Redis TIME, without waiting a retention window.</summary>
/// <param name="age">The age of the coverage marker.</param>
/// <returns>The marker write operation.</returns>
protected async Task SeedCoverage(TimeSpan age)
{
var milliseconds = (long)await Database.ScriptEvaluateAsync("local t = redis.call('TIME'); return t[1] * 1000 + math.floor(t[2] / 1000)");
await Database.StringSetAsync(CoverageKey, milliseconds - (long)age.TotalMilliseconds);
}

/// <summary>Gets the production-format key for a synthetic proof identifier.</summary>
/// <param name="jti">The synthetic proof identifier.</param>
/// <returns>The replay key.</returns>
protected RedisKey ReplayKey(string jti) => "{" + _prefix + "}:" + DpopEncoding.Hash("resource\0" + Origin + "\0" + Thumbprint + "\0" + jti);

/// <summary>Reserves a synthetic proof through the production Lua path.</summary>
/// <param name="store">The independent replica.</param>
/// <param name="jti">The synthetic proof identifier.</param>
/// <returns>The reservation result.</returns>
protected Task<DpopReplayResult> Consume(IDpopReplayStore store, string jti = "synthetic-proof") => store.TryConsume(Origin, Thumbprint, jti, CancellationToken.None);

/// <summary>Closes all scenario-owned replica connections.</summary>
protected void Destroy()
{
foreach (var store in _stores) store.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.AuthProxy.BearerRoutes.Dpop;

namespace Cratis.AuthProxy.Integration.Specs;

/// <summary>Exercises Redis TIME checks independently of coverage readiness.</summary>
/// <param name="redis">The shared Redis process.</param>
[Collection(RedisReplayCollection.Name)]
public class when_a_replica_clock_disagrees_with_redis(RedisReplayFixture redis) : given_a_redis_replay_store(redis)
{
/// <summary>Skew beyond thirty seconds refuses health and reservation without evidence.</summary>
/// <param name="seconds">The clock offset in seconds.</param>
/// <returns>The scenario operation.</returns>
[Theory]
[InlineData(31)]
[InlineData(-31)]
public async Task should_refuse_health_and_reservation_outside_thirty_seconds(int seconds)
{
await SeedCoverage(TimeSpan.FromSeconds(391));
var store = Replica(new OffsetClock(seconds));
(await store.CheckHealth(CancellationToken.None)).ShouldEqual(DpopReplayResult.Unavailable);
(await Consume(store)).ShouldEqual(DpopReplayResult.Unavailable);
(await Database.KeyExistsAsync(ReplayKey("synthetic-proof"))).ShouldBeFalse();
}

/// <summary>A reservation rechecks skew even when cached health remains ready.</summary>
/// <returns>The scenario operation.</returns>
[Fact]
public async Task should_check_clock_skew_again_despite_cached_health()
{
await SeedCoverage(TimeSpan.FromSeconds(391));
var clock = new OffsetClock(0);
var store = Replica(clock);
(await store.CheckHealth(CancellationToken.None)).ShouldEqual(DpopReplayResult.Accepted);
clock.Seconds = 31;
(await store.CheckHealth(CancellationToken.None)).ShouldEqual(DpopReplayResult.Accepted);
(await Consume(store)).ShouldEqual(DpopReplayResult.Unavailable);
}

sealed class OffsetClock(int seconds) : TimeProvider
{
public int Seconds { get; set; } = seconds;
public override DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow.AddSeconds(Seconds);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.AuthProxy.BearerRoutes.Dpop;

namespace Cratis.AuthProxy.Integration.Specs;

/// <summary>Exercises loss of replay evidence during a real Redis restart.</summary>
/// <param name="redis">The shared Redis process.</param>
[Collection(RedisReplayCollection.Name)]
public class when_redis_restarts_without_replay_evidence(RedisReplayFixture redis) : given_a_redis_replay_store(redis)
{
/// <summary>Old and new replicas fail closed until full coverage is restored.</summary>
/// <returns>The scenario operation.</returns>
[Fact]
public async Task should_quarantine_restarted_redis_instead_of_reusing_cached_health()
{
await SeedCoverage(TimeSpan.FromSeconds(391));
var warmed = Replica();
(await warmed.CheckHealth(CancellationToken.None)).ShouldEqual(DpopReplayResult.Accepted);
(await Consume(warmed)).ShouldEqual(DpopReplayResult.Accepted);
await Redis.Restart();
(await Database.KeyExistsAsync(CoverageKey)).ShouldBeFalse();
(await Database.KeyExistsAsync(ReplayKey("synthetic-proof"))).ShouldBeFalse();
(await Consume(warmed)).ShouldEqual(DpopReplayResult.Unavailable);
var restarted = Replica();
(await restarted.CheckHealth(CancellationToken.None)).ShouldEqual(DpopReplayResult.Unavailable);
(await Consume(restarted)).ShouldEqual(DpopReplayResult.Unavailable);
await SeedCoverage(TimeSpan.FromSeconds(391));
(await Consume(restarted)).ShouldEqual(DpopReplayResult.Accepted);
}
}
Loading
Loading