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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
35 changes: 34 additions & 1 deletion src/Core/Auth/JwtEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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].
*/
Expand Down
33 changes: 33 additions & 0 deletions tests/JwtEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading