diff --git a/CHANGELOG.md b/CHANGELOG.md index 1787007..862367d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## 4.4.1 + +### Bug Fix: CORS preflight on `/jwt/*` endpoints + +Browser CORS preflight requests (`OPTIONS /jwt/login`) were being routed +through `JwtEndpoint::process()`, which only accepts POST and returned +`405 method_not_allowed` with no CORS headers. Browsers then blocked +the actual `POST /jwt/login`, breaking JWT login for any same-page web +client (Shipyard 2.0+ in JWT mode, kyte-api-js v2 JWT consumers). + +Root cause: `Api::cors()` runs inside `validateRequest()`, which lives +downstream of the `/jwt` dispatch in `Api::route()`. So `/jwt/*` skipped +CORS entirely. (Same is technically true for `/mcp` but MCP is +server-to-server and doesn't trigger browser preflight.) + +Fix: `JwtEndpoint::handle()` now emits CORS headers on every response +and replies to OPTIONS with `204 No Content` + CORS preflight headers +before falling through to `process()`. Mirrors the permissive Origin +policy in `Api::cors()`. + +Regression test: `JwtEndpointTest::testHandleAnswersOptionsPreflightWith204` +exercises the OPTIONS path through `handle()` end-to-end with output +buffering and asserts the 204 response. + +No schema changes. Composer upgrade is sufficient. Customers running +Shipyard 2.0+ in JWT mode must update before JWT login will succeed +in a browser. + ## 4.4.0 > Phase 2 (MCP server) + Phase 2.5 (sensitive-data flag) + Phase 3 (JWT auth) all ship together. **No breaking changes** — every addition is opt-in, defaults preserve v4.3.x behavior bit-for-bit. Existing customers can upgrade in place without code changes. diff --git a/src/Core/Auth/JwtEndpoint.php b/src/Core/Auth/JwtEndpoint.php index b2d9dd5..a10aa9d 100644 --- a/src/Core/Auth/JwtEndpoint.php +++ b/src/Core/Auth/JwtEndpoint.php @@ -48,17 +48,50 @@ final class JwtEndpoint /** * Production entry point — reads globals, writes to SAPI. + * + * Handles CORS inline. Api::cors() lives in validateRequest() which + * runs *downstream* of the /jwt dispatch in Api::route(), so by the + * time we get here, no CORS headers have been emitted. Browser + * preflight (OPTIONS) on /jwt/login would otherwise see a 405 with + * no Access-Control-Allow-Origin and block the real POST. Mirrors + * the permissive Origin policy in Api::cors(). */ public static function handle(Api $api): void { + self::emitCorsHeaders(); + + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { + $reqHeaders = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ?? ''; + header('Access-Control-Allow-Methods: POST, OPTIONS'); + header("Access-Control-Allow-Headers: {$reqHeaders}"); + http_response_code(204); + return; + } + $rawBody = (string)file_get_contents('php://input'); $result = self::process($api, $_SERVER, $rawBody); http_response_code($result['status']); - header('Content-Type: application/json'); echo json_encode($result['body']); } + /** + * Emit the per-request CORS headers shared by all /jwt/* responses, + * including the OPTIONS preflight reply. Mirrors Api::cors() so a + * browser opening a session via /jwt/login sees the same Origin / + * Credentials / Content-Type contract as it would for HMAC login. + */ + private static function emitCorsHeaders(): void + { + $origin = $_SERVER['HTTP_ORIGIN'] + ?? $_SERVER['HTTP_REFERER'] + ?? $_SERVER['REMOTE_ADDR'] + ?? ''; + header("Access-Control-Allow-Origin: {$origin}"); + header('Access-Control-Allow-Credentials: true'); + header('Content-Type: application/json; charset=utf-8'); + } + /** * Pure dispatcher. Returns ['status' => httpCode, 'body' => array]. */ diff --git a/tests/JwtEndpointTest.php b/tests/JwtEndpointTest.php index 01b99d1..bd76fc9 100644 --- a/tests/JwtEndpointTest.php +++ b/tests/JwtEndpointTest.php @@ -247,6 +247,39 @@ public function testNonPostMethodReturns405(): void $this->assertSame(405, $result['status']); } + /** + * Regression: browser CORS preflight (OPTIONS) for /jwt/login was + * being routed to process() which only accepts POST and returned + * 405 with no CORS headers. Browsers then blocked the real POST. + * handle() now answers OPTIONS with 204 + CORS headers directly so + * the preflight succeeds and the actual login can proceed. + */ + public function testHandleAnswersOptionsPreflightWith204(): void + { + $prevServer = $_SERVER; + try { + $_SERVER = [ + 'REQUEST_METHOD' => 'OPTIONS', + 'REQUEST_URI' => '/jwt/login', + 'HTTP_ORIGIN' => 'https://shipyard.example.com', + 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'content-type, authorization', + 'REMOTE_ADDR' => '203.0.113.42', + ]; + + ob_start(); + JwtEndpoint::handle($this->api); + $body = ob_get_clean(); + + $this->assertSame(204, http_response_code(), 'OPTIONS preflight must reply 204'); + $this->assertSame('', $body, 'OPTIONS preflight must not write a body'); + } finally { + $_SERVER = $prevServer; + // Reset the response code for downstream tests since PHPUnit + // doesn't isolate http_response_code() between tests. + http_response_code(200); + } + } + private function doLogin(): array { $result = JwtEndpoint::process($this->api, $this->serverFor('/jwt/login'), $this->jsonBody([