diff --git a/src/boost/docs/session.md b/src/boost/docs/session.md index 9bfd9a35b..29bfb98fc 100644 --- a/src/boost/docs/session.md +++ b/src/boost/docs/session.md @@ -11,6 +11,7 @@ - [Regenerating the Session ID](#regenerating-the-session-id) - [Session Cache](#session-cache) - [Session Blocking](#session-blocking) +- [Configuring the Session Cookie](#configuring-the-session-cookie) - [Adding Custom Session Drivers](#adding-custom-session-drivers) - [Implementing the Driver](#implementing-the-driver) - [Registering the Driver](#registering-the-driver) @@ -329,6 +330,42 @@ Route::post('/profile', function () { })->block(); ``` + +## Configuring the Session Cookie + +Session cookie attributes are normally configured using your application's `config/session.php` configuration file. If you need to determine session cookie attributes dynamically for each request, you may register a callback using the `configureSessionCookieUsing` method on the `StartSession` middleware. + +This method should typically be called from the `boot` method of a [service provider](/docs/{{version}}/providers): + +```php + tenant()->sessionCookieDomain(), + ]); + }); + } +} +``` + +The callback receives the current request and the session cookie configuration, and should return the modified cookie configuration. This is useful when cookie attributes, such as the cookie domain, depend on the current request. In this example, `tenant()` represents an application-specific helper that returns the current tenant. + +If multiple callbacks are registered, they will be executed in the order they were registered. Each callback receives the cookie configuration returned by the previous callback. + ## Adding Custom Session Drivers diff --git a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php index 0b039bfb0..762044192 100644 --- a/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php +++ b/src/sanctum/src/Http/Middleware/EnsureFrontendRequestsAreStateful.php @@ -19,8 +19,6 @@ class EnsureFrontendRequestsAreStateful */ public function handle(Request $request, Closure $next): Response { - $this->configureSecureCookieSessions(); - return (new Pipeline(app()))->send($request)->through( static::fromFrontend($request) ? $this->frontendMiddleware() : [] )->then(function ($request) use ($next) { @@ -85,15 +83,4 @@ public static function statefulDomains(): array { return config('sanctum.stateful', []); } - - /** - * Configure secure cookie sessions. - */ - protected function configureSecureCookieSessions(): void - { - config([ - 'session.http_only' => true, - 'session.same_site' => 'lax', - ]); - } } diff --git a/src/sanctum/src/SanctumGuard.php b/src/sanctum/src/SanctumGuard.php index 68d156479..db3c5b59e 100644 --- a/src/sanctum/src/SanctumGuard.php +++ b/src/sanctum/src/SanctumGuard.php @@ -275,4 +275,14 @@ protected function getContextKeyForToken(?string $token): string return "__auth.guards.{$this->name}.user." . md5($token); } + + /** + * Flush all static state back to defaults. + * + * Boot or tests only. Resets registered macros on the guard class. + */ + public static function flushState(): void + { + static::flushMacros(); + } } diff --git a/src/sanctum/src/SanctumServiceProvider.php b/src/sanctum/src/SanctumServiceProvider.php index b84019158..35ade5137 100644 --- a/src/sanctum/src/SanctumServiceProvider.php +++ b/src/sanctum/src/SanctumServiceProvider.php @@ -5,7 +5,9 @@ namespace Hypervel\Sanctum; use Hypervel\Auth\AuthManager; +use Hypervel\Http\Request; use Hypervel\Sanctum\Console\Commands\PruneExpired; +use Hypervel\Session\Middleware\StartSession; use Hypervel\Support\Facades\Route; use Hypervel\Support\ServiceProvider; @@ -28,6 +30,8 @@ public function register(): void public function boot(): void { $this->registerSanctumGuard(); + $this->configureSessionCookies(); + if ($this->app->runningInConsole()) { $this->registerPublishing(); $this->registerCommands(); @@ -54,6 +58,23 @@ protected function registerSanctumGuard(): void }); } + /** + * Configure session cookies for stateful frontend requests. + */ + protected function configureSessionCookies(): void + { + StartSession::configureSessionCookieUsing(function (Request $request, array $cookie): array { + if (! $request->attributes->get('sanctum')) { + return $cookie; + } + + return array_replace($cookie, [ + 'http_only' => true, + 'same_site' => 'lax', + ]); + }); + } + /** * Register the package routes. */ diff --git a/src/session/src/Middleware/StartSession.php b/src/session/src/Middleware/StartSession.php index 9e15fa55a..c9db11919 100644 --- a/src/session/src/Middleware/StartSession.php +++ b/src/session/src/Middleware/StartSession.php @@ -23,6 +23,13 @@ class StartSession { + /** + * The callbacks used to configure the session cookie attributes. + * + * @var array + */ + protected static array $sessionCookieCallbacks = []; + /** * Create a new session middleware. */ @@ -106,7 +113,7 @@ protected function handleStatefulRequest(Request $request, Session $session, Clo $this->storeCurrentUrl($request, $session); - $this->addCookieToResponse($response, $session); + $this->addCookieToResponse($request, $response, $session); // Again, if the session has been configured we will need to close out the session // so that the attributes may be persisted to some storage medium. We will also @@ -189,10 +196,10 @@ protected function storeCurrentUrl(Request $request, Session $session): void /** * Add the session cookie to the application response. */ - protected function addCookieToResponse(Response $response, Session $session): void + protected function addCookieToResponse(Request $request, Response $response, Session $session): void { if ($this->sessionIsPersistent($config = $this->manager->getSessionConfig())) { - $cookieConfig = $this->getSessionCookieConfig($config); + $cookieConfig = $this->resolveSessionCookieConfig($request, $config); $response->headers->setCookie(new Cookie( $session->getName(), @@ -212,14 +219,11 @@ protected function addCookieToResponse(Response $response, Session $session): vo /** * Get the session cookie configuration. * - * Extracted as an extension point so subclasses can provide dynamic - * cookie settings without duplicating the rest of addCookieToResponse. - * * @return array{path: string, domain: string, secure: ?bool, http_only: bool, same_site: ?string, partitioned: bool} */ - protected function getSessionCookieConfig(array $config): array + protected function resolveSessionCookieConfig(Request $request, array $config): array { - return [ + $cookieConfig = [ 'path' => $config['path'] ?? '/', 'domain' => $config['domain'] ?? '', 'secure' => $config['secure'] ?? null, @@ -227,6 +231,27 @@ protected function getSessionCookieConfig(array $config): array 'same_site' => $config['same_site'] ?? null, 'partitioned' => $config['partitioned'] ?? false, ]; + + foreach (static::$sessionCookieCallbacks as $callback) { + $cookieConfig = $callback($request, $cookieConfig); + } + + return $cookieConfig; + } + + /** + * Register a callback to configure the session cookie attributes. + * + * Boot-only. The callback persists in a static property for the worker + * lifetime and runs on every session cookie write across all coroutines. + * Callbacks run in registration order; later callbacks receive the values + * returned by earlier callbacks and may overwrite them. + * + * @param Closure(Request, array): array $callback + */ + public static function configureSessionCookieUsing(Closure $callback): void + { + static::$sessionCookieCallbacks[] = $callback; } /** @@ -276,4 +301,14 @@ protected function sessionIsPersistent(?array $config = null): bool return ! is_null($config['driver'] ?? null); } + + /** + * Flush all static state back to defaults. + * + * Boot or tests only. Resets the registered cookie configuration callbacks. + */ + public static function flushState(): void + { + static::$sessionCookieCallbacks = []; + } } diff --git a/tests/AfterEachTestSubscriber.php b/tests/AfterEachTestSubscriber.php index 97d4c8d8e..bf3caaa95 100644 --- a/tests/AfterEachTestSubscriber.php +++ b/tests/AfterEachTestSubscriber.php @@ -143,12 +143,14 @@ public function notify(Finished $event): void \Hypervel\Routing\SortedMiddleware::flushCache(); \Hypervel\Routing\UrlGenerator::flushState(); \Hypervel\Sanctum\Sanctum::flushState(); + \Hypervel\Sanctum\SanctumGuard::flushState(); \Hypervel\Scout\Builder::flushState(); \Hypervel\Scout\Scout::flushState(); \Hypervel\Server\ServerManager::flushState(); \Hypervel\ServerProcess\ProcessCollector::flushState(); \Hypervel\ServerProcess\ProcessManager::flushState(); \Hypervel\Session\Middleware\AuthenticateSession::flushState(); + \Hypervel\Session\Middleware\StartSession::flushState(); \Hypervel\Session\Store::flushState(); \Hypervel\Support\Arr::flushState(); \Hypervel\Support\BinaryCodec::flushState(); diff --git a/tests/Routing/RoutingStaticStateTest.php b/tests/Routing/RoutingStaticStateTest.php index 952bd7938..dcb6b339e 100644 --- a/tests/Routing/RoutingStaticStateTest.php +++ b/tests/Routing/RoutingStaticStateTest.php @@ -12,8 +12,8 @@ use Hypervel\Routing\ResponseFactory; use Hypervel\Routing\Route; use Hypervel\Routing\RouteCollection; -use Hypervel\Routing\RouteRegistrar; use Hypervel\Routing\Router; +use Hypervel\Routing\RouteRegistrar; use Hypervel\Routing\UrlGenerator; use Hypervel\Tests\Routing\RoutingTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -36,12 +36,24 @@ public function testFlushStateClearsRoutingMacros(string $class): void $this->assertFalse($class::hasMacro('routingFlushProbe')); } + public static function macroableRoutingClasses(): array + { + return [ + [PendingResourceRegistration::class], + [PendingSingletonResourceRegistration::class], + [Redirector::class], + [ResponseFactory::class], + [RouteRegistrar::class], + [Router::class], + ]; + } + public function testRouteFlushStateClearsEnumCache(): void { $enumCache = new ReflectionProperty(Route::class, 'enumCache'); - $enumCache->setValue(null, ['Some\\Enum' => true]); + $enumCache->setValue(null, ['Some\Enum' => true]); - $this->assertSame(['Some\\Enum' => true], $enumCache->getValue()); + $this->assertSame(['Some\Enum' => true], $enumCache->getValue()); Route::flushState(); @@ -79,16 +91,4 @@ public function testThrottleRequestsFlushStateRestoresHashedKeys(): void $this->assertTrue($shouldHashKeys->getValue()); } - - public static function macroableRoutingClasses(): array - { - return [ - [PendingResourceRegistration::class], - [PendingSingletonResourceRegistration::class], - [Redirector::class], - [ResponseFactory::class], - [RouteRegistrar::class], - [Router::class], - ]; - } } diff --git a/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php b/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php index cc1a14e25..bb06f8f5d 100644 --- a/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php +++ b/tests/Sanctum/EnsureFrontendRequestsAreStatefulTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Sanctum; use Hypervel\Http\Request; +use Hypervel\Http\Response; use Hypervel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; use Hypervel\Testbench\TestCase; @@ -71,6 +72,21 @@ public function testStatefulDomainsCanBeOverridden(): void // Custom middleware with overridden statefulDomains SHOULD match $this->assertTrue(CustomStatefulMiddleware::fromFrontend($request)); } + + public function testMiddlewareDoesNotMutateSessionConfig(): void + { + $this->app->make('config')->set([ + 'session.http_only' => false, + 'session.same_site' => 'strict', + ]); + + $request = Request::create('http://localhost', server: ['HTTP_ORIGIN' => 'https://wrong.com']); + + (new EnsureFrontendRequestsAreStateful)->handle($request, fn () => new Response('ok')); + + $this->assertFalse($this->app->make('config')->get('session.http_only')); + $this->assertSame('strict', $this->app->make('config')->get('session.same_site')); + } } /** diff --git a/tests/Sanctum/FrontendCookieHardeningTest.php b/tests/Sanctum/FrontendCookieHardeningTest.php new file mode 100644 index 000000000..ce09909a8 --- /dev/null +++ b/tests/Sanctum/FrontendCookieHardeningTest.php @@ -0,0 +1,93 @@ +app->make('config')->set([ + 'session.driver' => 'array', + 'session.http_only' => false, + 'session.same_site' => 'strict', + 'sanctum.stateful' => ['test.com'], + ]); + + $provider = $this->app->make(SanctumServiceProvider::class); + $provider->register(); + $provider->boot(); + } + + public function testStatefulFrontendRequestForcesSecureSessionCookieAttributes(): void + { + $cookie = $this->sessionCookie($this->handleStatefulFrontendRequest()); + + $this->assertTrue($cookie->isHttpOnly()); + $this->assertSame('lax', $cookie->getSameSite()); + $this->assertFalse($this->app->make('config')->get('session.http_only')); + $this->assertSame('strict', $this->app->make('config')->get('session.same_site')); + } + + public function testSanctumSessionCookieHonorsApplicationCookieCallbacks(): void + { + StartSession::configureSessionCookieUsing(function (Request $request, array $cookie): array { + $cookie['domain'] = '.example.com'; + + return $cookie; + }); + + $cookie = $this->sessionCookie($this->handleStatefulFrontendRequest()); + + $this->assertSame('.example.com', $cookie->getDomain()); + $this->assertTrue($cookie->isHttpOnly()); + $this->assertSame('lax', $cookie->getSameSite()); + } + + public function testNonSanctumSessionCookieDoesNotReceiveSanctumHardening(): void + { + $request = Request::create('http://localhost/normal', 'GET'); + + $response = $this->app->make(StartSession::class) + ->handle($request, fn () => new Response('ok')); + + $cookie = $this->sessionCookie($response); + + $this->assertFalse($cookie->isHttpOnly()); + $this->assertSame('strict', $cookie->getSameSite()); + } + + protected function handleStatefulFrontendRequest(): Response + { + $request = Request::create('http://localhost/probe', 'GET', server: [ + 'HTTP_ORIGIN' => 'https://test.com', + ]); + + return (new EnsureFrontendRequestsAreStateful) + ->handle($request, fn () => new Response('ok')); + } + + protected function sessionCookie(Response $response): Cookie + { + $sessionCookieName = $this->app->make('config')->get('session.cookie'); + + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === $sessionCookieName) { + return $cookie; + } + } + + $this->fail("Session cookie [{$sessionCookieName}] was not set."); + } +} diff --git a/tests/Sanctum/SanctumGuardStaticStateTest.php b/tests/Sanctum/SanctumGuardStaticStateTest.php new file mode 100644 index 000000000..b1411f85d --- /dev/null +++ b/tests/Sanctum/SanctumGuardStaticStateTest.php @@ -0,0 +1,22 @@ + 'ok'); + + $this->assertTrue(SanctumGuard::hasMacro('staticStateProbe')); + + SanctumGuard::flushState(); + + $this->assertFalse(SanctumGuard::hasMacro('staticStateProbe')); + } +} diff --git a/tests/Session/Middleware/StartSessionTest.php b/tests/Session/Middleware/StartSessionTest.php index aa8dff377..048ab229d 100644 --- a/tests/Session/Middleware/StartSessionTest.php +++ b/tests/Session/Middleware/StartSessionTest.php @@ -4,17 +4,18 @@ namespace Hypervel\Tests\Session\Middleware; +use Hypervel\Http\Request; use Hypervel\Session\Middleware\StartSession; -use PHPUnit\Framework\TestCase; -use ReflectionMethod; +use Hypervel\Support\ClassInvoker; +use Hypervel\Tests\TestCase; class StartSessionTest extends TestCase { - public function testGetSessionCookieConfigReturnsDefaults(): void + public function testResolveSessionCookieConfigReturnsDefaults(): void { $middleware = $this->createStartSessionMock(); - $config = $this->invokeGetSessionCookieConfig($middleware, []); + $config = $this->invokeResolveSessionCookieConfig($middleware, Request::create('/'), []); $this->assertSame('/', $config['path']); $this->assertSame('', $config['domain']); @@ -24,11 +25,11 @@ public function testGetSessionCookieConfigReturnsDefaults(): void $this->assertFalse($config['partitioned']); } - public function testGetSessionCookieConfigReturnsConfiguredValues(): void + public function testResolveSessionCookieConfigReturnsConfiguredValues(): void { $middleware = $this->createStartSessionMock(); - $config = $this->invokeGetSessionCookieConfig($middleware, [ + $config = $this->invokeResolveSessionCookieConfig($middleware, Request::create('/'), [ 'path' => '/app', 'domain' => '.example.com', 'secure' => true, @@ -45,53 +46,94 @@ public function testGetSessionCookieConfigReturnsConfiguredValues(): void $this->assertTrue($config['partitioned']); } - public function testGetSessionCookieConfigCanBeOverridden(): void + public function testSessionCookieConfigCanBeConfiguredUsingCallback(): void { - $middleware = new CustomStartSession; + $middleware = $this->createStartSessionMock(); + + StartSession::configureSessionCookieUsing(function (Request $request, array $cookie): array { + $cookie['domain'] = '.custom.example.com'; - $config = $this->invokeGetSessionCookieConfig($middleware, [ + return $cookie; + }); + + $config = $this->invokeResolveSessionCookieConfig($middleware, Request::create('/'), [ 'path' => '/', 'domain' => '.example.com', ]); - // Custom middleware overrides domain $this->assertSame('.custom.example.com', $config['domain']); - // Other values come from config $this->assertSame('/', $config['path']); } - private function createStartSessionMock(): StartSession + public function testSessionCookieConfigCallbacksReceiveRequest(): void { - return new TestStartSession; + $middleware = $this->createStartSessionMock(); + + StartSession::configureSessionCookieUsing(function (Request $request, array $cookie): array { + $cookie['domain'] = '.' . $request->getHost(); + + return $cookie; + }); + + $config = $this->invokeResolveSessionCookieConfig( + $middleware, + Request::create('https://tenant.example.com'), + [] + ); + + $this->assertSame('.tenant.example.com', $config['domain']); } - private function invokeGetSessionCookieConfig(StartSession $middleware, array $config): array + public function testSessionCookieConfigCallbacksComposeInRegistrationOrder(): void { - $method = new ReflectionMethod($middleware, 'getSessionCookieConfig'); - $method->setAccessible(true); + $middleware = $this->createStartSessionMock(); + + StartSession::configureSessionCookieUsing(function (Request $request, array $cookie): array { + $cookie['same_site'] = 'strict'; + $cookie['domain'] = '.first.example.com'; + + return $cookie; + }); + StartSession::configureSessionCookieUsing(function (Request $request, array $cookie): array { + $cookie['same_site'] = 'lax'; + + return $cookie; + }); + + $config = $this->invokeResolveSessionCookieConfig($middleware, Request::create('/'), []); - return $method->invoke($middleware, $config); + $this->assertSame('.first.example.com', $config['domain']); + $this->assertSame('lax', $config['same_site']); } -} -/** - * Custom middleware for testing getSessionCookieConfig override. - */ -class CustomStartSession extends StartSession -{ - public function __construct() + public function testFlushStateClearsSessionCookieCallbacks(): void { - // Skip parent constructor for testing + $middleware = $this->createStartSessionMock(); + + StartSession::configureSessionCookieUsing(function (Request $request, array $cookie): array { + $cookie['domain'] = '.custom.example.com'; + + return $cookie; + }); + + $this->assertSame( + '.custom.example.com', + $this->invokeResolveSessionCookieConfig($middleware, Request::create('/'), [])['domain'] + ); + + StartSession::flushState(); + + $this->assertSame('', $this->invokeResolveSessionCookieConfig($middleware, Request::create('/'), [])['domain']); } - protected function getSessionCookieConfig(array $config): array + private function createStartSessionMock(): StartSession { - $cookieConfig = parent::getSessionCookieConfig($config); - - // Override domain - $cookieConfig['domain'] = '.custom.example.com'; + return new TestStartSession; + } - return $cookieConfig; + private function invokeResolveSessionCookieConfig(StartSession $middleware, Request $request, array $config): array + { + return (new ClassInvoker($middleware))->resolveSessionCookieConfig($request, $config); } }