diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
index fa7a3569..59ca3db2 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs
@@ -1,4 +1,5 @@
using System.Reflection;
+using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using Microsoft.Azure.Functions.Worker;
@@ -290,6 +291,60 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.Equal(TenantScoping.None, entry.TenantScoping);
}
+ ///
+ /// REGRESSION GUARD (drift bug 2026-06-22): AuthenticationMiddleware runs BEFORE
+ /// PolicyEnforcementMiddleware, so every catalog route whose policy is PublicAnonymous (fully
+ /// public) or DeviceOrBootstrapAuth (authenticated in-function via cert/bootstrap token, not
+ /// JWT) MUST be JWT-exempt in the middleware — otherwise it is 401'd before its policy can ever
+ /// be honored. That is exactly how the ticket-gated diagnostics/download route broke: it was
+ /// registered PublicAnonymous in the catalog but missing from the middleware's (former) parallel
+ /// allowlist. Deriving the exempt set from the catalog makes the drift structurally impossible;
+ /// this test locks the invariant so a future refactor can't reintroduce a hand-kept list.
+ ///
+ [Fact]
+ public void AnonymousAndDeviceRoutes_AreJwtExemptInAuthMiddleware()
+ {
+ var notExempt = new List();
+
+ foreach (var entry in EndpointAccessPolicyCatalog.Entries)
+ {
+ if (entry.Policy is not (EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth))
+ continue;
+
+ // Build a concrete request path from the template (sample value for any {param}).
+ var concrete = "/api/" + Regex.Replace(entry.RouteTemplate, @"\{[^}]+}", "sample");
+ if (!AuthenticationMiddleware.SkipsJwtValidation(entry.HttpMethod, concrete))
+ notExempt.Add($"{entry.HttpMethod} {entry.RouteTemplate} [{entry.Policy}]");
+ }
+
+ Assert.True(notExempt.Count == 0,
+ "These PublicAnonymous/DeviceOrBootstrapAuth routes are NOT JWT-exempt in " +
+ "AuthenticationMiddleware — they will be 401'd before their policy is honored:\n" +
+ string.Join("\n", notExempt.Select(r => $" - {r}")));
+ }
+
+ ///
+ /// Conversely, JWT-gated tiers (MemberRead and up) must NOT be exempt — otherwise the middleware
+ /// would wave authenticated traffic through unvalidated. Includes the deliberate contrast pair:
+ /// the ticket-gated diagnostics/download IS exempt (its HMAC ticket is the sole authority), but
+ /// its JWT sibling diagnostics/download-url (MemberRead) stays gated.
+ ///
+ [Theory]
+ [InlineData("GET", "/api/diagnostics/download", true)] // PublicAnonymous (ticket-gated)
+ [InlineData("GET", "/api/diagnostics/download-url", false)] // MemberRead (JWT)
+ [InlineData("POST", "/api/diagnostics/download-ticket", false)]// MemberRead (JWT) — mints the ticket
+ [InlineData("GET", "/api/health", true)] // PublicAnonymous
+ [InlineData("POST", "/api/agent/ingest", true)] // DeviceOrBootstrapAuth
+ [InlineData("GET", "/api/bootstrap/validate/ABC123", true)] // PublicAnonymous (param route)
+ [InlineData("GET", "/api/sessions", false)] // MemberRead
+ [InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // TenantAdminOrGlobalReader
+ [InlineData("GET", "/api/global/sessions", false)] // GlobalReadOrAdmin
+ [InlineData("GET", "/api/nonexistent", false)] // unregistered → fail-closed
+ public void SkipsJwtValidation_MatchesPolicyTier(string method, string path, bool expectedExempt)
+ {
+ Assert.Equal(expectedExempt, AuthenticationMiddleware.SkipsJwtValidation(method, path));
+ }
+
///
/// Named capture group for {tenantId} correctly extracts the value from request paths.
///
diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs
index 3f4eec66..41db982c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Middleware/AuthenticationMiddleware.cs
@@ -12,6 +12,7 @@ using System.Net;
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.DataAccess;
namespace AutopilotMonitor.Functions.Middleware;
@@ -33,43 +34,6 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
private readonly Dictionary Manager, DateTime LastAccessed)> _configManagerCache;
private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1);
- ///
- /// Routes that do not require JWT authentication (exact match).
- /// Device-to-cloud routes use their own certificate/hardware validation via SecurityValidator.
- /// Bootstrap device routes use bootstrap token validation via SecurityValidator.
- ///
- private static readonly string[] _anonymousRoutes =
- {
- "/api/health",
- "/api/stats/platform",
- // Ticket-gated diagnostics download (MCP/AI clients hold no JWT). Authority is the
- // HMAC-signed ?t= ticket, validated inside DiagnosticsTicketDownloadFunction; authz
- // already happened at mint time (diagnostics/download-ticket, MemberRead). Must mirror
- // the PublicAnonymous entry in EndpointAccessPolicyCatalog — this middleware runs BEFORE
- // PolicyEnforcementMiddleware, so a missing entry here 401s before the policy is honored.
- "/api/diagnostics/download",
- "/api/agent/register-session",
- "/api/agent/ingest",
- "/api/agent/telemetry",
- "/api/agent/config",
- "/api/agent/upload-url",
- "/api/agent/error",
- "/api/agent/distress",
- "/api/bootstrap/register-session",
- "/api/bootstrap/ingest",
- "/api/bootstrap/config",
- "/api/bootstrap/error",
- };
-
- ///
- /// Route prefixes that do not require JWT authentication (prefix match).
- /// Used for routes with path parameters where exact match is impossible.
- ///
- private static readonly string[] _anonymousPrefixes =
- {
- "/api/bootstrap/validate/", // /api/bootstrap/validate/{code}
- };
-
public AuthenticationMiddleware(
ILogger logger,
IConfiguration configuration,
@@ -95,11 +59,13 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
return;
}
- // Allow anonymous routes without JWT validation
+ // Allow JWT-exempt routes (public + device/bootstrap-authenticated) through without
+ // JWT validation. The exempt set is DERIVED from EndpointAccessPolicyCatalog — never a
+ // hand-kept parallel list — so a new anonymous/device route can't drift out of sync here.
var requestPath = httpContext.Request.Path.Value ?? string.Empty;
- if (IsAnonymousRoute(requestPath))
+ if (SkipsJwtValidation(httpContext.Request.Method, requestPath))
{
- _logger.LogDebug("[Auth Middleware] Anonymous route: {Path}", requestPath);
+ _logger.LogDebug("[Auth Middleware] JWT-exempt route: {Method} {Path}", httpContext.Request.Method, requestPath);
await next(context);
return;
}
@@ -302,18 +268,21 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
await next(context);
}
- private static bool IsAnonymousRoute(string path)
+ ///
+ /// Whether a route is exempt from JWT validation in this middleware. SINGLE SOURCE OF TRUTH is
+ /// : a route is JWT-exempt iff its policy is
+ /// (fully public) or
+ /// (authenticated later, in-function, via
+ /// device certificate / bootstrap token — not JWT). Deriving from the catalog instead of a
+ /// hand-kept parallel allowlist prevents drift: a new anonymous/device route registered in the
+ /// catalog is automatically exempt here, so it can never be 401'd before
+ /// PolicyEnforcementMiddleware honors its policy (the bug that broke the ticket-gated
+ /// diagnostics/download route). Unregistered routes (FindPolicy == null) are fail-closed:
+ /// JWT required. Method-aware so e.g. an unexpected verb on a public path is not waved through.
+ ///
+ internal static bool SkipsJwtValidation(string httpMethod, string path)
{
- foreach (var route in _anonymousRoutes)
- {
- if (path.Equals(route, StringComparison.OrdinalIgnoreCase))
- return true;
- }
- foreach (var prefix in _anonymousPrefixes)
- {
- if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
- return true;
- }
- return false;
+ var entry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, path);
+ return entry is { Policy: EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth };
}
}