diff --git a/api/app/Http/Middleware/CookieBearerToken.php b/api/app/Http/Middleware/CookieBearerToken.php index e81f7a35..8bb535d8 100644 --- a/api/app/Http/Middleware/CookieBearerToken.php +++ b/api/app/Http/Middleware/CookieBearerToken.php @@ -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; + } } diff --git a/api/tests/Feature/Security/HttpOnlyCookieAuthTest.php b/api/tests/Feature/Security/HttpOnlyCookieAuthTest.php index f7aaa2ce..c4a7eb3a 100644 --- a/api/tests/Feature/Security/HttpOnlyCookieAuthTest.php +++ b/api/tests/Feature/Security/HttpOnlyCookieAuthTest.php @@ -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 diff --git a/dev-docs/AUTH_ARCHITECTURE.md b/dev-docs/AUTH_ARCHITECTURE.md index da07788b..555771d6 100644 --- a/dev-docs/AUTH_ARCHITECTURE.md +++ b/dev-docs/AUTH_ARCHITECTURE.md @@ -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