From ca47b74d980ac62bd70316f0c5fb354a56c555a2 Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Thu, 10 Sep 2026 12:58:07 +0200 Subject: [PATCH 1/2] feat(JwtBearer): empty-JWKS detection (RequireSigningKeys) + periodic authority re-validation (PeriodicValidationInterval) --- CHANGELOG.md | 2 + .../JwtAuthorityValidationGuard.cs | 145 ++++++++++++++++-- .../JwtAuthorityValidationOptions.cs | 17 ++ .../PublicAPI.Unshipped.txt | 4 + src/SafeWebCore.JwtBearer/README.md | 14 +- .../JwtAuthorityValidationGuardTests.cs | 129 +++++++++++++++- .../StephanReproIntegrationTests.cs | 10 +- 7 files changed, 301 insertions(+), 20 deletions(-) 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..d812bc9 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,6 +160,12 @@ private static JwtAuthorityValidationGuard CreateGuard(FakeManager manager, bool } private static FakeManager SuccessfulManager() + => new(() => Task.FromResult(new OpenIdConnectConfiguration + { + SigningKeys = { new SymmetricSecurityKey(new byte[32]) }, + })); + + private static FakeManager EmptySigningKeysManager() => new(() => Task.FromResult(new OpenIdConnectConfiguration())); private static FakeManager PermanentFailureManager() @@ -90,6 +176,15 @@ private static FakeManager PermanentFailureManager() return new FakeManager(() => Task.FromException(outer)); } + private static CountingManager CountingSuccessfulManager() + => new(() => Task.FromResult(new OpenIdConnectConfiguration + { + SigningKeys = { new SymmetricSecurityKey(new byte[32]) }, + })); + + 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 +193,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" + } + ] } """; From 5be4a76e2d25ef99bab5bfe6e3756d995ae7a1e4 Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Thu, 10 Sep 2026 13:05:12 +0200 Subject: [PATCH 2/2] fix(JwtBearer): use public RSA JWK in tests instead of SymmetricSecurityKey (S6781) --- .../JwtAuthorityValidationGuardTests.cs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs b/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs index d812bc9..22b541b 100644 --- a/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs +++ b/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs @@ -160,14 +160,27 @@ private static JwtAuthorityValidationGuard CreateGuard( } private static FakeManager SuccessfulManager() - => new(() => Task.FromResult(new OpenIdConnectConfiguration - { - SigningKeys = { new SymmetricSecurityKey(new byte[32]) }, - })); + => 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'."); @@ -177,10 +190,7 @@ private static FakeManager PermanentFailureManager() } private static CountingManager CountingSuccessfulManager() - => new(() => Task.FromResult(new OpenIdConnectConfiguration - { - SigningKeys = { new SymmetricSecurityKey(new byte[32]) }, - })); + => new(static () => Task.FromResult(ConfigurationWithPublicKeys())); private static CountingManager CountingTransientFailureManager() => new(() => Task.FromException(new TimeoutException("IDX20807: timed out")));