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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
145 changes: 130 additions & 15 deletions src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,24 @@
/// <summary>
/// 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 <see cref="JwtAuthorityValidationOptions.EnforceStaticConfigurationChecks"/>).
/// 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 <see cref="JwtAuthorityValidationOptions.EnforceStaticConfigurationChecks"/>).
/// </summary>
public sealed partial class JwtAuthorityValidationGuard : IHostedService
public sealed partial class JwtAuthorityValidationGuard : IHostedService, IDisposable
{
private readonly IConfigurationManager<OpenIdConnectConfiguration> _configurationManager;
private readonly JwtBearerOptions _jwtOptions;
private readonly ILogger<JwtAuthorityValidationGuard> _logger;
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;

/// <summary>
/// Initializes a new <see cref="JwtAuthorityValidationGuard"/>.
Expand All @@ -50,43 +55,117 @@
$"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;
}

/// <inheritdoc />
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);
}
}

/// <inheritdoc />
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;
}

/// <inheritdoc />
public void Dispose()

Check warning on line 101 in src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs

View workflow job for this annotation

GitHub Actions / Build, test, and pack

Symbol 'SafeWebCore.JwtBearer.JwtAuthorityValidationGuard.Dispose() -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)
{
_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
}

/// <inheritdoc />
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)
{
Expand All @@ -106,6 +185,33 @@
}
}

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)";

/// <summary>
Expand Down Expand Up @@ -138,4 +244,13 @@

[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
}
17 changes: 17 additions & 0 deletions src/SafeWebCore.JwtBearer/JwtAuthorityValidationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public sealed class JwtAuthorityValidationOptions
/// (requests still fail closed with 401 until the authority is fixed).
/// </summary>
public bool FailFast { get; set; }

/// <summary>
/// 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
Expand All @@ -27,4 +28,20 @@ public sealed class JwtAuthorityValidationOptions
/// </summary>
public bool EnforceStaticConfigurationChecks { get; set; } = true;

/// <summary>
/// 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 <see cref="FailFast"/> is set. Set to false to downgrade this to a Warning for authorities
/// that intentionally publish no keys.
/// </summary>
public bool RequireSigningKeys { get; set; } = true;

/// <summary>
/// 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.
/// </summary>
public TimeSpan? PeriodicValidationInterval { get; set; }

}
4 changes: 4 additions & 0 deletions src/SafeWebCore.JwtBearer/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions src/SafeWebCore.JwtBearer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`.

Expand Down
Loading
Loading