diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..00ec317
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+/.github export-ignore
+/tests export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpstan.neon export-ignore
+/phpunit.xml export-ignore
+/CLAUDE.md export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e60768a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,60 @@
+name: CI
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+permissions:
+ contents: read
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ name: PHP ${{ matrix.php }} · ${{ matrix.deps }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - php: '8.1'
+ deps: 'lowest'
+ - php: '8.3'
+ deps: 'highest'
+ - php: '8.4'
+ deps: 'highest'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mbstring, openssl, json
+ coverage: none
+
+ - name: Validate composer.json
+ run: composer validate --strict
+
+ - name: Install dependencies
+ run: |
+ if [ "${{ matrix.deps }}" = "lowest" ]; then
+ composer update --prefer-lowest --prefer-stable --no-progress --no-interaction
+ else
+ composer update --prefer-stable --no-progress --no-interaction
+ fi
+
+ - name: Audit dependencies
+ run: composer audit
+
+ - name: Run PHPUnit
+ run: vendor/bin/phpunit
+
+ - name: Run PHPStan (Level 9)
+ if: matrix.deps == 'highest'
+ run: vendor/bin/phpstan analyse --no-progress
+
+ - name: Run PHPCS (PSR-12)
+ run: vendor/bin/phpcs --standard=PSR12 src tests
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b5054a5
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,29 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.0] - 2026-10-10
+
+### Added
+
+- **`Cors\CorsMiddleware`** / `CorsConfig` — Fetch-standard CORS: preflight answered without reaching
+ the handler (`204`, or `403` for disallowed origin/method/header), exact origin matching,
+ `Vary: Origin`, credentials only with explicit origins.
+- **`Proxy\TrustedProxyMiddleware`** / `TrustedProxyConfig` — resolves the client IP into the
+ `client_ip` attribute, honouring `X-Forwarded-For` only from trusted proxies (IPv4/IPv6 CIDR).
+- **`Headers\SecurityHeadersMiddleware`** / `SecurityHeadersConfig` — adapter for
+ `bepsvpt/secure-headers` with JSON-API defaults (HSTS, `nosniff`, `DENY`, restrictive CSP,
+ `strict-origin-when-cross-origin`).
+- **`Csrf\CsrfMiddleware`** / `CsrfConfig` — adapter for `yiisoft/csrf`; protects every
+ state-changing method, accepts the token from the body or header.
+- **`Jwt\JwtAuthMiddleware`** / `JwtConfig` — adapter for `firebase/php-jwt`; single algorithm,
+ minimum secret length, `iss`/`aud` checks, public paths, claims in the `user` attribute.
+- **`RateLimit\RateLimitMiddleware`** / `RateLimitConfig` — adapter for `symfony/rate-limiter`;
+ `429` with `Retry-After` and `X-RateLimit-*` headers, keyed by the resolved client IP.
+- `Exception\InvalidConfigurationException` and `Exception\MissingDependencyException`, both
+ implementing `Exception\SecurityException`.
+
+[0.1.0]: https://github.com/PivotPHP/pivotphp-security/releases/tag/v0.1.0
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..e0504e4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,39 @@
+# CLAUDE.md - PivotPHP Security
+
+Este arquivo orienta o trabalho no pacote **pivotphp/security**.
+
+## Visão geral
+
+Middlewares PSR-15 de segurança para o PivotPHP e qualquer pipeline PSR-15. Dependências
+obrigatórias: apenas interfaces PSR (`psr/http-message`, `psr/http-factory`,
+`psr/http-server-*`). Cada adapter depende da sua biblioteca, declarada em `suggest` e com versão
+mínima em `conflict`.
+
+| Componente | Implementação |
+|---|---|
+| `Cors` | própria (nenhuma biblioteca PSR-7 madura e ativa compatível com PSR-7 2.0) |
+| `Proxy` | própria (só existe acoplado ao HttpFoundation) |
+| `Headers` | adapter de `bepsvpt/secure-headers` |
+| `Csrf` | adapter de `yiisoft/csrf` |
+| `Jwt` | adapter de `firebase/php-jwt` |
+| `RateLimit` | adapter de `symfony/rate-limiter` (`RateLimiterFactory`; a interface só existe a partir da 7.3) |
+
+## Comandos
+
+```bash
+composer test # PHPUnit
+composer phpstan # PHPStan nível 9 (src + tests)
+composer cs:check # PSR-12
+composer quality:check # phpstan + cs:check + test
+```
+
+## Regras de design
+
+- **Um middleware por preocupação**; config em objeto imutável (`readonly`, PHP 8.1 — não usar
+ `readonly class`, que exige 8.2), validado no construtor com `InvalidConfigurationException`.
+- **Fail closed**: falha de verificação devolve resposta (`401/403/429`) criada pela
+ `ResponseFactoryInterface` injetada; nunca chama o handler. Não lançar exceção para o cliente.
+- **Não implementar criptografia**: JWT, tokens CSRF e contagem atômica ficam nas bibliotecas.
+- **Sem estado estático entre requisições** (por isso nonces de CSP não são suportados; o
+ `JWT::$leeway` do firebase é restaurado em `finally`).
+- Matriz de CI: PHP 8.1 com dependências mínimas; 8.3 e 8.4 com máximas.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2fcc058
--- /dev/null
+++ b/README.md
@@ -0,0 +1,258 @@
+# PivotPHP Security
+
+[](https://www.php.net/)
+[](LICENSE)
+[](https://www.php-fig.org/psr/psr-15/)
+
+Security middlewares for [PivotPHP](https://github.com/PivotPHP) and **any PSR-15 pipeline**.
+
+- One middleware per concern: CORS, trusted proxies, security headers, CSRF, JWT, rate limiting.
+- **Fail closed**: a failed check returns `401`/`403`/`429` and never reaches your handler.
+- **Validated configuration**: unsafe settings (`*` origin with credentials, empty JWT secret,
+ `POST` declared "safe"...) throw `InvalidConfigurationException` when the config is built.
+- **No hand-rolled crypto**: JWT, CSRF tokens, header building and rate limiting delegate to
+ established libraries; this package provides the PSR-15 adapters.
+
+The only required dependencies are the PSR interfaces. Each adapter needs its library, installed on
+demand:
+
+| Middleware | Library | Install |
+|---|---|---|
+| `Cors\CorsMiddleware` | — | — |
+| `Proxy\TrustedProxyMiddleware` | — | — |
+| `Headers\SecurityHeadersMiddleware` | [bepsvpt/secure-headers](https://github.com/bepsvpt/secure-headers) | `composer require bepsvpt/secure-headers` |
+| `Csrf\CsrfMiddleware` | [yiisoft/csrf](https://github.com/yiisoft/csrf) | `composer require yiisoft/csrf` |
+| `Jwt\JwtAuthMiddleware` | [firebase/php-jwt](https://github.com/firebase/php-jwt) | `composer require firebase/php-jwt` |
+| `RateLimit\RateLimitMiddleware` | [symfony/rate-limiter](https://symfony.com/doc/current/rate_limiter.html) | `composer require symfony/rate-limiter` |
+
+## Installation
+
+```bash
+composer require pivotphp/security
+```
+
+Middlewares that build responses take a PSR-17 `ResponseFactoryInterface` (e.g.
+`PivotPHP\Http\Factory\Psr17Factory` or `Nyholm\Psr7\Factory\Psr17Factory`).
+
+## Recommended order
+
+```text
+TrustedProxy → SecurityHeaders → CORS → RateLimit → (body parsing) → CSRF → JWT → routes
+```
+
+- **TrustedProxy first**, so every later middleware sees the real client IP.
+- **SecurityHeaders and CORS before anything that rejects**, so `401`/`403`/`429` responses still
+ carry security and CORS headers (the browser can read the error).
+- **CORS before authentication**: preflight requests carry no credentials.
+- **Body parsing before CSRF**, so form fields are visible.
+
+## CORS
+
+```php
+use PivotPHP\Security\Cors\CorsConfig;
+use PivotPHP\Security\Cors\CorsMiddleware;
+
+$cors = new CorsMiddleware($responseFactory, new CorsConfig(
+ allowedOrigins: ['https://app.example.com'],
+ allowCredentials: true,
+));
+```
+
+| Option | Default | Notes |
+|---|---|---|
+| `allowedOrigins` | `[]` | Exact `scheme://host[:port]`, lower case, no path. `['*']` allows any origin. |
+| `allowedMethods` | `GET, POST, PUT, PATCH, DELETE` | |
+| `allowedHeaders` | `Content-Type, Authorization` | Request headers accepted in preflight. |
+| `exposedHeaders` | `[]` | Response headers readable by the browser. |
+| `allowCredentials` | `false` | |
+| `maxAge` | `600` | Preflight cache in seconds; `null` omits the header. |
+
+Behaviour:
+
+- Requests without `Origin` pass through untouched.
+- Preflight (`OPTIONS` + `Access-Control-Request-Method`) is answered by the middleware: `204` with
+ `Access-Control-Allow-*` when origin, method and headers are allowed; `403` otherwise. The handler
+ is never called.
+- Actual requests always reach the handler; `Access-Control-Allow-Origin` is added only for allowed
+ origins. `Vary: Origin` is added whenever the response depends on the origin.
+
+> ⚠️ **Security:** `*` with `allowCredentials: true` is rejected at configuration. The `null`
+> origin (sandboxed iframes, `file://`) cannot be allowed. CORS does not protect the server — it
+> only tells browsers what other origins may read; authenticate and authorize every request.
+
+## Trusted proxies
+
+```php
+use PivotPHP\Security\Proxy\TrustedProxyConfig;
+use PivotPHP\Security\Proxy\TrustedProxyMiddleware;
+
+$proxy = new TrustedProxyMiddleware(new TrustedProxyConfig(
+ trustedProxies: ['10.0.0.0/8', '2001:db8::/32'],
+));
+
+// later: $request->getAttribute('client_ip')
+```
+
+| Option | Default | Notes |
+|---|---|---|
+| `trustedProxies` | `[]` | IPv4/IPv6 addresses or CIDR ranges. |
+| `header` | `X-Forwarded-For` | Comma-separated IP chain. |
+| `attribute` | `client_ip` | Attribute receiving the resolved IP. |
+
+The forwarding header is honoured **only** when `REMOTE_ADDR` is a trusted proxy. The chain is read
+right to left, skipping trusted proxies; the first untrusted address is the client. A malformed entry
+stops the walk. The attribute is not set when `REMOTE_ADDR` is missing or invalid.
+
+> ⚠️ **Security:** never trust `X-Forwarded-For` from the internet. List only the proxies you run
+> (load balancer, reverse proxy).
+
+## Security headers
+
+```php
+use PivotPHP\Security\Headers\SecurityHeadersConfig;
+use PivotPHP\Security\Headers\SecurityHeadersMiddleware;
+
+$headers = new SecurityHeadersMiddleware(); // API defaults
+```
+
+Default headers (JSON API, OWASP REST guidance):
+
+```text
+Strict-Transport-Security: max-age=31536000; includeSubDomains
+X-Content-Type-Options: nosniff
+X-Frame-Options: DENY
+Referrer-Policy: strict-origin-when-cross-origin
+Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
+X-Permitted-Cross-Domain-Policies: none
+```
+
+| Option | Default | Notes |
+|---|---|---|
+| `hsts`, `hstsMaxAge`, `hstsIncludeSubDomains`, `hstsPreload` | `true`, `31536000`, `true`, `false` | Preload requires subdomains and max-age ≥ 1 year. |
+| `frameOptions` | `deny` | `deny` or `sameorigin`. |
+| `referrerPolicy` | `strict-origin-when-cross-origin` | Any standard value. |
+| `contentSecurityPolicy` | `default-src 'none'; frame-ancestors 'none'` | `bepsvpt/secure-headers` CSP array; `null` disables. |
+| `overrides` | `[]` | Raw `bepsvpt/secure-headers` config (Permissions-Policy, COOP/COEP/CORP, reporting...). |
+
+```php
+new SecurityHeadersConfig(contentSecurityPolicy: [
+ 'default-src' => ['self' => true],
+ 'img-src' => ['allow' => ['https://cdn.example.com']],
+]);
+```
+
+Headers already set by the application are kept. The deprecated `X-XSS-Protection` is not sent.
+CSP nonces are not supported (the library keeps them in static state shared across requests).
+
+> ⚠️ **Security:** an HTML application needs a CSP that matches its assets; the default is meant for
+> APIs. Enable HSTS only on HTTPS-only hosts.
+
+## CSRF
+
+Needed when the API authenticates with **cookies** (session or JWT in a cookie). Pure bearer-token
+APIs are not exposed to CSRF.
+
+```php
+use PivotPHP\Security\Csrf\CsrfMiddleware;
+use Yiisoft\Csrf\Hmac\HmacCsrfToken;
+use Yiisoft\Csrf\MaskedCsrfToken;
+
+$token = new MaskedCsrfToken(new HmacCsrfToken($identityGenerator, $secret, 'sha256', 3600));
+$csrf = new CsrfMiddleware($responseFactory, $token);
+
+// render: $request->getAttribute('csrf_token')->getValue()
+```
+
+Token strategies come from `yiisoft/csrf`: `SynchronizerCsrfToken` (random token in a storage, e.g.
+the session) or `HmacCsrfToken` (stateless, bound to an identity such as the session id). Wrap either
+in `MaskedCsrfToken` when the token is rendered in HTML (BREACH mitigation).
+
+| Option (`CsrfConfig`) | Default | Notes |
+|---|---|---|
+| `parameterName` | `_csrf` | Form field. |
+| `headerName` | `X-CSRF-Token` | Header (SPA/AJAX). |
+| `safeMethods` | `GET, HEAD, OPTIONS` | `POST/PUT/PATCH/DELETE` cannot be declared safe. |
+| `attribute` | `csrf_token` | Attribute holding the token object. |
+| `failureStatus` | `403` | Any 4xx. |
+
+Every other method needs a valid token in the body field or header; otherwise the response is the
+failure status and the handler is not called.
+
+> ⚠️ **Security:** keep `GET` free of side effects — safe methods are never checked. Combine with
+> `SameSite` cookies.
+
+## JWT authentication
+
+```php
+use PivotPHP\Security\Jwt\JwtAuthMiddleware;
+use PivotPHP\Security\Jwt\JwtConfig;
+
+$auth = new JwtAuthMiddleware($responseFactory, new JwtConfig(
+ key: $_ENV['JWT_SECRET'], // ≥ 32 bytes for HS256
+ algorithm: 'HS256',
+ publicPaths: ['/health', '/docs/*'],
+));
+
+// in the handler: $request->getAttribute('user') — the claims as an array
+```
+
+| Option | Default | Notes |
+|---|---|---|
+| `key` | — | Secret (HS*) or public key / PEM (RS*, PS256, ES*, EdDSA). |
+| `algorithm` | `HS256` | Exactly one algorithm is accepted. |
+| `publicPaths` | `[]` | `fnmatch` patterns that skip authentication. |
+| `cookieName` | `null` | Cookie read when `Authorization` is absent. |
+| `issuer` | `null` | Required `iss`. |
+| `audience` | `[]` | Accepted `aud` values (any match). |
+| `leeway` | `0` | Clock skew in seconds. |
+| `attribute` | `user` | Attribute receiving the claims. |
+
+Signature, `exp`, `nbf` and `iat` are verified by `firebase/php-jwt` (non-numeric time claims are
+rejected); `iss`/`aud` are verified here. Failures return `401` with `WWW-Authenticate: Bearer`.
+
+> ⚠️ **Security:** empty secrets, secrets shorter than the hash output (32/48/64 bytes) and unknown
+> algorithms are rejected at configuration. Load keys from the environment or a secret store, never
+> from source code.
+
+## Rate limiting
+
+```php
+use PivotPHP\Security\RateLimit\RateLimitConfig;
+use PivotPHP\Security\RateLimit\RateLimitMiddleware;
+use Symfony\Component\RateLimiter\RateLimiterFactory;
+
+$limiter = new RateLimiterFactory(
+ ['id' => 'login', 'policy' => 'sliding_window', 'limit' => 5, 'interval' => '1 minute'],
+ $storage, // e.g. CacheStorage over Redis
+ $lockFactory, // symfony/lock — required for correct counts under concurrency
+);
+
+$loginLimit = new RateLimitMiddleware($responseFactory, $limiter, new RateLimitConfig(keyPrefix: 'login:'));
+```
+
+| Option (`RateLimitConfig`) | Default | Notes |
+|---|---|---|
+| `keyPrefix` | `''` | One prefix per limit/route group. |
+| `clientIpAttribute` | `client_ip` | Set by `TrustedProxyMiddleware`; falls back to `REMOTE_ADDR`. |
+| `exposeHeaders` | `true` | `X-RateLimit-Limit`/`X-RateLimit-Remaining` on accepted responses. |
+
+A fourth constructor argument accepts a custom key resolver
+(`fn (ServerRequestInterface $r): ?string`, e.g. an API key). Over the limit: `429` with
+`Retry-After`, `X-RateLimit-Limit` and `X-RateLimit-Remaining: 0`; the handler is not called.
+Use one middleware instance per limit (e.g. a stricter one on `/login`).
+
+> ⚠️ **Security:** with `InMemoryStorage` or without a `LockFactory`, limits are per process and
+> concurrent requests can exceed them. Use a shared storage and a lock in production.
+
+## Testing
+
+```bash
+composer test # PHPUnit
+composer phpstan # PHPStan level 9
+composer cs:check # PSR-12
+composer quality:check # all of the above
+```
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..8c8e4ad
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,84 @@
+{
+ "name": "pivotphp/security",
+ "description": "Security middlewares for PivotPHP and any PSR-15 pipeline: CORS, trusted proxies, security headers, CSRF, JWT and rate limiting",
+ "type": "library",
+ "keywords": [
+ "php",
+ "security",
+ "psr-15",
+ "middleware",
+ "cors",
+ "csrf",
+ "jwt",
+ "rate-limit",
+ "security-headers",
+ "pivotphp"
+ ],
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Caio Alberto Fernandes"
+ }
+ ],
+ "support": {
+ "issues": "https://github.com/PivotPHP/pivotphp-security/issues",
+ "source": "https://github.com/PivotPHP/pivotphp-security"
+ },
+ "require": {
+ "php": "^8.1",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1|^2.0",
+ "psr/http-server-handler": "^1.0",
+ "psr/http-server-middleware": "^1.0"
+ },
+ "require-dev": {
+ "bepsvpt/secure-headers": "^9.1",
+ "firebase/php-jwt": "^7.1.1",
+ "nyholm/psr7": "^1.8",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^10.5|^11.0",
+ "roave/security-advisories": "dev-latest",
+ "squizlabs/php_codesniffer": "^3.7",
+ "symfony/rate-limiter": "^6.4|^7.0|^8.0",
+ "yiisoft/csrf": "^2.2"
+ },
+ "suggest": {
+ "bepsvpt/secure-headers": "Required by Headers\\SecurityHeadersMiddleware (^9.1)",
+ "firebase/php-jwt": "Required by Jwt\\JwtAuthMiddleware (^7.1.1)",
+ "symfony/rate-limiter": "Required by RateLimit\\RateLimitMiddleware (^6.4|^7.0|^8.0)",
+ "yiisoft/csrf": "Required by Csrf\\CsrfMiddleware (^2.2)"
+ },
+ "conflict": {
+ "bepsvpt/secure-headers": "<9.1",
+ "firebase/php-jwt": "<7.1.1",
+ "symfony/rate-limiter": "<6.4",
+ "yiisoft/csrf": "<2.2"
+ },
+ "autoload": {
+ "psr-4": {
+ "PivotPHP\\Security\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "PivotPHP\\Security\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit",
+ "phpstan": "phpstan analyse --no-progress",
+ "cs:check": "phpcs --standard=PSR12 src tests",
+ "cs:fix": "phpcbf --standard=PSR12 src tests",
+ "quality:check": [
+ "@phpstan",
+ "@cs:check",
+ "@test"
+ ]
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true,
+ "config": {
+ "sort-packages": true
+ }
+}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..8f2ee06
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,9 @@
+includes:
+ - vendor/phpstan/phpstan-strict-rules/rules.neon
+
+parameters:
+ level: 9
+ paths:
+ - src
+ - tests
+ treatPhpDocTypesAsCertain: false
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..ed49bf3
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Cors/CorsConfig.php b/src/Cors/CorsConfig.php
new file mode 100644
index 0000000..c621108
--- /dev/null
+++ b/src/Cors/CorsConfig.php
@@ -0,0 +1,122 @@
+ */
+ public readonly array $allowedOrigins;
+
+ /** @var list */
+ public readonly array $allowedMethods;
+
+ /** @var list lower-cased */
+ public readonly array $allowedHeaders;
+
+ /** @var list */
+ public readonly array $exposedHeaders;
+
+ /**
+ * @param list $allowedOrigins exact origins, or `['*']` for any
+ * @param list $allowedMethods
+ * @param list $allowedHeaders request headers allowed in preflight
+ * @param list $exposedHeaders response headers readable by the browser
+ * @param int|null $maxAge preflight cache in seconds (null omits the header)
+ */
+ public function __construct(
+ array $allowedOrigins = [],
+ array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
+ array $allowedHeaders = ['Content-Type', 'Authorization'],
+ array $exposedHeaders = [],
+ public readonly bool $allowCredentials = false,
+ public readonly ?int $maxAge = 600,
+ ) {
+ foreach ($allowedOrigins as $origin) {
+ self::assertOrigin($origin);
+ }
+
+ if ($allowCredentials && in_array('*', $allowedOrigins, true)) {
+ throw new InvalidConfigurationException(
+ 'CORS: "*" cannot be used as an allowed origin when credentials are allowed; '
+ . 'list the origins explicitly.'
+ );
+ }
+
+ if ($maxAge !== null && $maxAge < 0) {
+ throw new InvalidConfigurationException('CORS: maxAge must be zero or positive.');
+ }
+
+ $this->allowedOrigins = array_values(array_unique($allowedOrigins));
+ $this->allowedMethods = array_values(array_unique(array_map(
+ static fn (string $method): string => self::assertToken(strtoupper($method), 'method'),
+ $allowedMethods
+ )));
+ $this->allowedHeaders = array_values(array_unique(array_map(
+ static fn (string $header): string => self::assertToken(strtolower($header), 'header'),
+ $allowedHeaders
+ )));
+ $this->exposedHeaders = array_values(array_unique(array_map(
+ static fn (string $header): string => self::assertToken($header, 'exposed header'),
+ $exposedHeaders
+ )));
+ }
+
+ public function allowsAnyOrigin(): bool
+ {
+ return in_array('*', $this->allowedOrigins, true);
+ }
+
+ public function allowsOrigin(string $origin): bool
+ {
+ return $this->allowsAnyOrigin() || in_array($origin, $this->allowedOrigins, true);
+ }
+
+ private static function assertOrigin(string $origin): void
+ {
+ if ($origin === '*') {
+ return;
+ }
+
+ if ($origin === 'null') {
+ throw new InvalidConfigurationException(
+ 'CORS: the "null" origin is sent by sandboxed documents and file:// pages and must not be allowed.'
+ );
+ }
+
+ $parts = parse_url($origin);
+ $isValid = is_array($parts)
+ && isset($parts['scheme'], $parts['host'])
+ && in_array($parts['scheme'], ['http', 'https'], true)
+ && !isset($parts['path'])
+ && !isset($parts['query'])
+ && !isset($parts['fragment'])
+ && !isset($parts['user'])
+ && $origin === strtolower($origin);
+
+ if (!$isValid) {
+ throw new InvalidConfigurationException(sprintf(
+ 'CORS: invalid origin "%s"; expected lower-case "scheme://host[:port]" without path or trailing slash.',
+ $origin
+ ));
+ }
+ }
+
+ private static function assertToken(string $value, string $kind): string
+ {
+ if (preg_match('/^[!#$%&\'*+.^_`|~0-9A-Za-z-]+$/', $value) !== 1) {
+ throw new InvalidConfigurationException(sprintf('CORS: invalid %s "%s".', $kind, $value));
+ }
+
+ return $value;
+ }
+}
diff --git a/src/Cors/CorsMiddleware.php b/src/Cors/CorsMiddleware.php
new file mode 100644
index 0000000..4733dea
--- /dev/null
+++ b/src/Cors/CorsMiddleware.php
@@ -0,0 +1,169 @@
+getHeaderLine('Origin');
+
+ if ($origin === '') {
+ return $handler->handle($request);
+ }
+
+ if ($this->isPreflight($request)) {
+ return $this->preflight($request, $origin);
+ }
+
+ $response = $handler->handle($request);
+
+ if (!$this->config->allowsOrigin($origin)) {
+ return $this->vary($response, ['Origin']);
+ }
+
+ $response = $this->withAllowOrigin($response, $origin);
+
+ if ($this->config->exposedHeaders !== []) {
+ $response = $response->withHeader(
+ 'Access-Control-Expose-Headers',
+ implode(', ', $this->config->exposedHeaders)
+ );
+ }
+
+ return $response;
+ }
+
+ private function isPreflight(ServerRequestInterface $request): bool
+ {
+ return strtoupper($request->getMethod()) === 'OPTIONS'
+ && $request->hasHeader('Access-Control-Request-Method');
+ }
+
+ private function preflight(ServerRequestInterface $request, string $origin): ResponseInterface
+ {
+ $vary = ['Origin', 'Access-Control-Request-Method', 'Access-Control-Request-Headers'];
+
+ if (!$this->config->allowsOrigin($origin)) {
+ return $this->vary($this->forbidden('CORS origin not allowed'), $vary);
+ }
+
+ $method = strtoupper($request->getHeaderLine('Access-Control-Request-Method'));
+ if (!in_array($method, $this->config->allowedMethods, true)) {
+ return $this->vary($this->forbidden('CORS method not allowed'), $vary);
+ }
+
+ $requestedHeaders = $this->requestedHeaders($request);
+ foreach ($requestedHeaders as $header) {
+ if (!in_array($header, $this->config->allowedHeaders, true)) {
+ return $this->vary($this->forbidden('CORS header not allowed'), $vary);
+ }
+ }
+
+ $response = $this->withAllowOrigin($this->responseFactory->createResponse(204), $origin)
+ ->withHeader('Access-Control-Allow-Methods', implode(', ', $this->config->allowedMethods));
+
+ if ($this->config->allowedHeaders !== []) {
+ $response = $response->withHeader(
+ 'Access-Control-Allow-Headers',
+ implode(', ', $this->config->allowedHeaders)
+ );
+ }
+
+ if ($this->config->maxAge !== null) {
+ $response = $response->withHeader('Access-Control-Max-Age', (string) $this->config->maxAge);
+ }
+
+ return $this->vary($response, $vary);
+ }
+
+ /**
+ * @return list lower-cased header names
+ */
+ private function requestedHeaders(ServerRequestInterface $request): array
+ {
+ $line = $request->getHeaderLine('Access-Control-Request-Headers');
+ if (trim($line) === '') {
+ return [];
+ }
+
+ return array_values(array_filter(array_map(
+ static fn (string $header): string => strtolower(trim($header)),
+ explode(',', $line)
+ ), static fn (string $header): bool => $header !== ''));
+ }
+
+ private function withAllowOrigin(ResponseInterface $response, string $origin): ResponseInterface
+ {
+ if ($this->config->allowsAnyOrigin()) {
+ return $response->withHeader('Access-Control-Allow-Origin', '*');
+ }
+
+ $response = $this->vary($response->withHeader('Access-Control-Allow-Origin', $origin), ['Origin']);
+
+ if ($this->config->allowCredentials) {
+ $response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
+ }
+
+ return $response;
+ }
+
+ private function forbidden(string $message): ResponseInterface
+ {
+ return ErrorResponse::create($this->responseFactory, 403, $message);
+ }
+
+ /**
+ * Adds header names to `Vary` without duplicating existing entries.
+ *
+ * @param list $names
+ */
+ private function vary(ResponseInterface $response, array $names): ResponseInterface
+ {
+ if ($this->config->allowsAnyOrigin()) {
+ return $response;
+ }
+
+ $current = array_values(array_filter(array_map(
+ 'trim',
+ explode(',', $response->getHeaderLine('Vary'))
+ ), static fn (string $value): bool => $value !== ''));
+
+ $lower = array_map('strtolower', $current);
+ foreach ($names as $name) {
+ if (!in_array(strtolower($name), $lower, true)) {
+ $current[] = $name;
+ $lower[] = strtolower($name);
+ }
+ }
+
+ return $response->withHeader('Vary', implode(', ', $current));
+ }
+}
diff --git a/src/Csrf/CsrfConfig.php b/src/Csrf/CsrfConfig.php
new file mode 100644
index 0000000..2684a6b
--- /dev/null
+++ b/src/Csrf/CsrfConfig.php
@@ -0,0 +1,50 @@
+ */
+ public readonly array $safeMethods;
+
+ /**
+ * @param list $safeMethods methods that never require a token
+ * @param string $attribute request attribute receiving the `CsrfTokenInterface`
+ */
+ public function __construct(
+ public readonly string $parameterName = '_csrf',
+ public readonly string $headerName = 'X-CSRF-Token',
+ array $safeMethods = ['GET', 'HEAD', 'OPTIONS'],
+ public readonly string $attribute = 'csrf_token',
+ public readonly int $failureStatus = 403,
+ ) {
+ if ($parameterName === '' || $headerName === '' || $attribute === '') {
+ throw new InvalidConfigurationException('CSRF: parameterName, headerName and attribute must not be empty.');
+ }
+
+ $safeMethods = array_values(array_unique(array_map('strtoupper', $safeMethods)));
+ $unsafe = array_intersect($safeMethods, ['POST', 'PUT', 'PATCH', 'DELETE']);
+ if ($unsafe !== []) {
+ throw new InvalidConfigurationException(sprintf(
+ 'CSRF: state-changing methods cannot be declared safe (%s).',
+ implode(', ', $unsafe)
+ ));
+ }
+
+ if ($failureStatus < 400 || $failureStatus > 499) {
+ throw new InvalidConfigurationException('CSRF: failureStatus must be a 4xx status code.');
+ }
+
+ $this->safeMethods = $safeMethods;
+ }
+}
diff --git a/src/Csrf/CsrfMiddleware.php b/src/Csrf/CsrfMiddleware.php
new file mode 100644
index 0000000..fa48079
--- /dev/null
+++ b/src/Csrf/CsrfMiddleware.php
@@ -0,0 +1,93 @@
+config = $config ?? new CsrfConfig();
+
+ $failure = new class ($responseFactory, $this->config->failureStatus) implements RequestHandlerInterface {
+ public function __construct(
+ private readonly ResponseFactoryInterface $factory,
+ private readonly int $status,
+ ) {
+ }
+
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return ErrorResponse::create($this->factory, $this->status, 'Invalid CSRF token');
+ }
+ };
+
+ $this->inner = (new CsrfTokenMiddleware($responseFactory, $token, $failure))
+ ->withParameterName($this->config->parameterName)
+ ->withHeaderName($this->config->headerName)
+ ->withSafeMethods($this->config->safeMethods);
+ }
+
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $request = $request->withAttribute($this->config->attribute, $this->token);
+
+ // yiisoft/csrf reads the parsed body as an array; PSR-7 also allows objects.
+ // Validate against an array view, but hand the original request to the handler.
+ $body = $request->getParsedBody();
+ if (!is_object($body)) {
+ return $this->inner->process($request, $handler);
+ }
+
+ $original = $request;
+ $next = new class ($handler, $original) implements RequestHandlerInterface {
+ public function __construct(
+ private readonly RequestHandlerInterface $handler,
+ private readonly ServerRequestInterface $original,
+ ) {
+ }
+
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return $this->handler->handle($this->original);
+ }
+ };
+
+ return $this->inner->process($request->withParsedBody(get_object_vars($body)), $next);
+ }
+}
diff --git a/src/Exception/InvalidConfigurationException.php b/src/Exception/InvalidConfigurationException.php
new file mode 100644
index 0000000..26d2aa5
--- /dev/null
+++ b/src/Exception/InvalidConfigurationException.php
@@ -0,0 +1,17 @@
+ */
+ public readonly array $contentSecurityPolicy;
+
+ /** @var array */
+ public readonly array $overrides;
+
+ /**
+ * @param array|null $contentSecurityPolicy CSP in bepsvpt/secure-headers format
+ * (e.g. `['default-src' => ['self' => true]]`); `null` disables the header
+ * @param array $overrides raw bepsvpt/secure-headers configuration
+ */
+ public function __construct(
+ public readonly bool $hsts = true,
+ public readonly int $hstsMaxAge = 31536000,
+ public readonly bool $hstsIncludeSubDomains = true,
+ public readonly bool $hstsPreload = false,
+ public readonly string $frameOptions = 'deny',
+ public readonly string $referrerPolicy = 'strict-origin-when-cross-origin',
+ ?array $contentSecurityPolicy = [
+ 'default-src' => ['none' => true],
+ 'frame-ancestors' => ['none' => true],
+ ],
+ array $overrides = [],
+ ) {
+ if ($hstsMaxAge < 0) {
+ throw new InvalidConfigurationException('Security headers: hstsMaxAge must be zero or positive.');
+ }
+
+ if ($hstsPreload && (!$hstsIncludeSubDomains || $hstsMaxAge < 31536000)) {
+ throw new InvalidConfigurationException(
+ 'Security headers: HSTS preload requires includeSubDomains and a max-age of at least 31536000.'
+ );
+ }
+
+ if (!in_array(strtolower($frameOptions), ['deny', 'sameorigin'], true)) {
+ throw new InvalidConfigurationException('Security headers: frameOptions must be "deny" or "sameorigin".');
+ }
+
+ $policies = [
+ 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin',
+ 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url',
+ ];
+ if (!in_array($referrerPolicy, $policies, true)) {
+ throw new InvalidConfigurationException(sprintf(
+ 'Security headers: invalid referrerPolicy "%s".',
+ $referrerPolicy
+ ));
+ }
+
+ $this->contentSecurityPolicy = $contentSecurityPolicy === null
+ ? ['enable' => false]
+ : ['enable' => true] + $contentSecurityPolicy;
+ $this->overrides = $overrides;
+ }
+
+ /**
+ * Configuration array consumed by `bepsvpt/secure-headers`.
+ *
+ * @return array
+ */
+ public function toLibraryConfig(): array
+ {
+ return [
+ 'x-content-type-options' => 'nosniff',
+ 'x-frame-options' => strtoupper($this->frameOptions),
+ 'x-xss-protection' => '',
+ 'x-download-options' => '',
+ 'x-permitted-cross-domain-policies' => 'none',
+ 'referrer-policy' => $this->referrerPolicy,
+ 'cross-origin-embedder-policy' => '',
+ 'cross-origin-opener-policy' => '',
+ 'cross-origin-resource-policy' => '',
+ 'permissions-policy' => ['enable' => false],
+ 'hsts' => [
+ 'enable' => $this->hsts,
+ 'max-age' => $this->hstsMaxAge,
+ 'include-sub-domains' => $this->hstsIncludeSubDomains,
+ 'preload' => $this->hstsPreload,
+ ],
+ 'csp' => $this->contentSecurityPolicy,
+ ];
+ }
+}
diff --git a/src/Headers/SecurityHeadersMiddleware.php b/src/Headers/SecurityHeadersMiddleware.php
new file mode 100644
index 0000000..1540ef1
--- /dev/null
+++ b/src/Headers/SecurityHeadersMiddleware.php
@@ -0,0 +1,92 @@
+ */
+ private readonly array $headers;
+
+ public function __construct(?SecurityHeadersConfig $config = null)
+ {
+ if (!class_exists(SecureHeaders::class)) {
+ throw MissingDependencyException::forPackage(self::class, 'bepsvpt/secure-headers');
+ }
+
+ $config ??= new SecurityHeadersConfig();
+
+ $libraryConfig = array_replace_recursive(
+ self::libraryDefaults(),
+ $config->toLibraryConfig(),
+ $config->overrides
+ );
+
+ $headers = [];
+ foreach ((new SecureHeaders($libraryConfig))->headers() as $name => $value) {
+ if (is_string($name) && is_string($value) && $value !== '') {
+ $headers[$name] = $value;
+ }
+ }
+
+ $this->headers = $headers;
+ }
+
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $response = $handler->handle($request);
+
+ foreach ($this->headers as $name => $value) {
+ if (!$response->hasHeader($name)) {
+ $response = $response->withHeader($name, $value);
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * Headers this middleware adds (useful for inspection and tests).
+ *
+ * @return array
+ */
+ public function headers(): array
+ {
+ return $this->headers;
+ }
+
+ /**
+ * The library reads several keys without defaults, so its shipped
+ * configuration is used as the base.
+ *
+ * @return array
+ */
+ private static function libraryDefaults(): array
+ {
+ $file = dirname((string) (new ReflectionClass(SecureHeaders::class))->getFileName(), 2)
+ . '/config/secure-headers.php';
+
+ /** @var array $defaults */
+ $defaults = require $file;
+
+ return $defaults;
+ }
+}
diff --git a/src/Jwt/JwtAuthMiddleware.php b/src/Jwt/JwtAuthMiddleware.php
new file mode 100644
index 0000000..952ced6
--- /dev/null
+++ b/src/Jwt/JwtAuthMiddleware.php
@@ -0,0 +1,137 @@
+`, then from the
+ * configured cookie.
+ * - Signature, algorithm, `exp`, `nbf` and `iat` are verified by
+ * `firebase/php-jwt`; `iss` and `aud` are verified here when configured.
+ * - On success the claims (as an array) are stored in the `user` attribute and
+ * the **same** request is passed to the handler.
+ * - Any failure returns `401` with `WWW-Authenticate: Bearer` and never
+ * reaches the handler.
+ */
+final class JwtAuthMiddleware implements MiddlewareInterface
+{
+ public function __construct(
+ private readonly ResponseFactoryInterface $responseFactory,
+ private readonly JwtConfig $config,
+ ) {
+ if (!class_exists(JWT::class)) {
+ throw MissingDependencyException::forPackage(self::class, 'firebase/php-jwt');
+ }
+ }
+
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ if ($this->config->isPublicPath($request->getUri()->getPath())) {
+ return $handler->handle($request);
+ }
+
+ $token = $this->extractToken($request);
+ if ($token === null) {
+ return $this->unauthorized('Bearer realm="api"', 'Authentication required');
+ }
+
+ $claims = $this->decode($token);
+ if ($claims === null || !$this->hasValidIssuer($claims) || !$this->hasValidAudience($claims)) {
+ return $this->unauthorized('Bearer realm="api", error="invalid_token"', 'Invalid token');
+ }
+
+ return $handler->handle($request->withAttribute($this->config->attribute, $claims));
+ }
+
+ private function extractToken(ServerRequestInterface $request): ?string
+ {
+ $authorization = $request->getHeaderLine('Authorization');
+ if (preg_match('/^Bearer\s+(\S+)$/i', $authorization, $matches) === 1) {
+ return $matches[1];
+ }
+
+ if ($this->config->cookieName !== null) {
+ $cookie = $request->getCookieParams()[$this->config->cookieName] ?? null;
+ if (is_string($cookie) && $cookie !== '') {
+ return $cookie;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array|null
+ */
+ private function decode(string $token): ?array
+ {
+ $previousLeeway = JWT::$leeway;
+ JWT::$leeway = $this->config->leeway;
+
+ try {
+ $payload = JWT::decode($token, new Key($this->config->key, $this->config->algorithm));
+ $claims = json_decode((string) json_encode($payload, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
+ } catch (Throwable) {
+ return null;
+ } finally {
+ JWT::$leeway = $previousLeeway;
+ }
+
+ if (!is_array($claims)) {
+ return null;
+ }
+
+ /** @var array $claims */
+ return $claims;
+ }
+
+ /**
+ * @param array $claims
+ */
+ private function hasValidIssuer(array $claims): bool
+ {
+ return $this->config->issuer === null || ($claims['iss'] ?? null) === $this->config->issuer;
+ }
+
+ /**
+ * @param array $claims
+ */
+ private function hasValidAudience(array $claims): bool
+ {
+ if ($this->config->audience === []) {
+ return true;
+ }
+
+ $aud = $claims['aud'] ?? null;
+ $values = is_array($aud) ? $aud : [$aud];
+
+ foreach ($values as $value) {
+ if (is_string($value) && in_array($value, $this->config->audience, true)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function unauthorized(string $challenge, string $message): ResponseInterface
+ {
+ return ErrorResponse::create($this->responseFactory, 401, $message)
+ ->withHeader('WWW-Authenticate', $challenge);
+ }
+}
diff --git a/src/Jwt/JwtConfig.php b/src/Jwt/JwtConfig.php
new file mode 100644
index 0000000..d7fc827
--- /dev/null
+++ b/src/Jwt/JwtConfig.php
@@ -0,0 +1,99 @@
+ 32, 'HS384' => 48, 'HS512' => 64];
+
+ private const ALGORITHMS = [
+ 'HS256', 'HS384', 'HS512',
+ 'RS256', 'RS384', 'RS512',
+ 'PS256',
+ 'ES256', 'ES384', 'ES256K',
+ 'EdDSA',
+ ];
+
+ /** @var list */
+ public readonly array $publicPaths;
+
+ /** @var list */
+ public readonly array $audience;
+
+ /**
+ * @param string|OpenSSLAsymmetricKey|OpenSSLCertificate $key verification key
+ * (secret for HS*, public key for the other algorithms)
+ * @param list $publicPaths paths that skip authentication (`fnmatch` patterns, e.g. `/health`, `/docs/*`)
+ * @param string|null $cookieName cookie read when the `Authorization` header is absent
+ * @param string|null $issuer required `iss` claim
+ * @param list $audience accepted `aud` values (any match)
+ * @param int $leeway clock skew tolerance in seconds for `exp`/`nbf`/`iat`
+ */
+ public function __construct(
+ #[\SensitiveParameter]
+ public readonly string|OpenSSLAsymmetricKey|OpenSSLCertificate $key,
+ public readonly string $algorithm = 'HS256',
+ array $publicPaths = [],
+ public readonly ?string $cookieName = null,
+ public readonly ?string $issuer = null,
+ array $audience = [],
+ public readonly int $leeway = 0,
+ public readonly string $attribute = 'user',
+ ) {
+ if (!in_array($algorithm, self::ALGORITHMS, true)) {
+ throw new InvalidConfigurationException(sprintf('JWT: unsupported algorithm "%s".', $algorithm));
+ }
+
+ if (is_string($key)) {
+ if ($key === '') {
+ throw new InvalidConfigurationException('JWT: the key must not be empty.');
+ }
+
+ $minBytes = self::HMAC_MIN_BYTES[$algorithm] ?? 0;
+ if (strlen($key) < $minBytes) {
+ throw new InvalidConfigurationException(sprintf(
+ 'JWT: %s secrets must be at least %d bytes long.',
+ $algorithm,
+ $minBytes
+ ));
+ }
+ } elseif (isset(self::HMAC_MIN_BYTES[$algorithm])) {
+ throw new InvalidConfigurationException('JWT: HMAC algorithms require a string secret.');
+ }
+
+ if ($leeway < 0) {
+ throw new InvalidConfigurationException('JWT: leeway must be zero or positive.');
+ }
+
+ if ($attribute === '' || $cookieName === '' || $issuer === '') {
+ throw new InvalidConfigurationException('JWT: attribute, cookieName and issuer must not be empty strings.');
+ }
+
+ $this->publicPaths = array_values($publicPaths);
+ $this->audience = array_values($audience);
+ }
+
+ public function isPublicPath(string $path): bool
+ {
+ foreach ($this->publicPaths as $pattern) {
+ if (fnmatch($pattern, $path)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Proxy/IpRange.php b/src/Proxy/IpRange.php
new file mode 100644
index 0000000..75de568
--- /dev/null
+++ b/src/Proxy/IpRange.php
@@ -0,0 +1,73 @@
+ $maxBits) {
+ return null;
+ }
+
+ return [$network, (int) $parts[1]];
+ }
+}
diff --git a/src/Proxy/TrustedProxyConfig.php b/src/Proxy/TrustedProxyConfig.php
new file mode 100644
index 0000000..f5ff44d
--- /dev/null
+++ b/src/Proxy/TrustedProxyConfig.php
@@ -0,0 +1,56 @@
+ */
+ public readonly array $trustedProxies;
+
+ /**
+ * @param list $trustedProxies IPv4/IPv6 addresses or CIDR ranges (e.g. `10.0.0.0/8`)
+ * @param string $header forwarding header holding a comma-separated IP chain
+ * @param string $attribute request attribute that receives the client IP
+ */
+ public function __construct(
+ array $trustedProxies = [],
+ public readonly string $header = 'X-Forwarded-For',
+ public readonly string $attribute = 'client_ip',
+ ) {
+ foreach ($trustedProxies as $proxy) {
+ if (!IpRange::isValid($proxy)) {
+ throw new InvalidConfigurationException(sprintf(
+ 'Trusted proxy "%s" is not a valid IP address or CIDR range.',
+ $proxy
+ ));
+ }
+ }
+
+ if ($header === '' || $attribute === '') {
+ throw new InvalidConfigurationException('Trusted proxy header and attribute must not be empty.');
+ }
+
+ $this->trustedProxies = array_values($trustedProxies);
+ }
+
+ public function isTrusted(string $ip): bool
+ {
+ foreach ($this->trustedProxies as $proxy) {
+ if (IpRange::contains($proxy, $ip)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Proxy/TrustedProxyMiddleware.php b/src/Proxy/TrustedProxyMiddleware.php
new file mode 100644
index 0000000..81dd610
--- /dev/null
+++ b/src/Proxy/TrustedProxyMiddleware.php
@@ -0,0 +1,69 @@
+resolve($request);
+
+ if ($clientIp !== null) {
+ $request = $request->withAttribute($this->config->attribute, $clientIp);
+ }
+
+ return $handler->handle($request);
+ }
+
+ private function resolve(ServerRequestInterface $request): ?string
+ {
+ $remote = $request->getServerParams()['REMOTE_ADDR'] ?? null;
+
+ if (!is_string($remote) || filter_var($remote, FILTER_VALIDATE_IP) === false) {
+ return null;
+ }
+
+ if (!$this->config->isTrusted($remote)) {
+ return $remote;
+ }
+
+ $chain = array_map('trim', explode(',', $request->getHeaderLine($this->config->header)));
+ $client = $remote;
+
+ foreach (array_reverse($chain) as $hop) {
+ if ($hop === '' || filter_var($hop, FILTER_VALIDATE_IP) === false) {
+ break;
+ }
+
+ $client = $hop;
+
+ if (!$this->config->isTrusted($hop)) {
+ break;
+ }
+ }
+
+ return $client;
+ }
+}
diff --git a/src/RateLimit/RateLimitConfig.php b/src/RateLimit/RateLimitConfig.php
new file mode 100644
index 0000000..501aaf5
--- /dev/null
+++ b/src/RateLimit/RateLimitConfig.php
@@ -0,0 +1,32 @@
+config = $config ?? new RateLimitConfig();
+ $this->keyResolver = $keyResolver === null ? null : Closure::fromCallable($keyResolver);
+ }
+
+ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+ {
+ $limit = $this->limiterFactory->create($this->config->keyPrefix . $this->clientKey($request))->consume();
+
+ if (!$limit->isAccepted()) {
+ $retryAfter = max(0, $limit->getRetryAfter()->getTimestamp() - time());
+
+ return ErrorResponse::create($this->responseFactory, 429, 'Too Many Requests')
+ ->withHeader('Retry-After', (string) $retryAfter)
+ ->withHeader('X-RateLimit-Limit', (string) $limit->getLimit())
+ ->withHeader('X-RateLimit-Remaining', '0');
+ }
+
+ $response = $handler->handle($request);
+
+ if (!$this->config->exposeHeaders) {
+ return $response;
+ }
+
+ return $response
+ ->withHeader('X-RateLimit-Limit', (string) $limit->getLimit())
+ ->withHeader('X-RateLimit-Remaining', (string) $limit->getRemainingTokens());
+ }
+
+ private function clientKey(ServerRequestInterface $request): string
+ {
+ if ($this->keyResolver !== null) {
+ $key = ($this->keyResolver)($request);
+ if ($key !== null && $key !== '') {
+ return $key;
+ }
+ }
+
+ $ip = $request->getAttribute($this->config->clientIpAttribute);
+ if (is_string($ip) && $ip !== '') {
+ return $ip;
+ }
+
+ $remote = $request->getServerParams()['REMOTE_ADDR'] ?? null;
+
+ return is_string($remote) && $remote !== '' ? $remote : 'unknown';
+ }
+}
diff --git a/src/Support/ErrorResponse.php b/src/Support/ErrorResponse.php
new file mode 100644
index 0000000..f7dc524
--- /dev/null
+++ b/src/Support/ErrorResponse.php
@@ -0,0 +1,26 @@
+createResponse($status)
+ ->withHeader('Content-Type', 'application/json; charset=utf-8');
+
+ $response->getBody()->write((string) json_encode(['error' => $message], JSON_THROW_ON_ERROR));
+
+ return $response;
+ }
+}
diff --git a/tests/Cors/CorsMiddlewareTest.php b/tests/Cors/CorsMiddlewareTest.php
new file mode 100644
index 0000000..5ee065a
--- /dev/null
+++ b/tests/Cors/CorsMiddlewareTest.php
@@ -0,0 +1,196 @@
+handler = new RecordingHandler();
+ }
+
+ private function middleware(CorsConfig $config): CorsMiddleware
+ {
+ return new CorsMiddleware(new Psr17Factory(), $config);
+ }
+
+ private function credentialsConfig(): CorsConfig
+ {
+ return new CorsConfig(allowedOrigins: ['https://app.example.com'], allowCredentials: true);
+ }
+
+ public function testRequestWithoutOriginPassesThroughUntouched(): void
+ {
+ $response = $this->middleware($this->credentialsConfig())
+ ->process(new ServerRequest('GET', '/x'), $this->handler);
+
+ self::assertSame(1, $this->handler->calls);
+ self::assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+ self::assertFalse($response->hasHeader('Vary'));
+ }
+
+ public function testAllowedOriginWithCredentialsEchoesOriginAndVaries(): void
+ {
+ $request = new ServerRequest('GET', '/x', ['Origin' => 'https://app.example.com']);
+ $response = $this->middleware($this->credentialsConfig())->process($request, $this->handler);
+
+ self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin'));
+ self::assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
+ self::assertSame('Origin', $response->getHeaderLine('Vary'));
+ }
+
+ /**
+ * SPEC-064: a disallowed origin must not receive `*` nor credentials.
+ */
+ public function testDisallowedOriginGetsNoCorsHeaders(): void
+ {
+ $request = new ServerRequest('GET', '/x', ['Origin' => 'https://evil.example']);
+ $response = $this->middleware($this->credentialsConfig())->process($request, $this->handler);
+
+ self::assertSame(1, $this->handler->calls);
+ self::assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+ self::assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+ self::assertSame('Origin', $response->getHeaderLine('Vary'));
+ }
+
+ public function testNullOriginIsNotAllowed(): void
+ {
+ $request = new ServerRequest('GET', '/x', ['Origin' => 'null']);
+ $response = $this->middleware($this->credentialsConfig())->process($request, $this->handler);
+
+ self::assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+ }
+
+ public function testWildcardWithCredentialsIsRejectedAtConfiguration(): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+
+ new CorsConfig(allowedOrigins: ['*'], allowCredentials: true);
+ }
+
+ public function testWildcardOriginSendsStarWithoutVary(): void
+ {
+ $request = new ServerRequest('GET', '/x', ['Origin' => 'https://any.example']);
+ $response = $this->middleware(new CorsConfig(allowedOrigins: ['*']))->process($request, $this->handler);
+
+ self::assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+ self::assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+ self::assertFalse($response->hasHeader('Vary'));
+ }
+
+ /**
+ * SPEC-063: the preflight is answered by the middleware, with headers, and never reaches the handler.
+ */
+ public function testAllowedPreflightIsAnsweredWithoutCallingTheHandler(): void
+ {
+ $request = new ServerRequest('OPTIONS', '/x', [
+ 'Origin' => 'https://app.example.com',
+ 'Access-Control-Request-Method' => 'PUT',
+ 'Access-Control-Request-Headers' => 'content-type, Authorization',
+ ]);
+ $response = $this->middleware($this->credentialsConfig())->process($request, $this->handler);
+
+ self::assertSame(0, $this->handler->calls);
+ self::assertSame(204, $response->getStatusCode());
+ self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin'));
+ self::assertSame('GET, POST, PUT, PATCH, DELETE', $response->getHeaderLine('Access-Control-Allow-Methods'));
+ self::assertSame('content-type, authorization', $response->getHeaderLine('Access-Control-Allow-Headers'));
+ self::assertSame('600', $response->getHeaderLine('Access-Control-Max-Age'));
+ self::assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
+ self::assertSame(
+ 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers',
+ $response->getHeaderLine('Vary')
+ );
+ }
+
+ /**
+ * @return iterable}>
+ */
+ public static function rejectedPreflights(): iterable
+ {
+ yield 'origin' => [['Origin' => 'https://evil.example', 'Access-Control-Request-Method' => 'GET']];
+ yield 'method' => [['Origin' => 'https://app.example.com', 'Access-Control-Request-Method' => 'TRACE']];
+ yield 'header' => [[
+ 'Origin' => 'https://app.example.com',
+ 'Access-Control-Request-Method' => 'GET',
+ 'Access-Control-Request-Headers' => 'X-Secret',
+ ]];
+ }
+
+ /**
+ * @param array $headers
+ */
+ #[DataProvider('rejectedPreflights')]
+ public function testRejectedPreflightFailsClosed(array $headers): void
+ {
+ $response = $this->middleware($this->credentialsConfig())
+ ->process(new ServerRequest('OPTIONS', '/x', $headers), $this->handler);
+
+ self::assertSame(0, $this->handler->calls);
+ self::assertSame(403, $response->getStatusCode());
+ self::assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+ }
+
+ public function testPlainOptionsWithoutPreflightHeaderReachesHandler(): void
+ {
+ $request = new ServerRequest('OPTIONS', '/x', ['Origin' => 'https://app.example.com']);
+ $this->middleware($this->credentialsConfig())->process($request, $this->handler);
+
+ self::assertSame(1, $this->handler->calls);
+ }
+
+ public function testExposedHeadersAndExistingVaryArePreserved(): void
+ {
+ $handler = new class implements RequestHandlerInterface {
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return new Response(200, ['Vary' => 'Accept-Encoding, origin']);
+ }
+ };
+
+ $config = new CorsConfig(allowedOrigins: ['https://app.example.com'], exposedHeaders: ['X-Request-Id']);
+ $request = new ServerRequest('GET', '/x', ['Origin' => 'https://app.example.com']);
+ $response = $this->middleware($config)->process($request, $handler);
+
+ self::assertSame('X-Request-Id', $response->getHeaderLine('Access-Control-Expose-Headers'));
+ self::assertSame('Accept-Encoding, origin', $response->getHeaderLine('Vary'));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function invalidOrigins(): iterable
+ {
+ yield 'null' => ['null'];
+ yield 'trailing slash' => ['https://app.example.com/'];
+ yield 'path' => ['https://app.example.com/api'];
+ yield 'no scheme' => ['app.example.com'];
+ yield 'upper case' => ['https://App.example.com'];
+ yield 'ftp' => ['ftp://app.example.com'];
+ }
+
+ #[DataProvider('invalidOrigins')]
+ public function testInvalidOriginsAreRejectedAtConfiguration(string $origin): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+
+ new CorsConfig(allowedOrigins: [$origin]);
+ }
+}
diff --git a/tests/Csrf/CsrfMiddlewareTest.php b/tests/Csrf/CsrfMiddlewareTest.php
new file mode 100644
index 0000000..ad6c8df
--- /dev/null
+++ b/tests/Csrf/CsrfMiddlewareTest.php
@@ -0,0 +1,168 @@
+token;
+ }
+
+ public function set(string $token): void
+ {
+ $this->token = $token;
+ }
+
+ public function remove(): void
+ {
+ $this->token = null;
+ }
+ };
+
+ $this->token = new SynchronizerCsrfToken(new RandomCsrfTokenGenerator(), $storage);
+ $this->handler = new RecordingHandler();
+ }
+
+ private function middleware(?CsrfConfig $config = null): CsrfMiddleware
+ {
+ return new CsrfMiddleware(new Psr17Factory(), $this->token, $config);
+ }
+
+ public function testSafeMethodPassesAndExposesTheToken(): void
+ {
+ $response = $this->middleware()->process(new ServerRequest('GET', '/form'), $this->handler);
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame($this->token, $this->handler->attribute('csrf_token'));
+ }
+
+ /**
+ * SPEC-067: a legitimate POST with a valid token (form field) is accepted.
+ */
+ public function testValidTokenInBodyIsAccepted(): void
+ {
+ $request = (new ServerRequest('POST', '/form'))->withParsedBody(['_csrf' => $this->token->getValue()]);
+
+ $response = $this->middleware()->process($request, $this->handler);
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame(1, $this->handler->calls);
+ }
+
+ public function testValidTokenInHeaderIsAccepted(): void
+ {
+ $request = new ServerRequest('DELETE', '/item/1', ['X-CSRF-Token' => $this->token->getValue()]);
+
+ self::assertSame(200, $this->middleware()->process($request, $this->handler)->getStatusCode());
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function unsafeMethods(): iterable
+ {
+ yield 'POST' => ['POST'];
+ yield 'PUT' => ['PUT'];
+ yield 'PATCH' => ['PATCH'];
+ yield 'DELETE' => ['DELETE'];
+ }
+
+ /**
+ * SPEC-067: every state-changing method is protected, not only POST.
+ */
+ #[DataProvider('unsafeMethods')]
+ public function testUnsafeMethodWithoutTokenFailsClosed(string $method): void
+ {
+ $response = $this->middleware()->process(new ServerRequest($method, '/x'), $this->handler);
+
+ self::assertSame(403, $response->getStatusCode());
+ self::assertSame(0, $this->handler->calls);
+ self::assertSame('{"error":"Invalid CSRF token"}', (string) $response->getBody());
+ }
+
+ public function testWrongTokenFailsClosed(): void
+ {
+ $this->token->getValue();
+ $request = new ServerRequest('POST', '/x', ['X-CSRF-Token' => 'forged']);
+
+ self::assertSame(403, $this->middleware()->process($request, $this->handler)->getStatusCode());
+ }
+
+ public function testObjectParsedBodyIsValidatedAndOriginalRequestReachesHandler(): void
+ {
+ $body = (object) ['_csrf' => $this->token->getValue(), 'name' => 'x'];
+ $request = (new ServerRequest('POST', '/form'))->withParsedBody($body);
+
+ $response = $this->middleware()->process($request, $this->handler);
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame($body, $this->handler->request?->getParsedBody());
+ }
+
+ public function testCustomNamesAndStatus(): void
+ {
+ $config = new CsrfConfig(parameterName: 'token', headerName: 'X-XSRF', failureStatus: 419);
+
+ $ok = (new ServerRequest('POST', '/x', ['X-XSRF' => $this->token->getValue()]));
+ self::assertSame(200, $this->middleware($config)->process($ok, $this->handler)->getStatusCode());
+
+ $bad = new ServerRequest('POST', '/x', ['X-CSRF-Token' => $this->token->getValue()]);
+ self::assertSame(419, $this->middleware($config)->process($bad, $this->handler)->getStatusCode());
+ }
+
+ public function testStatelessHmacAndMaskedTokens(): void
+ {
+ $identity = new class implements CsrfTokenIdentityGeneratorInterface {
+ public string $id = 'session-a';
+
+ public function generate(): string
+ {
+ return $this->id;
+ }
+ };
+ $this->token = new MaskedCsrfToken(new HmacCsrfToken($identity, str_repeat('k', 32), 'sha256', 300));
+
+ $value = $this->token->getValue();
+ self::assertNotSame($value, $this->token->getValue(), 'masked tokens differ on every render');
+
+ $request = new ServerRequest('POST', '/x', ['X-CSRF-Token' => $value]);
+ self::assertSame(200, $this->middleware()->process($request, $this->handler)->getStatusCode());
+
+ $identity->id = 'session-b';
+ self::assertSame(403, $this->middleware()->process($request, new RecordingHandler())->getStatusCode());
+ }
+
+ public function testStateChangingMethodsCannotBeDeclaredSafe(): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+
+ new CsrfConfig(safeMethods: ['GET', 'post']);
+ }
+}
diff --git a/tests/Functional/PipelineTest.php b/tests/Functional/PipelineTest.php
new file mode 100644
index 0000000..a56ba80
--- /dev/null
+++ b/tests/Functional/PipelineTest.php
@@ -0,0 +1,142 @@
+handler = new RecordingHandler();
+
+ $middlewares = [
+ new TrustedProxyMiddleware(new TrustedProxyConfig(['10.0.0.0/8'])),
+ new SecurityHeadersMiddleware(),
+ new CorsMiddleware($factory, new CorsConfig(['https://app.example.com'], allowCredentials: true)),
+ new RateLimitMiddleware($factory, new RateLimiterFactory(
+ ['id' => 'api', 'policy' => 'fixed_window', 'limit' => 3, 'interval' => '1 minute'],
+ new InMemoryStorage()
+ )),
+ new JwtAuthMiddleware($factory, new JwtConfig(self::SECRET, publicPaths: ['/health'])),
+ ];
+
+ $this->pipeline = array_reduce(
+ array_reverse($middlewares),
+ static fn (RequestHandlerInterface $next, MiddlewareInterface $middleware): RequestHandlerInterface =>
+ new class ($middleware, $next) implements RequestHandlerInterface {
+ public function __construct(
+ private readonly MiddlewareInterface $middleware,
+ private readonly RequestHandlerInterface $next,
+ ) {
+ }
+
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return $this->middleware->process($request, $this->next);
+ }
+ },
+ $this->handler
+ );
+ }
+
+ /**
+ * @param array $headers
+ */
+ private function send(string $method, string $path, array $headers = []): ResponseInterface
+ {
+ return $this->pipeline->handle(
+ new ServerRequest($method, $path, $headers, null, '1.1', ['REMOTE_ADDR' => '10.0.0.5'])
+ );
+ }
+
+ public function testPreflightNeedsNoCredentialsAndCarriesSecurityHeaders(): void
+ {
+ $response = $this->send('OPTIONS', '/me', [
+ 'Origin' => 'https://app.example.com',
+ 'Access-Control-Request-Method' => 'GET',
+ 'Access-Control-Request-Headers' => 'authorization',
+ ]);
+
+ self::assertSame(204, $response->getStatusCode());
+ self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin'));
+ self::assertSame('nosniff', $response->getHeaderLine('X-Content-Type-Options'));
+ self::assertSame(0, $this->handler->calls);
+ }
+
+ public function testAuthenticatedRequestReachesHandlerWithUserAndClientIp(): void
+ {
+ $token = JWT::encode(['sub' => 'u1', 'exp' => time() + 60], self::SECRET, 'HS256');
+
+ $response = $this->send('GET', '/me', [
+ 'Authorization' => 'Bearer ' . $token,
+ 'Origin' => 'https://app.example.com',
+ 'X-Forwarded-For' => '198.51.100.4',
+ ]);
+
+ self::assertSame(200, $response->getStatusCode());
+ self::assertSame('198.51.100.4', $this->handler->attribute('client_ip'));
+ self::assertSame(['sub' => 'u1', 'exp' => $this->claim('exp')], $this->handler->attribute('user'));
+ self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin'));
+ self::assertSame('2', $response->getHeaderLine('X-RateLimit-Remaining'));
+ }
+
+ public function testRejectionsStillCarrySecurityAndCorsHeaders(): void
+ {
+ $response = $this->send('GET', '/me', ['Origin' => 'https://app.example.com']);
+
+ self::assertSame(401, $response->getStatusCode());
+ self::assertSame('DENY', $response->getHeaderLine('X-Frame-Options'));
+ self::assertSame('https://app.example.com', $response->getHeaderLine('Access-Control-Allow-Origin'));
+ }
+
+ public function testRateLimitAppliesPerResolvedClientIp(): void
+ {
+ for ($i = 0; $i < 3; $i++) {
+ $this->send('GET', '/health', ['X-Forwarded-For' => '198.51.100.4']);
+ }
+
+ $blocked = $this->send('GET', '/health', ['X-Forwarded-For' => '198.51.100.4']);
+ $other = $this->send('GET', '/health', ['X-Forwarded-For' => '198.51.100.5']);
+
+ self::assertSame(429, $blocked->getStatusCode());
+ self::assertSame(200, $other->getStatusCode());
+ }
+
+ private function claim(string $name): mixed
+ {
+ $user = $this->handler->attribute('user');
+
+ return is_array($user) ? ($user[$name] ?? null) : null;
+ }
+}
diff --git a/tests/Headers/SecurityHeadersMiddlewareTest.php b/tests/Headers/SecurityHeadersMiddlewareTest.php
new file mode 100644
index 0000000..0e88de6
--- /dev/null
+++ b/tests/Headers/SecurityHeadersMiddlewareTest.php
@@ -0,0 +1,104 @@
+process(new ServerRequest('GET', '/'), new RecordingHandler());
+
+ self::assertSame('max-age=31536000; includeSubDomains', $response->getHeaderLine('Strict-Transport-Security'));
+ self::assertSame('nosniff', $response->getHeaderLine('X-Content-Type-Options'));
+ self::assertSame('DENY', $response->getHeaderLine('X-Frame-Options'));
+ self::assertSame('strict-origin-when-cross-origin', $response->getHeaderLine('Referrer-Policy'));
+ self::assertSame(
+ "default-src 'none'; frame-ancestors 'none'",
+ $response->getHeaderLine('Content-Security-Policy')
+ );
+ self::assertFalse($response->hasHeader('X-XSS-Protection'));
+ self::assertFalse($response->hasHeader('Permissions-Policy'));
+ }
+
+ public function testHeadersSetByTheApplicationAreKept(): void
+ {
+ $handler = new class implements RequestHandlerInterface {
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return new Response(200, ['Content-Security-Policy' => "default-src 'self'"]);
+ }
+ };
+
+ $response = (new SecurityHeadersMiddleware())->process(new ServerRequest('GET', '/'), $handler);
+
+ self::assertSame("default-src 'self'", $response->getHeaderLine('Content-Security-Policy'));
+ }
+
+ public function testOptionsAreApplied(): void
+ {
+ $middleware = new SecurityHeadersMiddleware(new SecurityHeadersConfig(
+ hsts: false,
+ frameOptions: 'sameorigin',
+ referrerPolicy: 'no-referrer',
+ contentSecurityPolicy: null,
+ overrides: ['cross-origin-opener-policy' => 'same-origin'],
+ ));
+
+ $headers = $middleware->headers();
+
+ self::assertArrayNotHasKey('Strict-Transport-Security', $headers);
+ self::assertArrayNotHasKey('Content-Security-Policy', $headers);
+ self::assertSame('SAMEORIGIN', $headers['X-Frame-Options']);
+ self::assertSame('no-referrer', $headers['Referrer-Policy']);
+ self::assertSame('same-origin', $headers['Cross-Origin-Opener-Policy']);
+ }
+
+ public function testCustomCsp(): void
+ {
+ $headers = (new SecurityHeadersMiddleware(new SecurityHeadersConfig(
+ contentSecurityPolicy: [
+ 'default-src' => ['self' => true],
+ 'img-src' => ['allow' => ['https://cdn.example']],
+ ],
+ )))->headers();
+
+ self::assertSame("default-src 'self'; img-src https://cdn.example", $headers['Content-Security-Policy']);
+ }
+
+ public function testPreloadRequiresSubDomainsAndLongMaxAge(): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+
+ new SecurityHeadersConfig(hstsIncludeSubDomains: false, hstsPreload: true);
+ }
+
+ public function testInvalidFrameOptionsIsRejected(): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+
+ new SecurityHeadersConfig(frameOptions: 'allow-from https://x');
+ }
+
+ public function testInvalidReferrerPolicyIsRejected(): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+
+ new SecurityHeadersConfig(referrerPolicy: 'never');
+ }
+}
diff --git a/tests/Jwt/JwtAuthMiddlewareTest.php b/tests/Jwt/JwtAuthMiddlewareTest.php
new file mode 100644
index 0000000..3e99652
--- /dev/null
+++ b/tests/Jwt/JwtAuthMiddlewareTest.php
@@ -0,0 +1,189 @@
+handler = new RecordingHandler();
+ }
+
+ /**
+ * @param array $claims
+ */
+ private static function token(array $claims = [], string $secret = self::SECRET, string $alg = 'HS256'): string
+ {
+ return JWT::encode($claims + ['sub' => 'u1', 'exp' => time() + 60], $secret, $alg);
+ }
+
+ /**
+ * @param array $headers
+ */
+ private function call(array $headers, ?JwtConfig $config = null, string $path = '/me'): ResponseInterface
+ {
+ $config ??= new JwtConfig(self::SECRET);
+
+ return (new JwtAuthMiddleware(new Psr17Factory(), $config))
+ ->process(new ServerRequest('GET', $path, $headers), $this->handler);
+ }
+
+ /**
+ * SPEC-054: the claims reach the handler.
+ */
+ public function testValidTokenExposesClaimsToHandler(): void
+ {
+ $response = $this->call(['Authorization' => 'Bearer ' . self::token(['role' => 'admin'])]);
+
+ self::assertSame(200, $response->getStatusCode());
+ $user = $this->handler->attribute('user');
+ self::assertIsArray($user);
+ self::assertSame('u1', $user['sub']);
+ self::assertSame('admin', $user['role']);
+ }
+
+ public function testMissingTokenFailsClosed(): void
+ {
+ $response = $this->call([]);
+
+ self::assertSame(401, $response->getStatusCode());
+ self::assertSame(0, $this->handler->calls);
+ self::assertSame('Bearer realm="api"', $response->getHeaderLine('WWW-Authenticate'));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function invalidTokens(): iterable
+ {
+ yield 'wrong secret' => [self::token([], str_repeat('x', 32))];
+ yield 'expired' => [self::token(['exp' => time() - 10])];
+ yield 'not yet valid' => [self::token(['nbf' => time() + 60])];
+ yield 'string exp (SPEC-060)' => [
+ JWT::urlsafeB64Encode('{"alg":"HS256","typ":"JWT"}') . '.'
+ . JWT::urlsafeB64Encode('{"sub":"u1","exp":"1"}') . '.'
+ . JWT::urlsafeB64Encode(hash_hmac('sha256', JWT::urlsafeB64Encode('{"alg":"HS256","typ":"JWT"}') . '.'
+ . JWT::urlsafeB64Encode('{"sub":"u1","exp":"1"}'), self::SECRET, true)),
+ ];
+ yield 'alg none' => [
+ JWT::urlsafeB64Encode('{"alg":"none","typ":"JWT"}') . '.' . JWT::urlsafeB64Encode('{"sub":"u1"}') . '.',
+ ];
+ yield 'garbage' => ['not.a.jwt'];
+ }
+
+ #[DataProvider('invalidTokens')]
+ public function testInvalidTokenFailsClosed(string $token): void
+ {
+ $response = $this->call(['Authorization' => 'Bearer ' . $token]);
+
+ self::assertSame(401, $response->getStatusCode());
+ self::assertSame(0, $this->handler->calls);
+ self::assertStringContainsString('invalid_token', $response->getHeaderLine('WWW-Authenticate'));
+ }
+
+ public function testAlgorithmConfusionIsRejected(): void
+ {
+ $config = new JwtConfig(self::SECRET, 'HS256');
+ $hs512 = JWT::encode(['sub' => 'u1'], str_repeat('s', 64), 'HS512');
+
+ self::assertSame(401, $this->call(['Authorization' => 'Bearer ' . $hs512], $config)->getStatusCode());
+ }
+
+ /**
+ * SPEC-055: public paths are honoured.
+ */
+ public function testPublicPathsSkipAuthentication(): void
+ {
+ $config = new JwtConfig(self::SECRET, publicPaths: ['/health', '/docs/*']);
+
+ self::assertSame(200, $this->call([], $config, '/health')->getStatusCode());
+ self::assertSame(200, $this->call([], $config, '/docs/openapi.json')->getStatusCode());
+ self::assertSame(401, $this->call([], $config, '/healthz')->getStatusCode());
+ }
+
+ public function testTokenFromCookie(): void
+ {
+ $config = new JwtConfig(self::SECRET, cookieName: 'session');
+ $request = (new ServerRequest('GET', '/me'))->withCookieParams(['session' => self::token()]);
+
+ $response = (new JwtAuthMiddleware(new Psr17Factory(), $config))->process($request, $this->handler);
+
+ self::assertSame(200, $response->getStatusCode());
+ }
+
+ public function testIssuerAndAudienceAreEnforced(): void
+ {
+ $config = new JwtConfig(self::SECRET, issuer: 'https://auth.example', audience: ['api']);
+
+ $ok = self::token(['iss' => 'https://auth.example', 'aud' => ['web', 'api']]);
+ self::assertSame(200, $this->call(['Authorization' => 'Bearer ' . $ok], $config)->getStatusCode());
+
+ $badIss = self::token(['iss' => 'https://evil.example', 'aud' => 'api']);
+ self::assertSame(401, $this->call(['Authorization' => 'Bearer ' . $badIss], $config)->getStatusCode());
+
+ $badAud = self::token(['iss' => 'https://auth.example', 'aud' => 'web']);
+ self::assertSame(401, $this->call(['Authorization' => 'Bearer ' . $badAud], $config)->getStatusCode());
+ }
+
+ public function testLeewayIsAppliedAndRestored(): void
+ {
+ $config = new JwtConfig(self::SECRET, leeway: 30);
+ $token = self::token(['exp' => time() - 10]);
+
+ self::assertSame(200, $this->call(['Authorization' => 'Bearer ' . $token], $config)->getStatusCode());
+ self::assertSame(0, JWT::$leeway);
+ }
+
+ /**
+ * SPEC-060: empty or short secrets cannot be configured.
+ *
+ * @return iterable
+ */
+ public static function weakKeys(): iterable
+ {
+ yield 'empty' => ['', 'HS256'];
+ yield 'short HS256' => ['short-secret', 'HS256'];
+ yield 'short HS512' => [str_repeat('k', 32), 'HS512'];
+ yield 'unknown algorithm' => [self::SECRET, 'none'];
+ }
+
+ #[DataProvider('weakKeys')]
+ public function testWeakConfigurationIsRejected(string $key, string $algorithm): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+
+ new JwtConfig($key, $algorithm);
+ }
+
+ public function testRsaTokensAreVerifiedWithThePublicKey(): void
+ {
+ $private = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
+ self::assertNotFalse($private);
+ openssl_pkey_export($private, $privatePem);
+ $details = openssl_pkey_get_details($private);
+ self::assertIsArray($details);
+ self::assertIsString($privatePem);
+
+ $config = new JwtConfig((string) $details['key'], 'RS256');
+ $token = JWT::encode(['sub' => 'u1', 'exp' => time() + 60], $privatePem, 'RS256');
+
+ self::assertSame(200, $this->call(['Authorization' => 'Bearer ' . $token], $config)->getStatusCode());
+ }
+}
diff --git a/tests/Proxy/TrustedProxyMiddlewareTest.php b/tests/Proxy/TrustedProxyMiddlewareTest.php
new file mode 100644
index 0000000..1e74e97
--- /dev/null
+++ b/tests/Proxy/TrustedProxyMiddlewareTest.php
@@ -0,0 +1,124 @@
+ $forwarded];
+ $handler = new RecordingHandler();
+
+ (new TrustedProxyMiddleware($config))->process(
+ new ServerRequest('GET', '/', $headers, null, '1.1', ['REMOTE_ADDR' => $remote]),
+ $handler
+ );
+
+ return $handler->attribute('client_ip');
+ }
+
+ /**
+ * SPEC-065: a direct client cannot choose its IP through X-Forwarded-For.
+ */
+ public function testForwardedHeaderIsIgnoredFromUntrustedPeer(): void
+ {
+ self::assertSame('203.0.113.7', $this->resolve('203.0.113.7', '8.8.8.8', new TrustedProxyConfig()));
+ self::assertSame(
+ '203.0.113.7',
+ $this->resolve('203.0.113.7', '8.8.8.8', new TrustedProxyConfig(['10.0.0.0/8']))
+ );
+ }
+
+ public function testClientIsTakenFromTrustedProxy(): void
+ {
+ $config = new TrustedProxyConfig(['10.0.0.0/8']);
+
+ self::assertSame('198.51.100.4', $this->resolve('10.0.0.5', '198.51.100.4', $config));
+ }
+
+ public function testChainIsWalkedRightToLeftSkippingTrustedHops(): void
+ {
+ $config = new TrustedProxyConfig(['10.0.0.0/8', '192.0.2.10']);
+
+ // the left-most entry is attacker-controlled; the first untrusted hop from the right wins
+ self::assertSame('198.51.100.4', $this->resolve('10.0.0.5', '1.2.3.4, 198.51.100.4, 192.0.2.10', $config));
+ }
+
+ public function testMalformedEntryStopsAtLastTrustedHop(): void
+ {
+ $config = new TrustedProxyConfig(['10.0.0.0/8']);
+
+ self::assertSame('10.0.0.5', $this->resolve('10.0.0.5', 'not-an-ip', $config));
+ self::assertSame('10.1.1.1', $this->resolve('10.0.0.5', '198.51.100.4, garbage, 10.1.1.1', $config));
+ }
+
+ public function testMissingHeaderFromTrustedProxyFallsBackToRemoteAddr(): void
+ {
+ self::assertSame('10.0.0.5', $this->resolve('10.0.0.5', null, new TrustedProxyConfig(['10.0.0.0/8'])));
+ }
+
+ public function testInvalidRemoteAddrLeavesAttributeUnset(): void
+ {
+ self::assertNull($this->resolve('garbage', '8.8.8.8', new TrustedProxyConfig(['10.0.0.0/8'])));
+ }
+
+ public function testIpv6RangesAreSupported(): void
+ {
+ $config = new TrustedProxyConfig(['fd00::/8']);
+
+ self::assertSame('2001:db8::1', $this->resolve('fd12::1', '2001:db8::1', $config));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function ranges(): iterable
+ {
+ yield 'exact v4' => ['192.0.2.1', '192.0.2.1', true];
+ yield 'exact v4 miss' => ['192.0.2.1', '192.0.2.2', false];
+ yield '/8 hit' => ['10.0.0.0/8', '10.255.1.1', true];
+ yield '/8 miss' => ['10.0.0.0/8', '11.0.0.1', false];
+ yield '/12 partial byte hit' => ['172.16.0.0/12', '172.31.255.255', true];
+ yield '/12 partial byte miss' => ['172.16.0.0/12', '172.32.0.1', false];
+ yield '/0' => ['0.0.0.0/0', '8.8.8.8', true];
+ yield 'v6 /64 hit' => ['2001:db8::/64', '2001:db8::abcd', true];
+ yield 'v6 /64 miss' => ['2001:db8::/64', '2001:db8:0:1::1', false];
+ yield 'family mismatch' => ['10.0.0.0/8', '::ffff:10.0.0.1', false];
+ }
+
+ #[DataProvider('ranges')]
+ public function testIpRangeMatching(string $range, string $ip, bool $expected): void
+ {
+ self::assertSame($expected, IpRange::contains($range, $ip));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function invalidRanges(): iterable
+ {
+ yield 'text' => ['localhost'];
+ yield 'prefix too long' => ['10.0.0.0/33'];
+ yield 'negative prefix' => ['10.0.0.0/-1'];
+ yield 'empty' => [''];
+ }
+
+ #[DataProvider('invalidRanges')]
+ public function testInvalidTrustedProxyIsRejected(string $range): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+
+ new TrustedProxyConfig([$range]);
+ }
+}
diff --git a/tests/RateLimit/RateLimitMiddlewareTest.php b/tests/RateLimit/RateLimitMiddlewareTest.php
new file mode 100644
index 0000000..9298bfd
--- /dev/null
+++ b/tests/RateLimit/RateLimitMiddlewareTest.php
@@ -0,0 +1,119 @@
+ 'test', 'policy' => 'fixed_window', 'limit' => $limit, 'interval' => '1 minute'],
+ new InMemoryStorage()
+ );
+
+ return new RateLimitMiddleware(new Psr17Factory(), $factory, $config, $resolver);
+ }
+
+ private static function request(string $remote, ?string $clientIp = null): ServerRequestInterface
+ {
+ $request = new ServerRequest('POST', '/login', [], null, '1.1', ['REMOTE_ADDR' => $remote]);
+
+ return $clientIp === null ? $request : $request->withAttribute('client_ip', $clientIp);
+ }
+
+ private static function send(RateLimitMiddleware $middleware, ServerRequestInterface $request): ResponseInterface
+ {
+ return $middleware->process($request, new RecordingHandler());
+ }
+
+ public function testRequestsWithinTheLimitExposeHeaders(): void
+ {
+ $middleware = $this->middleware();
+
+ $first = self::send($middleware, self::request('203.0.113.1'));
+
+ self::assertSame(200, $first->getStatusCode());
+ self::assertSame('2', $first->getHeaderLine('X-RateLimit-Limit'));
+ self::assertSame('1', $first->getHeaderLine('X-RateLimit-Remaining'));
+ }
+
+ public function testOverTheLimitFailsClosedWith429(): void
+ {
+ $middleware = $this->middleware();
+ $handler = new RecordingHandler();
+
+ $middleware->process(self::request('203.0.113.1'), $handler);
+ $middleware->process(self::request('203.0.113.1'), $handler);
+ $blocked = $middleware->process(self::request('203.0.113.1'), $handler);
+
+ self::assertSame(2, $handler->calls);
+ self::assertSame(429, $blocked->getStatusCode());
+ self::assertSame('0', $blocked->getHeaderLine('X-RateLimit-Remaining'));
+ self::assertGreaterThan(0, (int) $blocked->getHeaderLine('Retry-After'));
+ self::assertLessThanOrEqual(60, (int) $blocked->getHeaderLine('Retry-After'));
+ }
+
+ public function testClientsAreLimitedIndependently(): void
+ {
+ $middleware = $this->middleware(1);
+
+ self::assertSame(200, self::send($middleware, self::request('203.0.113.1'))->getStatusCode());
+ self::assertSame(200, self::send($middleware, self::request('203.0.113.2'))->getStatusCode());
+ self::assertSame(429, self::send($middleware, self::request('203.0.113.1'))->getStatusCode());
+ }
+
+ /**
+ * SPEC-065: the key comes from the trusted client_ip attribute, not from raw headers.
+ */
+ public function testClientIpAttributeTakesPrecedenceOverRemoteAddr(): void
+ {
+ $middleware = $this->middleware(1);
+
+ self::assertSame(200, self::send($middleware, self::request('10.0.0.5', '198.51.100.1'))->getStatusCode());
+ self::assertSame(200, self::send($middleware, self::request('10.0.0.5', '198.51.100.2'))->getStatusCode());
+ self::assertSame(429, self::send($middleware, self::request('10.0.0.9', '198.51.100.1'))->getStatusCode());
+ }
+
+ public function testForwardedHeaderAloneDoesNotChangeTheKey(): void
+ {
+ $middleware = $this->middleware(1);
+
+ self::send($middleware, self::request('203.0.113.1')->withHeader('X-Forwarded-For', '1.1.1.1'));
+ $second = self::send($middleware, self::request('203.0.113.1')->withHeader('X-Forwarded-For', '2.2.2.2'));
+
+ self::assertSame(429, $second->getStatusCode());
+ }
+
+ public function testCustomResolverAndHiddenHeaders(): void
+ {
+ $middleware = $this->middleware(
+ 1,
+ new RateLimitConfig(exposeHeaders: false),
+ static fn (ServerRequestInterface $request): string => $request->getHeaderLine('X-Api-Key')
+ );
+
+ $ok = self::send($middleware, self::request('203.0.113.1')->withHeader('X-Api-Key', 'a'));
+ self::assertFalse($ok->hasHeader('X-RateLimit-Limit'));
+ $otherKey = self::send($middleware, self::request('203.0.113.1')->withHeader('X-Api-Key', 'b'));
+ $sameKey = self::send($middleware, self::request('203.0.113.9')->withHeader('X-Api-Key', 'a'));
+
+ self::assertSame(200, $otherKey->getStatusCode());
+ self::assertSame(429, $sameKey->getStatusCode());
+ }
+}
diff --git a/tests/Support/RecordingHandler.php b/tests/Support/RecordingHandler.php
new file mode 100644
index 0000000..8f333ba
--- /dev/null
+++ b/tests/Support/RecordingHandler.php
@@ -0,0 +1,33 @@
+request = $request;
+ $this->calls++;
+
+ return new Response(200, ['Content-Type' => 'text/plain'], 'handler');
+ }
+
+ public function attribute(string $name): mixed
+ {
+ return $this->request?->getAttribute($name);
+ }
+}