From 3c419239ba599283965fd9d5a7c70b9ae6876337 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:55:52 +0000 Subject: [PATCH 1/9] Fix JWT temporal claim validation JWT registered date claims are parsed before signature validation. Malformed external values could therefore escape the provider as native PHP errors, while an epoch-zero expiration was incorrectly treated as an absent claim.\n\nTranslate parser failures at the untrusted decode boundary into TokenInvalidException without hiding application-owned encode failures. Treat only null expiration as absent, and document why not-before validation remains active during refresh.\n\nAdd focused regressions for malformed registered dates, string-zero parsing, epoch-zero expiration, and the existing leeway behavior. --- src/jwt/src/Providers/Lcobucci.php | 9 +++- src/jwt/src/Validations/ExpiredClaim.php | 4 +- src/jwt/src/Validations/NotBeforeClaim.php | 6 +++ tests/Jwt/Providers/LcobucciTest.php | 59 ++++++++++++++++++++++ tests/Jwt/Validations/ExpiredClaimTest.php | 11 ++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index 27a531156..cf534f1a8 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -21,6 +21,7 @@ use Lcobucci\JWT\Signer\Rsa; use Lcobucci\JWT\Token\RegisteredClaims; use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Throwable; class Lcobucci extends Provider implements ProviderContract { @@ -88,8 +89,12 @@ public function decode(string $token): array try { /** @var \Lcobucci\JWT\Token\Plain */ $token = $this->config->parser()->parse($token); - } catch (Exception $e) { - throw new TokenInvalidException('Could not decode token: ' . $e->getMessage(), $e->getCode(), $e); + } catch (Throwable $exception) { + throw new TokenInvalidException( + 'Could not decode token: ' . $exception->getMessage(), + $exception->getCode(), + $exception, + ); } if (! $this->config->validator()->validate($token, ...$this->config->validationConstraints())) { diff --git a/src/jwt/src/Validations/ExpiredClaim.php b/src/jwt/src/Validations/ExpiredClaim.php index 4c8bc107d..7492134a3 100644 --- a/src/jwt/src/Validations/ExpiredClaim.php +++ b/src/jwt/src/Validations/ExpiredClaim.php @@ -12,7 +12,9 @@ class ExpiredClaim extends AbstractValidation implements TemporalValidation { public function validate(array $payload): void { - if (! $exp = ($payload['exp'] ?? null)) { + $exp = $payload['exp'] ?? null; + + if ($exp === null) { return; } diff --git a/src/jwt/src/Validations/NotBeforeClaim.php b/src/jwt/src/Validations/NotBeforeClaim.php index 7483b198a..09b2e748f 100644 --- a/src/jwt/src/Validations/NotBeforeClaim.php +++ b/src/jwt/src/Validations/NotBeforeClaim.php @@ -7,6 +7,12 @@ use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Support\Facades\Date; +/** + * Validate not-before timestamps during normal decoding and refresh. + * + * This validation deliberately does not implement TemporalValidation. Skipping + * it during refresh would replace a future `nbf` with the current time. + */ class NotBeforeClaim extends AbstractValidation { public function validate(array $payload): void diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php index b961caa0f..07963da23 100644 --- a/tests/Jwt/Providers/LcobucciTest.php +++ b/tests/Jwt/Providers/LcobucciTest.php @@ -6,12 +6,16 @@ use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\SecretMissingException; +use Hypervel\Jwt\Exceptions\TokenExpiredException; use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Jwt\Providers\Lcobucci; use Hypervel\Jwt\Providers\Provider; +use Hypervel\Jwt\Validations\ExpiredClaim; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Date; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use TypeError; class LcobucciTest extends TestCase { @@ -164,6 +168,44 @@ public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecod $this->getProvider('secret', Provider::ALGO_HS256)->decode('foo.bar.baz'); } + #[DataProvider('malformedRegisteredDateProvider')] + public function testMalformedRegisteredDatesAreReportedAsInvalidTokens(string $claim, mixed $value): void + { + $provider = $this->getProvider('secret', Provider::ALGO_HS256); + + try { + $provider->decode($this->encodeExternalToken([$claim => $value], 'different-secret')); + + $this->fail('Expected the malformed registered date to be rejected.'); + } catch (TokenInvalidException $exception) { + $this->assertStringStartsWith('Could not decode token:', $exception->getMessage()); + $this->assertInstanceOf(TypeError::class, $exception->getPrevious()); + } + } + + public static function malformedRegisteredDateProvider(): array + { + return [ + 'null expiration' => ['exp', null], + 'boolean not-before' => ['nbf', true], + 'array issued-at' => ['iat', []], + 'object expiration' => ['exp', (object) ['timestamp' => 0]], + ]; + } + + public function testExternalStringZeroExpirationIsRejected(): void + { + $secret = str_repeat('s', 64); + $payload = $this->getProvider($secret, Provider::ALGO_HS256) + ->decode($this->encodeExternalToken(['exp' => '0'], $secret)); + + $this->assertSame(0, $payload['exp']); + + $this->expectException(TokenExpiredException::class); + + (new ExpiredClaim)->validate($payload); + } + public function testShouldThrowAnExceptionWhenTheAlgorithmPassedIsInvalid(): void { $this->expectException(JwtException::class); @@ -325,6 +367,23 @@ private function getRandomString(int $length = 64): string return $randomString; } + private function encodeExternalToken(array $payload, string $secret): string + { + $segments = [ + $this->base64UrlEncode(json_encode(['typ' => 'JWT', 'alg' => Provider::ALGO_HS256], JSON_THROW_ON_ERROR)), + $this->base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR)), + ]; + + $segments[] = $this->base64UrlEncode(hash_hmac('sha256', implode('.', $segments), $secret, true)); + + return implode('.', $segments); + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + private function getDummyPrivateKey(): string { return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa'); diff --git a/tests/Jwt/Validations/ExpiredClaimTest.php b/tests/Jwt/Validations/ExpiredClaimTest.php index 66de8d524..bec3a993e 100644 --- a/tests/Jwt/Validations/ExpiredClaimTest.php +++ b/tests/Jwt/Validations/ExpiredClaimTest.php @@ -21,10 +21,21 @@ public function testValid(): void $validation = new ExpiredClaim(['leeway' => 3600]); $validation->validate([]); + $validation->validate(['exp' => null]); $validation->validate(['exp' => Date::now()->timestamp + 3600]); $validation->validate(['exp' => Date::now()->timestamp - 3600]); } + public function testEpochZeroIsExpired(): void + { + CarbonImmutable::setTestNow('2000-01-01T00:00:00.000000Z'); + + $this->expectException(TokenExpiredException::class); + $this->expectExceptionMessage('Token has expired'); + + (new ExpiredClaim)->validate(['exp' => 0]); + } + public function testInvalid(): void { CarbonImmutable::setTestNow('2000-01-01T00:00:00.000000Z'); From 37eed0e5e2564a8a32ae70019c1d1ba9752ff34c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:56:17 +0000 Subject: [PATCH 2/9] Make JWT revocation settlement truthful JWT previously maintained separate refresh and blacklist lifetimes, ignored cache write failures, and cleared guard state before revocation had settled. Those gaps could allow a refreshable token to outlive its blacklist entry or report a successful logout without durable invalidation.\n\nUse refresh_ttl as the single acceptance and retention lifetime, including expiration leeway and the final minute boundary. Reject missing issued-at claims before infinite refresh, use one clock snapshot for finite retention, honor grace periods without allowing repeated refreshes to extend them, and keep explicit permanent invalidation immediate.\n\nReturn and enforce real storage results through the manager and guard. Preserve guard state and suppress Logout when persistence fails. Remove the destructive unused PSR adapter, retain the tagged-cache and custom-storage extension points, and keep configuration defaults at their owning merged boundary.\n\nThe regressions cover finite and infinite refresh, missing claims, delayed cache reads, leeway, grace, false writes and flushes, zero identifiers, provider replacement, custom storage, and transactional logout. --- src/jwt/config/jwt.php | 18 +- src/jwt/src/Blacklist.php | 103 ++++--- src/jwt/src/ClaimFactory.php | 2 +- src/jwt/src/Contracts/StorageContract.php | 6 +- src/jwt/src/JwtGuard.php | 8 +- src/jwt/src/JwtManager.php | 39 ++- src/jwt/src/JwtServiceProvider.php | 25 +- src/jwt/src/Storage/PsrCache.php | 59 ---- src/jwt/src/Storage/TaggedCache.php | 12 +- tests/Jwt/BlacklistTest.php | 315 +++++++++++++++++++++- tests/Jwt/JwtConfigTest.php | 14 +- tests/Jwt/JwtGuardEventTest.php | 28 ++ tests/Jwt/JwtGuardTest.php | 30 +++ tests/Jwt/JwtManagerTest.php | 236 +++++++++++----- tests/Jwt/JwtServiceProviderTest.php | 101 ++++++- tests/Jwt/Storage/PsrCacheTest.php | 62 ----- tests/Jwt/Storage/TaggedCacheTest.php | 24 +- 17 files changed, 763 insertions(+), 319 deletions(-) delete mode 100644 src/jwt/src/Storage/PsrCache.php delete mode 100644 tests/Jwt/Storage/PsrCacheTest.php diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index 7611b7ef4..592dced40 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -57,7 +57,7 @@ | Public Key |-------------------------------------------------------------------------- | - | A path or resource to your public key. + | The public key contents or a file:// URI. | | E.g. 'file://path/to/public/key' | @@ -70,7 +70,7 @@ | Private Key |-------------------------------------------------------------------------- | - | A path or resource to your private key. + | The private key contents or a file:// URI. | | E.g. 'file://path/to/private/key' | @@ -118,6 +118,9 @@ | the original token being created until they must re-authenticate. | Defaults to 2 weeks. | + | This value also determines how long blacklist entries for refreshable + | tokens are retained. A null value retains those entries forever. + | | You can also set this to null, to yield an infinite refresh time. | Some may want this instead of never expiring tokens for e.g. a mobile app. | This is not particularly recommended, so make sure you have appropriate @@ -287,17 +290,6 @@ 'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0), - /* - | ------------------------------------------------------------------------- - | Refresh time to live of blacklist - | ------------------------------------------------------------------------- - | - | Number of minutes from issue date in which a JWT can be refreshed. - | - */ - - 'blacklist_refresh_ttl' => (int) env('JWT_BLACKLIST_REFRESH_TTL', 20160), - /* |-------------------------------------------------------------------------- | Providers diff --git a/src/jwt/src/Blacklist.php b/src/jwt/src/Blacklist.php index e816862a8..6645e3da8 100644 --- a/src/jwt/src/Blacklist.php +++ b/src/jwt/src/Blacklist.php @@ -15,7 +15,8 @@ class Blacklist implements BlacklistContract public function __construct( protected StorageContract $storage, protected int $gracePeriod = 0, - protected int $refreshTTL = 20160, + protected ?int $refreshTTL = 20160, + protected int $leeway = 0, protected string $key = 'jti' ) { } @@ -25,52 +26,70 @@ public function __construct( */ public function add(array $payload): bool { - // if there is no exp claim then add the jwt to - // the blacklist indefinitely - if (! array_key_exists('exp', $payload)) { - return $this->addForever($payload); + $expiration = $payload['exp'] ?? null; + + if ($expiration === null) { + return $this->addForeverWithGracePeriod($payload); + } + + $expiresAt = $this->timestamp($expiration)->addSeconds($this->leeway); + $issuedAt = $payload['iat'] ?? null; + + // Only a present iat can extend the boundary. Refresh rejects a missing iat + // before reaching the infinite-refresh return, so expiration alone bounds acceptance. + if ($issuedAt !== null) { + if ($this->refreshTTL === null) { + return $this->addForeverWithGracePeriod($payload); + } + + $expiresAt = $expiresAt->max( + $this->timestamp($issuedAt)->addMinutes($this->refreshTTL) + ); } - // if we have already added this token to the blacklist - if (! empty($this->storage->get($this->getKey($payload)))) { + $expiresAt = $expiresAt->addMinute(); + $now = Date::now(); + + // The unified boundary covers expiration acceptance, including leeway, and + // the refresh window, so terminal tokens need no cache I/O. + if ($expiresAt <= $now) { return true; } - $this->storage->add( - $this->getKey($payload), + $key = $this->getKey($payload); + + if (! empty($this->storage->get($key))) { + return true; + } + + return $this->storage->add( + $key, ['valid_until' => $this->getGraceTimestamp()], - $this->getMinutesUntilExpired($payload) + (int) ceil($now->diffInMinutes($expiresAt)), ); - - return true; } /** - * Get the number of minutes until the token expiry. + * Add the token (jti claim) to the blacklist indefinitely. */ - protected function getMinutesUntilExpired(array $payload): int + public function addForever(array $payload): bool { - $exp = $this->timestamp($payload['exp']); - $iat = $this->timestamp($payload['iat']); - - // get the latter of the two expiration dates and find - // the number of minutes until the expiration date, - // plus 1 minute to avoid overlap - return (int) ceil(abs( - $exp->max($iat->addMinutes($this->refreshTTL)) - ->addMinute() - ->diffInMinutes() - )); + return $this->storage->forever($this->getKey($payload), 'forever'); } /** - * Add the token (jti claim) to the blacklist indefinitely. + * Add the token to the blacklist indefinitely after its grace period. */ - public function addForever(array $payload): bool + protected function addForeverWithGracePeriod(array $payload): bool { - $this->storage->forever($this->getKey($payload), 'forever'); + $key = $this->getKey($payload); - return true; + // Rewriting the entry would restart its grace period on every concurrent refresh. + if (! empty($this->storage->get($key))) { + return true; + } + + return $this->storage->forever($key, ['valid_until' => $this->getGraceTimestamp()]); } /** @@ -105,9 +124,7 @@ public function remove(array $payload): bool */ public function clear(): bool { - $this->storage->flush(); - - return true; + return $this->storage->flush(); } /** @@ -129,7 +146,7 @@ protected function getGraceTimestamp(): int */ public function setGracePeriod(int $gracePeriod): static { - $this->gracePeriod = (int) $gracePeriod; + $this->gracePeriod = $gracePeriod; return $this; } @@ -145,13 +162,19 @@ public function getGracePeriod(): int /** * Get the unique key held within the blacklist. */ - public function getKey(array $payload): mixed + public function getKey(array $payload): string { - if (! $key = ($payload[$this->key] ?? null)) { - throw new TokenInvalidException("Claim `{$this->key}` is missing in payload for blacklist"); + $key = $payload[$this->key] ?? null; + + if (is_string($key) && $key !== '') { + return $key; + } + + if (is_int($key)) { + return (string) $key; } - return $key; + throw new TokenInvalidException("Claim `{$this->key}` is missing or invalid in payload for blacklist"); } /** @@ -177,9 +200,9 @@ public function setKey(string $key): static * * @return $this */ - public function setRefreshTTL(int $ttl): static + public function setRefreshTTL(?int $ttl): static { - $this->refreshTTL = (int) $ttl; + $this->refreshTTL = $ttl; return $this; } @@ -187,7 +210,7 @@ public function setRefreshTTL(int $ttl): static /** * Get the refresh time limit. */ - public function getRefreshTTL(): int + public function getRefreshTTL(): ?int { return $this->refreshTTL; } diff --git a/src/jwt/src/ClaimFactory.php b/src/jwt/src/ClaimFactory.php index 547fa1fd1..a60caf8b9 100644 --- a/src/jwt/src/ClaimFactory.php +++ b/src/jwt/src/ClaimFactory.php @@ -38,7 +38,7 @@ public function __construct(Repository $config) $issuer = $config->get('jwt.issuer'); $this->issuer = ($issuer === null || $issuer === '') ? null : $issuer; - $this->lockSubject = $config->boolean('jwt.lock_subject', true); + $this->lockSubject = $config->boolean('jwt.lock_subject'); } /** diff --git a/src/jwt/src/Contracts/StorageContract.php b/src/jwt/src/Contracts/StorageContract.php index ee6b12508..7b9a3a1f2 100644 --- a/src/jwt/src/Contracts/StorageContract.php +++ b/src/jwt/src/Contracts/StorageContract.php @@ -6,13 +6,13 @@ interface StorageContract { - public function add(string $key, mixed $value, int $minutes): void; + public function add(string $key, mixed $value, int $minutes): bool; - public function forever(string $key, mixed $value): void; + public function forever(string $key, mixed $value): bool; public function get(string $key): mixed; public function destroy(string $key): bool; - public function flush(): void; + public function flush(): bool; } diff --git a/src/jwt/src/JwtGuard.php b/src/jwt/src/JwtGuard.php index 159e730d2..1fdaa5319 100644 --- a/src/jwt/src/JwtGuard.php +++ b/src/jwt/src/JwtGuard.php @@ -379,15 +379,15 @@ public function logout(bool $forceForever = false): void $user = $this->cachedUser(); $token = $this->getToken(); + if ($token && $this->jwtManager->hasBlacklistEnabled()) { + $this->jwtManager->invalidate($token, $forceForever); + } + $this->forgetUser(); $this->forgetContextState('token'); if ($token) { CoroutineContext::forget($this->getPayloadContextKey($token)); - - if ($this->jwtManager->hasBlacklistEnabled()) { - $this->jwtManager->invalidate($token, $forceForever); - } } $this->fireLogoutEvent($user); diff --git a/src/jwt/src/JwtManager.php b/src/jwt/src/JwtManager.php index 955a402cf..2f57825e4 100644 --- a/src/jwt/src/JwtManager.php +++ b/src/jwt/src/JwtManager.php @@ -12,6 +12,7 @@ use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\TokenBlacklistedException; use Hypervel\Jwt\Exceptions\TokenExpiredException; +use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Jwt\Providers\Lcobucci; use Hypervel\Support\Facades\Date; use Hypervel\Support\Manager; @@ -35,7 +36,7 @@ public function __construct( ) { parent::__construct($container); - $this->blacklistEnabled = $this->config->boolean('jwt.blacklist_enabled', false); + $this->blacklistEnabled = $this->config->boolean('jwt.blacklist_enabled'); $this->blacklist = $this->blacklistEnabled ? $container->make(BlacklistContract::class) : null; @@ -64,7 +65,7 @@ public function createLcobucciDriver(): Lcobucci */ public function getDefaultDriver(): string { - return $this->config->string('jwt.driver', 'lcobucci'); + return $this->config->string('jwt.driver'); } /** @@ -99,7 +100,7 @@ public function decode(string $token, bool $validate = true, bool $checkBlacklis protected function validatePayload(array $payload, bool $refresh = false): void { - foreach ($this->config->array('jwt.validations', []) as $validation) { + foreach ($this->config->array('jwt.validations') as $validation) { $validation = $this->getValidation($validation); if ($refresh && $validation instanceof TemporalValidation) { @@ -134,15 +135,15 @@ public function refresh( if ($ttl === false) { /** @var null|int $ttl */ - $ttl = $this->config->get('jwt.ttl', 120); + $ttl = $this->config->get('jwt.ttl'); } $claims = $this->claimFactory->refresh( payload: $payload, ttl: $ttl, - refreshIssuedAt: $this->config->boolean('jwt.refresh_iat', false), + refreshIssuedAt: $this->config->boolean('jwt.refresh_iat'), resetClaims: $resetClaims, - persistentClaims: $this->config->array('jwt.persistent_claims', []), + persistentClaims: $this->config->array('jwt.persistent_claims'), customClaims: $customClaims, ); @@ -180,10 +181,16 @@ public function invalidate(string $token, bool $forceForever = false): bool throw new JwtException('You must have the blacklist enabled to invalidate a token.'); } - return call_user_func( - [$this->blacklist(), $forceForever ? 'addForever' : 'add'], - $this->decode($token, false, false) - ); + $payload = $this->decode($token, false, false); + $persisted = $forceForever + ? $this->blacklist()->addForever($payload) + : $this->blacklist()->add($payload); + + if (! $persisted) { + throw new JwtException('Unable to invalidate token because the blacklist write failed.'); + } + + return true; } /** @@ -191,14 +198,22 @@ public function invalidate(string $token, bool $forceForever = false): bool */ protected function validateRefreshWindow(array $payload): void { + $issuedAt = $payload['iat'] ?? null; + + // Blacklist retention for missing iat payloads depends on this rejection + // preceding the infinite-refresh return. + if ($issuedAt === null) { + throw new TokenInvalidException('Issued At (iat) claim is required to refresh a token.'); + } + /** @var null|int $refreshTtl */ - $refreshTtl = $this->config->get('jwt.refresh_ttl', 20160); + $refreshTtl = $this->config->get('jwt.refresh_ttl'); if ($refreshTtl === null) { return; } - if (Date::now() > Date::createFromTimestamp($payload['iat'])->addMinutes($refreshTtl)) { + if (Date::now() > Date::createFromTimestamp($issuedAt)->addMinutes($refreshTtl)) { throw new TokenExpiredException('Token has expired and can no longer be refreshed'); } } diff --git a/src/jwt/src/JwtServiceProvider.php b/src/jwt/src/JwtServiceProvider.php index b252c3b4a..72043323c 100644 --- a/src/jwt/src/JwtServiceProvider.php +++ b/src/jwt/src/JwtServiceProvider.php @@ -10,7 +10,7 @@ use Hypervel\Jwt\Console\JwtGenerateCertsCommand; use Hypervel\Jwt\Console\JwtSecretCommand; use Hypervel\Jwt\Contracts\BlacklistContract; -use Hypervel\Jwt\Http\Parser\AuthHeaders; +use Hypervel\Jwt\Contracts\StorageContract; use Hypervel\Jwt\Http\Parser\Cookie; use Hypervel\Jwt\Http\Parser\InputSource; use Hypervel\Jwt\Http\Parser\Parser; @@ -37,14 +37,14 @@ public function register(): void $this->app->singleton(Parser::class, function ($app) { $config = $app->make('config'); - $tokenKey = $config->string('jwt.token', 'token'); + $tokenKey = $config->string('jwt.token'); $chain = array_map( fn (string $extractor) => match ($extractor) { InputSource::class, Cookie::class => new $extractor($tokenKey), default => $app->make($extractor), }, - $config->array('jwt.parser', [AuthHeaders::class]), + $config->array('jwt.parser'), ); // The parser chain is stateless; request instances are passed per parse so @@ -55,19 +55,23 @@ public function register(): void $this->app->singleton(BlacklistContract::class, function ($app) { $config = $app->make('config'); - $storageClass = $config->string('jwt.providers.storage'); + $storageClass = $config->string('jwt.providers.storage', TaggedCache::class); $storage = match ($storageClass) { TaggedCache::class => new TaggedCache($this->cacheStoreForJwtBlacklist( $app, - $config->boolean('jwt.blacklist_enabled', false) + $config->boolean('jwt.blacklist_enabled') )), default => $app->make($storageClass), }; + /** @var null|int $refreshTtl */ + $refreshTtl = $config->get('jwt.refresh_ttl'); + return new Blacklist( - $storage, - $config->integer('jwt.blacklist_grace_period', 0), - $config->integer('jwt.blacklist_refresh_ttl', 20160) + storage: $storage, + gracePeriod: $config->integer('jwt.blacklist_grace_period'), + refreshTTL: $refreshTtl, + leeway: $config->integer('jwt.leeway'), ); }); @@ -105,7 +109,7 @@ protected function registerJwtGuard(): void /** @var null|int $ttl */ $ttl = array_key_exists('ttl', $config) ? $config['ttl'] - : $app->make('config')->get('jwt.ttl', 120); + : $app->make('config')->get('jwt.ttl'); $guard = new JwtGuard( name: $name, @@ -135,7 +139,8 @@ protected function cacheStoreForJwtBlacklist(Container $app, bool $blacklistEnab if ($blacklistEnabled && ! $repository->supportsTags()) { throw new RuntimeException( 'The JWT blacklist requires a taggable cache store (all-mode or any-mode). ' - . 'Use a taggable store or set a custom jwt.providers.storage.' + . 'Use a taggable store or configure a custom ' . StorageContract::class + . ' implementation in jwt.providers.storage.' ); } diff --git a/src/jwt/src/Storage/PsrCache.php b/src/jwt/src/Storage/PsrCache.php deleted file mode 100644 index d2cdb16f1..000000000 --- a/src/jwt/src/Storage/PsrCache.php +++ /dev/null @@ -1,59 +0,0 @@ -cache->set($key, $value, $minutes * 60); - } - - /** - * Add a new item into storage forever. - */ - public function forever(string $key, mixed $value): void - { - $this->cache->set($key, $value); - } - - /** - * Get an item from storage. - */ - public function get(string $key): mixed - { - return $this->cache->get($key); - } - - /** - * Remove an item from storage. - */ - public function destroy(string $key): bool - { - return $this->cache->delete($key); - } - - /** - * Remove all items associated with the tag. - */ - public function flush(): void - { - $this->cache->clear(); - } -} diff --git a/src/jwt/src/Storage/TaggedCache.php b/src/jwt/src/Storage/TaggedCache.php index cc07f215d..8a229eb0f 100644 --- a/src/jwt/src/Storage/TaggedCache.php +++ b/src/jwt/src/Storage/TaggedCache.php @@ -46,19 +46,19 @@ public function __construct( /** * Add a new item into storage. */ - public function add(string $key, mixed $value, int $minutes): void + public function add(string $key, mixed $value, int $minutes): bool { /* @phpstan-ignore-next-line */ - $this->cache->tags([$this->tag])->put($this->storageKey($key), $value, $minutes * 60); + return $this->cache->tags([$this->tag])->put($this->storageKey($key), $value, $minutes * 60); } /** * Add a new item into storage forever. */ - public function forever(string $key, mixed $value): void + public function forever(string $key, mixed $value): bool { /* @phpstan-ignore-next-line */ - $this->cache->tags([$this->tag])->forever($this->storageKey($key), $value); + return $this->cache->tags([$this->tag])->forever($this->storageKey($key), $value); } /** @@ -90,10 +90,10 @@ public function destroy(string $key): bool /** * Remove all items associated with the tag. */ - public function flush(): void + public function flush(): bool { /* @phpstan-ignore-next-line */ - $this->cache->tags([$this->tag])->flush(); + return $this->cache->tags([$this->tag])->flush(); } /** diff --git a/tests/Jwt/BlacklistTest.php b/tests/Jwt/BlacklistTest.php index 93ac3534a..2bcafbd82 100644 --- a/tests/Jwt/BlacklistTest.php +++ b/tests/Jwt/BlacklistTest.php @@ -7,6 +7,7 @@ use Hypervel\Jwt\Blacklist; use Hypervel\Jwt\Contracts\StorageContract; use Hypervel\Jwt\Exceptions\TokenInvalidException; +use Hypervel\Jwt\Validations\ExpiredClaim; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Date; use Hypervel\Tests\TestCase; @@ -27,6 +28,8 @@ class BlacklistTest extends TestCase protected function setUp(): void { + parent::setUp(); + CarbonImmutable::setTestNow('2000-01-01T00:00:00.000000Z'); $this->testNowTimestamp = Date::now()->timestamp; @@ -54,9 +57,10 @@ public function testAddAValidTokenToTheBlacklist(): void $this->storage->shouldReceive('add') ->with('foo', ['valid_until' => $this->testNowTimestamp], $refreshTTL + 1) - ->once(); + ->once() + ->andReturnTrue(); - $this->blacklist->setRefreshTTL($refreshTTL)->add($payload); + $this->assertTrue($this->blacklist->setRefreshTTL($refreshTTL)->add($payload)); } public function testAddATokenWithNoExpToTheBlacklistForever(): void @@ -69,9 +73,121 @@ public function testAddATokenWithNoExpToTheBlacklistForever(): void 'jti' => 'foo', ]; - $this->storage->shouldReceive('forever')->with('foo', 'forever')->once(); + $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); + $this->storage->shouldReceive('forever') + ->with('foo', ['valid_until' => $this->testNowTimestamp]) + ->once() + ->andReturnTrue(); + + $this->assertTrue($this->blacklist->add($payload)); + } + + public function testAddATokenWithNullExpirationToTheBlacklistForever(): void + { + $payload = [ + 'exp' => null, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + + $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); + $this->storage->shouldReceive('forever') + ->with('foo', ['valid_until' => $this->testNowTimestamp]) + ->once() + ->andReturnTrue(); + + $this->assertTrue($this->blacklist->add($payload)); + } + + public function testAddARefreshableTokenForeverWhenTheRefreshWindowIsDisabled(): void + { + $payload = [ + 'exp' => $this->testNowTimestamp + 3600, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + + $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); + $this->storage->shouldReceive('forever') + ->with('foo', ['valid_until' => $this->testNowTimestamp]) + ->once() + ->andReturnTrue(); + + $this->assertTrue($this->blacklist->setRefreshTTL(null)->add($payload)); + } + + public function testPermanentBlacklistEntryHonorsGraceWhenRefreshWindowIsDisabled(): void + { + $payload = [ + 'exp' => $this->testNowTimestamp + 3600, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + $entry = ['valid_until' => $this->testNowTimestamp + 300]; + $blacklist = new Blacklist($this->storage, gracePeriod: 300, refreshTTL: null); + + $this->storage->shouldReceive('get')->with('foo')->times(3)->andReturn(null, $entry, $entry); + $this->storage->shouldReceive('forever')->with('foo', $entry)->once()->andReturnTrue(); + + $this->assertTrue($blacklist->add($payload)); + $this->assertFalse($blacklist->has($payload)); + + CarbonImmutable::setTestNow(Date::now()->addSeconds(301)); + + $this->assertTrue($blacklist->has($payload)); + } + + public function testPermanentBlacklistEntryDoesNotSlideGraceOnRepeatedAdd(): void + { + $payload = [ + 'exp' => $this->testNowTimestamp + 3600, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + $entry = ['valid_until' => $this->testNowTimestamp + 300]; + $blacklist = new Blacklist($this->storage, gracePeriod: 300, refreshTTL: null); + + $this->storage->shouldReceive('get')->with('foo')->once()->andReturn($entry); + $this->storage->shouldReceive('forever')->never(); + + $this->assertTrue($blacklist->add($payload)); + } + + public function testAddTokenToBlacklistForeverReturnsTheStorageResult(): void + { + $payload = ['jti' => 'foo']; + + $this->storage->shouldReceive('forever')->with('foo', 'forever')->once()->andReturnFalse(); + + $this->assertFalse($this->blacklist->addForever($payload)); + } + + #[DataProvider('missingIssuedAtProvider')] + public function testMissingIssuedAtUsesTheFiniteExpirationBoundary(array $issuedAt): void + { + $payload = [ + 'exp' => $this->testNowTimestamp + 600, + 'jti' => 'foo', + ...$issuedAt, + ]; + $blacklist = new Blacklist($this->storage, refreshTTL: null, leeway: 60); + + $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); + $this->storage->shouldReceive('forever')->never(); + $this->storage->shouldReceive('add') + ->with('foo', ['valid_until' => $this->testNowTimestamp], 12) + ->once() + ->andReturnTrue(); - $this->blacklist->add($payload); + $this->assertTrue($blacklist->add($payload)); + } + + public static function missingIssuedAtProvider(): array + { + return [ + 'absent' => [[]], + 'null' => [['iat' => null]], + ]; } public function testReturnTrueWhenAddingAnExpiredTokenToTheBlacklist(): void @@ -94,7 +210,8 @@ public function testReturnTrueWhenAddingAnExpiredTokenToTheBlacklist(): void $this->storage->shouldReceive('add') ->with('foo', ['valid_until' => $this->testNowTimestamp], $refreshTTL + 1) - ->once(); + ->once() + ->andReturnTrue(); $this->assertTrue($this->blacklist->setRefreshTTL($refreshTTL)->add($payload)); } @@ -145,9 +262,150 @@ public function testBlacklistTtlRoundsFractionalMinutesUp(): void $this->storage->shouldReceive('add') ->with('foo', ['valid_until' => $nowTimestamp], 2) - ->once(); + ->once() + ->andReturnTrue(); - $this->blacklist->setRefreshTTL(0)->add($payload); + $this->assertTrue($this->blacklist->setRefreshTTL(0)->add($payload)); + } + + public function testFiniteLifetimeStaysPositiveWhenTheClockAdvancesDuringTheStorageLookup(): void + { + $base = CarbonImmutable::parse('2000-01-01T00:00:00.000000Z'); + + CarbonImmutable::setTestNow($base); + + $payload = [ + 'exp' => $base->getTimestamp() - 59, + 'iat' => $base->getTimestamp() - 59, + 'jti' => 'foo', + ]; + $blacklist = new Blacklist($this->storage, refreshTTL: 0); + + // Model the clock advancing while the tagged-cache lookup is in flight. + $this->storage->shouldReceive('get') + ->with('foo') + ->once() + ->andReturnUsing(function () use ($base) { + CarbonImmutable::setTestNow($base->addSeconds(2)); + + return null; + }); + $this->storage->shouldReceive('add') + ->with('foo', m::type('array'), m::on(fn (int $minutes): bool => $minutes >= 1)) + ->once() + ->andReturnTrue(); + + $this->assertTrue($blacklist->add($payload)); + } + + public function testExpirationLeewayCanDefineTheBlacklistLifetime(): void + { + $payload = [ + 'exp' => $this->testNowTimestamp + 600, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + $blacklist = new Blacklist($this->storage, refreshTTL: 5, leeway: 120); + + $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); + $this->storage->shouldReceive('add') + ->with('foo', ['valid_until' => $this->testNowTimestamp], 13) + ->once() + ->andReturnTrue(); + + $this->assertTrue($blacklist->add($payload)); + } + + public function testRefreshWindowCanDefineTheBlacklistLifetime(): void + { + $payload = [ + 'exp' => $this->testNowTimestamp + 600, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + $blacklist = new Blacklist($this->storage, refreshTTL: 20); + + $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); + $this->storage->shouldReceive('add') + ->with('foo', ['valid_until' => $this->testNowTimestamp], 21) + ->once() + ->andReturnTrue(); + + $this->assertTrue($blacklist->add($payload)); + } + + #[DataProvider('terminalExpirationProvider')] + public function testTerminalTokensSkipBlacklistStorage(int $expirationOffset): void + { + $payload = [ + 'exp' => $this->testNowTimestamp + $expirationOffset, + 'jti' => 'foo', + ]; + $blacklist = new Blacklist($this->storage, refreshTTL: null, leeway: 60); + + $this->storage->shouldReceive('get', 'add', 'forever')->never(); + + $this->assertTrue($blacklist->add($payload)); + } + + public static function terminalExpirationProvider(): array + { + return [ + 'elapsed' => [-121], + 'exact boundary' => [-120], + ]; + } + + public function testBlacklistEntryIsRetainedWhileExpirationLeewayStillAcceptsTheToken(): void + { + $payload = [ + 'exp' => $this->testNowTimestamp - 30, + 'jti' => 'foo', + ]; + $blacklist = new Blacklist($this->storage, refreshTTL: null, leeway: 60); + + // The same jwt.leeway feeds both owners: expiration is still accepted, + // so the entry must be written. + (new ExpiredClaim(['leeway' => 60]))->validate($payload); + + $this->storage->shouldReceive('get') + ->with('foo') + ->twice() + ->andReturn(null, ['valid_until' => $this->testNowTimestamp]); + $this->storage->shouldReceive('add') + ->with('foo', ['valid_until' => $this->testNowTimestamp], 2) + ->once() + ->andReturnTrue(); + + $this->assertTrue($blacklist->add($payload)); + $this->assertTrue($blacklist->has($payload)); + } + + public function testFiniteStorageFailureIsReturned(): void + { + $payload = [ + 'exp' => $this->testNowTimestamp + 60, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + + $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); + $this->storage->shouldReceive('add')->with('foo', m::type('array'), 2)->once()->andReturnFalse(); + + $this->assertFalse($this->blacklist->setRefreshTTL(0)->add($payload)); + } + + public function testPermanentStorageFailureIsReturned(): void + { + $payload = ['iat' => $this->testNowTimestamp, 'jti' => 'foo']; + + $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); + $this->storage->shouldReceive('forever') + ->with('foo', ['valid_until' => $this->testNowTimestamp]) + ->once() + ->andReturnFalse(); + + $this->assertFalse($this->blacklist->add($payload)); } public function testCheckWhetherATokenHasBeenBlacklisted(): void @@ -167,7 +425,7 @@ public function testCheckWhetherATokenHasBeenBlacklisted(): void } #[DataProvider('blacklistProvider')] - public function testCheckWhetherATokenHasNotBeenBlacklisted($result): void + public function testCheckWhetherATokenHasNotBeenBlacklisted(mixed $result): void { $payload = [ 'sub' => 1, @@ -253,17 +511,17 @@ public function testSetACustomUniqueKeyForTheBlacklist(): void 'jti' => 'foobar', ]; - $this->storage->shouldReceive('get')->with(1)->once()->andReturn(['valid_until' => $this->testNowTimestamp]); + $this->storage->shouldReceive('get')->with('1')->once()->andReturn(['valid_until' => $this->testNowTimestamp]); $this->assertTrue($this->blacklist->setKey('sub')->has($payload)); $this->assertSame('1', $this->blacklist->getKey($payload)); } - public function testEmptyTheBlacklist(): void + public function testEmptyTheBlacklistReturnsTheStorageResult(): void { - $this->storage->shouldReceive('flush'); + $this->storage->shouldReceive('flush')->once()->andReturnFalse(); - $this->assertTrue($this->blacklist->clear()); + $this->assertFalse($this->blacklist->clear()); } public function testSetAndGetTheBlacklistGracePeriod(): void @@ -278,13 +536,44 @@ public function testSetAndGetTheBlacklistRefreshTTL(): void $this->assertInstanceOf(Blacklist::class, $this->blacklist->setRefreshTTL(15)); $this->assertSame(15, $this->blacklist->getRefreshTTL()); + + $this->assertInstanceOf(Blacklist::class, $this->blacklist->setRefreshTTL(null)); + + $this->assertNull($this->blacklist->getRefreshTTL()); } public function testKeyNotExistsInPayload(): void { $this->expectException(TokenInvalidException::class); - $this->expectExceptionMessage('Claim `jti` is missing in payload for blacklist'); + $this->expectExceptionMessage('Claim `jti` is missing or invalid in payload for blacklist'); $this->blacklist->getKey([]); } + + public function testStringAndIntegerZeroAreValidBlacklistKeys(): void + { + $this->assertSame('0', $this->blacklist->getKey(['jti' => '0'])); + $this->assertSame('0', $this->blacklist->getKey(['jti' => 0])); + } + + #[DataProvider('invalidBlacklistKeyProvider')] + public function testInvalidBlacklistKeyShapeIsRejected(mixed $key): void + { + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessage('Claim `jti` is missing or invalid in payload for blacklist'); + + $this->blacklist->getKey(['jti' => $key]); + } + + public static function invalidBlacklistKeyProvider(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + 'boolean' => [false], + 'float' => [1.5], + 'array' => [[]], + 'object' => [(object) ['id' => 'foo']], + ]; + } } diff --git a/tests/Jwt/JwtConfigTest.php b/tests/Jwt/JwtConfigTest.php index 8913ba2f0..4e2546492 100644 --- a/tests/Jwt/JwtConfigTest.php +++ b/tests/Jwt/JwtConfigTest.php @@ -15,11 +15,10 @@ class JwtConfigTest extends TestCase { - public function testBlacklistDurationsAreLoadedAsIntegersFromEnvironment(): void + public function testBlacklistGracePeriodIsLoadedAsIntegerFromEnvironment(): void { $originalValues = $this->setEnvironmentVariables([ 'JWT_BLACKLIST_GRACE_PERIOD' => '30', - 'JWT_BLACKLIST_REFRESH_TTL' => '60', ]); try { @@ -28,13 +27,22 @@ public function testBlacklistDurationsAreLoadedAsIntegersFromEnvironment(): void $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; $this->assertSame(30, $config['blacklist_grace_period']); - $this->assertSame(60, $config['blacklist_refresh_ttl']); } finally { $this->restoreEnvironmentVariables($originalValues); Env::flushRepository(); } } + public function testObsoleteBlacklistRefreshTtlConfigurationIsAbsent(): void + { + $path = dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; + $contents = file_get_contents($path); + $config = require $path; + + $this->assertArrayNotHasKey('blacklist_refresh_ttl', $config); + $this->assertStringNotContainsString('JWT_BLACKLIST_REFRESH_TTL', $contents); + } + public function testLeewayIsLoadedAsIntegerFromEnvironment(): void { $originalValues = $this->setEnvironmentVariables([ diff --git a/tests/Jwt/JwtGuardEventTest.php b/tests/Jwt/JwtGuardEventTest.php index f4672d742..94c994390 100644 --- a/tests/Jwt/JwtGuardEventTest.php +++ b/tests/Jwt/JwtGuardEventTest.php @@ -18,6 +18,7 @@ use Hypervel\Http\Request; use Hypervel\Jwt\ClaimFactory; use Hypervel\Jwt\Contracts\ManagerContract; +use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Http\Parser\AuthHeaders; use Hypervel\Jwt\Http\Parser\InputSource; use Hypervel\Jwt\Http\Parser\Parser; @@ -143,6 +144,33 @@ public function testLogoutEventIsDispatchedWhenListening(): void $guard->logout(); } + public function testLogoutEventIsNotDispatchedWhenInvalidationFails(): void + { + $user = $this->user(1); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnTrue(); + $jwtManager->shouldReceive('invalidate') + ->with('token', false) + ->once() + ->andThrow(new JwtException('blacklist write failed')); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners', 'dispatch')->never(); + + $guard = $this->createGuard(jwtManager: $jwtManager, request: $this->requestWithToken('token')); + $guard->setUser($user); + $guard->setDispatcher($events); + + try { + $guard->logout(); + + $this->fail('Expected logout to fail when token invalidation fails.'); + } catch (JwtException $exception) { + $this->assertSame('blacklist write failed', $exception->getMessage()); + } + } + public function testAttemptingRegistersListener(): void { $listener = static fn (): null => null; diff --git a/tests/Jwt/JwtGuardTest.php b/tests/Jwt/JwtGuardTest.php index 80eec900f..5ff608653 100644 --- a/tests/Jwt/JwtGuardTest.php +++ b/tests/Jwt/JwtGuardTest.php @@ -545,6 +545,36 @@ public function testLogoutInvalidatesTokenAndClearsContext(): void $this->assertFalse($guard->hasUser()); } + public function testLogoutRetainsContextWhenTokenInvalidationFails(): void + { + $user = m::mock(Authenticatable::class); + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('valid-token')->once()->andReturn(['sub' => 1]); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnTrue(); + $jwtManager->shouldReceive('invalidate') + ->with('valid-token', false) + ->once() + ->andThrow(new JwtException('blacklist write failed')); + + $guard = $this->createGuard(jwtManager: $jwtManager, request: null) + ->setToken('valid-token') + ->setUser($user); + + $this->assertSame(['sub' => 1], $guard->getPayload()); + + try { + $guard->logout(); + + $this->fail('Expected logout to fail when token invalidation fails.'); + } catch (JwtException $exception) { + $this->assertSame('blacklist write failed', $exception->getMessage()); + } + + $this->assertSame('valid-token', $guard->getToken()); + $this->assertTrue($guard->hasUser()); + $this->assertSame(['sub' => 1], $guard->getPayload()); + } + public function testLogoutClearsDecodedPayloadCache(): void { $jwtManager = m::mock(ManagerContract::class); diff --git a/tests/Jwt/JwtManagerTest.php b/tests/Jwt/JwtManagerTest.php index 2627b0cef..23a85bc47 100644 --- a/tests/Jwt/JwtManagerTest.php +++ b/tests/Jwt/JwtManagerTest.php @@ -11,6 +11,7 @@ use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\TokenBlacklistedException; use Hypervel\Jwt\Exceptions\TokenExpiredException; +use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Jwt\JwtManager; use Hypervel\Jwt\Providers\Lcobucci; use Hypervel\Jwt\Validations\ExpiredClaim; @@ -23,6 +24,7 @@ use Hypervel\Tests\TestCase; use Mockery as m; use Mockery\MockInterface; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Uid\Uuid; class JwtManagerTest extends TestCase @@ -81,7 +83,7 @@ public function testEncodeAPayload(): void $this->mockUuid($jti); - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); $this->provider->shouldReceive('encode')->with($payload)->andReturn($token); $this->assertEquals($token, $this->createManager()->encode($payload)); @@ -95,7 +97,7 @@ public function testEncodeAddsJtiWhenBlacklistIsEnabledAndMissing(): void $this->mockUuid($jti); - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); $this->provider->shouldReceive('encode')->once()->with($payload + ['jti' => $jti])->andReturn($token); $this->assertSame($token, $this->createManager()->encode($payload)); @@ -106,7 +108,7 @@ public function testEncodeDoesNotAddJtiWhenBlacklistIsDisabled(): void $token = 'foo.bar.baz'; $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); $this->provider->shouldReceive('encode')->once()->with($payload)->andReturn($token); $this->assertSame($token, $this->createManager()->encode($payload)); @@ -119,7 +121,7 @@ public function testConstructorDoesNotResolveBlacklistWhenBlacklistIsDisabled(): $container->shouldReceive('make')->once()->with('config')->andReturn($config); $container->shouldReceive('make')->with(BlacklistContract::class)->never(); - $config->shouldReceive('boolean')->once()->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $config->shouldReceive('boolean')->once()->with('jwt.blacklist_enabled')->andReturnFalse(); $manager = new JwtManager($container, m::mock(ClaimFactory::class)); @@ -138,8 +140,8 @@ public function testDecodeAToken(): void 'jti' => 'foo', ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([ValidationStub::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([ValidationStub::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn([]); $this->provider->shouldReceive('decode')->with($token)->andReturn($payload); $this->blacklist->shouldReceive('has')->with($payload)->andReturn(false); @@ -162,8 +164,8 @@ public function testThrowExceptionWhenTokenIsBlacklisted(): void 'jti' => 'foo', ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([ValidationStub::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([ValidationStub::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn([]); $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); $this->blacklist->shouldReceive('has')->with($payload)->andReturn(true); @@ -194,13 +196,13 @@ public function testRefreshAToken(): void $this->mockUuid($refreshJti); - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([ValidationStub::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([ValidationStub::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn([]); - $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); - $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn(['iss']); - $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); - $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(20160); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn(['iss']); + $this->config->shouldReceive('get')->with('jwt.ttl')->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnFalse(); $this->claimFactory->shouldReceive('refresh')->once()->with( $payload, 120, @@ -212,7 +214,7 @@ public function testRefreshAToken(): void $this->provider->shouldReceive('decode')->twice()->with('foo.bar.baz')->andReturn($payload); $this->provider->shouldReceive('encode')->with($refreshPayload)->andReturn($refreshedToken); $this->blacklist->shouldReceive('has')->with($payload)->andReturn(false); - $this->blacklist->shouldReceive('add')->once()->with($payload); + $this->blacklist->shouldReceive('add')->once()->with($payload)->andReturnTrue(); $this->assertSame($refreshedToken, $this->createManager()->refresh($token)); } @@ -232,13 +234,13 @@ public function testRefreshDoesNotInvalidateOldTokenWhenEncodingReplacementFails 'iat' => $this->testNowTimestamp, ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub']]); - $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); - $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn([]); - $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); - $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(20160); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn([]); + $this->config->shouldReceive('get')->with('jwt.ttl')->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnFalse(); $this->claimFactory->shouldReceive('refresh')->once()->andReturn($refreshPayload); $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); $this->provider->shouldReceive('encode')->once()->with($refreshPayload + ['jti' => '11111111-1111-4111-8111-111111111111'])->andThrow(new JwtException('signing failed')); @@ -268,13 +270,13 @@ public function testRefreshOmitsExpirationWhenTtlIsNull(): void 'iat' => $this->testNowTimestamp, ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([ValidationStub::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([ValidationStub::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn([]); - $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); - $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn(['iss']); - $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(null); - $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(20160); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn(['iss']); + $this->config->shouldReceive('get')->with('jwt.ttl')->andReturn(null); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnFalse(); $this->claimFactory->shouldReceive('refresh')->once()->with( $payload, null, @@ -300,8 +302,8 @@ public function testDecodeStillRejectsExpiredTokensWhenExpiredClaimValidationIsE 'iat' => $this->testNowTimestamp, ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class, ExpiredClaim::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class, ExpiredClaim::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub']]); $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); @@ -323,13 +325,13 @@ public function testRefreshSkipsTemporalValidationsInsideRefreshWindow(): void 'exp' => $this->testNowTimestamp + 7200, ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class, ExpiredClaim::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class, ExpiredClaim::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub']]); - $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); - $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); - $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn([]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(20160); + $this->config->shouldReceive('get')->with('jwt.ttl')->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn([]); $this->claimFactory->shouldReceive('refresh')->once()->with( $payload, 120, @@ -356,8 +358,8 @@ public function testRefreshRejectsFutureNotBeforeClaim(): void 'nbf' => $this->testNowTimestamp + 3600, ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class, NotBeforeClaim::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class, NotBeforeClaim::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub'], 'leeway' => 0]); $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); $this->provider->shouldReceive('encode')->never(); @@ -380,13 +382,13 @@ public function testRefreshAllowsPastNotBeforeClaim(): void 'nbf' => $this->testNowTimestamp, ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class, NotBeforeClaim::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class, NotBeforeClaim::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub'], 'leeway' => 0]); - $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); - $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); - $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn([]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(20160); + $this->config->shouldReceive('get')->with('jwt.ttl')->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn([]); $this->claimFactory->shouldReceive('refresh')->once()->with( $payload, 120, @@ -414,12 +416,12 @@ public function testRefreshPassesResetClaimsCustomClaimsAndExplicitTtlToClaimFac 'tenant' => 'acme', ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub']]); - $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); - $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnTrue(); - $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn(['tenant']); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(20160); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn(['tenant']); $this->claimFactory->shouldReceive('refresh')->once()->with( $payload, null, @@ -454,10 +456,10 @@ public function testRefreshThrowsWhenRefreshWindowHasExpired(): void 'jti' => 'foo', ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([ValidationStub::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([ValidationStub::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn([]); - $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(10); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(10); $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); $this->provider->shouldReceive('encode')->never(); $this->blacklist->shouldReceive('has')->with($payload)->andReturn(false); @@ -485,13 +487,13 @@ public function testRefreshWindowCanBeDisabled(): void 'exp' => $this->testNowTimestamp + 7200, ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); - $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([ValidationStub::class]); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([ValidationStub::class]); $this->config->shouldReceive('array')->with('jwt')->andReturn([]); - $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(null); - $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn(['iss']); - $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); - $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(null); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn(['iss']); + $this->config->shouldReceive('get')->with('jwt.ttl')->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnFalse(); $this->claimFactory->shouldReceive('refresh')->once()->with( $payload, 120, @@ -506,24 +508,90 @@ public function testRefreshWindowCanBeDisabled(): void $this->assertSame($refreshedToken, $this->createManager()->refresh($token)); } - public function testInvalidateAToken(): void + #[DataProvider('missingIssuedAtProvider')] + public function testRefreshRejectsMissingIssuedAtBeforeAnInfiniteRefreshWindow(array $issuedAt): void + { + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessage('Issued At (iat) claim is required to refresh a token.'); + + $payload = [ + 'sub' => 1, + 'exp' => $this->testNowTimestamp + 3600, + ...$issuedAt, + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['sub']]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturnNull(); + $this->config->shouldReceive('get')->with('jwt.ttl')->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn([]); + $this->claimFactory->shouldReceive('refresh')->never(); + $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); + $this->provider->shouldReceive('encode')->never(); + + $this->createManager()->refresh('foo.bar.baz'); + } + + public static function missingIssuedAtProvider(): array + { + return [ + 'absent' => [[]], + 'null' => [['iat' => null]], + ]; + } + + public function testRefreshFailsWhenTheOldTokenCannotBeInvalidated(): void + { + $this->expectException(JwtException::class); + $this->expectExceptionMessage('Unable to invalidate token because the blacklist write failed.'); + + $payload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + $refreshPayload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + 'jti' => 'bar', + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([ValidationStub::class]); + $this->config->shouldReceive('array')->with('jwt')->andReturn([]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(20160); + $this->config->shouldReceive('get')->with('jwt.ttl')->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.persistent_claims')->andReturn([]); + $this->claimFactory->shouldReceive('refresh')->once()->andReturn($refreshPayload); + $this->provider->shouldReceive('decode')->twice()->with('foo.bar.baz')->andReturn($payload); + $this->provider->shouldReceive('encode')->once()->with($refreshPayload)->andReturn('baz.bar.foo'); + $this->blacklist->shouldReceive('has')->once()->with($payload)->andReturnFalse(); + $this->blacklist->shouldReceive('add')->once()->with($payload)->andReturnFalse(); + + $this->createManager()->refresh('foo.bar.baz'); + } + + public function testInvalidateAnExpiredButStructurallyValidToken(): void { $token = 'foo.bar.baz'; $payload = [ 'sub' => 1, 'iss' => 'http://example.com', - 'exp' => $this->testNowTimestamp + 3600, + 'exp' => $this->testNowTimestamp - 3600, 'nbf' => $this->testNowTimestamp, 'iat' => $this->testNowTimestamp, 'jti' => 'foo', ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations')->never(); $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); - $this->blacklist->shouldReceive('has')->with($payload)->andReturn(false); - $this->blacklist->shouldReceive('add')->with($payload)->andReturn(true); + $this->blacklist->shouldReceive('add')->once()->with($payload)->andReturnTrue(); - $this->createManager()->invalidate($token); + $this->assertTrue($this->createManager()->invalidate($token)); } public function testForceInvalidateATokenForever(): void @@ -538,15 +606,41 @@ public function testForceInvalidateATokenForever(): void 'jti' => 'foo', ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); - $this->blacklist->shouldReceive('has')->with($payload)->andReturn(false); - $this->blacklist->shouldReceive('addForever')->with($payload)->andReturn(true); + $this->blacklist->shouldReceive('addForever')->once()->with($payload)->andReturnTrue(); - $this->createManager()->invalidate($token, true); + $this->assertTrue($this->createManager()->invalidate($token, true)); + } + + #[DataProvider('failedInvalidationProvider')] + public function testInvalidateThrowsWhenBlacklistPersistenceFails(bool $forceForever, string $method): void + { + $this->expectException(JwtException::class); + $this->expectExceptionMessage('Unable to invalidate token because the blacklist write failed.'); + + $payload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); + $this->blacklist->shouldReceive($method)->once()->with($payload)->andReturnFalse(); + + $this->createManager()->invalidate('foo.bar.baz', $forceForever); + } + + public static function failedInvalidationProvider(): array + { + return [ + 'finite' => [false, 'add'], + 'forever' => [true, 'addForever'], + ]; } - public function testInvalidateIsIdempotentForAlreadyBlacklistedTokens(): void + public function testInvalidateDoesNotReadTheBlacklistBeforeWriting(): void { $token = 'foo.bar.baz'; $payload = [ @@ -558,7 +652,7 @@ public function testInvalidateIsIdempotentForAlreadyBlacklistedTokens(): void 'jti' => 'foo', ]; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); $this->blacklist->shouldNotReceive('has'); $this->blacklist->shouldReceive('add')->once()->with($payload)->andReturn(true); @@ -573,7 +667,7 @@ public function testThrowAnExceptionWhenEnableBlacklistIsSetToFalse(): void $token = 'foo.bar.baz'; - $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); $this->createManager()->invalidate($token); } @@ -616,7 +710,7 @@ private function mockClaimFactory(): void private function createManager(): JwtManager { - $this->config->shouldReceive('string')->with('jwt.driver', 'lcobucci')->andReturn('dummy'); + $this->config->shouldReceive('string')->with('jwt.driver')->andReturn('dummy'); $manager = new JwtManager($this->container, $this->claimFactory); $provider = $this->provider; diff --git a/tests/Jwt/JwtServiceProviderTest.php b/tests/Jwt/JwtServiceProviderTest.php index 127b32999..c76e9ad01 100644 --- a/tests/Jwt/JwtServiceProviderTest.php +++ b/tests/Jwt/JwtServiceProviderTest.php @@ -21,8 +21,12 @@ use Hypervel\Jwt\Http\Parser\Cookie; use Hypervel\Jwt\Http\Parser\Parser; use Hypervel\Jwt\JwtGuard; +use Hypervel\Jwt\JwtManager; use Hypervel\Jwt\JwtServiceProvider; +use Hypervel\Jwt\Providers\Lcobucci; use Hypervel\Jwt\Storage\TaggedCache; +use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Facades\Date; use Hypervel\Testbench\TestCase; use Mockery as m; use RuntimeException; @@ -116,7 +120,7 @@ public function testTaggedCacheStorageUsesCacheStore(): void $config->set('jwt.providers.storage', TaggedCache::class); $config->set('jwt.blacklist_enabled', true); $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.blacklist_refresh_ttl', 20160); + $config->set('jwt.refresh_ttl', 20160); $repository = m::mock(CacheRepository::class); $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); @@ -138,7 +142,7 @@ public function testTaggedCacheStorageAcceptsAnyModeCacheStore(): void $config->set('jwt.providers.storage', TaggedCache::class); $config->set('jwt.blacklist_enabled', true); $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.blacklist_refresh_ttl', 20160); + $config->set('jwt.refresh_ttl', 20160); $repository = m::mock(CacheRepository::class); $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); @@ -160,7 +164,7 @@ public function testTaggedCacheStorageAcceptsValidStackCacheStore(): void $config->set('jwt.providers.storage', TaggedCache::class); $config->set('jwt.blacklist_enabled', true); $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.blacklist_refresh_ttl', 20160); + $config->set('jwt.refresh_ttl', 20160); $repository = m::mock(CacheRepository::class); $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); @@ -185,7 +189,7 @@ public function testDisabledBlacklistAllowsNonTaggableCacheStore(): void $config->set('jwt.providers.storage', TaggedCache::class); $config->set('jwt.blacklist_enabled', false); $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.blacklist_refresh_ttl', 20160); + $config->set('jwt.refresh_ttl', 20160); $repository = m::mock(CacheRepository::class); $repository->shouldReceive('supportsTags')->never(); @@ -207,7 +211,7 @@ public function testDisabledBlacklistAllowsInvalidTaggableCacheStore(): void $config->set('jwt.providers.storage', TaggedCache::class); $config->set('jwt.blacklist_enabled', false); $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.blacklist_refresh_ttl', 20160); + $config->set('jwt.refresh_ttl', 20160); $store = m::mock(TaggableStore::class); $store->shouldReceive('supportsTags')->once()->andReturnFalse(); @@ -232,7 +236,8 @@ public function testEnabledTaggedCacheBlacklistRequiresTaggableCacheStore(): voi $this->expectException(RuntimeException::class); $this->expectExceptionMessage( 'The JWT blacklist requires a taggable cache store (all-mode or any-mode). ' - . 'Use a taggable store or set a custom jwt.providers.storage.' + . 'Use a taggable store or configure a custom ' . StorageContract::class + . ' implementation in jwt.providers.storage.' ); $config = $this->app->make('config'); @@ -256,7 +261,7 @@ public function testCustomBlacklistStorageBypassesTaggedCacheRequirement(): void $config->set('jwt.providers.storage', JwtServiceProviderCustomStorage::class); $config->set('jwt.blacklist_enabled', true); $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.blacklist_refresh_ttl', 20160); + $config->set('jwt.refresh_ttl', 20160); $cache = m::mock(); $cache->shouldReceive('store')->never(); @@ -269,6 +274,71 @@ public function testCustomBlacklistStorageBypassesTaggedCacheRequirement(): void $this->assertInstanceOf(Blacklist::class, $blacklist); } + public function testBlacklistReceivesFiniteRefreshTtlAndLeeway(): void + { + CarbonImmutable::setTestNow('2000-01-01T00:00:00.000000Z'); + + $config = $this->app->make('config'); + $config->set('jwt.providers.storage', JwtServiceProviderCustomStorage::class); + $config->set('jwt.refresh_ttl', 5); + $config->set('jwt.leeway', 120); + + $this->app->forgetInstance(BlacklistContract::class); + + /** @var Blacklist $blacklist */ + $blacklist = $this->app->make(BlacklistContract::class); + $storage = $this->app->make(JwtServiceProviderCustomStorage::class); + $now = Date::now()->timestamp; + + $this->assertSame(5, $blacklist->getRefreshTTL()); + $this->assertTrue($blacklist->add([ + 'exp' => $now + 600, + 'iat' => $now, + 'jti' => 'foo', + ])); + $this->assertSame(13, $storage->minutes); + } + + public function testBlacklistReceivesNullRefreshTtl(): void + { + $config = $this->app->make('config'); + $config->set('jwt.providers.storage', JwtServiceProviderCustomStorage::class); + $config->set('jwt.refresh_ttl', null); + + $this->app->forgetInstance(BlacklistContract::class); + + /** @var Blacklist $blacklist */ + $blacklist = $this->app->make(BlacklistContract::class); + $storage = $this->app->make(JwtServiceProviderCustomStorage::class); + + $this->assertNull($blacklist->getRefreshTTL()); + $this->assertTrue($blacklist->add([ + 'exp' => Date::now()->timestamp + 600, + 'iat' => Date::now()->timestamp, + 'jti' => 'foo', + ])); + $this->assertTrue($storage->foreverCalled); + } + + public function testOmittedStorageProviderUsesTheTaggedCacheDefault(): void + { + $config = $this->app->make('config'); + $config->set('jwt.providers', ['jwt' => Lcobucci::class]); + $config->set('jwt.blacklist_enabled', true); + + $repository = m::mock(CacheRepository::class); + $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); + $repository->shouldReceive('getStore')->once()->andReturn($this->taggableStore(TagMode::All)); + $cache = m::mock(); + $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); + + $this->app->instance('cache', $cache); + $this->app->forgetInstance(BlacklistContract::class); + $this->app->forgetInstance('jwt'); + + $this->assertInstanceOf(JwtManager::class, $this->app->make('jwt')); + } + protected function taggableStore(TagMode $mode): TaggableStore { /** @var TaggableStore $store */ @@ -282,12 +352,22 @@ protected function taggableStore(TagMode $mode): TaggableStore class JwtServiceProviderCustomStorage implements StorageContract { - public function add(string $key, mixed $value, int $minutes): void + public ?int $minutes = null; + + public bool $foreverCalled = false; + + public function add(string $key, mixed $value, int $minutes): bool { + $this->minutes = $minutes; + + return true; } - public function forever(string $key, mixed $value): void + public function forever(string $key, mixed $value): bool { + $this->foreverCalled = true; + + return true; } public function get(string $key): mixed @@ -300,7 +380,8 @@ public function destroy(string $key): bool return true; } - public function flush(): void + public function flush(): bool { + return true; } } diff --git a/tests/Jwt/Storage/PsrCacheTest.php b/tests/Jwt/Storage/PsrCacheTest.php deleted file mode 100644 index 808812754..000000000 --- a/tests/Jwt/Storage/PsrCacheTest.php +++ /dev/null @@ -1,62 +0,0 @@ -cache = m::mock(CacheInterface::class); - $this->storage = new PsrCache($this->cache); - } - - public function testAddTheItemToStorage() - { - $this->cache->shouldReceive('set')->with('foo', 'bar', 10 * 60)->once(); - - $this->storage->add('foo', 'bar', 10); - } - - public function testAddTheItemToStorageForever() - { - $this->cache->shouldReceive('set')->with('foo', 'bar')->once(); - - $this->storage->forever('foo', 'bar'); - } - - public function testGetAnItemFromStorage() - { - $this->cache->shouldReceive('get')->with('foo')->once()->andReturn(['foo' => 'bar']); - - $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); - } - - public function testRemoveTheItemFromStorage() - { - $this->cache->shouldReceive('delete')->with('foo')->once()->andReturn(true); - - $this->assertTrue($this->storage->destroy('foo')); - } - - public function testRemoveAllItemsFromStorage() - { - $this->cache->shouldReceive('clear')->withNoArgs()->once(); - - $this->storage->flush(); - } -} diff --git a/tests/Jwt/Storage/TaggedCacheTest.php b/tests/Jwt/Storage/TaggedCacheTest.php index 8ec28742a..fbc5df7a6 100644 --- a/tests/Jwt/Storage/TaggedCacheTest.php +++ b/tests/Jwt/Storage/TaggedCacheTest.php @@ -25,36 +25,36 @@ public function testAddTheItemToAllModeTaggedStorage(): void { $this->useStoreMode(TagMode::All); $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('put')->with('foo', 'bar', 10 * 60)->once(); + $this->cache->shouldReceive('put')->with('foo', 'bar', 10 * 60)->once()->andReturnFalse(); - $this->storage->add('foo', 'bar', 10); + $this->assertFalse($this->storage->add('foo', 'bar', 10)); } public function testAddTheItemToAnyModeTaggedStorageWithDirectKeyPrefix(): void { $this->useStoreMode(TagMode::Any); $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('put')->with('jwt_blacklist:foo', 'bar', 10 * 60)->once(); + $this->cache->shouldReceive('put')->with('jwt_blacklist:foo', 'bar', 10 * 60)->once()->andReturnTrue(); - $this->storage->add('foo', 'bar', 10); + $this->assertTrue($this->storage->add('foo', 'bar', 10)); } public function testAddTheItemToAllModeTaggedStorageForever(): void { $this->useStoreMode(TagMode::All); $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('forever')->with('foo', 'bar')->once(); + $this->cache->shouldReceive('forever')->with('foo', 'bar')->once()->andReturnFalse(); - $this->storage->forever('foo', 'bar'); + $this->assertFalse($this->storage->forever('foo', 'bar')); } public function testAddTheItemToAnyModeTaggedStorageForeverWithDirectKeyPrefix(): void { $this->useStoreMode(TagMode::Any); $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('forever')->with('jwt_blacklist:foo', 'bar')->once(); + $this->cache->shouldReceive('forever')->with('jwt_blacklist:foo', 'bar')->once()->andReturnTrue(); - $this->storage->forever('foo', 'bar'); + $this->assertTrue($this->storage->forever('foo', 'bar')); } public function testGetAnItemFromAllModeTaggedStorage(): void @@ -97,18 +97,18 @@ public function testRemoveAllAllModeTaggedItemsFromStorage(): void { $this->useStoreMode(TagMode::All); $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('flush')->withNoArgs()->once(); + $this->cache->shouldReceive('flush')->withNoArgs()->once()->andReturnFalse(); - $this->storage->flush(); + $this->assertFalse($this->storage->flush()); } public function testRemoveAllAnyModeTaggedItemsFromStorageUsesUnprefixedTagName(): void { $this->useStoreMode(TagMode::Any); $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('flush')->withNoArgs()->once(); + $this->cache->shouldReceive('flush')->withNoArgs()->once()->andReturnTrue(); - $this->storage->flush(); + $this->assertTrue($this->storage->flush()); } public function testConstructorDoesNotReadTagModeWhenStoreDoesNotSupportTags(): void From 8de1e5af16eca18eccfe81e5dce0a4a7fc6a4522 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:56:31 +0000 Subject: [PATCH 3/9] Publish JWT signing keys safely The certificate command wrote private and public keys directly, accepted RSA sizes rejected by the installed signer, and treated the valid passphrase string zero as empty. A failed or interrupted write could leave incomplete key material with permissive modes.\n\nUse the framework Filesystem owner to create the directory and atomically replace both key files with explicit private and public permissions. Reject RSA keys below 2048 bits before OpenSSL work begins, validate exported public-key contents, and preserve every non-empty passphrase.\n\nDeclare the direct Filesystem dependency and cover supported publication, file modes, invalid RSA sizes, passphrase encryption, overwrite behavior, and the existing EC validation paths. --- src/jwt/composer.json | 2 +- .../src/Console/JwtGenerateCertsCommand.php | 32 ++++---- .../Console/JwtGenerateCertsCommandTest.php | 75 +++++++++++++++---- 3 files changed, 77 insertions(+), 32 deletions(-) diff --git a/src/jwt/composer.json b/src/jwt/composer.json index 26564d11a..249eebbb4 100644 --- a/src/jwt/composer.json +++ b/src/jwt/composer.json @@ -26,7 +26,6 @@ "php": "^8.4", "lcobucci/jwt": "^5.0", "nesbot/carbon": "^3.13.1", - "psr/simple-cache": "^3.0", "hypervel/auth": "^0.4", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", @@ -34,6 +33,7 @@ "hypervel/console": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/filesystem": "^0.4", "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", "hypervel/support": "^0.4", diff --git a/src/jwt/src/Console/JwtGenerateCertsCommand.php b/src/jwt/src/Console/JwtGenerateCertsCommand.php index 13160880b..87f0b5976 100644 --- a/src/jwt/src/Console/JwtGenerateCertsCommand.php +++ b/src/jwt/src/Console/JwtGenerateCertsCommand.php @@ -5,6 +5,7 @@ namespace Hypervel\Jwt\Console; use Hypervel\Console\Command; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Env; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; @@ -42,7 +43,7 @@ class JwtGenerateCertsCommand extends Command /** * Execute the console command. */ - public function handle(): int + public function handle(Filesystem $files): int { $directory = $this->resolvePath((string) $this->option('dir')); $algorithm = strtolower((string) $this->option('algo')); @@ -66,6 +67,10 @@ public function handle(): int default => throw new RuntimeException('Unknown JWT certificate algorithm.'), }; + if ($keyType === OPENSSL_KEYTYPE_RSA && $bits < 2048) { + throw new RuntimeException('JWT RSA certificates must use at least 2048 bits.'); + } + if ($keyType === OPENSSL_KEYTYPE_EC) { $this->validateEcCurve($sha, $curve); } @@ -104,26 +109,15 @@ public function handle(): int } $details = openssl_pkey_get_details($key); + $publicKey = $details === false ? null : ($details['key'] ?? null); - if ($details === false || ! is_string($details['key'] ?? null)) { + if (! is_string($publicKey)) { throw new RuntimeException('Unable to export JWT public key.'); } - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create directory [{$directory}]."); - } - - if (file_put_contents($privateKeyPath, $privateKey) === false) { - throw new RuntimeException("Unable to write private key to [{$privateKeyPath}]."); - } - - if (! chmod($privateKeyPath, 0600)) { - throw new RuntimeException("Unable to secure private key [{$privateKeyPath}]."); - } - - if (file_put_contents($publicKeyPath, $details['key']) === false) { - throw new RuntimeException("Unable to write public key to [{$publicKeyPath}]."); - } + $files->ensureDirectoryExists($directory); + $files->replace($privateKeyPath, $privateKey, 0600); + $files->replace($publicKeyPath, $publicKey, 0644); Env::writeVariables([ 'JWT_ALGO' => $algorithmIdentifier, @@ -165,7 +159,9 @@ protected function validateEcCurve(int $sha, string $curve): void protected function resolvePassphrase(): ?string { if ($this->option('ask-passphrase')) { - return $this->secret('Passphrase') ?: null; + $passphrase = $this->secret('Passphrase'); + + return $passphrase !== null && $passphrase !== '' ? $passphrase : null; } $passphrase = $this->option('passphrase'); diff --git a/tests/Jwt/Console/JwtGenerateCertsCommandTest.php b/tests/Jwt/Console/JwtGenerateCertsCommandTest.php index cb9f283fb..e31531fe7 100644 --- a/tests/Jwt/Console/JwtGenerateCertsCommandTest.php +++ b/tests/Jwt/Console/JwtGenerateCertsCommandTest.php @@ -64,17 +64,22 @@ public function testGeneratesCertificatesAndWritesEnvironmentVariables(): void $this->artisan('jwt:generate-certs', [ '--force' => true, '--algo' => 'rsa', - '--bits' => 512, + '--bits' => 2048, '--sha' => 256, '--dir' => $directory, '--passphrase' => 'secret', ])->assertSuccessful(); - $privateKeyPath = $directory . '/jwt-rsa-512-private.pem'; - $publicKeyPath = $directory . '/jwt-rsa-512-public.pem'; + $privateKeyPath = $directory . '/jwt-rsa-2048-private.pem'; + $publicKeyPath = $directory . '/jwt-rsa-2048-public.pem'; + $this->assertDirectoryExists($directory); $this->assertFileExists($privateKeyPath); $this->assertFileExists($publicKeyPath); + $this->assertStringContainsString('-----BEGIN ENCRYPTED PRIVATE KEY-----', file_get_contents($privateKeyPath)); + $this->assertStringContainsString('-----BEGIN PUBLIC KEY-----', file_get_contents($publicKeyPath)); + $this->assertSame('0600', substr(sprintf('%o', fileperms($privateKeyPath)), -4)); + $this->assertSame('0644', substr(sprintf('%o', fileperms($publicKeyPath)), -4)); $contents = file_get_contents($this->app->environmentFilePath()); @@ -90,13 +95,13 @@ public function testGeneratesUnencryptedPrivateKeyWhenNoPassphraseIsConfigured() $this->artisan('jwt:generate-certs', [ '--force' => true, - '--algo' => 'rsa', - '--bits' => 512, + '--algo' => 'ec', '--sha' => 256, '--dir' => $directory, + '--curve' => 'prime256v1', ])->assertSuccessful(); - $privateKey = file_get_contents($directory . '/jwt-rsa-512-private.pem'); + $privateKey = file_get_contents($directory . '/jwt-ec-prime256v1-private.pem'); $this->assertStringContainsString('-----BEGIN PRIVATE KEY-----', $privateKey); $this->assertStringNotContainsString('-----BEGIN ENCRYPTED PRIVATE KEY-----', $privateKey); @@ -109,7 +114,6 @@ public function testGeneratesEcCertificates(): void $this->artisan('jwt:generate-certs', [ '--force' => true, '--algo' => 'ec', - '--bits' => 256, '--sha' => 256, '--dir' => $directory, '--curve' => 'prime256v1', @@ -175,6 +179,51 @@ public function testRejectsUnsupportedShaVariant(): void ]); } + public function testRejectsRsaKeysBelow2048BitsBeforeGeneration(): void + { + $directory = $this->temporaryDirectory('weak-rsa'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('JWT RSA certificates must use at least 2048 bits.'); + + try { + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'rsa', + '--bits' => 1024, + '--sha' => 256, + '--dir' => $directory, + ]); + } finally { + $this->assertDirectoryDoesNotExist($directory); + } + } + + public function testInteractivePassphrasePreservesStringZero(): void + { + $directory = $this->temporaryDirectory('zero-passphrase'); + + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'ec', + '--sha' => 256, + '--dir' => $directory, + '--curve' => 'prime256v1', + '--ask-passphrase' => true, + ]) + ->expectsQuestion('Passphrase', '0') + ->assertSuccessful(); + + $this->assertStringContainsString( + 'JWT_PASSPHRASE=0', + file_get_contents($this->app->environmentFilePath()), + ); + $this->assertStringContainsString( + '-----BEGIN ENCRYPTED PRIVATE KEY-----', + file_get_contents($directory . '/jwt-ec-prime256v1-private.pem'), + ); + } + public function testRefusesToOverwriteExistingCertificatesWithoutForce(): void { $directory = $this->temporaryDirectory('existing'); @@ -182,18 +231,18 @@ public function testRefusesToOverwriteExistingCertificatesWithoutForce(): void if (! is_dir($directory)) { mkdir($directory, 0777, true); } - file_put_contents($directory . '/jwt-rsa-512-private.pem', 'existing'); + file_put_contents($directory . '/jwt-ec-prime256v1-private.pem', 'existing'); $this->artisan('jwt:generate-certs', [ - '--algo' => 'rsa', - '--bits' => 512, + '--algo' => 'ec', '--sha' => 256, '--dir' => $directory, + '--curve' => 'prime256v1', ]) ->expectsOutputToContain('JWT certificates already exist. Use --force to overwrite them.') ->assertExitCode(Command::FAILURE); - $this->assertSame('existing', file_get_contents($directory . '/jwt-rsa-512-private.pem')); + $this->assertSame('existing', file_get_contents($directory . '/jwt-ec-prime256v1-private.pem')); } public function testFailsWhenEnvironmentFileIsMissing(): void @@ -205,10 +254,10 @@ public function testFailsWhenEnvironmentFileIsMissing(): void $this->artisan('jwt:generate-certs', [ '--force' => true, - '--algo' => 'rsa', - '--bits' => 512, + '--algo' => 'ec', '--sha' => 256, '--dir' => $directory, + '--curve' => 'prime256v1', ]) ->expectsOutputToContain("The file [{$environmentFile}] does not exist.") ->assertExitCode(Command::FAILURE); From 42caf16895acd373b0b7f9a3622ad55448eaeb58 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:56:48 +0000 Subject: [PATCH 4/9] Document JWT security and extension contracts Bring the canonical JWT guide in line with the corrected runtime behavior. Explain custom signing drivers and storage implementations, the taggable-cache requirement, unified refresh and blacklist retention, grace-aware invalidation, transactional logout failures, and the RSA key-size floor.\n\nKeep the prose application-focused and Laravel-shaped. Narrow the refresh example to token validity failures, describe key configuration accurately, and avoid presenting infrastructure or configuration failures as authentication errors. --- src/boost/docs/jwt.md | 51 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/boost/docs/jwt.md b/src/boost/docs/jwt.md index 4e28f2ee9..af31ed295 100644 --- a/src/boost/docs/jwt.md +++ b/src/boost/docs/jwt.md @@ -9,6 +9,7 @@ - [Configuring the Guard](#configuring-the-guard) - [User Models](#user-models) - [Signing Keys and Algorithms](#signing-keys-and-algorithms) + - [Custom Drivers](#custom-drivers) - [Token Lifetime](#token-lifetime) - [Subject Locking](#subject-locking) - [Token Sources](#token-sources) @@ -22,7 +23,7 @@ - [Logging Out and Invalidating Tokens](#logging-out-and-invalidating-tokens) - [Guard Methods](#guard-methods) - [Exceptions](#exceptions) -- [Differences From "php-open-source-saver/jwt-auth"](#differences-from-php-open-source-saver-jwt-auth) +- [Differences From php-open-source-saver/jwt-auth](#differences-from-php-open-source-saver-jwt-auth) ## Introduction @@ -91,6 +92,8 @@ php artisan jwt:generate-certs --force --algo=rsa --bits=4096 --sha=512 php artisan jwt:generate-certs --force --algo=ec --curve=prime256v1 --sha=256 ``` +RSA keys must be at least 2048 bits. + You may change the output directory using `--dir`. The directory may be absolute or relative to your application's base path. You may protect the private key with a passphrase using `--passphrase`, or prompt for it interactively using `--ask-passphrase`: @@ -186,7 +189,30 @@ For RSA and EC algorithms, configure `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, and `J ], ``` -The key values may be key contents or `file://` paths. +The key values may be key contents or a `file://` URI. + + +### Custom Drivers + +Custom JWT providers must implement the `Hypervel\Jwt\Contracts\ProviderContract` contract, which defines the `encode` and `decode` methods. + +You may register a custom JWT provider using the `extend` method. This is typically done in the `boot` method of a service provider: + +```php +use App\Jwt\CustomJwtProvider; +use Hypervel\Support\Facades\Jwt; + +public function boot(): void +{ + Jwt::extend('custom', fn ($app) => $app->make(CustomJwtProvider::class)); +} +``` + +After registering the driver, you may select it using the `driver` configuration option: + +```php +'driver' => 'custom', +``` ### Token Lifetime @@ -338,6 +364,8 @@ The blacklist uses the configured storage provider: The default tagged-cache storage requires your default cache store to support tags. Both all-mode and any-mode tagged stores are supported. When using any-mode tags, blacklist entries are written through tags but read and removed by a private plain-key prefix. +If your cache store does not support tags, implement `Hypervel\Jwt\Contracts\StorageContract` and configure your implementation using `jwt.providers.storage`. + If the blacklist store uses a cache stack or any node-local tier, a revoked token may still validate on another node until that node's local cache entry expires. Keep the upper-tier TTL short, or use a fully shared store such as Redis when revocation must be visible immediately across all nodes. You may configure a grace period for concurrent requests that are using the same token while a refresh is in progress: @@ -346,10 +374,10 @@ You may configure a grace period for concurrent requests that are using the same 'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0), ``` -The `blacklist_refresh_ttl` option keeps blacklist entries long enough to cover the token's refresh window: +The `refresh_ttl` option also controls how long blacklist entries are retained. When the refresh lifetime is `null`, revocations for refreshable tokens are retained forever: ```php -'blacklist_refresh_ttl' => env('JWT_BLACKLIST_REFRESH_TTL', 20160), +'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), ``` @@ -436,13 +464,15 @@ $newToken = Auth::guard('api')->refresh(); Expose refresh through a dedicated endpoint: ```php -use Hypervel\Jwt\Exceptions\JwtException; +use Hypervel\Jwt\Exceptions\TokenBlacklistedException; +use Hypervel\Jwt\Exceptions\TokenExpiredException; +use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Support\Facades\Auth; Route::post('/token/refresh', function () { try { $token = Auth::guard('api')->refresh(); - } catch (JwtException) { + } catch (TokenInvalidException|TokenExpiredException|TokenBlacklistedException) { abort(401, 'Token cannot be refreshed.'); } @@ -491,19 +521,21 @@ Managed claims such as `nbf`, `exp`, `iss`, and `jti` are rebuilt by the package ### Logging Out and Invalidating Tokens -The `logout` method clears the current guard user and token. If blacklist is enabled, it also invalidates the current token: +The `logout` method clears the guard's user, token, and decoded payload. When blacklisting is enabled, it invalidates the current token first: ```php Auth::guard('api')->logout(); ``` +If the blacklist write fails, a `JwtException` is thrown. The guard keeps its current state and does not dispatch the `Logout` event. + To invalidate a token directly, enable the blacklist and call `invalidate`: ```php Auth::guard('api')->invalidate(); ``` -You may pass `true` to blacklist the token forever: +You may pass `true` to blacklist the token forever. This also bypasses the configured grace period, so the revocation takes effect immediately: ```php Auth::guard('api')->invalidate(true); @@ -556,7 +588,7 @@ Common exceptions include: -## Differences From "php-open-source-saver/jwt-auth" +## Differences From php-open-source-saver/jwt-auth Hypervel JWT is based on `php-open-source-saver/jwt-auth`, but its internals are adapted for Hypervel: @@ -564,7 +596,6 @@ Hypervel JWT is based on `php-open-source-saver/jwt-auth`, but its internals are - Hypervel uses array payloads instead of upstream `Payload`, `Token`, and claim DTO objects. - Hypervel keeps the `Jwt` facade mapped to the array-based `JwtManager`, but does not include upstream `JwtAuth`, `JwtFactory`, or `JwtProvider` facades. -- The parser chain is stateless. Request instances are passed to the parser for each parse so coroutine requests cannot leak through singleton services. - Cookie token parsing is available but not enabled by default. - Upstream route-parameter and Lumen parser shortcuts are not included. - Upstream sliding refresh middleware is not included; use an explicit refresh endpoint that calls `Auth::guard(...)->refresh()`. From 8d76c748068d5c1a489db5cd3baf2ffd385988af Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:57:01 +0000 Subject: [PATCH 5/9] Keep the JWT package README focused Make the package README a thin entry point instead of a second documentation surface. Link to the canonical JWT guide, retain only the public differences developers must account for, and keep the tracked upstream reference last.\n\nRemove implementation detail and duplicated package guidance that would otherwise drift from the framework documentation. --- src/jwt/README.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/jwt/README.md b/src/jwt/README.md index cca127dba..2a9e0c8ce 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -1,21 +1,16 @@ JWT for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/jwt) +Documentation: https://hypervel.org/docs/jwt -Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth - -This package provides stateless JWT authentication for Hypervel applications, adapted for long-lived Swoole workers and coroutine-safe request state. - -## Differences From "php-open-source-saver/jwt-auth" +## Differences From php-open-source-saver/jwt-auth - Hypervel uses array payloads instead of upstream `Payload`, `Token`, and claim DTO objects. - Hypervel keeps the `Jwt` facade mapped to the array-based `JwtManager`, but does not include upstream `JwtAuth`, `JwtFactory`, or `JwtProvider` facades. -- Hypervel's parser chain is stateless and receives the request for each parse so coroutine requests cannot leak through singleton services. - Cookie token parsing is available but not enabled by default. - Upstream route-parameter and Lumen parser shortcuts are not included. - Upstream sliding refresh middleware is not included; use an explicit refresh endpoint that calls `Auth::guard(...)->refresh()`. - Namshi and Lumen integrations are not included. - The `show_black_list_exception` option is not included; JWT exceptions fail normally. -Full usage docs are available in `src/boost/docs/jwt.md`. +Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth From a84c7099f7987339effdc703e4a8abc685b0b562 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:57:17 +0000 Subject: [PATCH 6/9] Tighten JWT test fixtures Bring the remaining JWT fixtures in line with the repository's test conventions. Add explicit void returns, remove unused untyped state, and leave each test responsible only for behavior it actually exercises.\n\nThese changes keep the suite strict and readable without adding production code or test-only framework machinery. --- tests/Jwt/JwtGuardStaticStateTest.php | 2 +- tests/Jwt/Providers/ProviderTest.php | 8 +++----- tests/Jwt/Validations/RequiredClaimsTest.php | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/Jwt/JwtGuardStaticStateTest.php b/tests/Jwt/JwtGuardStaticStateTest.php index 758f5f79d..09a3ea246 100644 --- a/tests/Jwt/JwtGuardStaticStateTest.php +++ b/tests/Jwt/JwtGuardStaticStateTest.php @@ -9,7 +9,7 @@ class JwtGuardStaticStateTest extends TestCase { - public function testFlushStateClearsMacros() + public function testFlushStateClearsMacros(): void { JwtGuard::macro('testMacro', function () { return 'test'; diff --git a/tests/Jwt/Providers/ProviderTest.php b/tests/Jwt/Providers/ProviderTest.php index 1fed9f238..b27008489 100644 --- a/tests/Jwt/Providers/ProviderTest.php +++ b/tests/Jwt/Providers/ProviderTest.php @@ -9,9 +9,7 @@ class ProviderTest extends TestCase { - protected $provider; - - public function testSetTheAlgo() + public function testSetTheAlgo(): void { $provider = new ProviderStub('secret', 'HS256', []); @@ -20,7 +18,7 @@ public function testSetTheAlgo() $this->assertSame('HS512', $provider->getAlgo()); } - public function testSetTheSecret() + public function testSetTheSecret(): void { $provider = new ProviderStub('secret', 'HS256', []); @@ -29,7 +27,7 @@ public function testSetTheSecret() $this->assertSame('foo', $provider->getSecret()); } - public function testSetTheKeys() + public function testSetTheKeys(): void { $provider = new ProviderStub('secret', 'HS256', []); diff --git a/tests/Jwt/Validations/RequiredClaimsTest.php b/tests/Jwt/Validations/RequiredClaimsTest.php index d4a9f7c62..73d8bcc1c 100644 --- a/tests/Jwt/Validations/RequiredClaimsTest.php +++ b/tests/Jwt/Validations/RequiredClaimsTest.php @@ -10,7 +10,7 @@ class RequiredClaimsTest extends TestCase { - public function testValid() + public function testValid(): void { $this->expectNotToPerformAssertions(); @@ -18,7 +18,7 @@ public function testValid() (new RequiredClaims(['required_claims' => ['sub']]))->validate(['sub' => 'foo']); } - public function testInvalid() + public function testInvalid(): void { $this->expectException(TokenInvalidException::class); $this->expectExceptionMessage('Claims are missing: ["sub"]'); From d2fbf8f12cc21ffbb86deec880c24b6ffa3d6f39 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:57:33 +0000 Subject: [PATCH 7/9] Correct malformed Mockery prohibitions Several tests passed multiple method names to shouldNotReceive even though Mockery registers that call as one method expectation. The assertions looked strict but did not prohibit the intended calls.\n\nRegister each prohibited method through a never expectation and remove dead arguments from the single Database prohibition. This makes the existing tests enforce their stated contracts without changing framework behavior or adding recurrence machinery. --- tests/Auth/AuthPasswordBrokerTest.php | 2 +- tests/Auth/AuthTokenGuardTest.php | 4 ++-- tests/Console/CommandMutexTest.php | 2 +- tests/Database/DatabaseTransactionsTest.php | 2 +- tests/HttpServer/ServerTest.php | 2 +- tests/WebSocketServer/ServerHandshakeTest.php | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Auth/AuthPasswordBrokerTest.php b/tests/Auth/AuthPasswordBrokerTest.php index b0a5faed0..388f82ddf 100755 --- a/tests/Auth/AuthPasswordBrokerTest.php +++ b/tests/Auth/AuthPasswordBrokerTest.php @@ -107,7 +107,7 @@ public function testEventDispatcherCanBeReplacedOnAnExistingBroker(): void { $mocks = $this->getMocks(); $originalEvents = m::mock(Dispatcher::class); - $originalEvents->shouldNotReceive('hasListeners', 'dispatch'); + $originalEvents->shouldReceive('hasListeners', 'dispatch')->never(); $replacementEvents = m::mock(Dispatcher::class); $replacementEvents->shouldReceive('hasListeners')->once()->with(PasswordResetLinkSent::class)->andReturnTrue(); $replacementEvents->shouldReceive('dispatch') diff --git a/tests/Auth/AuthTokenGuardTest.php b/tests/Auth/AuthTokenGuardTest.php index 3a65fe497..0d291e7a7 100644 --- a/tests/Auth/AuthTokenGuardTest.php +++ b/tests/Auth/AuthTokenGuardTest.php @@ -284,7 +284,7 @@ public function testTokenLookupStopsAfterTheFirstNonEmptyString(): void $provider = m::mock(UserProvider::class); $request = m::mock(Request::class)->makePartial(); $request->shouldReceive('query')->once()->with('api_token')->andReturn('query-token'); - $request->shouldNotReceive('input', 'bearerToken', 'getPassword'); + $request->shouldReceive('input', 'bearerToken', 'getPassword')->never(); $guard = $this->createGuard($provider, $request); @@ -310,7 +310,7 @@ public function testTokenLookupPreservesStringZero(): void $provider = m::mock(UserProvider::class); $request = m::mock(Request::class)->makePartial(); $request->shouldReceive('query')->once()->with('api_token')->andReturn('0'); - $request->shouldNotReceive('input', 'bearerToken', 'getPassword'); + $request->shouldReceive('input', 'bearerToken', 'getPassword')->never(); $guard = $this->createGuard($provider, $request); diff --git a/tests/Console/CommandMutexTest.php b/tests/Console/CommandMutexTest.php index 1da1395fa..82b1b3f14 100644 --- a/tests/Console/CommandMutexTest.php +++ b/tests/Console/CommandMutexTest.php @@ -101,7 +101,7 @@ public function testCommandReleasesTheExactMutexInstanceThatItAcquired(): void $acquiredMutex->shouldReceive('create')->once()->with($this->command)->andReturnTrue(); $acquiredMutex->shouldReceive('forget')->once()->with($this->command)->andReturnTrue(); - $unusedMutex->shouldNotReceive('create', 'forget'); + $unusedMutex->shouldReceive('create', 'forget')->never(); $this->app->bind(CommandMutex::class, function () use ($acquiredMutex, $unusedMutex, &$resolutions) { return $resolutions++ === 0 ? $acquiredMutex : $unusedMutex; diff --git a/tests/Database/DatabaseTransactionsTest.php b/tests/Database/DatabaseTransactionsTest.php index 1d0148535..11ad483a4 100644 --- a/tests/Database/DatabaseTransactionsTest.php +++ b/tests/Database/DatabaseTransactionsTest.php @@ -192,7 +192,7 @@ public function testTransactionIsRolledBackUsingSeparateMethods() $transactionManager = m::mock(new DatabaseTransactionsManager); $transactionManager->shouldReceive('begin')->once()->with('default', 1); $transactionManager->shouldReceive('rollback')->once()->with('default', 0); - $transactionManager->shouldNotReceive('commit', 1, 0); + $transactionManager->shouldNotReceive('commit'); $this->connection()->setTransactionManager($transactionManager); diff --git a/tests/HttpServer/ServerTest.php b/tests/HttpServer/ServerTest.php index 8af07241b..742556200 100644 --- a/tests/HttpServer/ServerTest.php +++ b/tests/HttpServer/ServerTest.php @@ -466,7 +466,7 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio $this->setServerName($server, 'http'); $swooleResponse = m::mock(SwooleResponse::class); - $swooleResponse->shouldNotReceive('status', 'header', 'cookie', 'rawcookie', 'write', 'sendfile', 'end'); + $swooleResponse->shouldReceive('status', 'header', 'cookie', 'rawcookie', 'write', 'sendfile', 'end')->never(); try { wait(fn () => $server->onRequest($this->createSwooleRequest(), $swooleResponse)); diff --git a/tests/WebSocketServer/ServerHandshakeTest.php b/tests/WebSocketServer/ServerHandshakeTest.php index ae3c3f167..bab58bc1e 100644 --- a/tests/WebSocketServer/ServerHandshakeTest.php +++ b/tests/WebSocketServer/ServerHandshakeTest.php @@ -136,7 +136,7 @@ public function testHandshakeCancellationSkipsFallbackEmissionAndReleasesContext $nativeServer->shouldNotReceive('isEstablished'); $response = m::mock(SwooleResponse::class); - $response->shouldNotReceive('status', 'header', 'end'); + $response->shouldReceive('status', 'header', 'end')->never(); try { (new HandshakeLifecycleServer($container, $router, $nativeServer)) From d2865381a5962cbd2426935878efc827193de2a7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:57:58 +0000 Subject: [PATCH 8/9] Record the completed JWT audit Add the reviewed JWT correctness, security, and lifecycle plan with its evidence, rejected designs, regression coverage, performance assessment, and completion criteria.\n\nRecord the final jwt-01 through jwt-15 decisions in the companion ledger, close the JWT checklist, and mark the shared enum-identifier and Macroable dependency revalidations complete. Clear the active routing entry now that implementation, verification, self-review, and independent review have all finished. --- ...amework-coroutine-state-lifecycle-audit.md | 12 +- ...-coroutine-state-lifecycle-audit-ledger.md | 29 +- ...-jwt-correctness-security-and-lifecycle.md | 400 ++++++++++++++++++ 3 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 docs/plans/2026-08-08-0426-jwt-correctness-security-and-lifecycle.md diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index e2bc93851..b241ab68c 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `inertia`; correctness and SSR lifecycle maintenance is recorded under `Complete Inertia correctness and SSR lifecycle maintenance`; detail plan `2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md`. Current upstream DevTools is the next Inertia work unit. -- **Ledger entries required for the active work:** `Complete Inertia correctness and SSR lifecycle maintenance`. -- **Pending revalidation carried into the active work:** None. Inertia revalidated `support-02`; current upstream DevTools remains separately scoped before the package checklist can be completed. +- **Active package or work unit:** None. JWT is complete; detail plan `2026-08-08-0426-jwt-correctness-security-and-lifecycle.md`. +- **Ledger entries required for the active work:** None. Completed JWT work is recorded under `Complete JWT correctness, security, and lifecycle maintenance`. +- **Pending revalidation carried into the active work:** None. JWT revalidated `support-02` and `macroable-03`. Inertia has separately scoped current-upstream DevTools work outstanding before its checklist can be completed. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1053,8 +1053,8 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events`, `bus`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia` (revalidation complete), `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum` (revalidation complete), `scout`, `session` (revalidation complete), `socialite` (revalidation complete), `telescope`, `testbench`; `translation` (revalidation complete); later full remaining consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | -| `macroable-03` | `macroable` | `cookie`, `log`, and `notifications` (revalidation complete); later full `jwt` audit | `Complete Macroable callable and test-state handling`; finding `macroable-03` | +| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia` (revalidation complete), `jwt` (revalidation complete), `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum` (revalidation complete), `scout`, `session` (revalidation complete), `socialite` (revalidation complete), `telescope`, `testbench`; `translation` (revalidation complete); later full remaining consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | +| `macroable-03` | `macroable` | `cookie`, `jwt`, `log`, and `notifications` (revalidation complete) | `Complete Macroable callable and test-state handling`; finding `macroable-03` | | `auth-01` | `support`, `auth` | `auth` (revalidation complete) | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts`, `support`, `filesystem`, and `foundation` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | | `sanctum-01` | `sanctum` | `encryption` and `sanctum` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `sanctum-01` | @@ -1314,7 +1314,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `fortify` - [x] `passkeys` - [ ] `permission` -- [ ] `jwt` +- [x] `jwt` - [x] `scout` - [ ] `telescope` - [ ] `sentry` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 8b073774a..c9bbe9515 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -175,7 +175,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `http-01` | Defect | Minor | High | The new Request reset clears macros but leaves four inherited mutable Symfony configuration surfaces alive between framework tests | Reset only the inherited MIME-format, method-override, allowed-method, and request-factory state that Hypervel still uses | - **Important rejected concerns:** Do not make macros coroutine-local, lock immutable runtime reads, clone or cache callables, or add a manager/provider/reset registry. Current Laravel 13.x still fails closures produced from internal functions and first-class methods; Hypervel intentionally supports its existing callable contract, and the local source comment explains the PHP invariant without narrating upstream history. Focused PHPUnit coverage disproved the original assumption that unsupported bindings are silent outside a booted application. The checked native `bindTo()` boundaries therefore suppress their expected warnings and immediately inspect the nullable result; per-call reflection costs more, registration-time classification adds disproportionate parallel state, and warning-tolerant tests would hide real standalone behavior. -- **Cross-package implications:** Cookie, Log, and Notifications have revalidated their direct Macroable dependencies; JWT retains later full-audit revalidation. The later `database`, `support`, and `testing` audits must retain the completed static reset boundaries and centralized subscriber ownership. The full HTTP audit retained the completed inherited-state reset under shared finding `http-01`; Testing still owns its later full-audit revalidation. +- **Cross-package implications:** Cookie, JWT, Log, and Notifications have revalidated their direct Macroable dependencies. The later `database`, `support`, and `testing` audits must retain the completed static reset boundaries and centralized subscriber ownership. The full HTTP audit retained the completed inherited-state reset under shared finding `http-01`; Testing still owns its later full-audit revalidation. - **Completeness method:** Consumer/reset counts come from a whole-source trait-composition sweep, and documentation contradiction counts come from a whole-docs pattern sweep rather than an expected-file enumeration. - **Approved implementation boundary:** Keep the two magic dispatch paths explicit because their legal binding sequences differ. Add no helper or dynamic abstraction. Existing non-Closure callable dispatch is unchanged; ordinary closure macros still perform one binding operation. Suppressing the checked native warning adds a measured approximately 16 nanoseconds to closure-macro invocation; the owner approved that negligible opt-in cost after reviewing the more expensive or more complex alternatives. The additional binding attempt is confined to previously failing closure shapes. Static cleanup runs only between tests. Owner approval covers the callable behavior, eight additive public testing hooks, cross-package manifest corrections, and test-signature improvement. - **Implementation:** Macro dispatch now applies the legal instance/class and static/class binding sequence, suppresses only the native warnings whose nullable results are checked immediately, and retains already-valid first-class callables when PHP forbids rebinding. Three existing static resets now include macros; eight framework classes gained the standard static reset hook and central subscriber registration. Request's reset also restores the four mutable inherited Symfony configuration surfaces that remain live in Hypervel. Cookie, JWT, log, and notifications declare their direct Macroable dependency. Collections, HTTP-client, and response documentation now matches centralized framework cleanup. Macroable's own fixtures release their registries, and the earlier Testbench manifest regression now forces its restoration path with deterministic valid-PHP probe content instead of assuming a child rebuild must differ from the baseline. @@ -1745,7 +1745,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Important rejected concerns:** Do not normalize Slack option identities, invent an image-element alternative-text limit that Slack does not publish, add an ID registry, or retain byte-counted compatibility with upstream. Retain `ReadsQueueAttributes` as Queue's intentional domain alias over `ReadsClassAttributes`; it is not dead indirection. - **Implementation and boundaries:** Notifications now exposes current storage attachment, Slack select, Builder URL, webhook, queue-precedence, and database relationship/scope surfaces. Generated action IDs are nonempty and byte-bounded without changing ordinary IDs; option values are emitted verbatim, reject empty identities, and observe Slack's 150-character maximum; static selects enforce one through 100 options and placeholders use 150 characters. All published Block Kit limits count characters, the image-block constructor no longer bypasses its alternative-text invariant, the image element enforces its documented URL limit, and Builder JSON failures retain their cause. Multibyte payloads may contain more bytes than before while remaining inside Slack's character limits; malformed over-limit text fails rather than being silently substituted. The sender saves and restores exact nested failure-attempt state in coroutine context, and the boot listener marks only active attempts. Manager aliases share the auto-singleton, while delivery/locale state remains coroutine-local. Anonymous identities, database scopes/relations, queued clones, Slack fluent chains, split metadata, docs, and test ownership have truthful types and mapping. Redundant binding, lossy value normalization, false dependencies, mocked listener simulations, stale guide limitations, raw PHPUnit bases, order-dependent fixtures, and obsolete suppressions are removed. -- **Cross-package revalidation:** `notifications-07` is complete at the Contracts-owned Factory boundary. `notifications-08` and the Notifications side of `queue-41` are complete; public read/unread scopes must be called through `DatabaseNotification::query()` because same-named instance predicates shadow static scope dispatch. `support-02` remains correct across channel identifiers. `macroable-03` is complete for Cookie, Log, and Notifications, with JWT still pending. `notifications-12` revalidates Horizon's legacy webhook and modern Web API representations without changing Horizon source. This work also moved Translation's two missing-key probe globals to class-owned integration-test setup and teardown, removing verified order dependence without changing Translation source or completing its later package audit. +- **Cross-package revalidation:** `notifications-07` is complete at the Contracts-owned Factory boundary. `notifications-08` and the Notifications side of `queue-41` are complete; public read/unread scopes must be called through `DatabaseNotification::query()` because same-named instance predicates shadow static scope dispatch. `support-02` remains correct across channel identifiers. `macroable-03` is complete for Cookie, JWT, Log, and Notifications. `notifications-12` revalidates Horizon's legacy webhook and modern Web API representations without changing Horizon source. This work also moved Translation's two missing-key probe globals to class-owned integration-test setup and teardown, removing verified order dependence without changing Translation source or completing its later package audit. - **Regression tests:** Counterfactual coverage spans storage disk selection and basename/MIME behavior; select serialization, chaining, IDs, exact option identities, cardinality, placeholders, published multibyte boundaries, malformed truncation input, image constructor/element validation, and Builder JSON failures; modern/legacy Slack routing and exact Horizon payloads; queue precedence; frozen database read state and read/unread scopes; manager alias identity and concurrent local state; nested, sequential, external, successful, exceptional, and sibling-coroutine failure ownership; package metadata; generated relationship types; and process-global fixture cleanup. The database scope regressions use builder dispatch explicitly to avoid the static-call trap described above. - **Performance and complexity:** Successful channel attempts add only constant-time coroutine-context reads/writes and exact restoration around the existing transport call. No request-wide path, lock, I/O, retry, container loop, registry, pool, reflection, serialization layer, or retained allocation is added. Character counting replaces nanosecond-scale byte checks only while constructing Slack payload fields; malformed-text validation runs only on the already-over-limit truncation path, and exact option preservation removes a regex. Attachment, metadata, documentation, and test changes are cold. Removing the redundant manager binding and false dependencies simplifies construction and packaging. - **Laravel-facing result:** Current supported Laravel Notifications, MailMessage, Slack, queue, database relationship, manager, named-argument, and protected extension surfaces are preserved or restored. Changes are additive except for verified automatic-ID, option-identity, protocol-boundary, Builder-error, image-construction, and failure-ownership corrections; no public API is removed. Hypervel deliberately emits Slack option identities verbatim where Laravel lowercases and strips them, and counts Slack's published limits as characters rather than bytes. Hypervel retains coroutine-local manager state and direct Slack delivery. @@ -2065,3 +2065,28 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and complexity:** Registration adds one fixed-size comparison after validation; user-handle derivation adds one empty-string comparison; verification adds one O(1) length check that can avoid a guaranteed-useless indexed query. Successful validation adds only a `try` boundary. Credential lookup remains one unique index with no hash, cast, collision branch, or extra round trip. Pruning removes a destructive bulk branch without adding a known-owner query. Publication, workflow, metadata, docs, and tests add no application runtime cost. - **Validation and review:** Changed Passkeys and Fortify tests, all four database integrations, root/split package metadata, workflow/script checks, formatting, both PHPStan configurations, the full parallel suite, Testbench package mode, dogfood, stale scans, and `git diff --check` passed. The canonical Fortify documentation was reviewed against the final public APIs, configuration, and extension surfaces without adding a brittle Markdown-structure test. Post-gate review corrections were revalidated with the complete affected test files and targeted static checks. Independent review verified every final correction and signed off with no remaining finding. - **Assessment:** Passkeys is owner-bound, protocol-sized, database-portable, failure-truthful, worker-lifecycle-safe, and current at the audited Laravel surface. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, stale compatibility path, meaningful hot-path regression, broken Laravel API, unresolved defect, or deferred TODO. + +### Complete JWT correctness, security, and lifecycle maintenance + +- **Status and inspected surface:** Complete; implementation, focused validation, the authoritative gate, fresh caller/callee and performance self-review, and independent code review are signed off. The work corrected every verified prior-audit finding and the same-family defects found while tracing parser, refresh, revocation, storage, guard, certificate, configuration, package metadata, test, and documentation paths. The detailed design is recorded in [`2026-08-08-0426-jwt-correctness-security-and-lifecycle.md`](2026-08-08-0426-jwt-correctness-security-and-lifecycle.md). + +| Findings | Final decision | +|---|---| +| `jwt-01`, `jwt-13` | Validate epoch-zero expiration and translate every untrusted registered-date parse failure to `TokenInvalidException` without hiding application-owned encode failures. | +| `jwt-02`, `jwt-05`, `jwt-14`, `jwt-15` | Use nullable `refresh_ttl` as the single refresh/revocation lifetime, require `iat` before infinite refresh, retain missing-`iat` payloads to their finite acceptance boundary, include leeway and the minute margin, and use one clock snapshot across cache lookup and TTL calculation. Automatic permanent retention honors a non-sliding grace period; explicit `addForever()` remains immediate. | +| `jwt-03` | Make storage writes and flushes truthful booleans, fail invalidation on false persistence, and settle logout only after revocation succeeds. | +| `jwt-04`, `jwt-08`, `jwt-09` | Reject RSA keys below 2048 bits, preserve passphrase string `"0"`, and publish generated keys atomically through Filesystem with explicit private/public modes. | +| `jwt-06` | Accept non-empty string and integer blacklist identifiers, including zero, and reject every other shape at the string-only storage boundary. | +| `jwt-07` | Delete the unused destructive PSR cache adapter and its split dependency while retaining the custom `StorageContract` extension point. | +| `jwt-10` | Keep fallbacks only for children of the replace-whole providers group and remove duplicate defaults from guaranteed merged top-level configuration. | +| `jwt-11`, `jwt-12` | Complete bounded test cleanup, correct malformed Mockery prohibitions, document the final public behavior and extension points once, and keep the package README thin. | + +- **Architecture and worker ownership:** The manager, parser, blacklist, and storage remain worker-lived stateless or boot-configured services. Guard user, token, payload, claims, and TTL state remains coroutine-local. No clone, scoped rebinding, lock, registry, retry, cleanup hook, or new context slot was added. +- **Correctness and security:** Attacker-controlled malformed dates no longer escape the JWT exception boundary. Refresh cannot bypass a missing `iat`; revocation survives every supported finite, infinite, leeway, grace, and delayed-cache boundary; false cache writes cannot report success; and failed logout retains guard state and emits no event. Key generation cannot publish unsupported RSA keys, partial direct writes, weak file modes, or a dropped string-zero passphrase. +- **Cross-package revalidation:** `support-02` remains complete because JWT inherits `Manager::driver(UnitEnum|string|null)` and adds only the required blacklist payload-to-string boundary. `macroable-03` is complete for JWT because its split manifest retains the direct sorted `hypervel/macroable` dependency. Filesystem owns atomic replacement and directory creation; Cache owns truthful tagged-write results. JWT consumes those boundaries without local workarounds. +- **Inherited upstream evidence:** Current upstream shares the malformed registered-date escape, absolute blacklist lifetime, and discarded storage-result defects. Hypervel's duplicate blacklist lifetime and falsey array-payload key check were local. No external issue or pull request was opened without owner authorization. +- **Important rejected concerns:** Do not add parser pre-validation, claim DTOs, recursive coercion, validation-chain introspection, revocation retries or logs, a key registry, a third storage mode, deep config merge, a certificate transaction or lock, guard cloning, scoped rebinding, leeway mutators, or compatibility readers for removed Hypervel-only surfaces. Deliberate validation bypasses and invalid custom payload shapes receive no parallel machinery. +- **Regression coverage:** Counterfactual tests cover malformed registered dates before signature validation, epoch zero, finite/infinite and missing-claim refresh, leeway and minute boundaries, cache-delayed clocks, truthful write/flush failures, zero identifiers, grace-aware permanent retention and non-sliding repeated writes, immediate force-forever invalidation, transactional logout, provider-array replacement, custom storage, RSA floor and file modes, string-zero encryption, obsolete configuration, facade metadata, and the corrected test prohibitions. +- **Performance and compatibility:** Terminal tokens skip cache I/O; existing cache results are inspected without retry; and boundary arithmetic adds no I/O. Automatic permanent revocation performs one existing-entry read, matching finite revocation, so repeated concurrent refreshes cannot slide grace. Parser, config, docs, metadata, and command-only publication add no request hot-path work. Supported Laravel auth and manager APIs are preserved; only unsafe Hypervel-only storage/configuration surfaces are removed. +- **Validation and review:** Changed tests, the complete JWT suite, affected sibling suites, strict split metadata, facade lint, stale scans, `git diff --check`, formatting, both PHPStan configurations, the full parallel suite, Testbench package mode, and dogfood passed. Post-gate review corrections passed focused and complete JWT coverage. Independent review re-read every changed file, reproduced the grace regression and sliding-window risk, verified their final corrections, and signed off with no remaining finding. +- **Assessment:** JWT is parse-safe, revocation-truthful, grace-correct, coroutine-safe, securely published, and accurately documented. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, stale path, meaningful hot-path regression, unintended Laravel API break, or unresolved defect. diff --git a/docs/plans/2026-08-08-0426-jwt-correctness-security-and-lifecycle.md b/docs/plans/2026-08-08-0426-jwt-correctness-security-and-lifecycle.md new file mode 100644 index 000000000..03e824f59 --- /dev/null +++ b/docs/plans/2026-08-08-0426-jwt-correctness-security-and-lifecycle.md @@ -0,0 +1,400 @@ +# JWT Correctness, Security, and Lifecycle Maintenance + +## Status + +Complete; implementation, verification, fresh self-review, final audit records, and independent code review are signed off. + +## Scope + +Correct the verified JWT validation, revocation, storage, key-publication, configuration, documentation, and test defects recorded in `.tmp/audit-findings/jwt.md`, together with the adjacent malformed-date, missing-`iat` refresh, and logout-settlement defects found while tracing those paths. Preserve the package's array-payload design, coroutine-scoped guard state, Laravel-shaped auth guard, current signing/refresh behavior, and direct provider/storage extension points. + +This is not a fresh package-wide audit. Work is limited to the accepted findings, same-family issues discovered while tracing or implementing them, the routed `support-02` and `macroable-03` revalidations, and the audit records required to close JWT. + +## References + +- Repository rules: `AGENTS.md` +- Core audit plan: `docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md` +- Audit ledger: `docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md` +- Prior findings: `.tmp/audit-findings/jwt.md` in the main Components worktree, audited at Components `db70c7ce7def14382d7d22d2f90b15e8db0ae9d7` +- Current package: `src/jwt/`, `tests/Jwt/`, `src/support/src/Facades/Jwt.php`, and `src/boost/docs/jwt.md` +- Recorded current upstream: `PHP-Open-Source-Saver/jwt-auth` at `ce08363a9986e5253efd3663ed4f75c976bec89a` +- Installed JWT engine: `lcobucci/jwt` 5.6, especially `vendor/lcobucci/jwt/src/Token/Parser.php` +- Existing shared owners: `Filesystem::replace()`, `Filesystem::ensureDirectoryExists()`, Cache's boolean write/flush contracts, and `ServiceProvider::mergeConfigFrom()` +- Routed ledger entries: `Normalize framework enum identifiers at string boundaries` and `Complete Macroable callable and test-state handling` + +## Existing contracts to preserve + +- JWT payloads remain arrays; no mutable upstream token, payload, or claim DTO layer is restored. +- `JwtManager::encode()` and `decode()` retain their string/array contracts, and `ManagerContract` and the `Jwt` facade retain their current Laravel-shaped manager surface. +- Guard token, user, payload, claims, and TTL state remains in `CoroutineContext`; no state moves onto the worker-lifetime guard. +- The parser remains stateless and receives each request directly. +- Nullable token and refresh TTLs, explicit refresh endpoints, subject locking, custom claims, configurable validation chains, custom extractors, and custom provider/storage implementations remain supported. +- Tagged blacklist storage continues to support both all-mode and any-mode tag stores. +- `NotBeforeClaim` continues to run during refresh so a future token cannot be refreshed into an immediately valid token. +- `IssuedAtClaim` and `NotBeforeClaim` retain their current epoch-zero behavior; only expiration has a verified falsey-zero defect. +- `JwtGuard::logout()` continues to invalidate expired but structurally valid tokens because invalidation deliberately skips temporal validation. +- `JwtGuard` keeps Macroable and the split package keeps its direct `hypervel/macroable` dependency. +- Inherited `Manager::driver(UnitEnum|string|null)` remains the sole enum-driver normalization boundary; JWT adds no duplicate enum handling. + +The accepted public cleanup removes one unsafe Hypervel-only storage adapter and one duplicate Hypervel-only configuration key. No compatibility alias or stale path remains. Supported Laravel auth APIs are unchanged. + +## Anti-overengineering constraints + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +## Architecture and evidence + +`JwtServiceProvider` creates a worker-lifetime manager, parser, and blacklist. Those objects retain only boot configuration and finite immutable caches. Request and operation state belongs to `JwtGuard`'s coroutine context. No finding requires cloning, locking, scoped rebinding, additional cleanup, or another worker cache. + +The security-sensitive paths are: + +```text +bearer token -> Lcobucci parser -> signature check -> array claims -> validations +logout/refresh/invalidate -> Blacklist -> StorageContract -> tagged cache +jwt:generate-certs -> OpenSSL -> Filesystem publication -> .env publication +``` + +The evidence fixes the following boundaries: + +- Lcobucci converts `exp`, `nbf`, and `iat` before signature validation. Its private `convertDate(int|float|string)` throws `TypeError` for untrusted null/bool/array/object values before its own invalid-token guard. Hypervel currently catches only `Exception`, so an invalidly signed anonymous bearer token can escape as a 500. +- Lcobucci converts JSON string `"0"` and fractional `0.5` date claims to timestamp integer `0`. `ExpiredClaim` then treats zero as absent. +- `validateRefreshWindow()` returns immediately for an infinite refresh lifetime before reading `iat`. A signed token without `iat` can therefore refresh indefinitely when `refresh_iat` rebuilds the claim, or reach an uncaught encode `TypeError` when the old claim is retained. +- Current upstream has one refresh lifetime and feeds it to blacklist retention. Hypervel's separate `blacklist_refresh_ttl` can end revocation while refresh remains allowed; infinite refresh is especially unsafe. +- Carbon's signed minute difference is target minus receiver: `now->diffInMinutes(future)` is positive, while `future->diffInMinutes(now)` is negative. The current `abs()` therefore turns an elapsed revocation boundary into a new positive TTL. +- `Blacklist::add()` currently checks the boundary against one clock read, performs a cache lookup, then calculates the TTL against a later clock read. The shipped Redis-backed lookup can yield across the boundary, producing a nonpositive TTL after the method has selected the write path. Cache may then either report failed invalidation or report success without storing the revocation. +- Expiration validation accepts a token through `exp + leeway`, so blacklist retention and its terminal-I/O skip must use that same acceptance boundary rather than bare `exp`. +- Cache repository and tagged-cache writes expose booleans, but JWT's storage contract discards them. False persistence currently looks like successful revocation. +- PSR-16 cannot clear only JWT keys. The unused shipped `PsrCache::flush()` calls the default store's application-wide `clear()` and raw keys can collide. +- Lcobucci rejects RSA keys below 2048 bits, while the command currently publishes 512/1024-bit pairs as successful. +- `Filesystem::replace()` already writes a sibling temporary file, applies mode before rename, atomically publishes it, and cleans up failures. JWT should consume that owner rather than duplicate it. +- `mergeConfigFrom()` shallow-merges top-level package defaults. Top-level JWT keys therefore need no repeated literal defaults, while the replaceable nested `providers` array needs explicit child fallbacks. + +Storage false-success, absolute blacklist lifetime, and malformed registered-date conversion are inherited upstream defects. The duplicate blacklist lifetime and array-payload falsey key guard are Hypervel-specific. Record that provenance, but do not open an external issue or pull request without owner authorization. + +## Final finding decisions + +| ID | Category | Severity | Confidence | Final treatment | +|---|---|---:|---:|---| +| `jwt-01` | Defect | Major | High | Treat absent/null expiration as optional but validate timestamp zero; cover direct zero and an external-style JSON string-zero round trip. | +| `jwt-02` | Defect | Major | High | Delete `blacklist_refresh_ttl`; use nullable `refresh_ttl` as the one refresh and revocation lifetime. Genuinely refreshable tokens with infinite refresh require permanent, grace-aware revocation. | +| `jwt-03` | Defect | Major | High | Make storage writes/flush truthful booleans, propagate failure, throw from manager invalidation, and make guard logout transactional. | +| `jwt-04` | Defect | Major | High | Method-inject Filesystem and atomically publish private/public keys at modes `0600`/`0644`; create the directory through the existing `0755` Filesystem boundary. | +| `jwt-05` | Defect | Major | High | Include expiration leeway, use one clock snapshot for the terminal decision and positive remaining lifetime, and skip cache I/O only when the unified acceptance boundary has elapsed. | +| `jwt-06` | Defect (`support-02`) | Major | High | Accept non-empty strings and integers, including zero, as blacklist identifiers; normalize to string and reject every other shape with `TokenInvalidException`. | +| `jwt-07` | Defect | Major | High | Delete the unused destructive PSR adapter and its split dependency; retain tagged storage plus the custom `StorageContract` extension point. | +| `jwt-08` | Defect | Major | High | Reject RSA sizes below 2048 before generation; keep EC unchanged and avoid repeated slow RSA generation in tests. | +| `jwt-09` | Defect | Minor | High | Preserve interactive passphrase string `"0"`; only null/empty means no passphrase. | +| `jwt-10` | Defect | Minor | High | Add nested provider fallbacks and remove redundant top-level defaults without deep-merge machinery. | +| `jwt-11` | Defect | Minor | High | Bring the surviving JWT tests to repository setup/type conventions and remove dead fixture state. | +| `jwt-12` | Defect | Minor | High | Correct signing-key/config/storage guidance, document existing custom drivers, and make the README a thin public-difference index. | +| `jwt-13` | Defect | Major | High | Convert every parser failure from untrusted registered date claims into `TokenInvalidException` before guard handling. | +| `jwt-14` | Defect | Major | High | Use the finite expiration boundary when `iat` is absent but `exp` exists; retain forever only when no safe terminal boundary exists. | +| `jwt-15` | Defect | Major | High | Reject missing/null `iat` before the infinite-refresh return so a signed token cannot refresh indefinitely or reach malformed replacement encoding. | + +## Implementation design + +### 1. Untrusted parse and temporal validation boundaries + +In `Providers\Lcobucci::decode()`, widen only the catch around the third-party parser call to `Throwable`: + +```php +try { + /** @var \Lcobucci\JWT\Token\Plain */ + $token = $this->config->parser()->parse($token); +} catch (Throwable $exception) { + throw new TokenInvalidException( + 'Could not decode token: ' . $exception->getMessage(), + $exception->getCode(), + $exception, + ); +} +``` + +Do not wrap signature validation, claim mapping, or `encode()`. Decode accepts attacker-controlled bytes and owns conversion to a JWT exception; encode receives application-owned claims and malformed reserved claims should fail fast. + +In `ExpiredClaim`, replace truthiness with a null boundary: + +```php +$exp = $payload['exp'] ?? null; + +if ($exp === null) { + return; +} +``` + +Retain the existing leeway and strict time comparison. Do not change the sibling `IssuedAtClaim` or `NotBeforeClaim` truthiness checks: timestamp zero is valid and non-future there, so the change would be style-only. + +Add a concise class explanation to `NotBeforeClaim`: it deliberately does not implement `TemporalValidation`, because skipping it during refresh would replace a future `nbf` with the current time. Existing future/past refresh tests already prove the behavior; do not duplicate them. + +### 2. One revocation lifetime and truthful storage + +Remove `blacklist_refresh_ttl` and `JWT_BLACKLIST_REFRESH_TTL` everywhere. Change `Blacklist::$refreshTTL`, its constructor argument, `setRefreshTTL()`, and `getRefreshTTL()` to `?int`. Add a constructor-held integer leeway beside the existing grace period. `JwtServiceProvider` must pass nullable `$config->get('jwt.refresh_ttl')` and the configured integer `jwt.leeway`; an integer getter for refresh TTL would silently break infinite refresh. + +`Blacklist::add()` follows these rules in order: + +1. Missing/null `exp` has no finite normal-authentication boundary, so retain the revocation forever while honoring the grace period. +2. Start with `exp + leeway`. If `iat` is missing/null, select that finite boundary even when refresh TTL is null: `validateRefreshWindow()` rejects the payload before the infinite-refresh return, so it cannot be refreshed. +3. With a present `iat`, null refresh TTL permits refresh forever, so retain the revocation forever while honoring the grace period. +4. Otherwise select `max(exp + leeway, iat + refreshTTL)`. +5. Add one minute to whichever finite boundary was selected so revocation survives the strict equality instant at which normal decode still accepts the token. +6. Snapshot the current time once, compute the signed remaining minutes from that snapshot to the future boundary, and never use `abs()` from the boundary to now. +7. If the boundary is no longer future at that snapshot, return true before any cache read or write. When the write path is selected, reuse the snapshot so time passing during the cache lookup cannot produce a nonpositive TTL. +8. If the key already exists, return true; otherwise return the storage adapter's boolean. + +Both automatic permanent branches call a protected `addForeverWithGracePeriod()` helper. It resolves the key, returns early when storage already contains the entry so repeated concurrent refreshes cannot slide the grace window, then stores `['valid_until' => $this->getGraceTimestamp()]` forever. Add one short WHY comment to the guard because rewriting would restart the grace period. `has()` already applies that timestamp before treating the entry as active. The public `addForever()` remains the explicit immediate permanent-revocation API and keeps the `'forever'` sentinel. + +The early skip and lifetime unification must land together. Use a positive `iat !== null` branch and explain that only a present `iat` can extend the boundary because refresh rejects a missing claim before the infinite-refresh return. Add one short WHY comment at the skip stating that the unified boundary covers both expiration acceptance, including leeway, and the refresh window. These comments record the cross-file invariant that makes finite retention and skipped I/O safe. + +Make the storage contract truthful: + +```php +public function add(string $key, mixed $value, int $minutes): bool; +public function forever(string $key, mixed $value): bool; +public function flush(): bool; +``` + +Return the tagged-cache results directly. `Blacklist::addForever()` and `clear()` return their storage results. Update the sole custom test implementation. Do not retry failed cache writes. + +Replace `JwtManager::invalidate()`'s dynamic call with an explicit finite/forever branch, inspect the returned boolean, throw a descriptive `JwtException` on false, and return true only after persistence succeeds. Its public signature stays `bool`; the contract becomes truthful true-or-exception. Refresh inherits that settlement rule after encoding the replacement token. This order is deliberate: invalidating first could revoke the caller's old token and then fail to produce a replacement, while encoding first allows a failed invalidation to discard an undelivered replacement without locking out the caller. + +Make `JwtGuard::logout()` transactional: + +```text +capture user and token +invalidate first when blacklisting applies +clear user, token, and payload context only after success +dispatch Logout last +``` + +A revocation failure retains guard state and emits no Logout event. A missing token still clears local state normally. Structurally invalid/badly signed tokens fail logout because they cannot be revoked; expired but structurally valid tokens still revoke because manager invalidation decodes with temporal validation disabled. + +In `validateRefreshWindow()`, reject absent/null `iat` with `TokenInvalidException` before reading or returning for null `refresh_ttl`. Add a short WHY comment that blacklist retention for missing-`iat` payloads depends on this rejection preceding the infinite-refresh return. This closes both current failure modes: `refresh_iat = true` can no longer rebuild a missing claim and refresh indefinitely, while `refresh_iat = false` cannot carry null into replacement encoding. Do not add generic temporal coercion; provider-decoded registered dates are integers after the corrected parse boundary. + +### 3. Blacklist identifier boundary and storage surface + +Make `Blacklist::getKey()` return `string`. Accept only: + +- a non-empty string, including `"0"`; +- an integer, including `0`, normalized to its decimal string. + +Reject missing, null, empty string, bool, float, array, and object values with the existing `TokenInvalidException` family. This is the local string-only storage boundary required by `support-02`; do not stringify enums, Stringable objects, or arbitrary payload values. + +Use one accurate message for both absent and invalid identifier shapes: the configured claim is “missing or invalid in payload for blacklist”. + +Delete: + +- `src/jwt/src/Storage/PsrCache.php`; +- `tests/Jwt/Storage/PsrCacheTest.php`; +- JWT's direct `psr/simple-cache` split dependency. + +Keep the root dependency because other packages own it. Do not add a key index, prefix-only third mode, unsupported `flush()`, registry, or synchronization layer. The shipped implementation requires a taggable store. Applications using another store may configure their own `StorageContract` implementation. + +Reword the service-provider error to name that exact extension point rather than imply another shipped adapter exists. + +### 4. Certificate generation and publication + +Change `JwtGenerateCertsCommand::handle()` to method-inject `Hypervel\Filesystem\Filesystem`. + +For RSA, reject `--bits < 2048` before OpenSSL generation. The floor applies equally to RS256, RS384, and RS512 and is not configurable because it comes from the installed signer. EC curve/SHA validation remains unchanged. + +Assign and validate the generated public-key contents before publication so the typed Filesystem call receives a proven string: + +```php +$publicKey = $details === false ? null : ($details['key'] ?? null); + +if (! is_string($publicKey)) { + throw new RuntimeException('Unable to export JWT public key.'); +} +``` + +Publish through existing owners: + +```php +$files->ensureDirectoryExists($directory); +$files->replace($privateKeyPath, $privateKey, 0600); +$files->replace($publicKeyPath, $publicKey, 0644); +``` + +The directory's default changes deliberately from permissive `0777` to `0755`. Add `hypervel/filesystem` as a direct, sorted JWT split dependency. The root already contains the package. + +JWT tests own directory creation, generated contents, final modes, algorithm/env output, and command validation. They do not repeat Filesystem's checked-write, temporary-file cleanup, or failure-injection suite. Do not add a cross-file transaction, lock, backup, rollback layer, or JWT-specific writer. + +In `resolvePassphrase()`, preserve every non-empty string returned by the secret prompt, including `"0"`; map only null/empty to null. Add no passphrase-strength policy. + +### 5. Configuration ownership + +Retain explicit fallbacks only for children of the replaceable `providers` array: + +```php +$config->string('jwt.providers.jwt', Lcobucci::class); +$config->string('jwt.providers.storage', TaggedCache::class); +``` + +Remove duplicated literal defaults from top-level reads in: + +- `JwtManager` (`blacklist_enabled`, `driver`, `validations`, `ttl`, `refresh_iat`, `persistent_claims`, and nullable `refresh_ttl`); +- `JwtServiceProvider` (`token`, `parser`, `blacklist_enabled`, `blacklist_grace_period`, and the guard's nullable global `ttl`); +- `ClaimFactory` (`lock_subject`). + +Use typed getters for non-null settings and plain `get()` for documented nullable values. Package config is top-level shallow-merged before these services resolve, so no fallback is lost; explicit null remains meaningful. Do not introduce deep merge, a normalized config object, or a default registry. + +Add a provider regression that replaces the whole `jwt.providers` array with only one child and proves the omitted sibling uses its intended default. The previously missing storage child currently throws during eager blacklist resolution on the first `jwt` resolution when blacklisting is enabled; test that real boundary. + +Update the `refresh_ttl` config comment to state its second public responsibility: it also bounds blacklist retention for refreshable tokens, and null retains those entries forever. + +### 6. Documentation and package metadata + +Update `src/boost/docs/jwt.md` in simple Laravel-docs prose: + +- add a concise `Custom Drivers` subsection near signing algorithms that names `ProviderContract`, registers a provider during service-provider boot with `Jwt::extend('custom', fn ($app) => $app->make(CustomJwtProvider::class))`, and selects it through `jwt.driver`; +- remove `blacklist_refresh_ttl` and explain that blacklist retention follows `refresh_ttl`, with null refresh retaining revocation forever for refreshable tokens; +- state that the shipped tagged storage requires a taggable cache store and non-taggable stores require a custom `StorageContract` implementation; +- state the 2048-bit minimum for generated RSA keys; +- narrow the refresh-endpoint example to token-validity exceptions so configuration and infrastructure failures are not reported as authentication failures; +- document invalidation-first logout settlement: failed persistence retains guard state, emits no `Logout` event, and propagates `JwtException`; +- document that force-forever invalidation bypasses the grace period and takes effect immediately; +- retain the current warning about node-local cache tiers and the current explicit-refresh guidance. + +Correct the public/private key config comments to “key contents or a `file://` URI”. Bare paths and resources are not supported. + +Make `src/jwt/README.md` follow the thin package format: + +1. package header; +2. `Documentation: https://hypervel.org/docs/jwt`; +3. unquoted `Differences From php-open-source-saver/jwt-auth` containing only user-visible API/feature differences; +4. upstream link last. + +Remove the DeepWiki badge, duplicated package description/docs pointer, and the parser-singleton implementation bullet. Retain the actual public differences: array payloads, facade surface, parser sources/integrations, explicit refresh endpoint, and omitted upstream options. + +Update `src/jwt/composer.json` in sorted order: add `hypervel/filesystem`, retain the already-correct direct `hypervel/macroable`, and remove `psr/simple-cache` only from this split. + +### 7. Test cleanup and audit records + +Apply the bounded cleanup: + +- call `parent::setUp()` in `BlacklistTest`; +- type the Blacklist invalid-value provider argument as `mixed`; +- add `: void` to the three `ProviderTest`, two `RequiredClaimsTest`, and one `JwtGuardStaticStateTest` methods; +- remove the unused untyped `ProviderTest::$provider` property; +- delete PsrCache's source and test rather than leaving stale fixtures. +- make the invalidation success fixtures state their exact contracts: one blacklist write, no validation for expired-token invalidation, and no separate blacklist read before the idempotent write; +- correct all nine malformed `shouldNotReceive()` calls found during self-review: six false multi-method declarations with strict-mock backstops, two partial-mock declarations whose current call order still reaches the sole registered prohibition first, and one correct Database prohibition carrying dead arguments. Mockery's variadic method forwards through a single-parameter closure despite promising one or many method names; use `shouldReceive(...)->never()` for multiple methods and `shouldNotReceive('commit')` for the single Database method. Do not patch Mockery, add recurrence machinery, or open an external report without authorization. + +At final audit bookkeeping: + +- record `jwt-01` through `jwt-15`, important rejected concerns, inherited-upstream evidence, validation, performance, and review in the companion ledger; +- mark `support-02` revalidated through inherited `Manager::driver()` plus local `jwt-06`; +- mark `macroable-03` revalidated through the existing direct split dependency; +- retain the active JWT routing fields during implementation, then update the dependency rows and JWT checklist only after the completed work has passed every gate and owner review; +- preserve no abandoned design or external-report promise in the records. + +The single active-package routing field will conflict mechanically with other parallel audit branches. Reconcile those three lines during merges; do not redesign the routing model in this package work. + +## Regression plan + +### Provider and validation + +- `ExpiredClaimTest`: absent/null expiration remains optional; integer zero throws `TokenExpiredException`; existing future/leeway behavior remains. +- `LcobucciTest`: an encoded external-style JSON string `"0"` decodes to timestamp zero and is rejected by expiration validation; a compact data provider feeds null, bool, array, and object date values across representative `exp`, `nbf`, and `iat` claims in an invalidly signed token and always receives `TokenInvalidException` with the native failure chained. +- Existing manager tests continue proving that future `nbf` blocks refresh and past `nbf` permits it; no duplicate NotBefore test is added for the explanatory comment. + +### Revocation and storage + +- `BlacklistTest`: finite and null refresh TTLs; missing/null `exp`; missing/null `iat` using the finite `exp + leeway + margin` boundary even when refresh TTL is null; automatic permanent writes honor grace without sliding it on repeated calls, while explicit `addForever()` remains immediate; permanent writes only when no finite acceptance boundary exists; elapsed/exact/sub-minute boundaries; `exp + leeway`-dominant and refresh-dominant bounds; a bare-expiration boundary that has elapsed while `ExpiredClaim` still accepts the payload, with one comment identifying the shared leeway and the written entry then observed by `has()`; cache access before the leeway-aware terminal boundary and no cache access after it; a finite write whose mocked cache lookup advances time past the boundary and still receives a positive TTL; finite/forever/clear false propagation; nullable accessor; string/integer zero keys; and one invalid-shape data provider with accurate failure wording. +- `TaggedCacheTest`: boolean results propagate from finite write, forever write, and flush, while existing all-mode/any-mode key behavior remains. +- `JwtManagerTest`: finite and force-forever writes are called exactly once; false persistence throws; invalidation delegates existing-key idempotence to the blacklist write without a separate read; refresh does not report success after failed invalidation; missing/null `iat` fails through `TokenInvalidException`; specifically, null `refresh_ttl`, `refresh_iat = true`, required claims without `iat`, and a payload containing `exp` but no `iat` must fail before replacement construction; and expired but structurally valid payloads invalidate without consulting temporal validation. +- `JwtGuardTest` / `JwtGuardEventTest`: successful logout clears the same context and emits the same event; failed revocation retains user/token/payload state and emits no Logout event; force-forever delegation remains. +- `JwtServiceProviderTest`: nullable and finite refresh TTL wiring, integer leeway wiring, shallow replacement of `providers`, default storage fallback, custom storage boolean contract, and exact non-taggable-store guidance. + +### Commands, config, docs, and conventions + +- `JwtGenerateCertsCommandTest`: one real 2048-bit RSA success path proves key contents, private/public modes, directory creation, and env output; below-floor RSA fails before key generation; non-RSA-specific tests use valid fast `prime256v1` fixtures without ignored RSA options; interactive passphrase `"0"` is recorded and actually encrypts the private key; overwrite, missing-env, SHA, curve, and invalid-algorithm behavior remains. +- `JwtConfigTest`: retain nullable/integer `refresh_ttl`, remove the duplicate blacklist-TTL test, prove the obsolete key/env string is absent, and retain the published defaults. +- Existing provider/storage/config tests receive only expectation changes required by removing redundant defaults and truthful booleans. The missing-`iat` manager fixture explicitly configures null `refresh_ttl` and `refresh_iat = true` while asserting the failure outcome rather than pinning config-read order. +- The six surviving untyped test methods and one setup omission are corrected without a repository-wide sweep. +- The nine verified Mockery expectation corrections are validated through their existing JWT, Auth, HTTP Server, WebSocket Server, Console, and Database test files; no new test tests the test correction. + +## Validation sequence + +During implementation, run each changed test file immediately. After each coherent slice, run the affected JWT group rather than the full repository gate. + +Before code review: + +```shell +./vendor/bin/phpunit --no-progress tests/Jwt +composer validate --strict src/jwt/composer.json +composer facade -- --lint 'Hypervel\Support\Facades\Jwt' +git diff --check +composer fix +``` + +`composer fix` is the authoritative checkpoint and runs formatting, both PHPStan configurations, the complete parallel Components suite, Testbench package mode, and dogfood in script order. Do not run those full checks separately around it. + +Run broad stale/reference checks for: + +- `blacklist_refresh_ttl` and `JWT_BLACKLIST_REFRESH_TTL`; +- `Hypervel\Jwt\Storage\PsrCache` and JWT's `psr/simple-cache` dependency; +- discarded `void` storage implementations; +- redundant top-level JWT config defaults; +- stale README/docs wording, quoted Differences heading, and internal parser bullet; +- route/dependency/checklist consistency for `support-02`, `macroable-03`, and JWT. + +After the full gate, perform a fresh self-review without trusting this plan. Trace every changed caller/callee, provider parse boundary, revocation lifetime, storage false result, logout state transition, key publication, config merge, public type/config removal, documentation statement, normal-path allocation/I/O, and stale symbol. Any unexpected issue returns to focused investigation and second opinion before modification. Then request independent code review through signoff. + +## Performance and lifecycle assessment + +- No new cache/network call, retry, lock, registry, context slot, or service resolution is introduced. The blacklist retains one additional immutable boot-configured integer for leeway and no mutable request state. +- Dead tokens perform fewer cache operations because terminal blacklist writes are skipped. +- Truthful storage returns inspect results already produced by Cache; no additional I/O occurs. +- Automatic permanent revocation performs the same existing-entry read as finite revocation so repeated concurrent refreshes cannot slide the grace window. Explicit `addForever()` remains a write-only operation. +- The decode correction changes only the caught throwable type around an existing parser call. +- Exact key checks and config fallback cleanup add no meaningful hot-path work. +- Leeway is read once when the worker-lifetime blacklist is constructed; the boundary calculation adds one date modifier and no I/O. Its terminal decision and TTL reuse one clock snapshot, while the grace timestamp keeps its independent write-time clock read. +- Permanent storage when a present `iat` makes refresh genuinely infinite is required security retention, not avoidable overhead. +- Filesystem work is console-only and replaces direct publication with the existing atomic primitive. +- RSA command coverage stays fast by retaining one supported real RSA generation and using EC for unrelated tests. +- Guard state remains coroutine-local; the logout reorder adds no state and performs the same invalidation call earlier. + +## Explicitly rejected designs + +- No parser pre-validator, wrapper, claim DTO, recursive temporal coercion, or catch around application-owned encode input. +- No truthiness rewrite for `IssuedAtClaim` or `NotBeforeClaim` without a behavioral defect. +- No validation-chain introspection in blacklist retention; explicitly removing `ExpiredClaim` while continuing to stamp `exp` is a deliberate validation bypass, not a second retention contract. +- No required `exp`, new refresh validator, HMAC-zero special case, resource-key conversion, or generic payload traversal. +- No revocation retry, write-ahead log, cache-key registry, prefix-only incomplete mode, third shipped storage mode, or external Redis suite. +- No deep config merge, config normalizer, default registry, compatibility alias, or deprecated setting reader. +- No certificate lock, two-file transaction, backup, cross-file rollback, JWT-owned atomic writer, configurable RSA floor, or duplicated Filesystem failure tests. +- No guard cloning, scoped rebinding, subject lock, coroutine cleanup hook, static registry, or new worker cache. +- No leeway getter or mutator; leeway is immutable boot configuration and has no runtime consumer or upstream public counterpart. +- No external upstream issue or pull request without owner authorization. + +## Completion criteria + +- Every accepted finding is corrected at its existing owner, with `jwt-02`/`jwt-05` and `jwt-14`/`jwt-15` each landing inseparably. +- Storage failure cannot report successful invalidate, refresh, or logout. +- Untrusted malformed registered dates never escape as native `Error`/`TypeError`. +- Epoch-zero expiration is rejected, while `iat`/`nbf` zero behavior is unchanged. +- Finite, infinite, missing-claim, leeway-extended, already-terminal, and cache-delayed revocation lifetimes are safe and tested. +- Missing/null `iat` cannot bypass an infinite refresh window or reach replacement encoding, and its blacklist entry remains finite when `exp` provides the complete acceptance boundary. +- Certificate files are complete before publication and have explicit final modes; unsupported RSA sizes fail before generation. +- No destructive PSR adapter, duplicate blacklist TTL, stale dependency, stale comment, or duplicated README documentation remains. +- Custom drivers and custom storage remain clear, Laravel-shaped extension points. +- JWT revalidates `support-02` and `macroable-03` without duplicate machinery. +- Focused tests, strict split metadata, facade lint, stale scans, `git diff --check`, and `composer fix` are green. +- Fresh self-review and independent code review find no unresolved correctness, security, lifecycle, API, documentation, performance, dead-code, or overengineering issue. +- Final audit records are concise, accurate, and updated only after the implementation is complete. From 2b5877f6244b1f6ee147d82c33cf2eaa4c8b4969 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:22:47 +0000 Subject: [PATCH 9/9] Clarify JWT revocation and key publication guarantees The existing blacklist read prevents repeated revocations from restarting a configured grace period. It does not provide cross-store atomic insertion for first writes that were already overlapping. Document the bounded scheduling tradeoff and the safety properties that make additional synchronization unnecessary. Clarify that certificate publication is atomic per generated file rather than across the pair. Add the missing method documentation to the public storage extension contract so its finite, permanent, read, removal, and flush operations are explicit. --- ...work-coroutine-state-lifecycle-audit-ledger.md | 4 ++-- ...0426-jwt-correctness-security-and-lifecycle.md | 6 +++--- src/jwt/src/Blacklist.php | 7 ++++++- src/jwt/src/Contracts/StorageContract.php | 15 +++++++++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 62d4e8383..a8bdade83 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -2130,7 +2130,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `jwt-01`, `jwt-13` | Validate epoch-zero expiration and translate every untrusted registered-date parse failure to `TokenInvalidException` without hiding application-owned encode failures. | | `jwt-02`, `jwt-05`, `jwt-14`, `jwt-15` | Use nullable `refresh_ttl` as the single refresh/revocation lifetime, require `iat` before infinite refresh, retain missing-`iat` payloads to their finite acceptance boundary, include leeway and the minute margin, and use one clock snapshot across cache lookup and TTL calculation. Automatic permanent retention honors a non-sliding grace period; explicit `addForever()` remains immediate. | | `jwt-03` | Make storage writes and flushes truthful booleans, fail invalidation on false persistence, and settle logout only after revocation succeeds. | -| `jwt-04`, `jwt-08`, `jwt-09` | Reject RSA keys below 2048 bits, preserve passphrase string `"0"`, and publish generated keys atomically through Filesystem with explicit private/public modes. | +| `jwt-04`, `jwt-08`, `jwt-09` | Reject RSA keys below 2048 bits, preserve passphrase string `"0"`, and publish each generated key atomically through Filesystem with explicit private/public modes. | | `jwt-06` | Accept non-empty string and integer blacklist identifiers, including zero, and reject every other shape at the string-only storage boundary. | | `jwt-07` | Delete the unused destructive PSR cache adapter and its split dependency while retaining the custom `StorageContract` extension point. | | `jwt-10` | Keep fallbacks only for children of the replace-whole providers group and remove duplicate defaults from guaranteed merged top-level configuration. | @@ -2142,6 +2142,6 @@ Append package entries in checklist order. Keep each entry compact but complete - **Inherited upstream evidence:** Current upstream shares the malformed registered-date escape, absolute blacklist lifetime, and discarded storage-result defects. Hypervel's duplicate blacklist lifetime and falsey array-payload key check were local. No external issue or pull request was opened without owner authorization. - **Important rejected concerns:** Do not add parser pre-validation, claim DTOs, recursive coercion, validation-chain introspection, revocation retries or logs, a key registry, a third storage mode, deep config merge, a certificate transaction or lock, guard cloning, scoped rebinding, leeway mutators, or compatibility readers for removed Hypervel-only surfaces. Deliberate validation bypasses and invalid custom payload shapes receive no parallel machinery. - **Regression coverage:** Counterfactual tests cover malformed registered dates before signature validation, epoch zero, finite/infinite and missing-claim refresh, leeway and minute boundaries, cache-delayed clocks, truthful write/flush failures, zero identifiers, grace-aware permanent retention and non-sliding repeated writes, immediate force-forever invalidation, transactional logout, provider-array replacement, custom storage, RSA floor and file modes, string-zero encryption, obsolete configuration, facade metadata, and the corrected test prohibitions. -- **Performance and compatibility:** Terminal tokens skip cache I/O; existing cache results are inspected without retry; and boundary arithmetic adds no I/O. Automatic permanent revocation performs one existing-entry read, matching finite revocation, so repeated concurrent refreshes cannot slide grace. Parser, config, docs, metadata, and command-only publication add no request hot-path work. Supported Laravel auth and manager APIs are preserved; only unsafe Hypervel-only storage/configuration surfaces are removed. +- **Performance and compatibility:** Terminal tokens skip cache I/O; existing cache results are inspected without retry; and boundary arithmetic adds no I/O. Automatic permanent revocation performs one existing-entry read, matching finite revocation, so repeated revocations cannot restart grace. Already-overlapping first writes may shift a non-zero deadline by cache or scheduling latency; this bounded tradeoff avoids cross-store atomic-insertion machinery. Parser, config, docs, metadata, and command-only publication add no request hot-path work. Supported Laravel auth and manager APIs are preserved; only unsafe Hypervel-only storage/configuration surfaces are removed. - **Validation and review:** Changed tests, the complete JWT suite, affected sibling suites, strict split metadata, facade lint, stale scans, `git diff --check`, formatting, both PHPStan configurations, the full parallel suite, Testbench package mode, and dogfood passed. Post-gate review corrections passed focused and complete JWT coverage. Independent review re-read every changed file, reproduced the grace regression and sliding-window risk, verified their final corrections, and signed off with no remaining finding. - **Assessment:** JWT is parse-safe, revocation-truthful, grace-correct, coroutine-safe, securely published, and accurately documented. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, stale path, meaningful hot-path regression, unintended Laravel API break, or unresolved defect. diff --git a/docs/plans/2026-08-08-0426-jwt-correctness-security-and-lifecycle.md b/docs/plans/2026-08-08-0426-jwt-correctness-security-and-lifecycle.md index 03e824f59..e8a83485b 100644 --- a/docs/plans/2026-08-08-0426-jwt-correctness-security-and-lifecycle.md +++ b/docs/plans/2026-08-08-0426-jwt-correctness-security-and-lifecycle.md @@ -93,7 +93,7 @@ Storage false-success, absolute blacklist lifetime, and malformed registered-dat | `jwt-01` | Defect | Major | High | Treat absent/null expiration as optional but validate timestamp zero; cover direct zero and an external-style JSON string-zero round trip. | | `jwt-02` | Defect | Major | High | Delete `blacklist_refresh_ttl`; use nullable `refresh_ttl` as the one refresh and revocation lifetime. Genuinely refreshable tokens with infinite refresh require permanent, grace-aware revocation. | | `jwt-03` | Defect | Major | High | Make storage writes/flush truthful booleans, propagate failure, throw from manager invalidation, and make guard logout transactional. | -| `jwt-04` | Defect | Major | High | Method-inject Filesystem and atomically publish private/public keys at modes `0600`/`0644`; create the directory through the existing `0755` Filesystem boundary. | +| `jwt-04` | Defect | Major | High | Method-inject Filesystem and atomically publish each private and public key at modes `0600`/`0644`; create the directory through the existing `0755` Filesystem boundary. | | `jwt-05` | Defect | Major | High | Include expiration leeway, use one clock snapshot for the terminal decision and positive remaining lifetime, and skip cache I/O only when the unified acceptance boundary has elapsed. | | `jwt-06` | Defect (`support-02`) | Major | High | Accept non-empty strings and integers, including zero, as blacklist identifiers; normalize to string and reject every other shape with `TokenInvalidException`. | | `jwt-07` | Defect | Major | High | Delete the unused destructive PSR adapter and its split dependency; retain tagged storage plus the custom `StorageContract` extension point. | @@ -156,7 +156,7 @@ Remove `blacklist_refresh_ttl` and `JWT_BLACKLIST_REFRESH_TTL` everywhere. Chang 7. If the boundary is no longer future at that snapshot, return true before any cache read or write. When the write path is selected, reuse the snapshot so time passing during the cache lookup cannot produce a nonpositive TTL. 8. If the key already exists, return true; otherwise return the storage adapter's boolean. -Both automatic permanent branches call a protected `addForeverWithGracePeriod()` helper. It resolves the key, returns early when storage already contains the entry so repeated concurrent refreshes cannot slide the grace window, then stores `['valid_until' => $this->getGraceTimestamp()]` forever. Add one short WHY comment to the guard because rewriting would restart the grace period. `has()` already applies that timestamp before treating the entry as active. The public `addForever()` remains the explicit immediate permanent-revocation API and keeps the `'forever'` sentinel. +Both automatic permanent branches call a protected `addForeverWithGracePeriod()` helper. It resolves the key, returns early when storage already contains the entry so repeated revocations cannot restart the grace period, then stores `['valid_until' => $this->getGraceTimestamp()]` forever. Add one short WHY comment to the guard because rewriting would restart the grace period. `has()` already applies that timestamp before treating the entry as active. The public `addForever()` remains the explicit immediate permanent-revocation API and keeps the `'forever'` sentinel. The early skip and lifetime unification must land together. Use a positive `iat !== null` branch and explain that only a present `iat` can extend the boundary because refresh rejects a missing claim before the infinite-refresh return. Add one short WHY comment at the skip stating that the unified boundary covers both expiration acceptance, including leeway, and the refresh window. These comments record the cross-file invariant that makes finite retention and skipped I/O safe. @@ -361,7 +361,7 @@ After the full gate, perform a fresh self-review without trusting this plan. Tra - No new cache/network call, retry, lock, registry, context slot, or service resolution is introduced. The blacklist retains one additional immutable boot-configured integer for leeway and no mutable request state. - Dead tokens perform fewer cache operations because terminal blacklist writes are skipped. - Truthful storage returns inspect results already produced by Cache; no additional I/O occurs. -- Automatic permanent revocation performs the same existing-entry read as finite revocation so repeated concurrent refreshes cannot slide the grace window. Explicit `addForever()` remains a write-only operation. +- Automatic permanent revocation performs the same existing-entry read as finite revocation so repeated revocations cannot restart grace. Already-overlapping first writes may shift a non-zero deadline by cache or scheduling latency; this bounded tradeoff avoids cross-store atomic-insertion machinery. Explicit `addForever()` remains a write-only operation. - The decode correction changes only the caught throwable type around an existing parser call. - Exact key checks and config fallback cleanup add no meaningful hot-path work. - Leeway is read once when the worker-lifetime blacklist is constructed; the boundary calculation adds one date modifier and no I/O. Its terminal decision and TTL reuse one clock snapshot, while the grace timestamp keeps its independent write-time clock read. diff --git a/src/jwt/src/Blacklist.php b/src/jwt/src/Blacklist.php index 6645e3da8..394e82449 100644 --- a/src/jwt/src/Blacklist.php +++ b/src/jwt/src/Blacklist.php @@ -84,7 +84,12 @@ protected function addForeverWithGracePeriod(array $payload): bool { $key = $this->getKey($payload); - // Rewriting the entry would restart its grace period on every concurrent refresh. + // Avoid restarting grace on repeated revocations. Rare overlapping first writes + // may shift a non-zero deadline by cache or scheduling latency, normally less + // than the timestamp's one-second precision. They cannot remove the entry or + // keep extending it after the overlapping writes settle; zero grace remains + // immediate. Preventing that bounded shift would require atomic insertion + // across every supported store. if (! empty($this->storage->get($key))) { return true; } diff --git a/src/jwt/src/Contracts/StorageContract.php b/src/jwt/src/Contracts/StorageContract.php index 7b9a3a1f2..555211a02 100644 --- a/src/jwt/src/Contracts/StorageContract.php +++ b/src/jwt/src/Contracts/StorageContract.php @@ -6,13 +6,28 @@ interface StorageContract { + /** + * Store an item for the given number of minutes. + */ public function add(string $key, mixed $value, int $minutes): bool; + /** + * Store an item indefinitely. + */ public function forever(string $key, mixed $value): bool; + /** + * Retrieve an item from storage. + */ public function get(string $key): mixed; + /** + * Remove an item from storage. + */ public function destroy(string $key): bool; + /** + * Remove all items from storage. + */ public function flush(): bool; }