fix: prevent cross-app auth session sharing on localhost

Root cause: browsers don't scope cookies by port. With SESSION_DOMAIN=
localhost, all three SPAs share cookies. The CookieBearerToken middleware
iterated all cookie names and picked the first match, so logging into
the organizer app (port 5174) also authenticated the portal (port 5175).

Fix: CookieBearerToken now resolves the correct cookie name from the
Origin header (same logic as SetAuthCookie trait). It only reads the
cookie matching the requesting app — portal origin reads only
crewli_portal_token, app origin reads only crewli_app_token, etc.

Falls back to first-available cookie when no Origin header is present
(server-to-server requests, tests without explicit Origin).

Added 3 cross-app isolation tests:
- app cookie does NOT authenticate portal requests
- portal cookie does NOT authenticate app requests
- correct cookie + matching origin = authenticated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 17:19:42 +02:00
parent 821bfc5bcf
commit a9ef384515
3 changed files with 97 additions and 7 deletions

View File

@@ -23,15 +23,63 @@ final class CookieBearerToken
return $next($request);
}
foreach (self::COOKIE_NAMES as $cookieName) {
$token = $request->cookie($cookieName);
// Resolve the cookie name for the requesting app via Origin header.
// This prevents cross-app cookie leakage on localhost where the
// browser sends all cookies regardless of port.
$cookieName = $this->resolveCookieName($request);
if ($cookieName) {
$token = $request->cookie($cookieName);
if ($token) {
$request->headers->set('Authorization', 'Bearer ' . $token);
break;
}
}
return $next($request);
}
private function resolveCookieName(Request $request): ?string
{
$origin = $request->headers->get('Origin')
?? $request->headers->get('Referer')
?? '';
if ($origin === '') {
// No Origin — fall back to first available cookie (e.g. server-to-server)
foreach (self::COOKIE_NAMES as $name) {
if ($request->cookie($name)) {
return $name;
}
}
return null;
}
$originHost = parse_url($origin, PHP_URL_HOST);
$originPort = parse_url($origin, PHP_URL_PORT);
$map = [
'admin' => [config('app.frontend_admin_url', 'http://localhost:5173'), 'crewli_admin_token'],
'app' => [config('app.frontend_app_url', 'http://localhost:5174'), 'crewli_app_token'],
'portal' => [config('app.frontend_portal_url', 'http://localhost:5175'), 'crewli_portal_token'],
];
foreach ($map as [$configuredUrl, $cookieName]) {
$configHost = parse_url($configuredUrl, PHP_URL_HOST);
$configPort = parse_url($configuredUrl, PHP_URL_PORT);
if ($originHost === $configHost && $originPort === $configPort) {
return $cookieName;
}
}
// Origin didn't match any configured frontend — fall back to first available
foreach (self::COOKIE_NAMES as $name) {
if ($request->cookie($name)) {
return $name;
}
}
return null;
}
}

View File

@@ -192,6 +192,45 @@ final class HttpOnlyCookieAuthTest extends TestCase
$response->assertUnauthorized();
}
// --- Cross-App Isolation Tests ---
public function test_app_cookie_does_not_authenticate_portal_requests(): void
{
$user = User::factory()->create();
$token = $user->createToken('auth-token')->plainTextToken;
// App cookie is set, but request comes from portal origin —
// middleware should only read crewli_portal_token, not crewli_app_token
$response = $this->withUnencryptedCookie('crewli_app_token', $token)
->getJson('/api/v1/auth/me', ['Origin' => 'http://localhost:5175']);
$response->assertUnauthorized();
}
public function test_portal_cookie_does_not_authenticate_app_requests(): void
{
$user = User::factory()->create();
$token = $user->createToken('auth-token')->plainTextToken;
$response = $this->withUnencryptedCookie('crewli_portal_token', $token)
->getJson('/api/v1/auth/me', ['Origin' => 'http://localhost:5174']);
$response->assertUnauthorized();
}
public function test_correct_cookie_authenticates_with_matching_origin(): void
{
$user = User::factory()->create();
$token = $user->createToken('auth-token')->plainTextToken;
// Portal cookie + portal origin = authenticated
$response = $this->withUnencryptedCookie('crewli_portal_token', $token)
->getJson('/api/v1/auth/me', ['Origin' => 'http://localhost:5175']);
$response->assertOk();
$response->assertJsonPath('data.id', $user->id);
}
// --- Helper ---
private function findCookie($response, string $name): ?\Symfony\Component\HttpFoundation\Cookie

View File

@@ -52,11 +52,14 @@ On successful login (`POST /auth/login`), the server:
### Validation
The `CookieBearerToken` middleware (registered before `auth:sanctum` in the API middleware stack):
1. Checks for any of the three cookie names in the request
2. If found, sets the `Authorization: Bearer` header on the request
3. Sanctum's existing token validation processes the header normally
1. Reads the `Origin` (or `Referer`) header to identify which app is making the request
2. Resolves the correct cookie name for that app (e.g. portal origin → `crewli_portal_token`)
3. Reads only that cookie and sets `Authorization: Bearer` on the request
4. Sanctum's existing token validation processes the header normally
If an `Authorization` header is already present (e.g. from the portal token flow), the middleware skips cookie injection.
**Cross-app isolation:** In local development, all three SPAs share `localhost` (different ports). Browsers do not scope cookies by port, so all three app cookies are sent with every API request. The middleware prevents cross-app authentication by only reading the cookie that matches the requesting app's Origin header. Without this, logging into one app would authenticate all apps.
If the `Origin` header is absent (e.g. server-to-server requests), the middleware falls back to the first available cookie. If an `Authorization` header is already present (e.g. from the portal token flow), the middleware skips cookie injection entirely.
### Rotation