Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ Three conventions, because a changelog nobody can rely on is worse than none:
method announces itself at the consumer's next build; a renamed class in the rendered markup and
a changed default in the container never do, so they carry the same marker.

## [0.5.2]

### Fixed

- **A CORS preflight is answered wherever a CORS header is written.** `OPTIONS` matched no route on
the authorization server or on the resource server's metadata, so a preflight fell through to
whatever the host had; a host with a deny-everything fallback policy answered `401`. Measured
against a running deployment, 2026-09-08, on all three public documents.

It bites later than it looks, and that is the reason it is worth a release rather than a note. A
browser preflights only once a request stops being simple, so the client that finds this is the
first one to add a request header of its own, and what that client is shown is the browser's
generic "no `Access-Control-Allow-Origin` header is present" pointing at CORS configuration that
is in fact correct. Authenticating a preflight cannot be right in any case: the browser sends it
with no credentials by specification, so there is nothing in it to authenticate.

`OPTIONS` is now mapped on the two discovery documents, the JWKS, the `/.well-known` catch-alls,
`/token`, and both forms of the RFC 9728 protected-resource metadata. That is every route that
already wrote `Access-Control-Allow-Origin` and no other: `/authorize` MUST have none (OAuth 2.1
§3.2, RFC 9700 §2.6), and a change that made every `OPTIONS` succeed would have taken that with
it, so a test holds the boundary from both sides.

The requested headers are echoed rather than published as a fixed list. These endpoints are read
without credentials and already answer `Access-Control-Allow-Origin: *` with no
`Access-Control-Allow-Credentials`, so naming back what was asked grants nothing they do not
already grant to anyone who asks, and a fixed list would make the next header a client adds the
next incident.

Nothing a consumer compiles against moved; the version turns because the behaviour did.

## [0.5.1] - 2026-09-02

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
cost twenty minutes instead of a rollback.
-->
<PropertyGroup>
<Version>0.5.1</Version>
<Version>0.5.2</Version>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -202,7 +202,7 @@
-->
<PropertyGroup Condition="'$(IsPackable)' != 'false'">
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>0.5.0</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>0.5.1</PackageValidationBaselineVersion>
</PropertyGroup>

<Target Name="RefuseDefaultPackageDescription"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,22 @@ namespace Boltway.AuthorizationServer.Endpoints;
/// <c>"contains CORS metadata, but a middleware was not found"</c> - a <b>500 on every discovery
/// document</b>, while the 404 catch-all keeps working. Measured, and invisible to a test fixture
/// that happens to call <c>UseCors()</c>. Writing the one header the documents need removes the
/// dependency: these are simple cross-origin GETs, so no preflight is involved.
/// dependency.
/// </para>
/// <para>
/// This paragraph used to end "these are simple cross-origin GETs, so no preflight is involved",
/// and that was true of the clients we had rather than of the endpoint. A browser preflights as
/// soon as a request stops being simple - one non-safelisted request header is enough - and
/// <c>OPTIONS</c> matched no route here, so it fell through to the host and a deny-everything
/// fallback policy answered <b>401</b>. Measured against a running deployment, 2026-09-08.
/// </para>
/// <para>
/// So <c>OPTIONS</c> is mapped wherever this server writes <c>Access-Control-Allow-Origin</c>, and
/// only there. Authenticating a preflight cannot be right in any case - the browser sends it
/// without credentials by specification, so there is nothing in it to authenticate - and the cost
/// of leaving it was never the 401 itself: the browser reports a missing
/// <c>Access-Control-Allow-Origin</c>, which sends the reader to CORS configuration that is
/// correct.
/// </para>
/// </remarks>
public static class DiscoveryEndpoints
Expand All @@ -59,13 +74,23 @@ public static IEndpointRouteBuilder MapOAuthDiscovery(
.MapMethods(path, ProbeMethods, () => Document(document))
.AllowAnonymous()
.WithName("boltway-discovery-" + path.Replace('/', '_'));

endpoints
.MapMethods(path, PreflightMethods, Preflight)
.AllowAnonymous()
.WithName("boltway-discovery-preflight-" + path.Replace('/', '_'));
}

endpoints
.MapMethods(AuthorizationServerPaths.Jwks, ProbeMethods, () => Jwks(keyRing))
.AllowAnonymous()
.WithName("boltway-jwks");

endpoints
.MapMethods(AuthorizationServerPaths.Jwks, PreflightMethods, Preflight)
.AllowAnonymous()
.WithName("boltway-jwks-preflight");

// Both shapes of well-known path that this server does not serve.
//
// RFC 8414 §3.1 *inserts* the well-known segment before an issuer path
Expand All @@ -81,19 +106,36 @@ public static IEndpointRouteBuilder MapOAuthDiscovery(
// string was inserted" - and a conforming client is then required to reject what it just
// fetched. A 404 lets it try the next probe instead of failing on a document it must not
// trust.
// The preflight is answered here as well, and a 404 still follows it. That reads backwards
// until you see what the alternative costs: a refused preflight tells a browser client
// nothing except "CORS", while an answered one lets the real request through to the bare
// 404 this route exists to give - which is the answer that lets a client try its next
// probe, the whole reason the route is here.
foreach (var (template, name) in NotFoundRoutes)
{
endpoints
.MapMethods(template, ProbeMethods, NotFound)
.AllowAnonymous()
.WithName(name);

endpoints
.MapMethods(template, PreflightMethods, Preflight)
.AllowAnonymous()
.WithName(name + "-preflight");
}

return endpoints;
}

private static readonly string[] ProbeMethods = ["GET", "HEAD"];

private static readonly string[] PreflightMethods = ["OPTIONS"];

private static PreflightResult Preflight() => new(DocumentMethods);

/// <summary>What a preflight on a read-only public document is told it may do.</summary>
private const string DocumentMethods = "GET, HEAD, OPTIONS";

/// <summary>
/// The well-known paths that get a bare 404.
/// </summary>
Expand Down Expand Up @@ -192,6 +234,47 @@ internal static void AllowAnyOrigin(HttpResponse response)
}
}

/// <summary>
/// The answer to a CORS preflight: allowed, with no body.
/// </summary>
/// <remarks>
/// <para>
/// <b>The requested headers are echoed rather than published as a list.</b> Every endpoint that
/// uses this serves something public and reads no credential, and the response already carries
/// <c>Access-Control-Allow-Origin: *</c> with no <c>Access-Control-Allow-Credentials</c> - so the
/// browser sends no cookie and no ambient authority, and naming back what was asked for grants
/// nothing the document does not already grant to anyone who asks. A fixed list would instead make
/// the next header a client adds the next incident, which is exactly how this defect was found.
/// </para>
/// <para>
/// <c>204</c> rather than <c>200</c>: there is nothing to send, and a preflight with a body is a
/// body every client throws away.
/// </para>
/// </remarks>
internal sealed class PreflightResult(string allowedMethods) : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);

var response = httpContext.Response;
DiscoveryHeaders.AllowAnyOrigin(response);
response.Headers[HeaderNames.AccessControlAllowMethods] = allowedMethods;

var asked = httpContext.Request.Headers[HeaderNames.AccessControlRequestHeaders];
if (!StringValues.IsNullOrEmpty(asked))
{
response.Headers[HeaderNames.AccessControlAllowHeaders] = asked;
}

// Ten minutes, the same order as the documents' own five: a preflight the browser has to
// repeat on every call is a round trip per request, and these answers do not change.
response.Headers[HeaderNames.AccessControlMaxAge] = "600";
response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
}
}

/// <summary>A JSON body with a strong ETag and a conditional-GET short circuit.</summary>
internal sealed class CachedJsonResult(ImmutableArray<byte> json, string etag, int maxAgeSeconds) : IResult
{
Expand Down
19 changes: 18 additions & 1 deletion src/Boltway.AuthorizationServer/Endpoints/TokenEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,21 @@ namespace Boltway.AuthorizationServer.Endpoints;
/// </remarks>
public static class TokenEndpoint
{
/// <summary>Map <c>POST /token</c>.</summary>
/// <summary>Map <c>POST /token</c>, and the preflight a browser client may send first.</summary>
/// <remarks>
/// <para>
/// <c>MapPost</c> rather than <c>MapMethods</c>, so routing answers <c>405</c> for every other
/// method by itself. <c>MapGet</c> would additionally serve HEAD, and a HEAD that reaches a
/// grant handler is a token exchange whose response the client never sees.
/// </para>
/// <para>
/// <c>OPTIONS</c> is the exception, and it is mapped for the same reason this endpoint writes a
/// CORS header at all: a browser-based client calls it directly. A public client posting
/// form-encoded fields sends a simple request and never preflights, which is why this was not
/// noticed - but a client that authenticates with an <c>Authorization</c> header, or adds any
/// header of its own, preflights, and the preflight matched no route and fell through to the
/// host. See <see cref="DiscoveryEndpoints"/> for what that cost.
/// </para>
/// </remarks>
public static IEndpointRouteBuilder MapToken(this IEndpointRouteBuilder endpoints)
{
Expand All @@ -44,9 +54,16 @@ public static IEndpointRouteBuilder MapToken(this IEndpointRouteBuilder endpoint
.AllowAnonymous()
.WithName("boltway-token");

endpoints
.MapMethods(AuthorizationServerPaths.Token, PreflightMethods, () => new PreflightResult("POST, OPTIONS"))
.AllowAnonymous()
.WithName("boltway-token-preflight");

return endpoints;
}

private static readonly string[] PreflightMethods = ["OPTIONS"];

/// <summary>
/// Run the exchange, and turn a store that cannot be reached into a load-shed rather than a crash.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ public static class ProtectedResourceMetadataEndpoints
{
private static readonly string[] ProbeMethods = ["GET", "HEAD"];

private static readonly string[] PreflightMethods = ["OPTIONS"];

private static PreflightResult Preflight() => new();

/// <summary>
/// One hour.
/// </summary>
Expand All @@ -103,6 +107,21 @@ public static IEndpointRouteBuilder MapProtectedResourceMetadata(this IEndpointR
.AllowAnonymous()
.WithName("boltway-prm-root");

// The CORS preflight, on both routes below as well. This document already answers
// `Access-Control-Allow-Origin: *` because browser-based clients read it, and OPTIONS
// matched no route - so a preflight fell through to the host, which on a deployment with a
// deny-everything fallback answers 401. Measured on the authorization server's identical
// routes, 2026-09-08; the same shape, found here by looking rather than by an incident.
//
// It bites later than it looks: a browser preflights only once a request stops being
// simple, so the client that finds it is the first one to add a header, and what it sees is
// the browser's "no Access-Control-Allow-Origin header is present" pointing at CORS
// configuration that is correct.
endpoints
.MapMethods(WellKnownResourceUri.Suffix, PreflightMethods, Preflight)
.AllowAnonymous()
.WithName("boltway-prm-root-preflight");

// E-23, the path-inserted form. A catch-all is mandatory: a plain route for the suffix
// alone 404s the "/mcp" variant, which is the URL a conformant client constructs first and
// the one this server's own challenges point at.
Expand All @@ -122,6 +141,11 @@ public static IEndpointRouteBuilder MapProtectedResourceMetadata(this IEndpointR
.AllowAnonymous()
.WithName("boltway-prm-inserted");

endpoints
.MapMethods(WellKnownResourceUri.Suffix + "/{*rest}", PreflightMethods, Preflight)
.AllowAnonymous()
.WithName("boltway-prm-inserted-preflight");

return endpoints;
}

Expand Down Expand Up @@ -166,6 +190,38 @@ internal static void AllowAnyOrigin(HttpResponse response)
}

/// <summary>A JSON body with a strong ETag and a conditional-GET short circuit.</summary>
/// <summary>
/// The answer to a CORS preflight: allowed, with no body.
/// </summary>
/// <remarks>
/// The requested headers are echoed rather than published as a list. This document is public and
/// is read with no credential, and the response carries <c>Access-Control-Allow-Origin: *</c> with
/// no <c>Access-Control-Allow-Credentials</c>, so the browser sends no ambient authority and naming
/// back what was asked grants nothing the document does not already grant to anyone. A fixed list
/// would make the next header a client adds the next incident.
/// </remarks>
internal sealed class PreflightResult : IResult
{
public Task ExecuteAsync(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);

var response = httpContext.Response;
MetadataHeaders.AllowAnyOrigin(response);
response.Headers[HeaderNames.AccessControlAllowMethods] = "GET, HEAD, OPTIONS";

var asked = httpContext.Request.Headers[HeaderNames.AccessControlRequestHeaders];
if (!StringValues.IsNullOrEmpty(asked))
{
response.Headers[HeaderNames.AccessControlAllowHeaders] = asked;
}

response.Headers[HeaderNames.AccessControlMaxAge] = "600";
response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
}
}

internal sealed class CachedJsonResult(ImmutableArray<byte> json, string etag, int maxAgeSeconds) : IResult
{
public async Task ExecuteAsync(HttpContext httpContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,87 @@ public async Task Discovery_allows_cross_origin_reads()
Assert.Equal("*", Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin")));
}

/// <summary>
/// A CORS preflight on a public document is answered, not authenticated.
/// </summary>
/// <remarks>
/// <para>
/// <c>OPTIONS</c> matched no route here, so it fell through to whatever the host had - and a
/// host with a deny-everything fallback policy answered <b>401</b>. Measured against a running
/// deployment on 2026-09-08, on all three public documents.
/// </para>
/// <para>
/// It had not bitten yet, and the reason it had not is the reason it is worth fixing rather
/// than noting: a browser preflights only when the request is not simple, and the OIDC client
/// libraries fetch these with CORS-safelisted headers alone. So the defect waits for the first
/// client that adds one header - and what that client sees is not a 401, it is the browser's
/// generic "no Access-Control-Allow-Origin header is present", pointing at CORS configuration
/// that is in fact correct. The cost is the hour spent looking in the wrong place.
/// </para>
/// <para>
/// Authenticating a preflight cannot be right in any case: the browser sends it with no
/// credentials by specification, so there is nothing there to authenticate.
/// </para>
/// </remarks>
[Theory]
[InlineData("/.well-known/oauth-authorization-server")]
[InlineData("/.well-known/openid-configuration")]
[InlineData("/.well-known/jwks.json")]
public async Task A_preflight_on_a_public_document_is_answered(string url)
{
using var request = new HttpRequestMessage(HttpMethod.Options, url);
request.Headers.Add("Origin", "https://client.example");
request.Headers.Add("Access-Control-Request-Method", "GET");
request.Headers.Add("Access-Control-Request-Headers", "x-request-id");

var response = await _client.SendAsync(request);

Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal("*", Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin")));
Assert.Contains("GET", Assert.Single(response.Headers.GetValues("Access-Control-Allow-Methods")), StringComparison.Ordinal);
// Echoed rather than published as a list. These documents are public and read without
// credentials, so naming what was asked for grants nothing that the document itself does
// not already grant, and it is what makes an unknown client work rather than the next
// header being the next incident.
Assert.Equal("x-request-id", Assert.Single(response.Headers.GetValues("Access-Control-Allow-Headers")));
}

/// <summary>The token endpoint too, which a browser-based client posts to.</summary>
[Fact]
public async Task A_preflight_on_the_token_endpoint_is_answered()
{
using var request = new HttpRequestMessage(HttpMethod.Options, "/token");
request.Headers.Add("Origin", "https://client.example");
request.Headers.Add("Access-Control-Request-Method", "POST");

var response = await _client.SendAsync(request);

Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal("*", Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin")));
Assert.Contains("POST", Assert.Single(response.Headers.GetValues("Access-Control-Allow-Methods")), StringComparison.Ordinal);
}

/// <summary>
/// The control, and it is the half that keeps the fix from being a CORS server.
/// </summary>
/// <remarks>
/// A preflight is answered where this server already writes
/// <c>Access-Control-Allow-Origin</c>, and nowhere else. The host's own routes are the host's,
/// and <c>/authorize</c> in particular MUST have no CORS at all (OAuth 2.1 §3.2, RFC 9700
/// §2.6) - a change that made every OPTIONS succeed would have taken that with it.
/// </remarks>
[Fact]
public async Task A_preflight_on_a_route_that_did_not_ask_for_cors_is_left_alone()
{
using var request = new HttpRequestMessage(HttpMethod.Options, "/some/app/route");
request.Headers.Add("Origin", "https://client.example");
request.Headers.Add("Access-Control-Request-Method", "GET");

var response = await _client.SendAsync(request);

Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
}

/// <summary>
/// The authorization endpoint has no CORS headers.
/// </summary>
Expand Down
Loading