Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ 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).

## [1.0.0] - 2026-10-10

First stable release: the public API (middlewares, configuration objects and exceptions) follows
Semantic Versioning from now on. Adopted by `pivotphp/core` 4.0.0, which removed its own security
middlewares in favour of this package.

### Added

- **`Jwt\JwtIssuer`** — issues tokens accepted by `JwtAuthMiddleware` from the same `JwtConfig`:
`issue(array $claims, int $ttl = 3600)` sets `iat`/`nbf`/`exp` and `iss`/`aud`; reserved claims cannot
be overridden; asymmetric algorithms require the private key.

### Changed

- Optional adapter libraries are constrained to their tested major versions via `conflict`
(`bepsvpt/secure-headers` 9.x, `firebase/php-jwt` 7.x from 7.1.1, `symfony/rate-limiter` 6.4–8.x,
`yiisoft/csrf` 2.x from 2.2). Required dependencies remain only the PSR-7/15/17 interfaces.
- Development tools raised past published advisories (`phpunit/phpunit` ^10.5.62|^11.5.50,
`squizlabs/php_codesniffer` ^3.13.6).

### Tests

- Scenarios ported from `pivotphp-core` (CORS and security headers), with expectations corrected where
the core asserted wrong behaviour (`Access-Control-Allow-Origin: null`, `*` on non-CORS requests,
preflight reaching the handler, `X-XSS-Protection`).

## [0.1.0] - 2026-10-10

### Added
Expand All @@ -26,4 +52,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Exception\InvalidConfigurationException` and `Exception\MissingDependencyException`, both
implementing `Exception\SecurityException`.

[1.0.0]: https://github.com/PivotPHP/pivotphp-security/releases/tag/v1.0.0
[0.1.0]: https://github.com/PivotPHP/pivotphp-security/releases/tag/v0.1.0
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ mínima em `conflict`.
| `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` |
| `Jwt` | adapter de `firebase/php-jwt` (verificação e emissão: `JwtAuthMiddleware`, `JwtIssuer`) |
| `RateLimit` | adapter de `symfony/rate-limiter` (`RateLimiterFactory`; a interface só existe a partir da 7.3) |

## Comandos
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ demand:
| `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` |
| `Jwt\JwtAuthMiddleware`, `Jwt\JwtIssuer` | [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
Expand Down Expand Up @@ -214,6 +214,25 @@ rejected); `iss`/`aud` are verified here. Failures return `401` with `WWW-Authen
> algorithms are rejected at configuration. Load keys from the environment or a secret store, never
> from source code.

### Issuing tokens

`JwtIssuer` shares the `JwtConfig` used by the middleware, so issued tokens always match what is
verified (algorithm, `iss`, `aud`):

```php
use PivotPHP\Security\Jwt\JwtIssuer;

$issuer = new JwtIssuer($config); // HS*: signs with the configured secret
// $issuer = new JwtIssuer($config, $privateKeyPem); // RS*/PS256/ES*/EdDSA: private key required

$token = $issuer->issue(['sub' => $userId, 'role' => 'admin'], ttl: 3600);
```

`iat`, `nbf` and `exp` come from the clock and `iss`/`aud` from the configuration; passing them in the
claims throws `InvalidConfigurationException`. Generate HMAC secrets with `bin2hex(random_bytes(32))`.
Refresh tokens are an application concern (issue a second token with a longer `ttl` and a `typ` claim,
and check it in your refresh endpoint).

## Rate limiting

```php
Expand Down
14 changes: 7 additions & 7 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,23 @@
"nyholm/psr7": "^1.8",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^10.5|^11.0",
"phpunit/phpunit": "^10.5.62|^11.5.50",
"roave/security-advisories": "dev-latest",
"squizlabs/php_codesniffer": "^3.7",
"squizlabs/php_codesniffer": "^3.13.6",
"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)",
"firebase/php-jwt": "Required by Jwt\\JwtAuthMiddleware and Jwt\\JwtIssuer (^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"
"bepsvpt/secure-headers": "<9.1 || >=10.0",
"firebase/php-jwt": "<7.1.1 || >=8.0",
"symfony/rate-limiter": "<6.4 || >=9.0",
"yiisoft/csrf": "<2.2 || >=3.0"
},
"autoload": {
"psr-4": {
Expand Down
101 changes: 101 additions & 0 deletions src/Jwt/JwtIssuer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace PivotPHP\Security\Jwt;

use Firebase\JWT\JWT;
use OpenSSLAsymmetricKey;
use OpenSSLCertificate;
use PivotPHP\Security\Exception\InvalidConfigurationException;
use PivotPHP\Security\Exception\MissingDependencyException;

/**
* Issues JWTs that `JwtAuthMiddleware` accepts (adapter for `firebase/php-jwt`).
*
* Shares the `JwtConfig` used for verification, so the algorithm, `iss` and
* `aud` always match. HMAC algorithms sign with the configured secret;
* asymmetric algorithms need the private key.
*
* ```php
* $issuer = new JwtIssuer($config);
* $token = $issuer->issue(['sub' => $user->id, 'role' => 'admin'], ttl: 3600);
* ```
*/
final class JwtIssuer
{
private const RESERVED = ['iat', 'nbf', 'exp', 'iss', 'aud'];

/**
* @param string|OpenSSLAsymmetricKey|null $privateKey signing key for RS*, PS256, ES* and EdDSA (ignored for HS*)
*/
public function __construct(
private readonly JwtConfig $config,
#[\SensitiveParameter]
private readonly string|OpenSSLAsymmetricKey|null $privateKey = null,
) {
if (!class_exists(JWT::class)) {
throw MissingDependencyException::forPackage(self::class, 'firebase/php-jwt');
}

if (!$this->isHmac() && ($privateKey === null || $privateKey === '')) {
throw new InvalidConfigurationException(sprintf(
'JWT: %s tokens need the private key to be issued.',
$config->algorithm
));
}
}

/**
* Issues a signed token.
*
* `iat`, `nbf`, `exp`, `iss` and `aud` are set from the clock and the
* configuration; passing them in `$claims` is rejected.
*
* @param array<string, mixed> $claims application claims (e.g. `sub`, roles)
* @param int $ttl lifetime in seconds
*/
public function issue(array $claims, int $ttl = 3600, ?int $now = null): string
{
if ($ttl <= 0) {
throw new InvalidConfigurationException('JWT: ttl must be positive.');
}

$reserved = array_intersect(array_keys($claims), self::RESERVED);
if ($reserved !== []) {
throw new InvalidConfigurationException(sprintf(
'JWT: the claims %s are set by the issuer and cannot be passed.',
implode(', ', $reserved)
));
}

$now ??= time();
$payload = $claims + ['iat' => $now, 'nbf' => $now, 'exp' => $now + $ttl];

if ($this->config->issuer !== null) {
$payload['iss'] = $this->config->issuer;
}

if ($this->config->audience !== []) {
$payload['aud'] = count($this->config->audience) === 1
? $this->config->audience[0]
: $this->config->audience;
}

return JWT::encode($payload, $this->signingKey(), $this->config->algorithm);
}

private function isHmac(): bool
{
return str_starts_with($this->config->algorithm, 'HS');
}

private function signingKey(): string|OpenSSLAsymmetricKey|OpenSSLCertificate
{
if ($this->isHmac() || $this->privateKey === null) {
return $this->config->key;
}

return $this->privateKey;
}
}
Loading
Loading