diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed7ec8d..e56ec68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: | 10.0.x @@ -40,11 +40,13 @@ jobs: mkdir -p artifacts/nupkg dotnet pack src/SafeWebCore/SafeWebCore.csproj -c ${{ env.Configuration }} --no-build -o artifacts/nupkg dotnet pack src/SafeWebCore.FraudDetection/SafeWebCore.FraudDetection.csproj -c ${{ env.Configuration }} --no-build -o artifacts/nupkg + dotnet pack src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj -c ${{ env.Configuration }} --no-build -o artifacts/nupkg + dotnet pack src/SafeWebCore.Analyzers/SafeWebCore.Analyzers.csproj -c ${{ env.Configuration }} --no-build -o artifacts/nupkg dotnet pack src/SafeWebCore.Testing/SafeWebCore.Testing.csproj -c ${{ env.Configuration }} --no-build -o artifacts/nupkg - name: Upload packages - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nupkgs path: artifacts/nupkg/* diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 1a32a2d..dbb8b94 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' @@ -40,9 +40,11 @@ jobs: dotnet pack src/SafeWebCore.Analyzers/SafeWebCore.Analyzers.csproj --configuration Release --no-build --output artifacts/nuget || echo "Analyzers pack skipped" dotnet pack src/SafeWebCore.Testing/SafeWebCore.Testing.csproj --configuration Release --no-build --output artifacts/nuget || echo "Testing pack skipped" dotnet pack src/SafeWebCore.FraudDetection/SafeWebCore.FraudDetection.csproj --configuration Release --no-build --output artifacts/nuget || echo "FraudDetection pack skipped" + dotnet pack src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj --configuration Release --no-build --output artifacts/nuget || echo "JwtBearer pack skipped" + - name: Upload NuGet artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: nuget-packages path: artifacts/nuget/*.nupkg diff --git a/CHANGELOG.md b/CHANGELOG.md index 404a4fc..8d6c250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `NetSecureHeadersOptions.PathPolicy(pathPrefix, customize)` extension method — creates a path policy that **inherits** the global configuration and only overrides explicitly set values. This prevents accidental security header downgrades (for example a weaker HSTS value on `/api`). +- **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. +- `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. - `NetSecureHeadersOptions.Clone()` — creates an independent copy of an options instance for safe mutation. diff --git a/README.md b/README.md index f6621a9..20bfe80 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ - 🔌 **Extensible** — add custom `IHeaderPolicy` implementations for any header - 📊 **CSP violation reporting** — built-in middleware for `/csp-report` endpoint using Reporting API v1 - 🔍 **Diagnostics preview** — `MapSafeWebCoreDiagnostics(...)` for an opt-in JSON preview of effective headers, path-policy resolution, and CSP mode +- 🔐 **JWT authority validation** — opt-in `SafeWebCore.JwtBearer` companion module turns a broken JWT authority into a fail-fast startup error or a loud Error log (solves [dotnet/aspnetcore#67991](https://github.com/dotnet/aspnetcore/issues/67991), reported by Stephan van Rooij), plus optional token hardening + - 📈 **Opt-in metrics** — `System.Diagnostics.Metrics` counters for core middleware and fraud detection - 🚨 **Fraud action pipeline** — `IFraudEventSink` / `FraudEvent` for reacting to fraud analysis results (logging, webhooks, custom actions) - 📦 **Companion packages** — `SafeWebCore.FraudDetection`, `SafeWebCore.Analyzers` (preview), and `SafeWebCore.Testing` (preview) @@ -375,6 +377,8 @@ Three complete, runnable ASP.NET Core applications demonstrating different integ | [**MinimalApi**](examples/MinimalApi/) | Minimal API | One-line A+ setup, inline nonce, CSP reporting, health probes | | [**MvcApp**](examples/MvcApp/) | MVC + Razor Views | Typed policy builders, path policies, nonce TagHelpers, controller attributes | | [**ApiService**](examples/ApiService/) | Web API Controllers | Custom CSP report sink, endpoint overrides, API preset | +| [**JwtBearerDemo**](examples/JwtBearerDemo/) | Minimal API + JWT | Fixed the dotnet/aspnetcore#67991 silent-401 bug: broken vs. fail-fast authority startup | + Each example is fully functional out of the box — just `dotnet run` from the example directory. diff --git a/SafeWebCore.slnx b/SafeWebCore.slnx index 3ffad78..719a812 100644 --- a/SafeWebCore.slnx +++ b/SafeWebCore.slnx @@ -1,13 +1,15 @@ - + + + diff --git a/docs/examples.md b/docs/examples.md index b7133cc..eb4f5bd 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -140,6 +140,32 @@ CSP violations are logged to `csp-violations.jsonl` next to the binary. --- +## 🔐 JWT Bearer Example (SafeWebCore.JwtBearer) + +**Location:** `examples/JwtBearerDemo/` + +### What it demonstrates + +This example is the reproduction of **dotnet/aspnetcore#67991** (reported by Stephan van Rooij, +[@svrooij](https://github.com/svrooij)): a misspelled JWT authority +(`organisations` instead of `organizations`) makes the app start normally while every request +returns `401` — and the root cause is logged below the default `Microsoft.AspNetCore: Warning` +threshold. The .NET team scheduled the fix for **.NET 12 Planning** only. + +With `SafeWebCore.JwtBearer` enabled, the same app **refuses to start** on a permanent (HTTP 4xx) +authority failure — or logs a loud `Error` when `FailFast = false`. + +### Run + +```bash +cd examples/JwtBearerDemo +dotnet run +``` + +Flip `const bool enableFix` in `Program.cs` to switch between the broken and the fixed behavior. +See `examples/JwtBearerDemo/README.md` for the full before/after walkthrough. + + ## 📊 Feature Matrix | Feature | MinimalApi | MvcApp | ApiService | diff --git a/docs/nuget-packages.md b/docs/nuget-packages.md index 907e1e4..2e98dc1 100644 --- a/docs/nuget-packages.md +++ b/docs/nuget-packages.md @@ -13,6 +13,8 @@ This document describes every packable project: identity, contents, publish stat |-----------|----------------|-----------|------------|----------------| | **SafeWebCore** | `1.3.5` | **Published** (`1.0.0`–`1.3.5`) | `SafeWebCore.1.3.5.nupkg` + `.snupkg` | Do **not** republish 1.3.5. Ship next as **1.4.0** (or later) after promoting Unreleased work | | **SafeWebCore.FraudDetection** | `1.0.0` | **Not published** | `SafeWebCore.FraudDetection.1.0.0.nupkg` | **Primary new package candidate** for first public release | +| **SafeWebCore.JwtBearer** | `1.0.0` | **Not published** | `SafeWebCore.JwtBearer.1.0.0.nupkg` | **New package candidate** — JWT authority fail-fast + token hardening | + | **SafeWebCore.Analyzers** | `1.0.0-preview.1` | **Not published** | `SafeWebCore.Analyzers.1.0.0-preview.1.nupkg` | Publish as **preview** only | | **SafeWebCore.Testing** | `1.0.0-preview.1` | **Not published** | `SafeWebCore.Testing.1.0.0-preview.1.nupkg` | Publish as **preview** only | @@ -27,6 +29,8 @@ From repo root: ```bash dotnet pack src/SafeWebCore/SafeWebCore.csproj -c Release -o artifacts/nupkg dotnet pack src/SafeWebCore.FraudDetection/SafeWebCore.FraudDetection.csproj -c Release -o artifacts/nupkg +dotnet pack src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj -c Release -o artifacts/nupkg + dotnet pack src/SafeWebCore.Analyzers/SafeWebCore.Analyzers.csproj -c Release -o artifacts/nupkg dotnet pack src/SafeWebCore.Testing/SafeWebCore.Testing.csproj -c Release -o artifacts/nupkg ``` @@ -196,6 +200,57 @@ Core is already at **1.3.5** while FraudDetection starts at **1.0.0**. That is n --- +# Package 2b — SafeWebCore.JwtBearer (new companion, unpublished) + +## Identity + +| Field | Value | +|-------|--------| +| PackageId | `SafeWebCore.JwtBearer` | +| Version | `1.0.0` (csproj) | +| Authors / Company | MPCoreDeveloper / Posseth Software | +| License | MIT | +| Project URL | https://github.com/MPCoreDeveloper/SafeWebCore | +| Readme in package | `src/SafeWebCore.JwtBearer/README.md` | +| Icon | `icon.png` | +| TFM | `net10.0` | + +## Purpose + +Solves [dotnet/aspnetcore#67991](https://github.com/dotnet/aspnetcore/issues/67991) +(reported by **Stephan van Rooij**): a misspelled JWT authority silently 401s every request +because the metadata failure is logged only at Information level, below the default log +threshold. The module adds a startup authority guard (fail fast or loud Error), optional token +hardening, and runtime metadata-failure logging — before the .NET team's planned fix in +**.NET 12 Planning**. + +## What consumers get + +```bash +dotnet add package SafeWebCore.JwtBearer +``` + +- `AddJwtBearerAuthorityValidation` — startup authority guard (Stephan's fix). +- `AddJwtBearerHardening` — optional token-validation hardening (algorithms, typ, audience/issuer, max lifetime, ...). +- `AddSafeWebCoreJwtBearer` — one-line registration (AddJwtBearer + hardening + guard). + +## Readiness scorecard + +| Check | Status | +|-------|--------| +| Builds in solution | ✅ | +| Unit + integration tests (xUnit v3) | ✅ (33, incl. Stephan's reproduction) | +| Public API baseline (`PublicAPI.*.txt`) | ✅ | +| README + icon packed | ✅ (verified) | +| `ci.yml` / `nuget-publish.yml` pack step | ✅ (added) | + +### Publish command + +```bash +dotnet pack src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj -c Release -o artifacts/nupkg +dotnet nuget push artifacts/nupkg/SafeWebCore.JwtBearer.1.0.0.nupkg --api-key %NUGET_API_KEY% --source https://api.nuget.org/v3/index.json +``` + # Package 3 — SafeWebCore.Analyzers (preview candidate) ## Identity @@ -359,6 +414,8 @@ Publish **FraudDetection 1.0.0** alone (no core bump). Valid because packages ar # After version bumps, changelog, and tests dotnet pack src/SafeWebCore/SafeWebCore.csproj -c Release -o artifacts/nupkg dotnet pack src/SafeWebCore.FraudDetection/SafeWebCore.FraudDetection.csproj -c Release -o artifacts/nupkg +dotnet pack src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj -c Release -o artifacts/nupkg + dotnet pack src/SafeWebCore.Analyzers/SafeWebCore.Analyzers.csproj -c Release -o artifacts/nupkg dotnet pack src/SafeWebCore.Testing/SafeWebCore.Testing.csproj -c Release -o artifacts/nupkg diff --git a/docs/projects.md b/docs/projects.md index 6e07db1..7a635c4 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -15,6 +15,8 @@ SafeWebCore/ ├── src/ # Shipable libraries │ ├── SafeWebCore/ # Core security-headers middleware (NuGet) │ ├── SafeWebCore.FraudDetection/ # Optional fraud module (NuGet candidate) +│ ├── SafeWebCore.JwtBearer/ # Optional JWT authority validation + hardening module (NuGet) + │ ├── SafeWebCore.Analyzers/ # Roslyn analyzers (NuGet preview candidate) │ └── SafeWebCore.Testing/ # Test helpers (NuGet preview candidate) ├── tests/ # Unit / integration tests @@ -37,6 +39,8 @@ SafeWebCore/ |---------|------| | SafeWebCore | `src/SafeWebCore/SafeWebCore.csproj` | | SafeWebCore.FraudDetection | `src/SafeWebCore.FraudDetection/SafeWebCore.FraudDetection.csproj` | +| SafeWebCore.JwtBearer | `src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj` | + | SafeWebCore.Analyzers | `src/SafeWebCore.Analyzers/SafeWebCore.Analyzers.csproj` | | SafeWebCore.Testing | `src/SafeWebCore.Testing/SafeWebCore.Testing.csproj` | | SafeWebCore.Tests | `tests/SafeWebCore.Tests/SafeWebCore.Tests.csproj` | @@ -475,17 +479,23 @@ SafeWebCore.Tests ──────────────► SafeWebCore SafeWebCore.Testing ────────────► SafeWebCore SafeWebCore.Benchmarks ─────────► SafeWebCore examples/* ─────────────────────► SafeWebCore +SafeWebCore.JwtBearer.Tests ► SafeWebCore.JwtBearer + SafeWebCore.FraudDetection.Tests ► SafeWebCore.FraudDetection SafeWebCore.Analyzers (standalone Roslyn package) SafeWebCore.FraudDetection (standalone; no dependency on SafeWebCore) +SafeWebCore.JwtBearer (standalone; depends on Microsoft.AspNetCore.Authentication.JwtBearer) + ``` --- # Project maturity matrix +| SafeWebCore.JwtBearer | Production-ready code, unpublished | **Yes** (first release candidate) | `1.0.0` after checklist (packaging/CI) | + | Project | Maturity | Ship as NuGet? | Recommended version policy | |---------|----------|----------------|----------------------------| | SafeWebCore | Production (published) | **Yes** (already) | SemVer stable; next feature drop → bump beyond 1.3.5 | diff --git a/examples/JwtBearerDemo/JwtBearerDemo.csproj b/examples/JwtBearerDemo/JwtBearerDemo.csproj new file mode 100644 index 0000000..c417347 --- /dev/null +++ b/examples/JwtBearerDemo/JwtBearerDemo.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + SafeWebCore.Examples.JwtBearerDemo + false + + + + + + + \ No newline at end of file diff --git a/examples/JwtBearerDemo/JwtBearerDemo.http b/examples/JwtBearerDemo/JwtBearerDemo.http new file mode 100644 index 0000000..4c14320 --- /dev/null +++ b/examples/JwtBearerDemo/JwtBearerDemo.http @@ -0,0 +1,8 @@ +@JwtBearerDemo_HostAddress = http://localhost:5120 + +### Weather forecast (protected endpoint) +### In the BROKEN setup this returns 401 UNAUTHORIZED with error="invalid_token" +### and nothing useful in the logs. In the FIXED setup the app refuses to start. +GET {{JwtBearerDemo_HostAddress}}/weatherforecast +Authorization: Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJkZW1vLXRva2VuIn0.sig +Accept: application/json \ No newline at end of file diff --git a/examples/JwtBearerDemo/Program.cs b/examples/JwtBearerDemo/Program.cs new file mode 100644 index 0000000..14a620c --- /dev/null +++ b/examples/JwtBearerDemo/Program.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using SafeWebCore.JwtBearer.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +// ============================================================================ +// SafeWebCore.JwtBearer demo - reproduction of dotnet/aspnetcore#67991 +// reported by Stephan van Rooij (@svrooij) +// https://github.com/dotnet/aspnetcore/issues/67991 +// +// FIXED (default): the SafeWebCore guard eagerly validates the authority metadata +// at startup. HTTP 4xx from the metadata endpoint (the permanent +// misconfiguration) stops the application with a clear error, or logs an Error +// if you prefer to keep the app running so all requests fail closed until the +// authority is fixed. +// +// BROKEN (dotnet run -- --broken): the guard is skipped. The misspelled authority +// lets the app start and run normally, every request returns 401 invalid_token, +// and nothing rises above the default Microsoft.AspNetCore: Warning log level. +// ============================================================================ +var brokenMode = args.Contains("--broken"); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + // The classic typo from issue #67991: "organisations" instead of "organizations". + // The discovery document for this url returns HTTP 400 (not 404), so the app + // starts fine but no signing keys are ever loaded. + options.Authority = "https://login.microsoftonline.com/organisations/v2.0"; + + // Block insecure HTTP metadata and enforce HTTPS for token validation. + options.RequireHttpsMetadata = true; + + // Token validation settings taken verbatim from the issue reproduction. + options.TokenValidationParameters.ValidateAudience = true; + options.TokenValidationParameters.ValidAudience = "api://safe-web-core-demo"; + options.TokenValidationParameters.ValidateIssuer = false; // allow tokens from multiple tenants + options.TokenValidationParameters.ValidateLifetime = true; + options.TokenValidationParameters.ValidateIssuerSigningKey = true; + options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(15); + options.TokenValidationParameters.RequireSignedTokens = true; + options.TokenValidationParameters.RequireExpirationTime = true; + + options.Events = new JwtBearerEvents + { + OnAuthenticationFailed = context => + { + // In the broken setup, set a breakpoint here to see: + // IDX10500: Signature validation failed. No security keys were provided. + return Task.CompletedTask; + }, + OnChallenge = context => Task.CompletedTask, + }; + }); + +if (!brokenMode) +{ + // SafeWebCore.JwtBearer fix + // ------------------------ + // Eagerly loads the OpenID Connect metadata at startup so a broken authority is + // never a silent availability incident. FailFast = true makes a permanent + // misconfiguration (HTTP 4xx) fail the startup, exactly as requested in #67991. + // Set FailFast = false to keep the app running with a loud Error log instead. + builder.Services.AddJwtBearerAuthorityValidation(options => options.FailFast = true); +} + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/weatherforecast", () => Results.Ok(new { forecast = "Sunny" })) + .RequireAuthorization(); + +await app.RunAsync(); \ No newline at end of file diff --git a/examples/JwtBearerDemo/Properties/launchSettings.json b/examples/JwtBearerDemo/Properties/launchSettings.json new file mode 100644 index 0000000..01e34d0 --- /dev/null +++ b/examples/JwtBearerDemo/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5120", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/examples/JwtBearerDemo/README.md b/examples/JwtBearerDemo/README.md new file mode 100644 index 0000000..1d2cbe5 --- /dev/null +++ b/examples/JwtBearerDemo/README.md @@ -0,0 +1,86 @@ +# SafeWebCore.JwtBearer Demo + +This example reproduces the exact scenario from **Stephan van Rooij's** issue +[dotnet/aspnetcore#67991](https://github.com/dotnet/aspnetcore/issues/67991) +and shows how **`SafeWebCore.JwtBearer`** fixes it. + +> [!IMPORTANT] +> If a **security** feature is **misconfigured**, the application should fail fast at +> startup instead of starting normally while silently returning `401` for everything. +> — **Stephan van Rooij ([@svrooij](https://github.com/svrooij))**, issue #67991, July 2026 + +The .NET team scheduled a fix for **.NET 12 Planning** only. This module gives you the +behavior today, on .NET 10. + +## The faulty behavior (the bug) + +The `Authority` is misspelled (`organisations` instead of `organizations`). Microsoft's +discovery endpoint for that URL returns **HTTP 400**, not 404, so the app: +- starts and runs normally, +- never loads signing keys, +- returns `401 invalid_token` for **every** request, +- logs the failure only at **Information** level, which the default `Microsoft.AspNetCore: Warning` + filter hides — so **nothing appears in the logs**. + +Request result (from issue #67991): + +```text +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer error="invalid_token", error_description="The signature key was not found" +``` + +## The fix (`SafeWebCore.JwtBearer`) + +The module registers a **startup guard** (`JwtAuthorityValidationGuard`) that eagerly loads the +OpenID Connect metadata for the configured authority. An HTTP **4xx** response (the typo case) is +classified as a **permanent misconfiguration** and: + +| Mode | Behavior | +|------|----------| +| `FailFast = true` *(secure default)* | The application **refuses to start** with a clear error that names the scheme and the metadata address. | +| `FailFast = false` | The application starts but logs at **Error** level; every request keeps failing closed (401) until the authority is fixed. | + +Transient failures (timeout, 5xx, DNS) only log a **Warning**, so a short identity-provider outage +does not take the app down. + +## Run it yourself + +```bash +cd examples/JwtBearerDemo + +# FIXED mode (default): the app refuses to start because the authority 400s +dotnet run + +# BROKEN mode: the stock behavior from issue #67991 - app starts, everything 401s silently +dotnet run -- --broken +``` + +### See the broken behavior first + +1. `dotnet run -- --broken` — the app starts and logs `Application started`. +2. Send the request from [`JwtBearerDemo.http`](JwtBearerDemo.http) (VS Code REST Client). + → **`401`**, and no `Error`/`Warning` in the console. + +### Then apply the fix + +1. `dotnet run` (default, guard enabled). +2. Startup hits the metadata endpoint, receives **HTTP 400**, and stops: + +```text +Unhandled exception. System.InvalidOperationException: The JWT authority for scheme 'Bearer' +is misconfigured (HTTP 4xx from 'https://login.microsoftonline.com/organisations/v2.0'). +Fix the Authority/MetadataAddress before starting. +``` + +You can also try `AddJwtBearerAuthorityValidation(o => o.FailFast = false)` in +[`Program.cs`](Program.cs) to keep the app running while logging an `Error` with the same +information. + +## What this demo teaches + +- A misconfigured authority fails **closed** (401), which is secure — the lock works. +- What is missing in stock ASP.NET Core is the **loudness**: nothing surfaces at the default log level. +- `SafeWebCore.JwtBearer` restores that loudness **at startup**, using the exact 4xx classification. + +See the main [module README](../../src/SafeWebCore.JwtBearer/README.md) for the full API +(`AddJwtBearerHardening`, `AddSafeWebCoreJwtBearer`, token hardening, runtime metadata logging). \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index 360eed7..1df16ec 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,8 +1,8 @@ # SafeWebCore – Examples -Three runnable ASP.NET Core applications that demonstrate different integration patterns for the [SafeWebCore](../README.md) security-header middleware library. +Four runnable ASP.NET Core applications that demonstrate different integration patterns for [SafeWebCore](../README.md) — including a companion-module demo for `SafeWebCore.JwtBearer`. -All examples reference the local `src/SafeWebCore` project directly so you can run them straight from a clone — no NuGet restore from the feed required. +All examples reference the local source projects directly so you can run them straight from a clone — no NuGet restore from the feed required. ## Examples at a glance @@ -11,6 +11,7 @@ All examples reference the local `src/SafeWebCore` project directly so you can r | [MinimalApi](MinimalApi/) | ASP.NET Core Minimal API | `AddNetSecureHeadersStrictAPlus`, `GetCspNonce()`, `SkipNetSecureHeaders()`, CSP report endpoint | | [MvcApp](MvcApp/) | ASP.NET Core MVC + Razor Views | MVC preset, typed policy builders, path policies, `[CspNonce]` attribute, nonce TagHelpers | | [ApiService](ApiService/) | Web API with controllers | API preset, custom `ICspReportSink`, `[SkipNetSecureHeaders]`, `[CspMode]` endpoint overrides | +| [JwtBearerDemo](JwtBearerDemo/) | Minimal API + JWT | `SafeWebCore.JwtBearer`: broken JWT authority (dotnet/aspnetcore#67991) vs. fail-fast startup | ## Prerequisites @@ -30,9 +31,19 @@ dotnet run # ApiService cd examples/ApiService dotnet run + +# JwtBearerDemo (read its README first: broken vs. fixed JWT authority) +cd examples/JwtBearerDemo +dotnet run ``` -Each example starts on `http://localhost:5000` by default. Open your browser, navigate to the root URL, and inspect the response headers in DevTools → Network to see the security headers in action. +Each example starts on `http://localhost:5000` by default (JwtBearerDemo uses `http://localhost:5120`). Open your browser or HTTP client and inspect the response headers in DevTools → Network to see the security headers in action. + +> [!NOTE] +> **JwtBearerDemo** is the reproduction of [dotnet/aspnetcore#67991](https://github.com/dotnet/aspnetcore/issues/67991) +> (reported by Stephan van Rooij). It is not about security headers: it shows how a misspelled +> JWT authority silently 401s every request, and how `SafeWebCore.JwtBearer` makes that a +> fail-fast (or loud-log) startup event instead. See its [README](JwtBearerDemo/README.md). ## Feature matrix @@ -59,4 +70,4 @@ Each example starts on `http://localhost:5000` by default. Open your browser, na - Read the full [documentation](../docs/) for API reference and configuration options. - See [docs/presets.md](../docs/presets.md) for all available presets and customisation patterns. - See [docs/csp-configuration.md](../docs/csp-configuration.md) for the fluent CSP builder and nonce usage. -- See [docs/advanced-configuration.md](../docs/advanced-configuration.md) for path policies, endpoint overrides, and custom sinks. +- See [docs/advanced-configuration.md](../docs/advanced-configuration.md) for path policies, endpoint overrides, and custom sinks. \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..802ab21 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/src/SafeWebCore.JwtBearer/Extensions/ServiceCollectionExtensions.cs b/src/SafeWebCore.JwtBearer/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..75e4120 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace SafeWebCore.JwtBearer.Extensions; + +/// +/// Extension methods for registering the JWT authority validation guard and optional hardening. +/// +public static class JwtAuthorityValidationExtensions +{ + /// + /// Registers a startup that eagerly loads the OpenID + /// Connect metadata for the JWT bearer authority and fails loud (or fails fast) when the authority + /// is misconfigured, so a typo'd authority is never a silent availability incident. + /// + /// The service collection. + /// Optional configuration of . + /// The service collection for chaining. + public static IServiceCollection AddJwtBearerAuthorityValidation( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(configure ?? (_ => { })); + if (!services.Any(descriptor => descriptor.ImplementationType == typeof(JwtAuthorityValidationGuard))) + { + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); + } + + return services; + } + + /// + /// Registers the optional JWT hardening rules plus the startup authority validation guard with a + /// secure default (a broken authority fails the application fast). Call this after AddJwtBearer. + /// + /// The service collection. + /// The JWT bearer scheme to harden (defaults to "Bearer"). + /// Optional configuration of . + /// The service collection for chaining. + public static IServiceCollection AddJwtBearerHardening( + this IServiceCollection services, + string? scheme = null, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var targetScheme = scheme ?? JwtBearerDefaults.AuthenticationScheme; + var hardening = new JwtBearerHardeningOptions(); + configure?.Invoke(hardening); + + services.AddSingleton>(Options.Create(hardening)); + services.Configure(targetScheme, options => JwtBearerHardeningApplication.Apply(options, hardening)); + services.AddSingleton, JwtBearerLoggingConfigurationPostConfigure>(); + + // Secure default: a broken authority must stop the application. The last registered + // JwtAuthorityValidationOptions always wins, so callers can opt out explicitly. + services.AddJwtBearerAuthorityValidation(options => options.FailFast = true); + + return services; + } + + /// + /// One-line registration that adds AddJwtBearer, the optional hardening rules and the + /// startup authority validation guard (fail fast by default) for the "Bearer" scheme. + /// + /// The service collection. + /// Configuration of (authority, issuer, audience, ...). + /// Optional configuration of . + /// Optional configuration of . + /// The service collection for chaining. + public static IServiceCollection AddSafeWebCoreJwtBearer( + this IServiceCollection services, + Action configureJwt, + Action? configureHardening = null, + Action? configureGuard = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureJwt); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(configureJwt); + services.AddJwtBearerHardening(null, configureHardening); + services.Configure(configureGuard ?? (_ => { })); + + return services; + } +} \ No newline at end of file diff --git a/src/SafeWebCore.JwtBearer/Internal/JwtBearerConfigurationValidator.cs b/src/SafeWebCore.JwtBearer/Internal/JwtBearerConfigurationValidator.cs new file mode 100644 index 0000000..bd7aa1b --- /dev/null +++ b/src/SafeWebCore.JwtBearer/Internal/JwtBearerConfigurationValidator.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; + +namespace SafeWebCore.JwtBearer; + +/// +/// Deterministic, network-free checks on the effective that +/// complement the framework's own JwtBearerOptions.Validate(). Used by the startup guard. +/// +internal static class JwtBearerConfigurationValidator +{ + /// + /// Returns the configuration issues found. An empty list means the configuration is sound. + /// + /// The effective JWT bearer options to inspect. + public static IReadOnlyList FindIssues(JwtBearerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var issues = new List(); + ValidateAuthorityAddress(options, issues); + ValidateValidationParameters(options, issues); + return issues; + } + + private static void ValidateAuthorityAddress(JwtBearerOptions options, List issues) + { + var address = options.Authority ?? options.MetadataAddress; + if (string.IsNullOrWhiteSpace(address)) + { + // The framework post-configure already requires Authority or MetadataAddress + // when it has to build the configuration manager itself. + return; + } + + if (!Uri.TryCreate(address, UriKind.Absolute, out var uri)) + { + issues.Add($"The authority/metadata address '{address}' is not an absolute URI."); + return; + } + + if (options.RequireHttpsMetadata + && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + issues.Add($"The authority/metadata address '{address}' does not use HTTPS while RequireHttpsMetadata is enabled."); + } + } + + private static void ValidateValidationParameters(JwtBearerOptions options, List issues) + { + var parameters = options.TokenValidationParameters; + + if (parameters.ValidateAudience + && parameters.AudienceValidator is null + && string.IsNullOrWhiteSpace(parameters.ValidAudience) + && (parameters.ValidAudiences is null || !parameters.ValidAudiences.Any())) + { + issues.Add("Audience validation is enabled but no ValidAudience(s) are configured."); + } + + var resolvesIssuerFromMetadata = !string.IsNullOrWhiteSpace(options.Authority) + || !string.IsNullOrWhiteSpace(options.MetadataAddress); + + if (!resolvesIssuerFromMetadata + && parameters.ValidateIssuer + && parameters.IssuerValidator is null + && string.IsNullOrWhiteSpace(parameters.ValidIssuer) + && (parameters.ValidIssuers is null || !parameters.ValidIssuers.Any())) + { + issues.Add("Issuer validation is enabled but no ValidIssuer(s) are configured and no authority metadata is used to resolve the issuer."); + } + + if (parameters.ValidAlgorithms is { } algorithms + && algorithms.Any(algorithm => string.Equals(algorithm, "none", StringComparison.OrdinalIgnoreCase))) + { + issues.Add("The algorithm 'none' must not be allowed in ValidAlgorithms."); + } + } +} diff --git a/src/SafeWebCore.JwtBearer/Internal/JwtBearerHardeningApplication.cs b/src/SafeWebCore.JwtBearer/Internal/JwtBearerHardeningApplication.cs new file mode 100644 index 0000000..fe04228 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/Internal/JwtBearerHardeningApplication.cs @@ -0,0 +1,203 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace SafeWebCore.JwtBearer; + +/// +/// Applies to the effective . +/// Every rule can only increase strictness; tighter settings already present on the token +/// validation parameters are never weakened. +/// +internal static class JwtBearerHardeningApplication +{ + private const string TypHeader = "typ"; + + /// + /// Applies the hardening rules to a instance. + /// + /// The effective JWT bearer options. + /// The hardening rules to enforce. + /// When none is present in . + public static void Apply(JwtBearerOptions options, JwtBearerHardeningOptions hardening) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(hardening); + + ApplyDefaultValidationRules(options, hardening); + ApplyAlgorithmRestrictions(options, hardening); + ApplyIssuerAndAudience(options, hardening); + + var existing = options.Events.OnTokenValidated; + options.Events.OnTokenValidated = context => OnTokenValidatedAsync(context, existing, hardening); + } + + private static void ApplyDefaultValidationRules(JwtBearerOptions options, JwtBearerHardeningOptions hardening) + { + var parameters = options.TokenValidationParameters; + + if (hardening.RequireSignedTokens) + { + parameters.RequireSignedTokens = true; + } + + if (hardening.RequireExpirationTime) + { + parameters.RequireExpirationTime = true; + } + + if (hardening.ValidateLifetime) + { + parameters.ValidateLifetime = true; + } + + if (hardening.MaximumClockSkew > TimeSpan.Zero) + { + parameters.ClockSkew = parameters.ClockSkew <= TimeSpan.Zero + ? hardening.MaximumClockSkew + : TimeSpan.FromTicks(Math.Min(parameters.ClockSkew.Ticks, hardening.MaximumClockSkew.Ticks)); + } + } + + private static void ApplyAlgorithmRestrictions(JwtBearerOptions options, JwtBearerHardeningOptions hardening) + { + if (hardening.AllowedAlgorithms.Count == 0) + { + return; + } + + if (hardening.AllowedAlgorithms.Any(algorithm => string.Equals(algorithm, "none", StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + "The algorithm 'none' must not be allowed in JwtBearerHardeningOptions.AllowedAlgorithms."); + } + + options.TokenValidationParameters.ValidAlgorithms = hardening.AllowedAlgorithms + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static void ApplyIssuerAndAudience(JwtBearerOptions options, JwtBearerHardeningOptions hardening) + { + var parameters = options.TokenValidationParameters; + + if (hardening.ValidateIssuer) + { + parameters.ValidateIssuer = true; + if (hardening.ValidIssuers.Count > 0) + { + parameters.ValidIssuers = Merge(parameters.ValidIssuers, hardening.ValidIssuers); + } + } + + if (hardening.ValidateAudience) + { + parameters.ValidateAudience = true; + parameters.RequireAudience = true; + if (hardening.ValidAudiences.Count > 0) + { + parameters.ValidAudiences = Merge(parameters.ValidAudiences, hardening.ValidAudiences); + } + } + } + + /// + /// Chains the claim-based hardening checks (token type, jti, nbf, iat, + /// maximum lifetime) onto the existing OnTokenValidated handler. + /// + /// The token validated context. + /// The previously configured handler, if any. + /// The hardening rules to enforce. + public static async Task OnTokenValidatedAsync( + TokenValidatedContext context, + Func? existing, + JwtBearerHardeningOptions hardening) + { + if (existing is not null) + { + await existing(context); + if (context.Result?.Succeeded == false) + { + return; + } + } + + var token = context.SecurityToken; + if (token is null) + { + return; + } + + var failure = FindConstraintViolation(token, hardening); + if (failure is not null) + { + context.Fail(failure); + } + } + + private static string? FindConstraintViolation(SecurityToken token, JwtBearerHardeningOptions hardening) + { + if (hardening.RequireTokenType) + { + var type = GetHeaderValue(token, TypHeader); + if (string.IsNullOrWhiteSpace(type) + || !hardening.AllowedTokenTypes.Any(candidate => string.Equals(candidate, type, StringComparison.OrdinalIgnoreCase))) + { + return $"The JWT typ header '{type}' is not allowed by SafeWebCore.JwtBearer hardening."; + } + } + + if (hardening.RequireJwtId && string.IsNullOrWhiteSpace(token.Id)) + { + return "The JWT does not contain a jti (JWT ID) claim required by SafeWebCore.JwtBearer hardening."; + } + + if (hardening.RequireNotBefore && token.ValidFrom == default) + { + return "The JWT does not contain an nbf (not-before) claim required by SafeWebCore.JwtBearer hardening."; + } + + if (hardening.RequireIssuedAt && GetIssuedAt(token) == default) + { + return "The JWT does not contain an iat (issued-at) claim required by SafeWebCore.JwtBearer hardening."; + } + + if (hardening.MaximumTokenLifetime is { } maximum + && maximum > TimeSpan.Zero + && token.ValidFrom != default + && token.ValidTo != default + && token.ValidTo - token.ValidFrom > maximum) + { + return $"The JWT lifetime ({token.ValidTo - token.ValidFrom}) exceeds the maximum of {maximum} configured by SafeWebCore.JwtBearer hardening."; + } + + return null; + } + + private static string[] Merge(IEnumerable? existing, IList additions) + => existing is null + ? additions.Distinct(StringComparer.Ordinal).ToArray() + : existing.Concat(additions).Distinct(StringComparer.Ordinal).ToArray(); + + private static string? GetHeaderValue(SecurityToken token, string key) + { + switch (token) + { + case JwtSecurityToken jwt: + return jwt.Header.TryGetValue(key, out var value) ? value as string : null; + case JsonWebToken json: + return json.TryGetHeaderValue(key, out var jsonValue) ? jsonValue as string : null; + default: + return null; + } + } + + private static DateTime GetIssuedAt(SecurityToken token) + => token switch + { + JsonWebToken json => json.IssuedAt, + JwtSecurityToken jwt => jwt.IssuedAt, + _ => default, + }; +} \ No newline at end of file diff --git a/src/SafeWebCore.JwtBearer/Internal/JwtBearerLoggingConfigurationPostConfigure.cs b/src/SafeWebCore.JwtBearer/Internal/JwtBearerLoggingConfigurationPostConfigure.cs new file mode 100644 index 0000000..48e9944 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/Internal/JwtBearerLoggingConfigurationPostConfigure.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace SafeWebCore.JwtBearer; + +/// +/// Runs after the framework's JwtBearerPostConfigureOptions and wraps the already-created +/// configuration manager with so metadata retrieval +/// failures surface loudly at runtime. Requires the hardening extension to be registered after +/// AddJwtBearer. +/// +internal sealed class JwtBearerLoggingConfigurationPostConfigure : IPostConfigureOptions +{ + private readonly JwtBearerHardeningOptions _hardening; + private readonly ILogger _logger; + + /// + /// Initializes a new . + /// + /// The hardening options controlling the runtime metadata logging. + /// The logger used by the wrapping decorator. + public JwtBearerLoggingConfigurationPostConfigure( + IOptions hardening, + ILogger logger) + { + _hardening = hardening.Value; + _logger = logger; + } + + /// + public void PostConfigure(string? name, JwtBearerOptions options) + { + if (!_hardening.EnableRuntimeMetadataLogging) + { + return; + } + + if (options.ConfigurationManager is BaseConfigurationManager manager && manager is not LoggingConfigurationManager) + { + options.ConfigurationManager = new LoggingConfigurationManager(manager, _logger); + } + } +} diff --git a/src/SafeWebCore.JwtBearer/Internal/LoggingConfigurationManager.cs b/src/SafeWebCore.JwtBearer/Internal/LoggingConfigurationManager.cs new file mode 100644 index 0000000..21d8c31 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/Internal/LoggingConfigurationManager.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace SafeWebCore.JwtBearer; + +/// +/// Wraps a so OpenID Connect metadata retrieval failures +/// surface loudly at Error (permanent HTTP 4xx) or Warning (transient) level, instead of the +/// framework default of logging them at Information level where the default log filter hides them. +/// All reads and writes are forwarded to the wrapped manager, preserving its caching and +/// last-known-good semantics. Failures are rate-limited to one log line per refresh interval. +/// +internal sealed partial class LoggingConfigurationManager : BaseConfigurationManager, IConfigurationManager +{ + private readonly BaseConfigurationManager _inner; + private readonly ILogger _logger; + private readonly object _sync = new(); + private DateTimeOffset _lastFailureLogged = DateTimeOffset.MinValue; + + /// + /// Initializes a new that decorates . + /// + /// The configuration manager to decorate. + /// The logger used to report metadata retrieval failures. + public LoggingConfigurationManager(BaseConfigurationManager inner, ILogger logger) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(logger); + + _inner = inner; + _logger = logger; + + // BaseConfigurationManager exposes these as non-virtual properties, so mirror the inner + // values on this instance to keep reads through base-typed references correct. + MetadataAddress = inner.MetadataAddress; + AutomaticRefreshInterval = inner.AutomaticRefreshInterval; + RefreshInterval = inner.RefreshInterval; + UseLastKnownGoodConfiguration = inner.UseLastKnownGoodConfiguration; + LastKnownGoodLifetime = inner.LastKnownGoodLifetime; + if (inner.LastKnownGoodConfiguration is { } lastKnownGood) + { + LastKnownGoodConfiguration = lastKnownGood; + } + } + + /// + public override Task GetBaseConfigurationAsync(CancellationToken cancel) + => GetBaseConfigurationCoreAsync(cancel); + + private async Task GetBaseConfigurationCoreAsync(CancellationToken cancel) + { + try + { + return await _inner.GetBaseConfigurationAsync(cancel).ConfigureAwait(false); + } + catch (Exception ex) when (ShouldLogFailure()) + { + var address = MetadataAddress ?? _inner.MetadataAddress ?? "(unknown)"; + if (JwtAuthorityValidationGuard.IsPermanent(ex)) + { + LogPermanentFailure(_logger, address, ex); // NOSONAR: implementation generated by the LoggerMessage source generator + } + else + { + LogTransientFailure(_logger, address, ex); // NOSONAR: implementation generated by the LoggerMessage source generator + } + + throw; + } + } + + /// + public Task GetConfigurationAsync(CancellationToken cancel) + => GetConfigurationCoreAsync(cancel); + + /// + public Task GetConfigurationAsync() + => GetConfigurationAsync(CancellationToken.None); + + private async Task GetConfigurationCoreAsync(CancellationToken cancel) + { + BaseConfiguration configuration = await GetBaseConfigurationCoreAsync(cancel).ConfigureAwait(false); + return (OpenIdConnectConfiguration)configuration; + } + + /// + public override void RequestRefresh() => _inner.RequestRefresh(); + + private bool ShouldLogFailure() + { + lock (_sync) + { + var now = DateTimeOffset.UtcNow; + if (now - _lastFailureLogged < _inner.RefreshInterval) + { + return false; + } + + _lastFailureLogged = now; + return true; + } + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Error, Message = "OpenID Connect metadata for '{MetadataAddress}' could not be retrieved (permanent configuration error, HTTP 4xx). Requests fail closed until this is fixed.")] + static partial void LogPermanentFailure(ILogger logger, string metadataAddress, Exception exception); // NOSONAR: implementation generated by the LoggerMessage source generator + + [LoggerMessage(EventId = 2, Level = LogLevel.Warning, Message = "OpenID Connect metadata for '{MetadataAddress}' could not be retrieved (transient failure). Requests keep failing closed until the metadata endpoint is reachable again.")] + static partial void LogTransientFailure(ILogger logger, string metadataAddress, Exception exception); // NOSONAR: implementation generated by the LoggerMessage source generator +} \ No newline at end of file diff --git a/src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs b/src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs new file mode 100644 index 0000000..250921c --- /dev/null +++ b/src/SafeWebCore.JwtBearer/JwtAuthorityValidationGuard.cs @@ -0,0 +1,141 @@ +using System.Net; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +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 ). +/// +public sealed partial class JwtAuthorityValidationGuard : IHostedService +{ + private readonly IConfigurationManager _configurationManager; + private readonly JwtBearerOptions _jwtOptions; + private readonly ILogger _logger; + private readonly string _scheme; + private readonly bool _failFast; + private readonly bool _enforceStaticConfigurationChecks; + + /// + /// Initializes a new . + /// + /// Monitor for the JWT bearer options of the validated scheme. + /// Guard options. + /// Logger used to report validation results. + /// Optional hardening rules used by the static configuration checks. + /// Thrown when the scheme has no OpenID Connect configuration manager. + public JwtAuthorityValidationGuard( + IOptionsMonitor jwtOptions, + IOptions options, + ILogger logger, + IOptions? hardeningOptions = null) + { + ArgumentNullException.ThrowIfNull(jwtOptions); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + _scheme = options.Value.Scheme ?? JwtBearerDefaults.AuthenticationScheme; + _jwtOptions = jwtOptions.Get(_scheme); + _configurationManager = _jwtOptions.ConfigurationManager + ?? throw new InvalidOperationException( + $"The JWT bearer scheme '{_scheme}' has no ConfigurationManager. Configure Authority or MetadataAddress first."); + _failFast = options.Value.FailFast; + _enforceStaticConfigurationChecks = options.Value.EnforceStaticConfigurationChecks; + _logger = logger; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (_enforceStaticConfigurationChecks) + { + RunStaticConfigurationChecks(cancellationToken); + } + + try + { + await _configurationManager.GetConfigurationAsync(cancellationToken); + LogMetadataLoaded(_logger, _scheme, AuthorityAddress); // NOSONAR: implementation generated by the LoggerMessage source generator + } + catch (Exception ex) + { + 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 + } + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private void RunStaticConfigurationChecks(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var issues = JwtBearerConfigurationValidator.FindIssues(_jwtOptions); + if (issues.Count == 0) + { + return; + } + + var message = string.Join("; ", issues); + LogConfigurationInvalid(_logger, _scheme, message); // NOSONAR: implementation generated by the LoggerMessage source generator + if (_failFast) + { + throw new InvalidOperationException($"The JWT bearer configuration for scheme '{_scheme}' is invalid: {message}"); + } + } + + private string AuthorityAddress => _jwtOptions.Authority ?? _jwtOptions.MetadataAddress ?? "(no authority configured)"; + + /// + /// Determines whether an authority metadata failure is permanent (HTTP 4xx) by walking the + /// exception chain for the HTTP status recorded by the IdentityModel document retriever + /// (HttpDocumentRetriever.StatusCode, value an ). + /// + internal static bool IsPermanent(Exception ex) + { + for (Exception? current = ex; current is not null; current = current.InnerException) + { + if (current.Data[HttpDocumentRetriever.StatusCode] is HttpStatusCode status + && (int)status is >= 400 and < 500) + { + return true; + } + } + + return false; + } + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "JWT authority metadata loaded successfully for scheme '{Scheme}' from '{MetadataAddress}'.")] + static partial void LogMetadataLoaded(ILogger logger, string scheme, string metadataAddress); // NOSONAR: implementation generated by the LoggerMessage source generator + + [LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "JWT authority metadata could not be loaded for scheme '{Scheme}' from '{MetadataAddress}' (permanent configuration error, HTTP 4xx).")] + static partial void LogPermanentFailure(ILogger logger, string scheme, string metadataAddress, Exception exception); // NOSONAR: implementation generated by the LoggerMessage source generator + + [LoggerMessage(EventId = 3, Level = LogLevel.Warning, Message = "JWT authority metadata could not be loaded for scheme '{Scheme}' from '{MetadataAddress}' (transient or unclassified). The app will start; requests fail closed until the IdP is reachable.")] + static partial void LogTransientFailure(ILogger logger, string scheme, string metadataAddress, Exception exception); // NOSONAR: implementation generated by the LoggerMessage source generator + + [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 +} \ No newline at end of file diff --git a/src/SafeWebCore.JwtBearer/JwtAuthorityValidationOptions.cs b/src/SafeWebCore.JwtBearer/JwtAuthorityValidationOptions.cs new file mode 100644 index 0000000..d5c0a1a --- /dev/null +++ b/src/SafeWebCore.JwtBearer/JwtAuthorityValidationOptions.cs @@ -0,0 +1,30 @@ +namespace SafeWebCore.JwtBearer; + +/// +/// Options for the . +/// +public sealed class JwtAuthorityValidationOptions +{ + /// + /// The authentication scheme to validate. Defaults to the JWT bearer default scheme ("Bearer"). + /// + public string? Scheme { get; set; } + + /// + /// When true, a permanent authority misconfiguration (HTTP 4xx from the metadata endpoint, + /// e.g. a typo such as "organisations") throws at startup, failing the application fast. + /// When false, the failure is logged at Error level and the application continues to start + /// (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 + /// RequireHttpsMetadata is set, audience/issuer validation must have a matching + /// configured value, the algorithm list must not contain none, and the clock skew + /// must not be negative. Violations are logged at Error level and, when + /// is set, throw at startup. + /// + public bool EnforceStaticConfigurationChecks { get; set; } = true; + +} diff --git a/src/SafeWebCore.JwtBearer/JwtBearerHardeningOptions.cs b/src/SafeWebCore.JwtBearer/JwtBearerHardeningOptions.cs new file mode 100644 index 0000000..9f3ee73 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/JwtBearerHardeningOptions.cs @@ -0,0 +1,102 @@ +namespace SafeWebCore.JwtBearer; + +/// +/// Optional hardening rules applied by AddJwtBearerHardening / AddSafeWebCoreJwtBearer. +/// Every rule is opt-in or safe by default and can only ever increase strictness; existing +/// (stronger) settings on the JwtBearerOptions are never weakened. +/// +public sealed class JwtBearerHardeningOptions +{ + /// + /// Requires tokens to be signed. When true, + /// is enforced so alg: none tokens are rejected. + /// + public bool RequireSignedTokens { get; set; } = true; + + /// + /// Requires a token to carry an exp claim. + /// + public bool RequireExpirationTime { get; set; } = true; + + /// + /// Enforces lifetime validation (nbf and exp ranges). + /// + public bool ValidateLifetime { get; set; } = true; + + /// + /// Requires the JWT typ header to be present and to match one of + /// . + /// + public bool RequireTokenType { get; set; } = true; + + /// + /// The allowed JWT typ header values when is true. + /// + public IList AllowedTokenTypes { get; } = new List { "JWT", "at+jwt" }; + + /// + /// Requires the token to carry a unique jti (JWT ID) claim. + /// + public bool RequireJwtId { get; set; } + + /// + /// Requires the token to carry an nbf (not before) claim. + /// + public bool RequireNotBefore { get; set; } + + /// + /// Requires the token to carry an iat (issued at) claim. + /// + public bool RequireIssuedAt { get; set; } + + /// + /// The maximum allowed . The tighter of the + /// existing clock skew and this value is applied. + /// + public TimeSpan MaximumClockSkew { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// When set, rejects tokens whose valid lifetime (exp - nbf) exceeds this duration. + /// A common limit for access tokens is one hour. + /// + public TimeSpan? MaximumTokenLifetime { get; set; } + + /// + /// When non-empty, restricts signature validation to the listed JWS algorithms + /// (for example RS256, RS384, RS512, ES256, ES384, + /// ES512, PS256, PS384, PS512). + /// + public IList AllowedAlgorithms { get; } = new List(); + + /// + /// Enforces issuer validation. Add the expected issuer(s) to . + /// Multi-tenant applications either leave this off (and use a converter) or list every + /// tenant issuer explicitly. + /// + public bool ValidateIssuer { get; set; } + + /// + /// The allowed issuer(s) used when is true. These are appended + /// to any values already configured on the token validation parameters. + /// + public IList ValidIssuers { get; } = new List(); + + /// + /// Enforces audience validation. Add the expected audience(s) to . + /// + public bool ValidateAudience { get; set; } + + /// + /// The allowed audience(s) used when is true. These are appended + /// to any values already configured on the token validation parameters. + /// + public IList ValidAudiences { get; } = new List(); + + /// + /// When true (default), the OpenID Connect metadata + /// is wrapped with a logging decorator so metadata retrieval failures surf at Error + /// (HTTP 4xx) or Warning (transient) level during runtime as well as at startup; without it + /// the framework only logs such failures at Information level. + /// + public bool EnableRuntimeMetadataLogging { get; set; } = true; +} diff --git a/src/SafeWebCore.JwtBearer/PublicAPI.Shipped.txt b/src/SafeWebCore.JwtBearer/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/SafeWebCore.JwtBearer/PublicAPI.Unshipped.txt b/src/SafeWebCore.JwtBearer/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..d958660 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/PublicAPI.Unshipped.txt @@ -0,0 +1,47 @@ +#nullable enable +SafeWebCore.JwtBearer.Extensions.JwtAuthorityValidationExtensions +SafeWebCore.JwtBearer.JwtAuthorityValidationGuard +SafeWebCore.JwtBearer.JwtAuthorityValidationGuard.JwtAuthorityValidationGuard(Microsoft.Extensions.Options.IOptionsMonitor! jwtOptions, Microsoft.Extensions.Options.IOptions! options, Microsoft.Extensions.Logging.ILogger! logger, Microsoft.Extensions.Options.IOptions? hardeningOptions = null) -> void +SafeWebCore.JwtBearer.JwtAuthorityValidationGuard.StartAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SafeWebCore.JwtBearer.JwtAuthorityValidationGuard.StopAsync(System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SafeWebCore.JwtBearer.JwtAuthorityValidationOptions +SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.EnforceStaticConfigurationChecks.get -> bool +SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.EnforceStaticConfigurationChecks.set -> void +SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.FailFast.get -> bool +SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.FailFast.set -> void +SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.JwtAuthorityValidationOptions() -> void +SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.Scheme.get -> string? +SafeWebCore.JwtBearer.JwtAuthorityValidationOptions.Scheme.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.AllowedAlgorithms.get -> System.Collections.Generic.IList! +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.AllowedTokenTypes.get -> System.Collections.Generic.IList! +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.EnableRuntimeMetadataLogging.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.EnableRuntimeMetadataLogging.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.JwtBearerHardeningOptions() -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.MaximumClockSkew.get -> System.TimeSpan +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.MaximumClockSkew.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.MaximumTokenLifetime.get -> System.TimeSpan? +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.MaximumTokenLifetime.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireExpirationTime.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireExpirationTime.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireIssuedAt.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireIssuedAt.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireJwtId.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireJwtId.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireNotBefore.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireNotBefore.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireSignedTokens.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireSignedTokens.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireTokenType.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.RequireTokenType.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.ValidateAudience.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.ValidateAudience.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.ValidateIssuer.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.ValidateIssuer.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.ValidateLifetime.get -> bool +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.ValidateLifetime.set -> void +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.ValidAudiences.get -> System.Collections.Generic.IList! +SafeWebCore.JwtBearer.JwtBearerHardeningOptions.ValidIssuers.get -> System.Collections.Generic.IList! +static SafeWebCore.JwtBearer.Extensions.JwtAuthorityValidationExtensions.AddJwtBearerAuthorityValidation(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? configure = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static SafeWebCore.JwtBearer.Extensions.JwtAuthorityValidationExtensions.AddJwtBearerHardening(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string? scheme = null, System.Action? configure = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static SafeWebCore.JwtBearer.Extensions.JwtAuthorityValidationExtensions.AddSafeWebCoreJwtBearer(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureJwt, System.Action? configureHardening = null, System.Action? configureGuard = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! \ No newline at end of file diff --git a/src/SafeWebCore.JwtBearer/README.md b/src/SafeWebCore.JwtBearer/README.md new file mode 100644 index 0000000..9b69496 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/README.md @@ -0,0 +1,155 @@ +# SafeWebCore.JwtBearer + +Optional companion module for **SafeWebCore** that makes a misconfigured or unreachable +**JWT authority fail loud before your users ever see a 401** — and optionally hardens +your token validation rules. + +This module exists because of a real issue reported by **Stephan van Rooij** +([@svrooij](https://github.com/svrooij)): +[**dotnet/aspnetcore#67991** — *"Setup JwtBearer authentication with faulty authority should crash"*](https://github.com/dotnet/aspnetcore/issues/67991). + +> If a security feature is misconfigured, the application should fail fast at startup +> instead of starting normally while silently returning 401 for everything. + +The .NET team only scheduled the fix for **.NET 12 Planning** +([milestone](https://github.com/dotnet/aspnetcore/issues/67991)). This module gives you the +behavior today, on **.NET 10**, plus additional opt-in JWT hardening. + +--- + +## The problem (Stephan's repro) + +A misspelled authority (`organisations` instead of `organizations`) makes the app **start fine** +but return **`401 invalid_token` for every request** — while logging the root cause only at +**Information** level, below the default `Microsoft.AspNetCore: Warning` filter. So: an app that +runs green, logs nothing, and 401s everything. Requests fail **closed** (no bypass), but the +**loudness is missing.** + +With SafeWebCore.JwtBearer, that same app either **refuses to start** (fail fast) or starts with a +**loud Error log**, depending on your preference. + +```text +Unhandled exception. System.InvalidOperationException: The JWT authority for scheme 'Bearer' +is misconfigured (HTTP 4xx from 'https://login.microsoftonline.com/organisations/v2.0'). +Fix the Authority/MetadataAddress before starting. +``` + +## Install + +```bash +dotnet add package SafeWebCore.JwtBearer +``` + +## Quick start + +### Option 1 — Startup validation only (Stephan's fix) + +```csharp +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => { /* your authority, issuer, audience, ... */ }); + +// Fail fast at startup when the authority is misconfigured (HTTP 4xx): +builder.Services.AddJwtBearerAuthorityValidation(o => o.FailFast = true); + +// Or fail loud but keep the app running (requests still fail closed): +// builder.Services.AddJwtBearerAuthorityValidation(); // FailFast defaults to false +``` + +### Option 2 — Startup validation + token hardening (recommended) + +```csharp +builder.Services.AddJwtBearerHardening(o => +{ + o.ValidateAudience = true; + o.ValidAudiences.Add("api://my-api"); + o.AllowedAlgorithms.Add("RS256"); + o.MaximumTokenLifetime = TimeSpan.FromHours(1); +}); +``` + +Hardening is applied after your `AddJwtBearer` configuration and can only **increase** strictness; +stronger settings you already defined are never weakened. It also registers the startup guard with +`FailFast = true` as a secure default. + +### Option 3 — Everything in one line + +```csharp +builder.Services.AddSafeWebCoreJwtBearer( + options => + { + options.Authority = "https://login.microsoftonline.com/organizations/v2.0"; + options.TokenValidationParameters.ValidAudiences = new[] + { + "api://98f7735b-23c5-4b12-bc16-0e2f3a5d7e21", + }; + }, + hardening => hardening.MaximumTokenLifetime = TimeSpan.FromHours(1)); +``` + +## Startup validation + +| Option | Default | Description | +|--------|---------|-------------| +| `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. | + +**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. + +## Token hardening (`JwtBearerHardeningOptions`) + +| Option | Default | Enforces | +|--------|---------|----------| +| `RequireSignedTokens` | `true` | Tokens must be signed (`alg: none` rejected). | +| `RequireExpirationTime` | `true` | An `exp` claim must be present. | +| `ValidateLifetime` | `true` | `nbf`/`exp` ranges are validated. | +| `RequireTokenType` | `true` | The `typ` header must be present and match `AllowedTokenTypes` (`JWT`, `at+jwt`). | +| `RequireJwtId` | `false` | A unique `jti` (JWT ID) claim is required. | +| `RequireNotBefore` | `false` | An `nbf` claim is required. | +| `RequireIssuedAt` | `false` | An `iat` claim is required. | +| `MaximumClockSkew` | `5 min` | Clock skew is capped at this value (the tighter of existing and configured wins). | +| `MaximumTokenLifetime` | `null` | Tokens whose valid lifetime (`exp - nbf`) exceeds this are rejected. Common: 1 hour. | +| `AllowedAlgorithms` | *(empty)* | When non-empty, only the listed JWS algorithms are accepted (`RS256`, `ES256`, `PS256`, ...). | +| `ValidateIssuer` / `ValidIssuers` | `false` / *(empty)* | Enforces issuer validation against the configured issuer(s). | +| `ValidateAudience` / `ValidAudiences` | `false` / *(empty)* | Enforces audience validation against the configured audience(s). | +| `EnableRuntimeMetadataLogging` | `true` | Wraps the configuration manager so metadata failures surf at `Error` (4xx) / `Warning` also during runtime, not just at startup. | + +## Runtime metadata logging + +.NET/IdentityModel only logs OpenID Connect metadata retrieval failures at **Information** level. +This module wraps the configuration manager (via `IPostConfigureOptions`) so that a **4xx** metadata +failure during runtime is logged at **Error** and a transient failure at **Warning**, rate-limited to +one line per refresh interval. Requires `AddJwtBearerHardening`/`AddSafeWebCoreJwtBearer` to run +**after** `AddJwtBearer`. + +## How it works + +1. **`JwtAuthorityValidationGuard`** (an `IHostedService`, registered at startup): + - runs the deterministic configuration checks, then eagerly fetches the OpenID Connect metadata + (issuer + signing keys) for your scheme's `IConfigurationManager`; + - **HTTP 4xx** → permanent → `Error` (+ fail fast when configured); + - **anything else** → transient → `Warning`, app starts; + - success → `Information`. Nothing else changes. +2. **`JwtBearerHardeningApplication`** applies the token-validation defaults and chains claim checks + (`typ`, `jti`, `nbf`, `iat`, maximum lifetime) onto your existing `OnTokenValidated` handler. +3. **`LoggingConfigurationManager`** decorates the manager so metadata failures stay loud after startup. + +## Reproduce it yourself + +The repository includes [**`examples/JwtBearerDemo`**](../../examples/JwtBearerDemo/) — a copy of +Stephan's exact reproduction (misspelled `organisations` authority, his exact token-validation +settings) with a one-boolean switch between the broken and the fixed behavior. + +## Limitations + +- The guard validates the authority **once at startup**. If the identity provider goes down *after* + startup, the runtime metadata logging (Option 2/3) keeps you informed; requests fail closed regardless. +- The metadata is fetched one extra time at startup (which also warms the configuration cache). +- `AddJwtBearerHardening` must be registered **after** `AddJwtBearer`. + +## License + +MIT, same as SafeWebCore. \ No newline at end of file diff --git a/src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj b/src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj new file mode 100644 index 0000000..7c018a2 --- /dev/null +++ b/src/SafeWebCore.JwtBearer/SafeWebCore.JwtBearer.csproj @@ -0,0 +1,67 @@ + + + + net10.0 + enable + enable + true + SafeWebCore.JwtBearer + + + + + SafeWebCore.JwtBearer + 1.0.0 + MPCoreDeveloper + Posseth Software + SafeWebCore.JwtBearer + + Optional companion module for SafeWebCore that validates the JWT authentication + authority at startup. Detects a misconfigured or unreachable OpenID Connect authority + (for example a typo such as "organisations") with a loud log or a fail-fast startup, + instead of a silent 100% 401 availability incident. + + jwt;bearer;oidc;authority;startup-validation;security;aspnetcore;safeweb + MIT + https://github.com/MPCoreDeveloper/SafeWebCore + https://github.com/MPCoreDeveloper/SafeWebCore + git + README.md + icon.png + + + + + + + + + + + + + + + + + + + + + + + true + $(WarningsAsErrors);RS0037 + $(WarningsNotAsErrors);RS0016;RS0017 + $(NoWarn);RS0027 + + + + + \ + true + + + + + diff --git a/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs b/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs new file mode 100644 index 0000000..1807528 --- /dev/null +++ b/tests/SafeWebCore.JwtBearer.Tests/JwtAuthorityValidationGuardTests.cs @@ -0,0 +1,101 @@ +using System.Net; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using SafeWebCore.JwtBearer; + +namespace SafeWebCore.JwtBearer.Tests; + +public sealed class JwtAuthorityValidationGuardTests +{ + [Fact] + public void IsPermanentDetectsHttp4xxOnInnerException() + { + var inner = new IOException("IDX20807: Unable to retrieve document from: 'metadata'."); + inner.Data[HttpDocumentRetriever.StatusCode] = HttpStatusCode.BadRequest; + var outer = new InvalidOperationException("IDX20803: Unable to obtain configuration.", inner); + + Assert.True(JwtAuthorityValidationGuard.IsPermanent(outer)); + } + + [Fact] + public void IsPermanentIgnoresTransientStatusCodes() + { + var inner = new IOException("IDX20807: Unable to retrieve document from: 'metadata'."); + inner.Data[HttpDocumentRetriever.StatusCode] = HttpStatusCode.ServiceUnavailable; + var outer = new InvalidOperationException("IDX20803: Unable to obtain configuration.", inner); + + Assert.False(JwtAuthorityValidationGuard.IsPermanent(outer)); + } + + [Fact] + public void IsPermanentReturnsFalseWithoutStatusCode() + { + var ex = new InvalidOperationException("IDX10803: Unable to obtain configuration.", new IOException("network down")); + + Assert.False(JwtAuthorityValidationGuard.IsPermanent(ex)); + } + + [Fact] + public async Task StartAsyncPermanentFailureWithFailFastThrows() + { + var guard = CreateGuard(PermanentFailureManager(), failFast: true); + + await Assert.ThrowsAsync(() => guard.StartAsync(CancellationToken.None)); + } + + [Fact] + public async Task StartAsyncPermanentFailureWithoutFailFastCompletes() + { + var guard = CreateGuard(PermanentFailureManager(), failFast: false); + + await guard.StartAsync(CancellationToken.None); + } + + [Fact] + public async Task StartAsyncSuccessCompletes() + { + var guard = CreateGuard(SuccessfulManager(), failFast: true); + + await guard.StartAsync(CancellationToken.None); + } + + private static JwtAuthorityValidationGuard CreateGuard(FakeManager manager, bool failFast) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.Configure(JwtBearerDefaults.AuthenticationScheme, o => + { + o.ConfigurationManager = manager; + o.TokenValidationParameters.ValidateAudience = false; + o.TokenValidationParameters.ValidateIssuer = false; + }); + services.Configure(o => o.FailFast = failFast); + services.AddHostedService(); + + using var provider = services.BuildServiceProvider(); + return provider.GetServices().OfType().Single(); + } + + private static FakeManager SuccessfulManager() + => new(() => Task.FromResult(new OpenIdConnectConfiguration())); + + private static FakeManager PermanentFailureManager() + { + var inner = new IOException("IDX20807: Unable to retrieve document from: 'metadata'."); + inner.Data[HttpDocumentRetriever.StatusCode] = HttpStatusCode.BadRequest; + var outer = new InvalidOperationException("IDX20803: Unable to obtain configuration.", inner); + return new FakeManager(() => Task.FromException(outer)); + } + + private sealed class FakeManager(Func> get) : IConfigurationManager + { + public Task GetConfigurationAsync(CancellationToken cancel) => get(); + + public void RequestRefresh() + { + } + } +} \ No newline at end of file diff --git a/tests/SafeWebCore.JwtBearer.Tests/JwtBearerConfigurationValidatorTests.cs b/tests/SafeWebCore.JwtBearer.Tests/JwtBearerConfigurationValidatorTests.cs new file mode 100644 index 0000000..eb698dd --- /dev/null +++ b/tests/SafeWebCore.JwtBearer.Tests/JwtBearerConfigurationValidatorTests.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using SafeWebCore.JwtBearer; + +namespace SafeWebCore.JwtBearer.Tests; + +public sealed class JwtBearerConfigurationValidatorTests +{ + [Fact] + public void FindIssuesReturnsEmptyForSoundConfiguration() + { + var options = new JwtBearerOptions { Authority = "https://login.example.com/tenant/v2.0" }; + options.TokenValidationParameters.ValidAudience = "api"; + options.TokenValidationParameters.ValidateAudience = true; + + Assert.Empty(JwtBearerConfigurationValidator.FindIssues(options)); + } + + [Fact] + public void FindIssuesReportsPlainHttpAuthorityWhenHttpsIsRequired() + { + var options = new JwtBearerOptions + { + Authority = "http://login.example.com/tenant/v2.0", + RequireHttpsMetadata = true, + }; + + var issues = JwtBearerConfigurationValidator.FindIssues(options); + + Assert.Contains(issues, issue => issue.Contains("HTTPS", StringComparison.Ordinal)); + } + + [Fact] + public void FindIssuesReportsRelativeAuthority() + { + var options = new JwtBearerOptions { Authority = "login.example.com/tenant/v2.0" }; + + var issues = JwtBearerConfigurationValidator.FindIssues(options); + + Assert.Contains(issues, issue => issue.Contains("absolute URI", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void FindIssuesReportsMissingAudienceWhenValidationIsEnabled() + { + var options = new JwtBearerOptions(); + options.TokenValidationParameters.ValidateAudience = true; + options.TokenValidationParameters.ValidateIssuer = false; + + var issues = JwtBearerConfigurationValidator.FindIssues(options); + + Assert.Contains(issues, issue => issue.Contains("Audience", StringComparison.Ordinal)); + } + + [Fact] + public void FindIssuesIgnoresAudienceRequirementWhenCustomValidatorIsSet() + { + var options = new JwtBearerOptions(); + options.TokenValidationParameters.ValidateAudience = true; + options.TokenValidationParameters.ValidateIssuer = false; + options.TokenValidationParameters.AudienceValidator = (_, _, _) => true; + + Assert.Empty(JwtBearerConfigurationValidator.FindIssues(options)); + } + + [Fact] + public void FindIssuesReportsMissingIssuerWithoutMetadata() + { + var options = new JwtBearerOptions(); + options.TokenValidationParameters.ValidateAudience = false; + options.TokenValidationParameters.ValidateIssuer = true; + + var issues = JwtBearerConfigurationValidator.FindIssues(options); + + Assert.Contains(issues, issue => issue.Contains("Issuer", StringComparison.Ordinal)); + } + + [Fact] + public void FindIssuesAllowsIssuerFromMetadata() + { + var options = new JwtBearerOptions { Authority = "https://login.example.com/tenant/v2.0" }; + options.TokenValidationParameters.ValidateAudience = false; + options.TokenValidationParameters.ValidateIssuer = true; + + Assert.Empty(JwtBearerConfigurationValidator.FindIssues(options)); + } + + [Fact] + public void FindIssuesReportsNoneAlgorithm() + { + var options = new JwtBearerOptions(); + options.TokenValidationParameters.ValidAlgorithms = new[] { "RS256", "none" }; + + var issues = JwtBearerConfigurationValidator.FindIssues(options); + + Assert.Contains(issues, issue => issue.Contains("none", StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/tests/SafeWebCore.JwtBearer.Tests/JwtBearerHardeningTests.cs b/tests/SafeWebCore.JwtBearer.Tests/JwtBearerHardeningTests.cs new file mode 100644 index 0000000..ce94d14 --- /dev/null +++ b/tests/SafeWebCore.JwtBearer.Tests/JwtBearerHardeningTests.cs @@ -0,0 +1,165 @@ +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; +using SafeWebCore.JwtBearer; + +namespace SafeWebCore.JwtBearer.Tests; + +public sealed class JwtBearerHardeningTests +{ + [Fact] + public void ApplyEnforcesSignedTokensLifetimeAndExpirationByDefault() + { + var options = new JwtBearerOptions(); + var hardening = new JwtBearerHardeningOptions(); + + JwtBearerHardeningApplication.Apply(options, hardening); + + Assert.True(options.TokenValidationParameters.RequireSignedTokens); + Assert.True(options.TokenValidationParameters.RequireExpirationTime); + Assert.True(options.TokenValidationParameters.ValidateLifetime); + } + + [Fact] + public void ApplyKeepsTighterClockSkew() + { + var options = new JwtBearerOptions(); + options.TokenValidationParameters.ClockSkew = TimeSpan.FromMinutes(2); + var hardening = new JwtBearerHardeningOptions { MaximumClockSkew = TimeSpan.FromMinutes(5) }; + + JwtBearerHardeningApplication.Apply(options, hardening); + + Assert.Equal(TimeSpan.FromMinutes(2), options.TokenValidationParameters.ClockSkew); + } + + [Fact] + public void ApplyRestrictsAlgorithmsAndMergesAudiences() + { + var options = new JwtBearerOptions(); + options.TokenValidationParameters.ValidAudiences = new[] { "spa" }; + var hardening = new JwtBearerHardeningOptions { ValidateAudience = true }; + hardening.ValidAudiences.Add("api"); + hardening.AllowedAlgorithms.Add("RS256"); + + JwtBearerHardeningApplication.Apply(options, hardening); + + Assert.True(options.TokenValidationParameters.ValidateAudience); + Assert.True(options.TokenValidationParameters.RequireAudience); + Assert.Contains("api", options.TokenValidationParameters.ValidAudiences!); + Assert.Contains("spa", options.TokenValidationParameters.ValidAudiences!); + Assert.Equal("RS256", Assert.Single(options.TokenValidationParameters.ValidAlgorithms!)); + } + + [Fact] + public void ApplyThrowsWhenNoneAlgorithmIsAllowed() + { + var options = new JwtBearerOptions(); + var hardening = new JwtBearerHardeningOptions(); + hardening.AllowedAlgorithms.Add("none"); + + Assert.Throws(() => JwtBearerHardeningApplication.Apply(options, hardening)); + } + + [Fact] + public async Task OnTokenValidatedRejectsDisallowedTokenType() + { + var context = CreateContext(BuildToken(typ: "pop")); + var hardening = new JwtBearerHardeningOptions(); + + await JwtBearerHardeningApplication.OnTokenValidatedAsync(context, existing: null, hardening); + + Assert.False(context.Result?.Succeeded); + } + + [Fact] + public async Task OnTokenValidatedAcceptsAllowedTokenType() + { + var context = CreateContext(BuildToken(typ: "at+jwt")); + var hardening = new JwtBearerHardeningOptions(); + + await JwtBearerHardeningApplication.OnTokenValidatedAsync(context, existing: null, hardening); + + Assert.Null(context.Result); + } + + [Fact] + public async Task OnTokenValidatedRejectsMissingJwtId() + { + var context = CreateContext(BuildToken(typ: "JWT", jti: null)); + var hardening = new JwtBearerHardeningOptions { RequireJwtId = true, RequireTokenType = false }; + + await JwtBearerHardeningApplication.OnTokenValidatedAsync(context, existing: null, hardening); + + Assert.False(context.Result?.Succeeded); + } + + [Fact] + public async Task OnTokenValidatedRejectsExcessiveLifetime() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var context = CreateContext(BuildToken(typ: "JWT", notBefore: now - 120, expiresAt: now + 7200)); + var hardening = new JwtBearerHardeningOptions { MaximumTokenLifetime = TimeSpan.FromHours(1), RequireTokenType = false }; + + await JwtBearerHardeningApplication.OnTokenValidatedAsync(context, existing: null, hardening); + + Assert.False(context.Result?.Succeeded); + } + + [Fact] + public async Task OnTokenValidatedStopsWhenExistingHandlerFailed() + { + var context = CreateContext(BuildToken(typ: "JWT")); + var hardening = new JwtBearerHardeningOptions(); + static Task Existing(TokenValidatedContext ctx) + { + ctx.Fail("existing failure"); + return Task.CompletedTask; + } + + await JwtBearerHardeningApplication.OnTokenValidatedAsync(context, Existing, hardening); + + Assert.False(context.Result?.Succeeded); + Assert.StartsWith("existing failure", context.Result?.Failure?.Message); + } + + private static TokenValidatedContext CreateContext(JsonWebToken token) + { + var scheme = new AuthenticationScheme(JwtBearerDefaults.AuthenticationScheme, null, typeof(JwtBearerHandler)); + return new TokenValidatedContext(new DefaultHttpContext(), scheme, new JwtBearerOptions()) + { + SecurityToken = token, + }; + } + + private static JsonWebToken BuildToken(string typ, string? jti = "jti-123", long? notBefore = null, long? expiresAt = null) + { + var encoder = Base64UrlEncoder(); + var header = encoder(Encoding.UTF8.GetBytes($"{{\"alg\":\"none\",\"typ\":\"{typ}\"}}")); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var parts = new List + { + $"\"aud\":\"api\"", + $"\"exp\":{expiresAt ?? now + 3600}", + $"\"iat\":{now - 120}", + }; + + if (notBefore is not null) + { + parts.Add($"\"nbf\":{notBefore}"); + } + + if (jti is not null) + { + parts.Add($"\"jti\":\"{jti}\""); + } + + var payload = encoder(Encoding.UTF8.GetBytes("{" + string.Join(",", parts) + "}")); + return new JsonWebToken($"{header}.{payload}.sig"); + } + + private static Func Base64UrlEncoder() + => bytes => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} \ No newline at end of file diff --git a/tests/SafeWebCore.JwtBearer.Tests/LoggingConfigurationManagerTests.cs b/tests/SafeWebCore.JwtBearer.Tests/LoggingConfigurationManagerTests.cs new file mode 100644 index 0000000..9419382 --- /dev/null +++ b/tests/SafeWebCore.JwtBearer.Tests/LoggingConfigurationManagerTests.cs @@ -0,0 +1,130 @@ +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using SafeWebCore.JwtBearer; + +namespace SafeWebCore.JwtBearer.Tests; + +public sealed class LoggingConfigurationManagerTests +{ + [Fact] + public async Task GetBaseConfigurationAsyncLogsPermanentFailureAtErrorAndThrows() + { + var records = new List<(LogLevel Level, string Message)>(); + var inner = new FakeBaseManager(_ => Task.FromException(PermanentException())); + var manager = new LoggingConfigurationManager(inner, new RecordingLogger(records)); + + var ex = await Assert.ThrowsAsync( + () => manager.GetBaseConfigurationAsync(CancellationToken.None)); + + Assert.Contains("IDX20803", ex.Message); + var record = Assert.Single(records); + Assert.Equal(LogLevel.Error, record.Level); + Assert.Contains(inner.MetadataAddress, record.Message); + } + + [Fact] + public async Task GetBaseConfigurationAsyncLogsTransientFailureAtWarningAndThrows() + { + var records = new List<(LogLevel Level, string Message)>(); + var inner = new FakeBaseManager( + _ => Task.FromException(new TimeoutException("IDX20807: timed out"))); + var manager = new LoggingConfigurationManager(inner, new RecordingLogger(records)); + + await Assert.ThrowsAsync(() => manager.GetBaseConfigurationAsync(CancellationToken.None)); + + var record = Assert.Single(records); + Assert.Equal(LogLevel.Warning, record.Level); + } + + [Fact] + public async Task GetConfigurationAsyncReturnsTheRetrievedConfiguration() + { + var inner = new FakeBaseManager( + _ => Task.FromResult(new OpenIdConnectConfiguration { Issuer = "https://issuer.example.com" })); + var manager = new LoggingConfigurationManager(inner, NullLogger.Instance); + + var configuration = await manager.GetConfigurationAsync(CancellationToken.None); + + Assert.Equal("https://issuer.example.com", configuration.Issuer); + } + + [Fact] + public void RequestRefreshDelegatesToInnerManager() + { + var inner = new FakeBaseManager( + _ => Task.FromResult(new OpenIdConnectConfiguration())); + var manager = new LoggingConfigurationManager(inner, NullLogger.Instance); + + manager.RequestRefresh(); + + Assert.True(inner.RefreshRequested); + } + + [Fact] + public void MetadataAddressIsForwardedFromInnerManager() + { + var inner = new FakeBaseManager( + _ => Task.FromResult(new OpenIdConnectConfiguration())); + var manager = new LoggingConfigurationManager(inner, NullLogger.Instance); + + Assert.Equal(inner.MetadataAddress, manager.MetadataAddress); + } + + [Fact] + public async Task FailuresAreRateLimitedToRefreshInterval() + { + var records = new List<(LogLevel Level, string Message)>(); + var inner = new FakeBaseManager(_ => Task.FromException(PermanentException())); + var manager = new LoggingConfigurationManager(inner, new RecordingLogger(records)); + + await Assert.ThrowsAsync(() => manager.GetBaseConfigurationAsync(CancellationToken.None)); + await Assert.ThrowsAsync(() => manager.GetBaseConfigurationAsync(CancellationToken.None)); + + Assert.Single(records); + } + + private static InvalidOperationException PermanentException() + { + var inner = new IOException("IDX20807: Unable to retrieve document from: 'metadata'."); + inner.Data[HttpDocumentRetriever.StatusCode] = HttpStatusCode.BadRequest; + return new InvalidOperationException("IDX20803: Unable to obtain configuration.", inner); + } + + private sealed class FakeBaseManager : BaseConfigurationManager + { + private readonly Func> _get; + + public FakeBaseManager(Func> get) + { + _get = get; + MetadataAddress = "https://login.example.com/tenant/v2.0"; + } + + public bool RefreshRequested { get; private set; } + + public override Task GetBaseConfigurationAsync(CancellationToken cancel) => _get(cancel); + + public override void RequestRefresh() => RefreshRequested = true; + } + + private sealed class RecordingLogger(List<(LogLevel Level, string Message)> records) : ILogger + { + 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/SafeWebCore.JwtBearer.Tests.csproj b/tests/SafeWebCore.JwtBearer.Tests/SafeWebCore.JwtBearer.Tests.csproj new file mode 100644 index 0000000..613672a --- /dev/null +++ b/tests/SafeWebCore.JwtBearer.Tests/SafeWebCore.JwtBearer.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + Exe + enable + enable + false + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/SafeWebCore.JwtBearer.Tests/StephanReproIntegrationTests.cs b/tests/SafeWebCore.JwtBearer.Tests/StephanReproIntegrationTests.cs new file mode 100644 index 0000000..9d7e3ba --- /dev/null +++ b/tests/SafeWebCore.JwtBearer.Tests/StephanReproIntegrationTests.cs @@ -0,0 +1,182 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SafeWebCore.JwtBearer.Extensions; + +namespace SafeWebCore.JwtBearer.Tests; + +/// +/// Recreates Stephan van Rooij's exact reproduction from dotnet/aspnetcore#67991 +/// (the misspelled authority https://login.microsoftonline.com/organisations/v2.0) and +/// proves both the faulty behavior (app starts, silent 401s) and the SafeWebCore fix. +/// The real network call is replaced by a deterministic HTTP stub so the tests never leave the machine. +/// +public sealed class StephanReproIntegrationTests +{ + private const string FaultyAuthority = "https://login.microsoftonline.com/organisations/v2.0"; + private const string CorrectAuthority = "https://login.microsoftonline.com/organizations/v2.0"; + + private const string FakeOpenIdConfiguration = """ + { + "issuer": "https://login.microsoftonline.com/organizations/v2.0", + "jwks_uri": "https://login.microsoftonline.com/organizations/v2.0/discovery/v2.0/keys", + "keys": [] + } + """; + + [Fact] + public async Task FaultyAuthorityWithoutGuardStartsAnd401sSilently() + { + var records = new List(); + await using var app = BuildReproApp(addGuard: false, failFast: false, records); + + await app.StartAsync(TestContext.Current.CancellationToken); // app starts normally - exactly what Stephan reported + + var client = app.GetTestServer().CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, "/weatherforecast"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", CreateFaultyToken()); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.DoesNotContain(records, record => record.Contains("JwtBearer", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(records, record => record.Contains("Failed to validate the token", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task FaultyAuthorityWithFailFastGuardDoesNotStart() + { + var records = new List(); + await using var app = BuildReproApp(addGuard: true, failFast: true, records); + + var exception = await Assert.ThrowsAsync(() => app.StartAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("JWT authority", exception.Message); + Assert.Contains(FaultyAuthority, exception.Message); + } + + [Fact] + public async Task FaultyAuthorityWithLoudGuardStartsWithErrorLog() + { + var records = new List(); + await using var app = BuildReproApp(addGuard: true, failFast: false, records); + + await app.StartAsync(TestContext.Current.CancellationToken); + + Assert.Contains(records, record => record.Contains("permanent configuration error", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(records, record => record.Contains(FaultyAuthority, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task CorrectAuthorityWithFailFastGuardStartsClean() + { + var records = new List(); + await using var app = BuildReproApp(addGuard: true, failFast: true, records, authority: CorrectAuthority, faulty: false); + + await app.StartAsync(TestContext.Current.CancellationToken); + + Assert.Contains(records, record => record.Contains("metadata loaded successfully", StringComparison.OrdinalIgnoreCase)); + } + + private static WebApplication BuildReproApp( + bool addGuard, + bool failFast, + List records, + string? authority = null, + bool faulty = true) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); // default template filter + builder.Logging.AddProvider(new RecordingLoggerProvider(records)); + + var handler = faulty + ? new StubHttpMessageHandler(HttpStatusCode.BadRequest, "The tenant is invalid.") + : new StubHttpMessageHandler(HttpStatusCode.OK, FakeOpenIdConfiguration); + + builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = authority ?? FaultyAuthority; + options.BackchannelHttpHandler = handler; + options.RequireHttpsMetadata = true; + + // Stephan's exact TokenValidationParameters from dotnet/aspnetcore#67991 + options.TokenValidationParameters.ValidateAudience = true; + options.TokenValidationParameters.ValidateIssuer = false; + options.TokenValidationParameters.ValidateLifetime = true; + options.TokenValidationParameters.ValidateIssuerSigningKey = true; + options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(15); + options.TokenValidationParameters.RequireSignedTokens = true; + options.TokenValidationParameters.RequireExpirationTime = true; + options.TokenValidationParameters.ValidAudience = "api://safe-web-core-repro"; + }); + builder.Services.AddAuthorization(); + + if (addGuard) + { + builder.Services.AddJwtBearerAuthorityValidation(o => o.FailFast = failFast); + } + + var app = builder.Build(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapGet("/weatherforecast", () => Results.Ok(new { forecast = "sunny" })).RequireAuthorization(); + return app; + } + + private static string CreateFaultyToken() + { + var header = Base64Url(Encoding.UTF8.GetBytes("{\"alg\":\"RS256\"}")); + var payload = Base64Url(Encoding.UTF8.GetBytes("{\"sub\":\"stefan-token\"}")); + return $"{header}.{payload}.not-a-real-signature"; + } + + private static string Base64Url(byte[] bytes) + => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + + private sealed class StubHttpMessageHandler(HttpStatusCode statusCode, string content) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(content), + }); + } + + private sealed class RecordingLoggerProvider(List records) : ILoggerProvider + { + public ILogger CreateLogger(string categoryName) => new RecordingLogger(records, categoryName); + + public void Dispose() + { + } + } + + private sealed class RecordingLogger(List records, string category) : ILogger + { + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + records.Add($"[{category}] {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