diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d6c250..8bd5799 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **New companion module: `SafeWebCore.JwtBearer`** — makes a misconfigured JWT authority fail loud (or fail fast) at startup instead of silently returning `401` for everything. Solves dotnet/aspnetcore#67991 (reported by Stephan van Rooij) today, while the .NET team schedules the fix for .NET 12 Planning. Implements `AddJwtBearerAuthorityValidation`, `AddJwtBearerHardening`, and the one-liner `AddSafeWebCoreJwtBearer`.
- `SafeWebCore.JwtBearer` token hardening — optional: require signed tokens / reject `alg: none`, algorithm allow-list, `typ` header checks (`JWT`/`at+jwt`), audience/issuer enforcement, maximum clock skew and token lifetime, `jti`/`nbf`/`iat` requirements.
- `SafeWebCore.JwtBearer` runtime metadata logging — wraps the OpenID Connect configuration manager so metadata retrieval failures are logged at Error (4xx) / Warning level during runtime, not only at startup.
+- `SafeWebCore.JwtBearer` empty-JWKS detection — a discovery document that loads but contains **no signing keys** is now a permanent configuration error: `Error` log + startup throw when `FailFast`, down-gradable to a `Warning` via `RequireSigningKeys = false`.
+- `SafeWebCore.JwtBearer` optional `PeriodicValidationInterval` — background re-validation of the authority while the app runs (never throws; permanent → Error, transient → Warning). Closes the last-known-good observability gap for identity-provider outages after a healthy start.
- `examples/JwtBearerDemo` — runnable reproduction of issue #67991 (broken vs. fixed behavior) and `StephanReproIntegrationTests` proving both sides.
- `NetSecureHeadersOptions.ApplyPreset(...)` is now **public** — the official inheritance mechanism to copy all values from another options instance (for example the global options) before applying overrides.
diff --git a/src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs b/src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs
index 250921c..75f2463 100644
--- a/src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs
+++ b/src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs
@@ -11,12 +11,13 @@ namespace SafeWebCore.JwtBearer;
///
/// Startup guard that eagerly loads the OpenID Connect metadata for the JWT bearer authority,
/// so a misconfigured or unreachable authority is detected at startup instead of surfacing as
-/// silent 401s at runtime. Permanent errors (HTTP 4xx from the metadata endpoint) are logged at
-/// Error level and can fail fast; transient errors (5xx, timeout, DNS) only log a Warning and
-/// let the application start. Optionally runs deterministic, network-free configuration checks
-/// first (see ).
+/// silent 401s at runtime. Permanent errors (HTTP 4xx from the metadata endpoint, or a discovery
+/// document without signing keys) are logged at Error level and can fail fast; transient errors
+/// (5xx, timeout, DNS) only log a Warning and let the application start. Optionally re-validates
+/// the authority on a schedule while the application runs and runs deterministic, network-free
+/// configuration checks (see ).
///
-public sealed partial class JwtAuthorityValidationGuard : IHostedService
+public sealed partial class JwtAuthorityValidationGuard : IHostedService, IDisposable
{
private readonly IConfigurationManager _configurationManager;
private readonly JwtBearerOptions _jwtOptions;
@@ -24,6 +25,10 @@ public sealed partial class JwtAuthorityValidationGuard : IHostedService
private readonly string _scheme;
private readonly bool _failFast;
private readonly bool _enforceStaticConfigurationChecks;
+ private readonly bool _requireSigningKeys;
+ private readonly TimeSpan? _periodicInterval;
+ private CancellationTokenSource? _periodicCancellation;
+ private Task? _periodicTask;
///
/// Initializes a new .
@@ -50,43 +55,117 @@ public JwtAuthorityValidationGuard(
$"The JWT bearer scheme '{_scheme}' has no ConfigurationManager. Configure Authority or MetadataAddress first.");
_failFast = options.Value.FailFast;
_enforceStaticConfigurationChecks = options.Value.EnforceStaticConfigurationChecks;
+ _requireSigningKeys = options.Value.RequireSigningKeys;
+ _periodicInterval = options.Value.PeriodicValidationInterval;
_logger = logger;
}
///
public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ await ValidateAuthorityAsync(_failFast, cancellationToken);
+
+ if (_periodicInterval is { } interval && interval > TimeSpan.Zero)
+ {
+ _periodicCancellation = new CancellationTokenSource();
+ _periodicTask = RunPeriodicValidationAsync(interval, _periodicCancellation.Token);
+ }
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ if (_periodicCancellation is null)
+ {
+ return;
+ }
+
+ await _periodicCancellation.CancelAsync();
+ if (_periodicTask is not null)
+ {
+ try
+ {
+ await _periodicTask.WaitAsync(CancellationToken.None);
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal shutdown.
+ }
+ }
+
+ _periodicCancellation.Dispose();
+ _periodicCancellation = null;
+ }
+
+ ///
+ public void Dispose()
+ {
+ _periodicCancellation?.Dispose();
+ _periodicCancellation = null;
+ }
+
+ private async Task ValidateAuthorityAsync(bool failFast, CancellationToken cancellationToken)
{
if (_enforceStaticConfigurationChecks)
{
RunStaticConfigurationChecks(cancellationToken);
}
+ OpenIdConnectConfiguration configuration;
try
{
- await _configurationManager.GetConfigurationAsync(cancellationToken);
- LogMetadataLoaded(_logger, _scheme, AuthorityAddress); // NOSONAR: implementation generated by the LoggerMessage source generator
+ configuration = await _configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
- if (IsPermanent(ex))
+ ReportMetadataFailure(ex, failFast);
+ return;
+ }
+
+ ReportConfigurationResult(configuration, failFast);
+ }
+
+ private void ReportConfigurationResult(OpenIdConnectConfiguration configuration, bool failFast)
+ {
+ if (configuration.SigningKeys.Count == 0)
+ {
+ if (_requireSigningKeys)
{
- LogPermanentFailure(_logger, _scheme, AuthorityAddress, ex); // NOSONAR: implementation generated by the LoggerMessage source generator
- if (_failFast)
+ LogNoSigningKeys(_logger, _scheme, AuthorityAddress); // NOSONAR: implementation generated by the LoggerMessage source generator
+ if (failFast)
{
throw new InvalidOperationException(
- $"The JWT authority for scheme '{_scheme}' is misconfigured (HTTP 4xx from '{AuthorityAddress}'). Fix the Authority/MetadataAddress before starting.",
- ex);
+ $"The OpenID Connect metadata for scheme '{_scheme}' from '{AuthorityAddress}' contains no signing keys; every token will fail validation. Use RequireSigningKeys = false to allow this.");
}
}
else
{
- LogTransientFailure(_logger, _scheme, AuthorityAddress, ex); // NOSONAR: implementation generated by the LoggerMessage source generator
+ LogNoSigningKeysWarning(_logger, _scheme, AuthorityAddress); // NOSONAR: implementation generated by the LoggerMessage source generator
}
+
+ return;
}
+
+ LogMetadataLoaded(_logger, _scheme, AuthorityAddress); // NOSONAR: implementation generated by the LoggerMessage source generator
}
- ///
- public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ private void ReportMetadataFailure(Exception ex, bool failFast)
+ {
+ if (IsPermanent(ex))
+ {
+ LogPermanentFailure(_logger, _scheme, AuthorityAddress, ex); // NOSONAR: implementation generated by the LoggerMessage source generator
+ if (failFast)
+ {
+ throw new InvalidOperationException(
+ $"The JWT authority for scheme '{_scheme}' is misconfigured (HTTP 4xx from '{AuthorityAddress}'). Fix the Authority/MetadataAddress before starting.",
+ ex);
+ }
+ }
+ else
+ {
+ LogTransientFailure(_logger, _scheme, AuthorityAddress, ex); // NOSONAR: implementation generated by the LoggerMessage source generator
+ }
+ }
private void RunStaticConfigurationChecks(CancellationToken cancellationToken)
{
@@ -106,6 +185,33 @@ private void RunStaticConfigurationChecks(CancellationToken cancellationToken)
}
}
+ private async Task RunPeriodicValidationAsync(TimeSpan interval, CancellationToken cancellationToken)
+ {
+ using var timer = new PeriodicTimer(interval);
+ try
+ {
+ while (await timer.WaitForNextTickAsync(cancellationToken))
+ {
+ try
+ {
+ await ValidateAuthorityAsync(failFast: false, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ LogPeriodicValidationFailed(_logger, _scheme, ex); // NOSONAR: implementation generated by the LoggerMessage source generator
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal shutdown.
+ }
+ }
+
private string AuthorityAddress => _jwtOptions.Authority ?? _jwtOptions.MetadataAddress ?? "(no authority configured)";
///
@@ -138,4 +244,13 @@ internal static bool IsPermanent(Exception ex)
[LoggerMessage(EventId = 4, Level = LogLevel.Error, Message = "The JWT bearer configuration for scheme '{Scheme}' is invalid: {ValidationMessage}")]
static partial void LogConfigurationInvalid(ILogger logger, string scheme, string validationMessage); // NOSONAR: implementation generated by the LoggerMessage source generator
+
+ [LoggerMessage(EventId = 5, Level = LogLevel.Error, Message = "The OpenID Connect metadata for scheme '{Scheme}' from '{MetadataAddress}' contains no signing keys; every token will fail validation.")]
+ static partial void LogNoSigningKeys(ILogger logger, string scheme, string metadataAddress); // NOSONAR: implementation generated by the LoggerMessage source generator
+
+ [LoggerMessage(EventId = 6, Level = LogLevel.Warning, Message = "The OpenID Connect metadata for scheme '{Scheme}' from '{MetadataAddress}' contains no signing keys.")]
+ static partial void LogNoSigningKeysWarning(ILogger logger, string scheme, string metadataAddress); // NOSONAR: implementation generated by the LoggerMessage source generator
+
+ [LoggerMessage(EventId = 7, Level = LogLevel.Warning, Message = "Periodic JWT authority validation failed for scheme '{Scheme}'.")]
+ static partial void LogPeriodicValidationFailed(ILogger logger, string scheme, Exception exception); // NOSONAR: implementation generated by the LoggerMessage source generator
}
\ No newline at end of file
diff --git a/src/SafeWebCore.JwtBearer/JwtAuthorityValidationOptions.cs b/src/SafeWebCore.JwtBearer/JwtAuthorityValidationOptions.cs
index d5c0a1a..9b25624 100644
--- a/src/SafeWebCore.JwtBearer/JwtAuthorityValidationOptions.cs
+++ b/src/SafeWebCore.JwtBearer/JwtAuthorityValidationOptions.cs
@@ -17,6 +17,7 @@ public sealed class JwtAuthorityValidationOptions
/// (requests still fail closed with 401 until the authority is fixed).
///
public bool FailFast { get; set; }
+
///
/// When true (default), the guard also runs deterministic, network-free configuration checks
/// at startup: the authority/metadata address must be an absolute HTTPS Uri when
@@ -27,4 +28,20 @@ public sealed class JwtAuthorityValidationOptions
///
public bool EnforceStaticConfigurationChecks { get; set; } = true;
+ ///
+ /// When true (default), a successfully retrieved discovery document that contains **no signing
+ /// keys** (empty JWKS) is treated as a permanent configuration error: logged at Error and thrown
+ /// when is set. Set to false to downgrade this to a Warning for authorities
+ /// that intentionally publish no keys.
+ ///
+ public bool RequireSigningKeys { get; set; } = true;
+
+ ///
+ /// When set, the authority metadata is **re-validated on this interval** while the application is
+ /// running (a background timer probes the metadata endpoint). Periodic failures are logged at Error
+ /// (permanent, HTTP 4xx) or Warning (transient) and never throw, so a running server is never taken
+ /// down by a health check. Null (default) disables periodic re-validation.
+ ///
+ public TimeSpan? PeriodicValidationInterval { get; set; }
+
}
diff --git a/src/SafeWebCore.JwtBearer/PublicAPI.Unshipped.txt b/src/SafeWebCore.JwtBearer/PublicAPI.Unshipped.txt
index d958660..3ab10ba 100644
--- a/src/SafeWebCore.JwtBearer/PublicAPI.Unshipped.txt
+++ b/src/SafeWebCore.JwtBearer/PublicAPI.Unshipped.txt
@@ -7,6 +7,10 @@ SafeWebCore.JwtBearer.JwtAuthorityValidationGuard.StopAsync(System.Threading.Can
SafeWebCore.JwtBearer.JwtAuthorityValidationOptions
SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.EnforceStaticConfigurationChecks.get -> bool
SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.EnforceStaticConfigurationChecks.set -> void
+SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.PeriodicValidationInterval.get -> System.TimeSpan?
+SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.PeriodicValidationInterval.set -> void
+SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.RequireSigningKeys.get -> bool
+SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.RequireSigningKeys.set -> void
SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.FailFast.get -> bool
SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.FailFast.set -> void
SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.JwtAuthorityValidationOptions() -> void
diff --git a/src/SafeWebCore.JwtBearer/README.md b/src/SafeWebCore.JwtBearer/README.md
index f2097c2..4f33e66 100644
--- a/src/SafeWebCore.JwtBearer/README.md
+++ b/src/SafeWebCore.JwtBearer/README.md
@@ -94,11 +94,20 @@ builder.Services.AddSafeWebCoreJwtBearer(
| `Scheme` | `Bearer` | The JWT bearer authentication scheme to validate. |
| `FailFast` | `false` | When true, a permanent (HTTP 4xx) authority failure **throws at startup**. |
| `EnforceStaticConfigurationChecks` | `true` | Runs deterministic, network-free checks: absolute HTTPS authority (when `RequireHttpsMetadata` is on), audience must be configured when validation is enabled, issuer required when no metadata resolves it, `none` algorithm rejected. |
+| `RequireSigningKeys` | `true` | A discovery document that loads but contains **no signing keys** (empty JWKS) is a permanent configuration error: `Error` log and (with `FailFast`) a startup throw. Set to `false` to only log a `Warning`. |
+| `PeriodicValidationInterval` | `null` | When set, the authority metadata is **re-validated on this interval** while the app runs. Periodic failures never throw — permanent ones log `Error`, transient ones `Warning` — so a running server is never killed by a health check. |
+
**4xx is permanent** (typo, unknown tenant, revoked metadata) → `Error` log and, with `FailFast`,
a startup crash. **Everything else** (5xx, timeout, DNS) is transient → `Warning` log, the app
starts, and requests fail closed (401) until the identity provider is reachable.
+A **200 response without signing keys** (empty JWKS) is permanent too: logged at `Error` and, with
+`FailFast`, a startup throw. Set `RequireSigningKeys = false` to downgrade it to a `Warning`.
+Setting `PeriodicValidationInterval` re-probes the endpoint while the app runs — important because
+IdentityModel's last-known-good cache otherwise masks metadata failures once a healthy configuration
+was loaded.
+
## Token hardening (`JwtBearerHardeningOptions`)
| Option | Default | Enforces |
@@ -149,8 +158,9 @@ settings) with a `--broken` switch between the broken and the fixed behavior.
- The guard validates the authority **once at startup**; requests keep failing closed (401) whenever
tokens cannot be validated. The runtime metadata logging (Option 2/3) reports retrieval failures
that **surface** — once IdentityModel has loaded a healthy configuration, its last-known-good
- behavior serves the cached metadata without throwing, so an identity-provider outage *after* a
- healthy start produces no Error log (requests are unaffected while the cached config is valid).
+ behavior serves the cached metadata without throwing. To actively detect an identity-provider
+ outage after a healthy start, set `PeriodicValidationInterval` (background re-validation that
+ never throws; permanent failures log at `Error`, transient at `Warning`).
- The metadata is fetched one extra time at startup (which also warms the configuration cache).
- `AddJwtBearerHardening` must be registered **after** `AddJwtBearer`.
diff --git a/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs b/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs
index 1807528..22b541b 100644
--- a/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs
+++ b/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs
@@ -2,8 +2,10 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.IdentityModel.Tokens;
using SafeWebCore.JwtBearer;
namespace SafeWebCore.JwtBearer.Tests;
@@ -62,17 +64,95 @@ public async Task StartAsyncSuccessCompletes()
await guard.StartAsync(CancellationToken.None);
}
- private static JwtAuthorityValidationGuard CreateGuard(FakeManager manager, bool failFast)
+ [Fact]
+ public async Task StartAsyncEmptySigningKeysWithFailFastThrows()
+ {
+ var guard = CreateGuard(EmptySigningKeysManager(), failFast: true);
+
+ var exception = await Assert.ThrowsAsync(() => guard.StartAsync(CancellationToken.None));
+
+ Assert.Contains("no signing keys", exception.Message);
+ }
+
+ [Fact]
+ public async Task StartAsyncEmptySigningKeysWithoutFailFastLogsError()
+ {
+ var records = new List<(LogLevel Level, string Message)>();
+ var guard = CreateGuard(EmptySigningKeysManager(), failFast: false, records: records);
+
+ await guard.StartAsync(CancellationToken.None);
+
+ var record = Assert.Single(records, record => record.Level == LogLevel.Error);
+ Assert.Contains("no signing keys", record.Message);
+ }
+
+ [Fact]
+ public async Task StartAsyncEmptySigningKeysAllowedByRequireSigningKeysFalseWarns()
+ {
+ var records = new List<(LogLevel Level, string Message)>();
+ var guard = CreateGuard(EmptySigningKeysManager(), failFast: true, records: records, requireSigningKeys: false);
+
+ await guard.StartAsync(CancellationToken.None);
+
+ var record = Assert.Single(records, record => record.Level == LogLevel.Warning);
+ Assert.Contains("no signing keys", record.Message);
+ }
+
+ [Fact]
+ public async Task PeriodicValidationRunsRepeatedly()
+ {
+ var manager = CountingSuccessfulManager();
+ var guard = CreateGuard(manager, failFast: true, periodicInterval: TimeSpan.FromMilliseconds(120));
+
+ await guard.StartAsync(TestContext.Current.CancellationToken);
+ await Task.Delay(TimeSpan.FromMilliseconds(400), TestContext.Current.CancellationToken);
+ await guard.StopAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(manager.GetCallCount() >= 2, "The authority should have been re-validated at least once.");
+ }
+
+ [Fact]
+ public async Task PeriodicValidationFailureDoesNotCrash()
+ {
+ var manager = CountingTransientFailureManager();
+ var guard = CreateGuard(manager, failFast: false, periodicInterval: TimeSpan.FromMilliseconds(100));
+
+ await guard.StartAsync(TestContext.Current.CancellationToken);
+ await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
+ await guard.StopAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(manager.GetCallCount() >= 2, "The periodic re-validation should have attempted another fetch.");
+ }
+
+ private static JwtAuthorityValidationGuard CreateGuard(
+ IConfigurationManager manager,
+ bool failFast,
+ List<(LogLevel Level, string Message)>? records = null,
+ bool requireSigningKeys = true,
+ TimeSpan? periodicInterval = null)
{
var services = new ServiceCollection();
services.AddLogging();
+ if (records is not null)
+ {
+ services.AddSingleton>(
+ new RecordingLogger(records));
+ }
+
services.Configure(JwtBearerDefaults.AuthenticationScheme, o =>
{
o.ConfigurationManager = manager;
o.TokenValidationParameters.ValidateAudience = false;
o.TokenValidationParameters.ValidateIssuer = false;
});
- services.Configure(o => o.FailFast = failFast);
+
+ services.Configure(o =>
+ {
+ o.FailFast = failFast;
+ o.RequireSigningKeys = requireSigningKeys;
+ o.PeriodicValidationInterval = periodicInterval;
+ });
+
services.AddHostedService();
using var provider = services.BuildServiceProvider();
@@ -80,8 +160,27 @@ private static JwtAuthorityValidationGuard CreateGuard(FakeManager manager, bool
}
private static FakeManager SuccessfulManager()
+ => new(static () => Task.FromResult(ConfigurationWithPublicKeys()));
+
+ private static FakeManager EmptySigningKeysManager()
=> new(() => Task.FromResult(new OpenIdConnectConfiguration()));
+ private static OpenIdConnectConfiguration ConfigurationWithPublicKeys()
+ => new()
+ {
+ SigningKeys =
+ {
+ new JsonWebKey
+ {
+ Kty = "RSA",
+ Use = "sig",
+ Kid = "test-key",
+ E = "AQAB",
+ N = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
+ },
+ },
+ };
+
private static FakeManager PermanentFailureManager()
{
var inner = new IOException("IDX20807: Unable to retrieve document from: 'metadata'.");
@@ -90,6 +189,12 @@ private static FakeManager PermanentFailureManager()
return new FakeManager(() => Task.FromException(outer));
}
+ private static CountingManager CountingSuccessfulManager()
+ => new(static () => Task.FromResult(ConfigurationWithPublicKeys()));
+
+ private static CountingManager CountingTransientFailureManager()
+ => new(() => Task.FromException(new TimeoutException("IDX20807: timed out")));
+
private sealed class FakeManager(Func> get) : IConfigurationManager
{
public Task GetConfigurationAsync(CancellationToken cancel) => get();
@@ -98,4 +203,34 @@ public void RequestRefresh()
{
}
}
+
+ private sealed class CountingManager(Func> get) : IConfigurationManager
+ {
+ private int _calls;
+
+ public Task GetConfigurationAsync(CancellationToken cancel)
+ {
+ Interlocked.Increment(ref _calls);
+ return get();
+ }
+
+ public int GetCallCount() => Volatile.Read(ref _calls);
+
+ public void RequestRefresh()
+ {
+ }
+ }
+
+ private sealed class RecordingLogger(List<(LogLevel Level, string Message)> records) : ILogger
+ where T : class
+ {
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ records.Add((logLevel, formatter(state, exception)));
+ }
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+ }
}
\ No newline at end of file
diff --git a/tests/SafeWebCore.JwtBearer.Tests/StephanReproIntegrationTests.cs b/tests/SafeWebCore.JwtBearer.Tests/StephanReproIntegrationTests.cs
index 9d7e3ba..618631a 100644
--- a/tests/SafeWebCore.JwtBearer.Tests/StephanReproIntegrationTests.cs
+++ b/tests/SafeWebCore.JwtBearer.Tests/StephanReproIntegrationTests.cs
@@ -27,7 +27,15 @@ public sealed class StephanReproIntegrationTests
{
"issuer": "https://login.microsoftonline.com/organizations/v2.0",
"jwks_uri": "https://login.microsoftonline.com/organizations/v2.0/discovery/v2.0/keys",
- "keys": []
+ "keys": [
+ {
+ "kty": "RSA",
+ "use": "sig",
+ "kid": "test-key",
+ "e": "AQAB",
+ "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw"
+ }
+ ]
}
""";