Skip to content

fix(jwt): handle CORS preflight on /jwt/* endpoints#84

Merged
kennethphough merged 1 commit into
masterfrom
fix/jwt-cors-preflight
May 22, 2026
Merged

fix(jwt): handle CORS preflight on /jwt/* endpoints#84
kennethphough merged 1 commit into
masterfrom
fix/jwt-cors-preflight

Conversation

@kennethphough

Copy link
Copy Markdown
Member

Summary

  • Browser CORS preflight (OPTIONS /jwt/login) was returning 405 method_not_allowed with no CORS headers, blocking the actual POST /jwt/login and breaking JWT login from any same-page web client (Shipyard 2.0+ in JWT mode, kyte-api-js v2 JWT consumers).
  • JwtEndpoint::handle() now emits CORS headers on every response and replies to OPTIONS with 204 No Content + preflight headers before falling through to process().
  • Mirrors the permissive Origin policy in Api::cors(). The /mcp endpoint has the same theoretical gap but never sees a preflight (server-to-server only) so it's left alone for this point release.

Root cause

Api::cors() runs inside validateRequest(), which lives downstream of the /jwt dispatch in Api::route(). So /jwt/* skipped CORS entirely. The fix is contained to JwtEndpoint — no changes to the route dispatcher or Api::cors().

Test plan

  • New regression test JwtEndpointTest::testHandleAnswersOptionsPreflightWith204 exercises handle() end-to-end with $_SERVER mocked for an OPTIONS request, output-buffers the response, and asserts http_response_code() === 204 + empty body
  • All 12 JwtEndpoint tests pass (11 existing + 1 new)
  • Manually verified on a v4.4.0 install: OPTIONS /jwt/login returns 204 with Access-Control-Allow-Origin/Allow-Credentials/Allow-Methods/Allow-Headers; POST /jwt/login returns 400 (empty body) with full CORS headers
  • Browser test: Shipyard 2.0 JWT login no longer blocked, end-to-end login + session works

Rollout

  • Bumps to v4.4.1 — patch-level fix, no schema changes, composer upgrade is sufficient.
  • Required for Shipyard 2.0+ deployments and any kyte-api-js v2 JWT consumer in a browser.

🤖 Generated with Claude Code

Browser CORS preflight (OPTIONS /jwt/login) was being routed through
JwtEndpoint::process(), which only accepts POST and returned 405 with
no CORS headers. Browsers then blocked the actual POST, breaking JWT
login for any same-page web client — including Shipyard 2.0+ in JWT
mode and any kyte-api-js v2 JWT consumer.

Root cause: Api::cors() runs inside validateRequest(), which is
downstream of the /jwt dispatch in Api::route(). So /jwt/* skipped
CORS entirely. Same theoretical problem exists for /mcp, but MCP is
server-to-server and never triggers browser preflight.

Fix:
  - JwtEndpoint::handle() now emits CORS headers on every response
    (Access-Control-Allow-Origin from the request's Origin/Referer,
    Allow-Credentials true, Content-Type application/json; charset=utf-8)
  - OPTIONS requests get an early 204 + preflight-specific headers
    (Allow-Methods POST,OPTIONS; Allow-Headers echoing the request's
    Access-Control-Request-Headers)
  - Mirrors the permissive Origin policy in Api::cors()

Regression test exercises handle() end-to-end with $_SERVER mocked
for an OPTIONS request, output-buffers the response, and asserts
http_response_code() === 204 + empty body.

All 12 JwtEndpoint tests pass (11 existing + 1 new).

Bumps to v4.4.1 — patch-level fix, no schema changes, composer
upgrade is sufficient. Required for Shipyard 2.0+ deployments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kennethphough
kennethphough merged commit ab57213 into master May 22, 2026
4 checks passed
@kennethphough
kennethphough deleted the fix/jwt-cors-preflight branch May 22, 2026 13:57
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.

1 participant