SessionMiddleware.php

PHP

Path: src/Session/SessionMiddleware.php

<?php

namespace mini\Session;

use mini\Mini;
use mini\Util\CacheControlHeader;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * PSR-15 middleware that adds session cookies to responses
 *
 * This middleware runs AFTER the request is handled, checking if the session
 * needs a cookie set (new session, regenerated ID, or destroy). If so, it adds
 * the Set-Cookie header to the PSR-7 response.
 *
 * This approach:
 * - Integrates with PSR-7 response handling
 * - Works in Swoole/ReactPHP/phasync (no setcookie() calls)
 * - Is testable (no direct header manipulation)
 */
class SessionMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // Process request through the rest of the chain
        $response = $handler->handle($request);

        // Check if session needs a cookie set
        try {
            $session = Mini::$mini->get(SessionInterface::class);
            $cookie = $session->getCookieToSet();

            if ($cookie !== null) {
                $response = $response->withAddedHeader('Set-Cookie', $this->formatCookie($cookie));
                $response = $this->applyCacheLimiter($response);
            }
        } catch (\Throwable) {
            // Session not available - nothing to do
        }

        return $response;
    }

    /**
     * Format cookie data as Set-Cookie header value
     *
     * @param array{name: string, value: string, options: array} $cookie
     * @return string
     */
    private function formatCookie(array $cookie): string
    {
        $parts = [
            rawurlencode($cookie['name']) . '=' . rawurlencode($cookie['value']),
        ];

        $options = $cookie['options'];

        if (isset($options['expires'])) {
            $parts[] = 'Expires=' . gmdate('D, d M Y H:i:s T', $options['expires']);
        }

        if (isset($options['path'])) {
            $parts[] = 'Path=' . $options['path'];
        }

        if (isset($options['domain'])) {
            $parts[] = 'Domain=' . $options['domain'];
        }

        // Secure flag - check if request came over HTTPS
        if (!empty($options['secure']) || $this->isSecureRequest()) {
            $parts[] = 'Secure';
        }

        if (!empty($options['httponly'])) {
            $parts[] = 'HttpOnly';
        }

        if (isset($options['samesite'])) {
            $parts[] = 'SameSite=' . $options['samesite'];
        }

        return implode('; ', $parts);
    }

    /**
     * Check if the current request is over HTTPS
     */
    private function isSecureRequest(): bool
    {
        try {
            $request = Mini::$mini->get(ServerRequestInterface::class);
            return $request->getUri()->getScheme() === 'https';
        } catch (\Throwable) {
            return false;
        }
    }

    /**
     * Apply cache control headers based on session.cache_limiter
     *
     * Prevents caching of responses that set session cookies, which could
     * leak personalized data to other users via CDN/proxy caches.
     *
     * Always restricts caching - if app set Cache-Control, we make it more
     * restrictive (never less). Setting a session cookie and caching the
     * response is always a bug.
     */
    private function applyCacheLimiter(ResponseInterface $response): ResponseInterface
    {
        $limiter = ini_get('session.cache_limiter') ?: 'nocache';

        // 'none' means don't touch cache headers at all
        if ($limiter === 'none') {
            return $response;
        }

        // Parse existing Cache-Control (if any)
        $cacheControl = new CacheControlHeader(
            $response->hasHeader('Cache-Control') ? $response->getHeaderLine('Cache-Control') : null
        );

        $maxAge = session_cache_expire() * 60;

        switch ($limiter) {
            case 'public':
                // Public caching allowed, but cap TTL
                $cacheControl = $cacheControl->withMaxTtl($maxAge);
                if (!$cacheControl->has('public') && !$cacheControl->has('private') &&
                    !$cacheControl->has('no-cache') && !$cacheControl->has('no-store')) {
                    $cacheControl = $cacheControl->with('public');
                }
                break;

            case 'private':
                // Restrict to private (browser only), cap TTL
                $cacheControl = $cacheControl
                    ->withRestrictedVisibility('private')
                    ->withMaxTtl($maxAge);
                $response = $response->withHeader('Expires', 'Thu, 19 Nov 1981 08:52:00 GMT');
                break;

            case 'private_no_expire':
                // Restrict to private, don't set max-age
                $cacheControl = $cacheControl->withRestrictedVisibility('private');
                break;

            case 'nocache':
            default:
                // Most restrictive - no caching at all
                $cacheControl = $cacheControl
                    ->withNoStore()
                    ->with('no-cache')
                    ->with('must-revalidate');
                $response = $response
                    ->withHeader('Pragma', 'no-cache')
                    ->withHeader('Expires', 'Thu, 19 Nov 1981 08:52:00 GMT');
                break;
        }

        return $response->withHeader('Cache-Control', (string) $cacheControl);
    }
}