Skip to content

A CORS preflight is answered wherever a CORS header is written - #39

Merged
TobiiNT merged 1 commit into
mainfrom
claude/retire-connector-directories-qs4iju
Sep 8, 2026
Merged

TobiiNT merged 1 commit into
mainfrom
claude/retire-connector-directories-qs4iju

Conversation

@TobiiNT

@TobiiNT TobiiNT commented Sep 8, 2026

Copy link
Copy Markdown
Owner

OPTIONS matched no route on this server. It fell through to whatever the host had, and a host
with a deny-everything fallback policy answered 401 - on the discovery documents, on the JWKS,
and on the RFC 9728 metadata the resource server serves.

Measured against a running deployment, 2026-09-08:

OPTIONS /.well-known/openid-configuration        -> 401    (GET -> 200, ACAO: *)
OPTIONS /.well-known/oauth-authorization-server  -> 401    (GET -> 200, ACAO: *)
OPTIONS /.well-known/jwks.json                   -> 401    (GET -> 200, ACAO: *)
OPTIONS /token                                   -> 401    (POST -> 400, ACAO: *)

Why this is worth a release, given nothing was broken that day

A browser preflights only once a request stops being simple, and the OIDC client libraries fetch
these with CORS-safelisted headers alone. So the defect waits for the first client that adds a
request header of its own.

What that client is shown 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 there. That is how this was found: chasing exactly that
message on a deployment whose CORS was right.

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.

The rule

Every route that writes Access-Control-Allow-Origin answers OPTIONS, and no other route
does.
That is the two discovery documents, the JWKS, the three /.well-known catch-alls,
/token, and both forms of the protected-resource metadata.

The boundary is held from both sides, because the permissive half is the half somebody would
later read as a licence: /authorize MUST have no CORS at all (OAuth 2.1 §3.2, RFC 9700 §2.6), and
a change that made every OPTIONS succeed would have taken that with it. There is a test for the
route that must not be touched, not only for the ones that must.

Three decisions

The requested headers are echoed, not published as a fixed list. These endpoints are read
without credentials and already answer * with no Access-Control-Allow-Credentials, so the
browser sends no ambient authority and naming back what was asked grants nothing they do not
already grant to anyone. A fixed list would make the next header a client adds the next incident,
which is precisely how this one arrived.

The /.well-known catch-alls answer the preflight too, and still 404 after it. That reads
backwards until you see the alternative: a refused preflight tells a browser client only "CORS",
while an answered one lets the real request reach the bare 404 the route exists to give - the
answer that lets a client try its next probe, which is the whole reason that route is there.

204 and not 200: there is nothing to send, and a preflight body is a body every client
discards.

A comment that had become false

DiscoveryEndpoints' remarks ended "these are simple cross-origin GETs, so no preflight is
involved"
. That was true of the clients we had rather than of the endpoint. Corrected in place,
with what it cost.

Red-first

The three document routes and /token fail without the change; the control - a host route that
never asked for CORS - passes before and after.

Failed!  - Failed: 4, Passed: 1, Total: 5      (before)
Passed!  - Failed: 0, Passed: 5, Total: 5      (after)

Checks

dotnet build Boltway.slnx      0 warnings
python3 scripts/check-dashes.py   0 in prose or comments
dotnet test Boltway.slnx       14 of 15 suites green, 2618 tests

The one red suite is Boltway.Storage.PostgreSql.Tests, which fails rather than skips without a
real server and has none in this container. That is the documented behaviour, not a regression: a
storage suite that skips itself is green in exactly the situation where it measured nothing.

Version

<Version> 0.5.1 -> 0.5.2 and PackageValidationBaselineVersion 0.5.0 -> 0.5.1, which
The_api_compatibility_baseline_is_the_previous_released_version holds to CHANGELOG's second
heading. Nothing a consumer compiles against moved; the version turns because the behaviour did.

Deploying it

No Boltway tag is needed. AUTH_TAG=main in the deploy workflow, because this repository has no
release process, so the next connector deploy carries this once ci has pushed the image.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HwL9CdWHruodcVHeQAKtGR


Generated by Claude Code

`OPTIONS` matched no route on this server. It fell through to whatever the host
had, and a host with a deny-everything fallback policy answered 401 - on the
discovery documents, on the JWKS, and on the RFC 9728 metadata the resource
server serves. Measured against a running deployment, 2026-09-08.

WHY IT IS WORTH A RELEASE RATHER THAN A NOTE, given nothing was broken today: 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. What that client
is shown is not a 401. It is the browser's generic "no
Access-Control-Allow-Origin header is present", pointing at CORS configuration
that is correct - and the cost is the hour spent looking there. 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.

The rule is one sentence: every route that writes Access-Control-Allow-Origin
answers OPTIONS, and no other route does. That is the two discovery documents,
the JWKS, the three /.well-known catch-alls, /token, and both forms of the
protected-resource metadata.

The boundary is held from both sides by tests, because the permissive half is
the half somebody would later read as a licence: /authorize MUST have no CORS at
all (OAuth 2.1 §3.2, RFC 9700 §2.6), and a change that made every OPTIONS
succeed would have taken that with it.

Three decisions in it:

- The requested headers are ECHOED rather than published as a fixed list. These
  endpoints are read without credentials and already answer `*` with no
  Access-Control-Allow-Credentials, so the browser sends no ambient authority
  and naming back what was asked grants nothing they do not already grant to
  anyone. A fixed list would make the next header a client adds the next
  incident, which is how this was found.
- The /.well-known catch-alls answer the preflight too, and still 404 after it.
  That reads backwards until you see the alternative: a refused preflight tells
  a browser client only "CORS", while an answered one lets the real request
  reach the bare 404 the route exists to give - the answer that lets a client
  try its next probe.
- 204 and not 200: there is nothing to send, and a preflight body is a body
  every client discards.

DiscoveryEndpoints' remarks said "these are simple cross-origin GETs, so no
preflight is involved". That was true of the clients we had rather than of the
endpoint, and it is corrected in place with what it cost.

Red-first: the three document routes and /token fail without the change, and
the control - a host route that never asked for CORS - passes before and after.

<Version> 0.5.2 and the baseline to 0.5.1, which the architecture test holds to
CHANGELOG's second heading. Nothing a consumer compiles against moved; the
version turns because the behaviour did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwL9CdWHruodcVHeQAKtGR
@TobiiNT
TobiiNT merged commit 402615d into main Sep 8, 2026
8 checks passed
@TobiiNT
TobiiNT deleted the claude/retire-connector-directories-qs4iju branch September 8, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants