Files
crewli/api/tests/Feature/Security/HttpOnlyCookieAuthTest.php
bert.hausmans a9ef384515 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>
2026-04-14 17:19:42 +02:00

247 lines
8.0 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Security;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
final class HttpOnlyCookieAuthTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RoleSeeder::class);
// Enable cookies for JSON requests (required for cookie-based auth testing)
$this->withCredentials();
}
// --- Login Cookie Tests ---
public function test_login_response_does_not_contain_token_in_json_body(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertOk();
$response->assertJsonMissing(['token']);
$this->assertArrayNotHasKey('token', $response->json('data'));
}
public function test_login_response_sets_httponly_cookie(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5174']);
$response->assertOk();
$response->assertCookie('crewli_app_token');
}
public function test_login_cookie_has_httponly_flag(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5174']);
$cookie = $this->findCookie($response, 'crewli_app_token');
$this->assertNotNull($cookie, 'Cookie crewli_app_token not found');
$this->assertTrue($cookie->isHttpOnly(), 'Cookie must be httpOnly');
}
public function test_login_cookie_has_samesite_strict(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5174']);
$cookie = $this->findCookie($response, 'crewli_app_token');
$this->assertNotNull($cookie);
$this->assertEquals('strict', strtolower($cookie->getSameSite()));
}
public function test_login_sets_admin_cookie_for_admin_origin(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5173']);
$response->assertOk();
$response->assertCookie('crewli_admin_token');
}
public function test_login_sets_portal_cookie_for_portal_origin(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['Origin' => 'http://localhost:5175']);
$response->assertOk();
$response->assertCookie('crewli_portal_token');
}
// --- Middleware Tests ---
public function test_request_with_auth_cookie_is_authenticated(): void
{
$user = User::factory()->create();
$token = $user->createToken('auth-token')->plainTextToken;
$response = $this->withUnencryptedCookie('crewli_app_token', $token)
->getJson('/api/v1/auth/me');
$response->assertOk();
$response->assertJsonPath('data.id', $user->id);
}
public function test_request_with_invalid_cookie_returns_401(): void
{
$response = $this->withUnencryptedCookie('crewli_app_token', 'invalid-token-value')
->getJson('/api/v1/auth/me');
$response->assertUnauthorized();
}
public function test_request_without_cookie_or_header_returns_401(): void
{
$response = $this->getJson('/api/v1/auth/me');
$response->assertUnauthorized();
}
// --- Logout Tests ---
public function test_logout_expires_auth_cookie(): void
{
$user = User::factory()->create();
$token = $user->createToken('auth-token')->plainTextToken;
$response = $this->withUnencryptedCookie('crewli_app_token', $token)
->postJson('/api/v1/auth/logout', [], ['Origin' => 'http://localhost:5174']);
$response->assertOk();
$cookie = $this->findCookie($response, 'crewli_app_token');
$this->assertNotNull($cookie, 'Cookie crewli_app_token not found in logout response');
// Expired cookie has a past expiry time
$this->assertTrue($cookie->getExpiresTime() < time(), 'Logout cookie must be expired');
}
// --- Refresh Tests ---
public function test_refresh_revokes_old_token_and_sets_new_cookie(): void
{
$user = User::factory()->create();
$accessToken = $user->createToken('auth-token');
$token = $accessToken->plainTextToken;
$response = $this->withUnencryptedCookie('crewli_app_token', $token)
->postJson('/api/v1/auth/refresh', [], ['Origin' => 'http://localhost:5174']);
$response->assertOk();
$response->assertCookie('crewli_app_token');
// New cookie should contain a different token
$newCookie = $this->findCookie($response, 'crewli_app_token');
$this->assertNotNull($newCookie);
$this->assertNotEquals($token, $newCookie->getValue(), 'New token must differ from old token');
// Old token should be revoked in the database
$this->assertNull(
\Laravel\Sanctum\PersonalAccessToken::findToken($token),
'Old token must be deleted from database',
);
// New token should be valid in the database
$this->assertNotNull(
\Laravel\Sanctum\PersonalAccessToken::findToken($newCookie->getValue()),
'New token must exist in database',
);
}
public function test_refresh_with_expired_token_returns_401(): void
{
$response = $this->withUnencryptedCookie('crewli_app_token', 'expired-or-invalid-token')
->postJson('/api/v1/auth/refresh');
$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
{
foreach ($response->headers->getCookies() as $cookie) {
if ($cookie->getName() === $name) {
return $cookie;
}
}
return null;
}
}