From fc5f60778afa00ca3b2ef55885d85826f2495bc7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:41:04 +0000 Subject: [PATCH 01/10] Reject malformed ECDSA signatures as invalid JWTs A token signed with ES256, ES384 or ES512 whose signature had the wrong length made Lcobucci::decode() throw lcobucci's ConversionFailed instead of TokenInvalidException. The guard and auth:jwt middleware therefore answered attacker-controlled input with a server error. Decoding now treats that signature as unverifiable, while key and secret configuration errors still propagate. Decoded claims map dates to timestamps with array_map. The getValue() branch is gone because lcobucci/jwt 5 returns plain claim values, and the provider contract and isAsymmetric() gain their titles. Tests reconcile the provider and token-validation coverage of tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth main (1bdb72de5): - Port the ES256 key fixtures and round trip from tymondesigns 5d188de, plus its nbf, jti and aud round trip. - Add the fork's test that a token signed with a replaced secret is rejected. - Turn the undecodable-token cases into one data provider that covers the original's token-validator inputs. - Add a regression test for the wrong-length ECDSA signature. The lcobucci v4 compatibility in 5d188de and its method_exists() switch in 033b2df do not apply, since the package requires lcobucci/jwt ^5.0. The README now explains why the Namshi provider is not included. Validation: LcobucciTest and the Jwt suite pass; composer lint:fix and composer analyse report no changes or errors. --- src/jwt/README.md | 3 +- src/jwt/src/Contracts/ProviderContract.php | 6 + src/jwt/src/Providers/Lcobucci.php | 29 +++-- tests/Jwt/Fixtures/keys/id_ecdsa | 5 + tests/Jwt/Fixtures/keys/id_ecdsa.pub | 4 + tests/Jwt/Providers/LcobucciTest.php | 134 ++++++++++++++++++++- 6 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 tests/Jwt/Fixtures/keys/id_ecdsa create mode 100644 tests/Jwt/Fixtures/keys/id_ecdsa.pub diff --git a/src/jwt/README.md b/src/jwt/README.md index 2a9e0c8cee..7b1aa10dc9 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -10,7 +10,8 @@ Documentation: https://hypervel.org/docs/jwt - 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 Namshi provider is not included. Lcobucci supports the same HMAC, RSA and ECDSA algorithms, and `tymondesigns/jwt-auth` no longer ships Namshi. +- Lumen integrations are not included. - The `show_black_list_exception` option is not included; JWT exceptions fail normally. Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth diff --git a/src/jwt/src/Contracts/ProviderContract.php b/src/jwt/src/Contracts/ProviderContract.php index 8fc434f613..01e0360cc1 100644 --- a/src/jwt/src/Contracts/ProviderContract.php +++ b/src/jwt/src/Contracts/ProviderContract.php @@ -6,7 +6,13 @@ interface ProviderContract { + /** + * Create a JSON Web Token. + */ public function encode(array $payload): string; + /** + * Decode a JSON Web Token. + */ public function decode(string $token): array; } diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index c8b19c6c54..48fc8b66ef 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -11,11 +11,11 @@ use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\SecretMissingException; use Hypervel\Jwt\Exceptions\TokenInvalidException; -use Hypervel\Support\Collection; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Signer\Ecdsa; +use Lcobucci\JWT\Signer\Ecdsa\ConversionFailed; use Lcobucci\JWT\Signer\Key; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa; @@ -98,21 +98,21 @@ public function decode(string $token): array ); } - if (! $this->config->validator()->validate($token, ...$this->config->validationConstraints())) { + try { + $verified = $this->config->validator()->validate($token, ...$this->config->validationConstraints()); + } catch (ConversionFailed) { + // ECDSA verification rejects a signature of the wrong length before comparing it. + $verified = false; + } + + if (! $verified) { throw new TokenInvalidException('Token Signature could not be verified.'); } - return Collection::wrap($token->claims()->all()) - ->map(function ($claim) { - if ($claim instanceof DateTimeInterface) { - return $claim->getTimestamp(); - } - - return is_object($claim) && method_exists($claim, 'getValue') - ? $claim->getValue() - : $claim; - }) - ->toArray(); + return array_map( + static fn (mixed $claim): mixed => $claim instanceof DateTimeInterface ? $claim->getTimestamp() : $claim, + $token->claims()->all(), + ); } /** @@ -202,6 +202,9 @@ protected function getSigner(): Signer return new $signer; } + /** + * Determine if the algorithm is asymmetric, and thus requires a public/private key combo. + */ protected function isAsymmetric(): bool { return is_subclass_of($this->signer, Rsa::class) diff --git a/tests/Jwt/Fixtures/keys/id_ecdsa b/tests/Jwt/Fixtures/keys/id_ecdsa new file mode 100644 index 0000000000..01e627443f --- /dev/null +++ b/tests/Jwt/Fixtures/keys/id_ecdsa @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIPKNCQlkD3SP1JniCj2RXvGlzp8NO9sYyQr05hdFlGKboAoGCCqGSM49 +AwEHoUQDQgAEhXUAmmzqpHbtmm4SY5ikZparlUyrmggNhDy8QoyNIlbrJzh6q0PF +crVbOchQZ2fGp0r+6o1tDTmz0AN3XWKJXw== +-----END EC PRIVATE KEY----- diff --git a/tests/Jwt/Fixtures/keys/id_ecdsa.pub b/tests/Jwt/Fixtures/keys/id_ecdsa.pub new file mode 100644 index 0000000000..aa285fcf86 --- /dev/null +++ b/tests/Jwt/Fixtures/keys/id_ecdsa.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhXUAmmzqpHbtmm4SY5ikZparlUyr +mggNhDy8QoyNIlbrJzh6q0PFcrVbOchQZ2fGp0r+6o1tDTmz0AN3XWKJXw== +-----END PUBLIC KEY----- diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php index 07decd660f..962abc7397 100644 --- a/tests/Jwt/Providers/LcobucciTest.php +++ b/tests/Jwt/Providers/LcobucciTest.php @@ -108,6 +108,64 @@ public function testEncodeAndDecodeATokenUsingAnAsymmetricRs256Key(): void $this->assertEquals($iat, $claims['iat']); } + public function testEncodeAndDecodeATokenWithNbfJtiAndAudClaims(): void + { + $payload = [ + 'sub' => 1, + 'exp' => $exp = $this->testNowTimestamp + 3600, + 'iat' => $iat = $this->testNowTimestamp, + 'nbf' => $nbf = $this->testNowTimestamp, + 'jti' => $jti = 'unique-token-id', + 'iss' => '/foo', + 'aud' => $aud = 'my-audience', + 'custom_claim' => 'foobar', + ]; + + $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256); + + $token = $provider->encode($payload); + $claims = $provider->decode($token); + + $this->assertEquals('1', $claims['sub']); + $this->assertEquals('/foo', $claims['iss']); + $this->assertEquals('foobar', $claims['custom_claim']); + $this->assertEquals($exp, $claims['exp']); + $this->assertEquals($iat, $claims['iat']); + $this->assertEquals($nbf, $claims['nbf']); + $this->assertEquals($jti, $claims['jti']); + $this->assertEquals([$aud], $claims['aud']); + } + + public function testEncodeAndDecodeATokenUsingAnAsymmetricEs256Key(): void + { + $payload = [ + 'sub' => 1, + 'exp' => $exp = $this->testNowTimestamp + 3600, + 'iat' => $iat = $this->testNowTimestamp, + 'iss' => '/foo', + 'custom_claim' => 'foobar', + ]; + + $provider = $this->getProvider( + $this->getRandomString(), + Provider::ALGO_ES256, + ['private' => $this->getDummyEcPrivateKey(), 'public' => $this->getDummyEcPublicKey()] + ); + + $token = $provider->encode($payload); + + $header = json_decode(base64_decode(explode('.', $token)[0]), true); + $this->assertEquals(Provider::ALGO_ES256, $header['alg']); + + $claims = $provider->decode($token); + + $this->assertEquals('1', $claims['sub']); + $this->assertEquals('/foo', $claims['iss']); + $this->assertEquals('foobar', $claims['custom_claim']); + $this->assertEquals($exp, $claims['exp']); + $this->assertEquals($iat, $claims['iat']); + } + public function testEncodeAndDecodeATokenWithMultipleAudiences(): void { $payload = [ @@ -160,12 +218,51 @@ public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecod ->decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNjQ5MjYxMDY1LCJpYXQiOjE2NDkyNTc0NjUsImlzcyI6Ii9mb29iYXIiLCJjdXN0b21fY2xhaW0iOiJmb29iYXIifQ.jamiInQiin-1RUviliPjZxl0MLEnQnVTbr2sGooeXBY'); } - public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecoded(): void + public function testShouldThrowATokenInvalidExceptionWhenAnEcdsaSignatureHasTheWrongLength(): void + { + $provider = $this->getProvider( + 'does_not_matter', + Provider::ALGO_ES256, + ['private' => $this->getDummyEcPrivateKey(), 'public' => $this->getDummyEcPublicKey()] + ); + + [$header, $payload] = explode('.', $provider->encode(['sub' => 1, 'iat' => $this->testNowTimestamp])); + + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIs('Token Signature could not be verified.'); + + $provider->decode("{$header}.{$payload}." . $this->base64UrlEncode('signature')); + } + + #[DataProvider('undecodableTokenProvider')] + public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecoded(string $token): void { $this->expectException(TokenInvalidException::class); $this->expectExceptionMessageIsOrContains('Could not decode token:'); - $this->getProvider('secret', Provider::ALGO_HS256)->decode('foo.bar.baz'); + $this->getProvider('secret', Provider::ALGO_HS256)->decode($token); + } + + /** + * Provide tokens that cannot be parsed. + * + * @return array + */ + public static function undecodableTokenProvider(): array + { + return [ + 'segments that are not encoded JSON' => ['foo.bar.baz'], + 'missing signature' => ['one.two.'], + 'missing header and signature' => ['.two.'], + 'missing header' => ['.two.three'], + 'missing claims' => ['one..three'], + 'separators only' => ['..'], + 'blank segments' => [' . . '], + 'padded segments' => [' one . two . three '], + 'two segments' => ['one.two'], + 'four segments' => ['one.two.three.four'], + 'five segments' => ['one.two.three.four.five'], + ]; } #[DataProvider('malformedRegisteredDateProvider')] @@ -327,6 +424,23 @@ public function testSetSecretTakesEffectOnSigning(): void $this->getProvider($originalSecret, Provider::ALGO_HS256)->decode($token); } + public function testShouldThrowAnExceptionWhenTheSecretHasBeenUpdatedAndAnOldTokenIsUsed(): void + { + $payload = ['sub' => '1', 'exp' => $this->testNowTimestamp + 3600, 'iat' => $this->testNowTimestamp, 'iss' => '/foo']; + + $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256); + $token = $provider->encode($payload); + + $this->assertSame($payload, $provider->decode($token)); + + $provider->setSecret($this->getRandomString()); + + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIs('Token Signature could not be verified.'); + + $provider->decode($token); + } + public function testSetKeysTakesEffectOnSigning(): void { $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; @@ -403,4 +517,20 @@ private function getAltPublicKey(): string { return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa_alt.pub'); } + + /** + * Get the ECDSA private key fixture. + */ + private function getDummyEcPrivateKey(): string + { + return file_get_contents(__DIR__ . '/../Fixtures/keys/id_ecdsa'); + } + + /** + * Get the ECDSA public key fixture. + */ + private function getDummyEcPublicKey(): string + { + return file_get_contents(__DIR__ . '/../Fixtures/keys/id_ecdsa.pub'); + } } From 26c3b04df90707031db1c5eee6699b798ec9aece Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:09:37 +0000 Subject: [PATCH 02/10] Support registered custom JWT claims and date objects Custom claims could not set exp, nbf, iat, iss or jti, so an application could not give one token a longer lifetime or its own issuer. Date claims also had to be Unix timestamps: a Carbon or DateTime value made the Lcobucci provider throw an error. Model and inline custom claims may now set every registered claim except sub and prv, both when a token is created and when it is refreshed. An explicit exp takes precedence over the TTL, and an explicit iat over refresh_iat. Date claims accept integers, integer strings, DateTimeInterface instances and DateInterval values, which one provider helper converts to whole seconds. Refresh now carries iss like any other claim and rebuilds only iat (per refresh_iat), nbf, exp and jti. IssuedAtClaim is no longer a temporal validation, so refresh rejects a token issued in the future instead of restamping it into a valid one. The refresh window and the blacklist retention for refreshable tokens both include jwt.leeway, so a revocation lasts as long as any refresh it must block. RequiredClaims lists missing claims as a JSON array. Before, a missing claim that was not first in required_claims produced a JSON object such as {"1":"iss"}. Tests reconcile the claims, payload and validator coverage of tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth main (1bdb72de5): - Port the datetime claim forms and the multidimensional claim round trip. - Cover custom registered claims at creation and refresh, the refresh issuer, future nbf and iat on refresh with and without leeway, and the leeway at the end of the refresh window and in blacklist retention. - Cover complete, subset and multiple missing required claims. Upstream's payload, claim and collection objects are replaced by arrays, so their DTO-only tests have no equivalent. The original's 6c70930a9 casts the TTL to int, which turns a null TTL into an immediately expiring token; Hypervel already casts the configured values while keeping null, so that cast is not ported. The config comments, README and JWT guide describe claim precedence, what refresh rebuilds and where leeway applies. Validation classes and JwtManager gain their missing method titles. Validation: the Jwt suite passes; composer lint:fix and composer analyse report no changes or errors. --- src/docs/jwt.md | 17 ++- src/jwt/README.md | 2 + src/jwt/config/jwt.php | 12 +- src/jwt/src/Blacklist.php | 6 +- src/jwt/src/ClaimFactory.php | 33 ++--- src/jwt/src/Contracts/TemporalValidation.php | 3 + src/jwt/src/JwtManager.php | 14 +- src/jwt/src/Providers/Lcobucci.php | 24 +++- .../src/Validations/AbstractValidation.php | 9 ++ src/jwt/src/Validations/ExpiredClaim.php | 3 + src/jwt/src/Validations/IssuedAtClaim.php | 6 +- src/jwt/src/Validations/NotBeforeClaim.php | 9 +- src/jwt/src/Validations/RequiredClaims.php | 5 +- tests/Jwt/BlacklistTest.php | 20 ++- tests/Jwt/ClaimFactoryTest.php | 108 ++++++++++++++- tests/Jwt/JwtManagerTest.php | 130 ++++++++++++++++-- tests/Jwt/Providers/LcobucciTest.php | 52 +++++++ tests/Jwt/Validations/RequiredClaimsTest.php | 26 +++- 18 files changed, 413 insertions(+), 66 deletions(-) diff --git a/src/docs/jwt.md b/src/docs/jwt.md index d8399e116e..3e33eaec63 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -166,6 +166,19 @@ class User extends Model implements JwtSubject Inline claims passed with the guard's `claims` method override model-defined custom claims for the next token. +Custom claims may also set the registered `exp`, `nbf`, `iat`, `iss`, and `jti` claims, replacing the values the package would otherwise add. An explicit `exp` takes precedence over the configured TTL. Date claims accept a Unix timestamp as an integer or string, a `DateTimeInterface` instance such as a Carbon date, or a `DateInterval` that is added to the current time: + +```php +use Hypervel\Support\Facades\Auth; +use Hypervel\Support\Facades\Date; + +$token = Auth::guard('api') + ->claims(['exp' => Date::now()->addDays(7)]) + ->login($user); +``` + +If you provide your own `jti`, your application is responsible for keeping it unique. The `sub` and `prv` claims always come from the user and its provider, so passing them as custom claims throws a `JwtException`. + ### Signing Keys and Algorithms @@ -348,7 +361,7 @@ The `required_claims` option controls which claims must exist in every token: ], ``` -If your application uses timestamp validations and your servers have small clock differences, configure `leeway` in seconds: +If your application uses timestamp validations and your servers have small clock differences, configure `leeway` in seconds. The leeway applies to the `exp`, `nbf`, and `iat` claims and to the end of the refresh window: ```php 'leeway' => (int) env('JWT_LEEWAY', 0), @@ -539,7 +552,7 @@ Claims listed in `persistent_claims` are preserved during refresh when they are ], ``` -Managed claims such as `nbf`, `exp`, `iss`, and `jti` are rebuilt by the package. The `iat` claim is rebuilt only when `refresh_iat` is enabled. +Every refreshed token receives a new `nbf` claim. It also receives a new `exp` claim unless the TTL is `null`, a new `jti` claim when the blacklist is enabled, and a new `iat` claim when `refresh_iat` is enabled. Other claims, including `iss`, are kept unless you reset claims. Claims passed with the `claims` method before refreshing take precedence over all of these, but may not set `sub` or `prv`. Before any new claims apply, the old token must still be refreshable. An explicit `iat` then becomes the starting point of the new token's refresh window. ### Logging Out and Invalidating Tokens diff --git a/src/jwt/README.md b/src/jwt/README.md index 7b1aa10dc9..170adb06ce 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -7,6 +7,8 @@ Documentation: https://hypervel.org/docs/jwt - 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. +- Custom claims may set registered claims such as `exp`, `iss` and `jti`, but not `sub` or `prv`, which always come from the user and its provider. Customize the subject with `JwtSubject::getJwtIdentifier()`. +- Hypervel adds a default `iss` only when `jwt.issuer` is set, rather than using the request URL, and a default `jti` only when the blacklist is enabled. - 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()`. diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index f56f419170..0772202e37 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -99,7 +99,7 @@ | Defaults to 2 hours. | | You can also set this to null, to yield a never expiring token. - | Some people may want this behaviour for e.g. a mobile app. + | Some people may want this behavior for e.g. a mobile app. | This is not particularly recommended, so make sure you have appropriate | systems in place to revoke the token if necessary. | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list. @@ -193,9 +193,10 @@ | Persistent Claims |-------------------------------------------------------------------------- | - | Specify the claim keys to be persisted when refreshing a token. - | `sub` and `iat` will automatically be persisted, in - | addition to the these claims. + | Specify the claim keys to keep when a token is refreshed with reset + | claims. On every refresh, `sub` and `prv` are kept, `nbf`, `exp` and + | `jti` are rebuilt, and `iat` is kept unless `refresh_iat` is enabled. + | Without a reset, other claims are kept too. | | Note: If a claim does not exist then it will be ignored. | @@ -215,7 +216,8 @@ | Meaning that if you have any unavoidable slight clock skew on | any of your servers then this will afford you some level of cushioning. | - | This applies to the claims `iat`, `nbf` and `exp`. + | This applies to the claims `iat`, `nbf` and `exp`, and to the end of + | the refresh window. | | Specify in seconds - only if you know you need it. | diff --git a/src/jwt/src/Blacklist.php b/src/jwt/src/Blacklist.php index 394e824493..596b9527f7 100644 --- a/src/jwt/src/Blacklist.php +++ b/src/jwt/src/Blacklist.php @@ -43,15 +43,15 @@ public function add(array $payload): bool } $expiresAt = $expiresAt->max( - $this->timestamp($issuedAt)->addMinutes($this->refreshTTL) + $this->timestamp($issuedAt)->addMinutes($this->refreshTTL)->addSeconds($this->leeway) ); } $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. + // The unified boundary covers expiration acceptance and the refresh window, + // each including leeway, so terminal tokens need no cache I/O. if ($expiresAt <= $now) { return true; } diff --git a/src/jwt/src/ClaimFactory.php b/src/jwt/src/ClaimFactory.php index ed914c16b0..d7b3313e5a 100644 --- a/src/jwt/src/ClaimFactory.php +++ b/src/jwt/src/ClaimFactory.php @@ -14,14 +14,14 @@ class ClaimFactory { /** - * Claims stamped by the factory itself when a token is refreshed. + * Claims that belong to a single token and are rebuilt when it is refreshed. */ - protected const array MANAGED_REFRESH_CLAIMS = ['iat', 'nbf', 'exp', 'iss', 'jti']; + protected const array MANAGED_REFRESH_CLAIMS = ['iat', 'nbf', 'exp', 'jti']; /** - * Claims owned by the guard, manager, or claim factory. + * Identity claims derived from the user and provider. */ - protected const array RESERVED_CUSTOM_CLAIMS = ['sub', 'prv', ...self::MANAGED_REFRESH_CLAIMS]; + protected const array RESERVED_CUSTOM_CLAIMS = ['sub', 'prv']; protected static array $subjectModelHashes = []; @@ -81,25 +81,18 @@ public function refresh( array $persistentClaims, array $customClaims = [], ): array { - $managed = array_flip(self::MANAGED_REFRESH_CLAIMS); - $persistent = array_diff_key( - array_intersect_key($payload, array_flip($persistentClaims)), - $managed, - ); - - $claims = $resetClaims - ? $persistent - : array_diff_key($payload, $managed); + $carried = $resetClaims + ? array_intersect_key($payload, array_flip($persistentClaims)) + : $payload; $this->rejectReservedCustomClaims($customClaims); - $claims = array_merge($claims, $persistent, $customClaims, [ - 'sub' => $payload['sub'], - ]); - - if (! $refreshIssuedAt) { - $claims['iat'] = $payload['iat']; - } + $claims = array_merge( + array_diff_key($carried, array_flip(self::MANAGED_REFRESH_CLAIMS)), + $refreshIssuedAt ? [] : ['iat' => $payload['iat']], + $customClaims, + ['sub' => $payload['sub']], + ); if (array_key_exists('prv', $payload)) { $claims['prv'] = $payload['prv']; diff --git a/src/jwt/src/Contracts/TemporalValidation.php b/src/jwt/src/Contracts/TemporalValidation.php index afbfaed10d..3d30ef3960 100644 --- a/src/jwt/src/Contracts/TemporalValidation.php +++ b/src/jwt/src/Contracts/TemporalValidation.php @@ -4,6 +4,9 @@ namespace Hypervel\Jwt\Contracts; +/** + * Mark a validation that refresh skips because the refresh window replaces it. + */ interface TemporalValidation { } diff --git a/src/jwt/src/JwtManager.php b/src/jwt/src/JwtManager.php index 2f57825e47..edbec2ed05 100644 --- a/src/jwt/src/JwtManager.php +++ b/src/jwt/src/JwtManager.php @@ -98,6 +98,9 @@ public function decode(string $token, bool $validate = true, bool $checkBlacklis return $payload; } + /** + * Run the configured validations against a decoded payload. + */ protected function validatePayload(array $payload, bool $refresh = false): void { foreach ($this->config->array('jwt.validations') as $validation) { @@ -111,6 +114,9 @@ protected function validatePayload(array $payload, bool $refresh = false): void } } + /** + * Get the cached validation instance for the given class. + */ protected function getValidation(string $class): ValidationContract { if ($validation = ($this->validations[$class] ?? null)) { @@ -213,7 +219,13 @@ protected function validateRefreshWindow(array $payload): void return; } - if (Date::now() > Date::createFromTimestamp($issuedAt)->addMinutes($refreshTtl)) { + // Blacklist retention adds the same leeway to this deadline, so revocations + // outlive every refresh attempt they must block. + $refreshableUntil = Date::createFromTimestamp($issuedAt) + ->addMinutes($refreshTtl) + ->addSeconds($this->config->integer('jwt.leeway')); + + if (Date::now() > $refreshableUntil) { throw new TokenExpiredException('Token has expired and can no longer be refreshed'); } } diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index 48fc8b66ef..9f614d64c6 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -4,6 +4,7 @@ namespace Hypervel\Jwt\Providers; +use DateInterval; use DateTimeImmutable; use DateTimeInterface; use Exception; @@ -11,6 +12,7 @@ use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\SecretMissingException; use Hypervel\Jwt\Exceptions\TokenInvalidException; +use Hypervel\Support\Facades\Date; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer; @@ -128,13 +130,13 @@ protected function getBuilderFromClaims(array $payload): Builder $builder = $builder->identifiedBy($value); break; case RegisteredClaims::EXPIRATION_TIME: - $builder = $builder->expiresAt(DateTimeImmutable::createFromFormat('U', (string) $value)); + $builder = $builder->expiresAt($this->getDateFromClaim($value)); break; case RegisteredClaims::NOT_BEFORE: - $builder = $builder->canOnlyBeUsedAfter(DateTimeImmutable::createFromFormat('U', (string) $value)); + $builder = $builder->canOnlyBeUsedAfter($this->getDateFromClaim($value)); break; case RegisteredClaims::ISSUED_AT: - $builder = $builder->issuedAt(DateTimeImmutable::createFromFormat('U', (string) $value)); + $builder = $builder->issuedAt($this->getDateFromClaim($value)); break; case RegisteredClaims::ISSUER: $builder = $builder->issuedBy($value); @@ -155,6 +157,22 @@ protected function getBuilderFromClaims(array $payload): Builder return $builder; } + /** + * Convert a date claim value to a date with whole-second precision. + */ + protected function getDateFromClaim(int|string|DateTimeInterface|DateInterval $value): DateTimeImmutable + { + if ($value instanceof DateInterval) { + $value = Date::now()->add($value); + } + + if ($value instanceof DateTimeInterface) { + $value = $value->getTimestamp(); + } + + return DateTimeImmutable::createFromFormat('U', (string) $value); + } + /** * Build the configuration. */ diff --git a/src/jwt/src/Validations/AbstractValidation.php b/src/jwt/src/Validations/AbstractValidation.php index e392795d36..db97f9e61b 100644 --- a/src/jwt/src/Validations/AbstractValidation.php +++ b/src/jwt/src/Validations/AbstractValidation.php @@ -10,13 +10,22 @@ abstract class AbstractValidation implements ValidationContract { + /** + * Create a new validation instance. + */ public function __construct( protected array $config = [] ) { } + /** + * Validate the payload. + */ abstract public function validate(array $payload): void; + /** + * Create a date from a claim timestamp. + */ protected function timestamp(int $timestamp): CarbonInterface { return Date::createFromTimestamp($timestamp); diff --git a/src/jwt/src/Validations/ExpiredClaim.php b/src/jwt/src/Validations/ExpiredClaim.php index 7492134a33..b35e29864b 100644 --- a/src/jwt/src/Validations/ExpiredClaim.php +++ b/src/jwt/src/Validations/ExpiredClaim.php @@ -10,6 +10,9 @@ class ExpiredClaim extends AbstractValidation implements TemporalValidation { + /** + * Validate that the token has not expired. + */ public function validate(array $payload): void { $exp = $payload['exp'] ?? null; diff --git a/src/jwt/src/Validations/IssuedAtClaim.php b/src/jwt/src/Validations/IssuedAtClaim.php index 90e87dfc49..70d0317e67 100644 --- a/src/jwt/src/Validations/IssuedAtClaim.php +++ b/src/jwt/src/Validations/IssuedAtClaim.php @@ -4,12 +4,14 @@ namespace Hypervel\Jwt\Validations; -use Hypervel\Jwt\Contracts\TemporalValidation; use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Support\Facades\Date; -class IssuedAtClaim extends AbstractValidation implements TemporalValidation +class IssuedAtClaim extends AbstractValidation { + /** + * Validate that the token was not issued in the future. + */ public function validate(array $payload): void { if (! $iat = ($payload['iat'] ?? null)) { diff --git a/src/jwt/src/Validations/NotBeforeClaim.php b/src/jwt/src/Validations/NotBeforeClaim.php index 09b2e748fa..3590165106 100644 --- a/src/jwt/src/Validations/NotBeforeClaim.php +++ b/src/jwt/src/Validations/NotBeforeClaim.php @@ -7,14 +7,11 @@ 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 { + /** + * Validate that the token may already be used. + */ public function validate(array $payload): void { if (! $nbf = ($payload['nbf'] ?? null)) { diff --git a/src/jwt/src/Validations/RequiredClaims.php b/src/jwt/src/Validations/RequiredClaims.php index 5e6af89707..0044ac4abb 100644 --- a/src/jwt/src/Validations/RequiredClaims.php +++ b/src/jwt/src/Validations/RequiredClaims.php @@ -8,6 +8,9 @@ class RequiredClaims extends AbstractValidation { + /** + * Validate that the payload contains every required claim. + */ public function validate(array $payload): void { if (! $required = $this->config['required_claims'] ?? []) { @@ -18,6 +21,6 @@ public function validate(array $payload): void return; } - throw new TokenInvalidException('Claims are missing: ' . json_encode($missingKeys)); + throw new TokenInvalidException('Claims are missing: ' . json_encode(array_values($missingKeys))); } } diff --git a/tests/Jwt/BlacklistTest.php b/tests/Jwt/BlacklistTest.php index 0a591e7c01..793d5f391a 100644 --- a/tests/Jwt/BlacklistTest.php +++ b/tests/Jwt/BlacklistTest.php @@ -316,24 +316,38 @@ public function testExpirationLeewayCanDefineTheBlacklistLifetime(): void $this->assertTrue($blacklist->add($payload)); } - public function testRefreshWindowCanDefineTheBlacklistLifetime(): void + #[DataProvider('refreshWindowLifetimeProvider')] + public function testRefreshWindowCanDefineTheBlacklistLifetime(int $leeway, int $minutes): void { $payload = [ 'exp' => $this->testNowTimestamp + 600, 'iat' => $this->testNowTimestamp, 'jti' => 'foo', ]; - $blacklist = new Blacklist($this->storage, refreshTTL: 20); + $blacklist = new Blacklist($this->storage, refreshTTL: 20, leeway: $leeway); $this->storage->shouldReceive('get')->with('foo')->once()->andReturnNull(); $this->storage->shouldReceive('add') - ->with('foo', ['valid_until' => $this->testNowTimestamp], 21) + ->with('foo', ['valid_until' => $this->testNowTimestamp], $minutes) ->once() ->andReturnTrue(); $this->assertTrue($blacklist->add($payload)); } + /** + * Provide leeways and the blacklist lifetime the refresh window then requires. + * + * @return array + */ + public static function refreshWindowLifetimeProvider(): array + { + return [ + 'no leeway' => [0, 21], + 'leeway longer than the retention margin' => [120, 23], + ]; + } + #[DataProvider('terminalExpirationProvider')] public function testTerminalTokensSkipBlacklistStorage(int $expirationOffset): void { diff --git a/tests/Jwt/ClaimFactoryTest.php b/tests/Jwt/ClaimFactoryTest.php index 02f92c0f26..ea2477c352 100644 --- a/tests/Jwt/ClaimFactoryTest.php +++ b/tests/Jwt/ClaimFactoryTest.php @@ -12,6 +12,7 @@ use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; use SensitiveParameter; @@ -57,10 +58,42 @@ public function testUsesJwtSubjectIdentifierAndCustomClaims(): void $this->assertSame('one', $claims['tenant']); } + #[DataProvider('customRegisteredClaimTtlProvider')] + public function testCustomClaimsOverrideDefaultRegisteredClaims(?int $ttl): void + { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-01 00:00:00')); + + $claims = $this->factory(['jwt' => ['issuer' => 'https://api.example.test', 'lock_subject' => false]])->make( + new ClaimFactoryJwtSubjectUser(42, 'jwt-42', ['exp' => 1767229200, 'iss' => 'https://model.example.test']), + new ClaimFactoryProvider, + $ttl, + ['iss' => 'https://tenant.example.test', 'iat' => 1767225000, 'nbf' => 1767226000, 'jti' => 'custom-jti'], + ); + + $this->assertSame(1767229200, $claims['exp']); + $this->assertSame('https://tenant.example.test', $claims['iss']); + $this->assertSame(1767225000, $claims['iat']); + $this->assertSame(1767226000, $claims['nbf']); + $this->assertSame('custom-jti', $claims['jti']); + } + + /** + * Provide token lifetimes that an explicit expiration replaces. + * + * @return array + */ + public static function customRegisteredClaimTtlProvider(): array + { + return [ + 'configured lifetime' => [120], + 'no expiration' => [null], + ]; + } + public function testRejectsReservedJwtSubjectClaims(): void { $this->expectException(JwtException::class); - $this->expectExceptionMessageIs('Custom JWT claims may not override reserved claims: exp, sub.'); + $this->expectExceptionMessageIs('Custom JWT claims may not override reserved claims: sub.'); $this->factory()->make( new ClaimFactoryJwtSubjectUser(42, 'jwt-42', ['sub' => 999, 'exp' => 123]), @@ -72,7 +105,7 @@ public function testRejectsReservedJwtSubjectClaims(): void public function testRejectsReservedInlineClaims(): void { $this->expectException(JwtException::class); - $this->expectExceptionMessageIs('Custom JWT claims may not override reserved claims: iss, prv.'); + $this->expectExceptionMessageIs('Custom JWT claims may not override reserved claims: prv.'); $this->factory()->make( new ClaimFactoryUser(42), @@ -184,10 +217,77 @@ public function testRefreshIssuedAtRestampsIat(): void $this->assertSame(1767225600, $claims['iat']); } + #[DataProvider('refreshIssuerProvider')] + public function testRefreshCarriesTheIssuerLikeOtherClaims( + bool $resetClaims, + array $persistentClaims, + array $customClaims, + ?string $expectedIssuer, + ): void { + $claims = $this->factory()->refresh( + payload: ['sub' => 42, 'iat' => 100, 'iss' => 'https://tenant.example.test'], + ttl: 120, + refreshIssuedAt: false, + resetClaims: $resetClaims, + persistentClaims: $persistentClaims, + customClaims: $customClaims, + ); + + $this->assertSame($expectedIssuer, $claims['iss'] ?? null); + } + + /** + * Provide refresh options and the issuer each one produces. + * + * @return array, array, null|string}> + */ + public static function refreshIssuerProvider(): array + { + return [ + 'normal refresh' => [false, [], [], 'https://tenant.example.test'], + 'reset without persistence' => [true, [], [], null], + 'reset with persistence' => [true, ['iss'], [], 'https://tenant.example.test'], + 'explicit replacement' => [false, [], ['iss' => 'https://other.example.test'], 'https://other.example.test'], + ]; + } + + #[DataProvider('refreshIssuedAtProvider')] + public function testRefreshCustomClaimsOverrideRebuiltClaims(bool $refreshIssuedAt): void + { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-01 00:00:00')); + + $claims = $this->factory()->refresh( + payload: ['sub' => 42, 'iat' => 100, 'nbf' => 100, 'exp' => 200, 'jti' => 'old-jti'], + ttl: 120, + refreshIssuedAt: $refreshIssuedAt, + resetClaims: false, + persistentClaims: [], + customClaims: ['iat' => 1767225000, 'nbf' => 1767226000, 'exp' => 1767229200, 'jti' => 'custom-jti'], + ); + + $this->assertSame(1767225000, $claims['iat']); + $this->assertSame(1767226000, $claims['nbf']); + $this->assertSame(1767229200, $claims['exp']); + $this->assertSame('custom-jti', $claims['jti']); + } + + /** + * Provide both refresh issued-at settings. + * + * @return array + */ + public static function refreshIssuedAtProvider(): array + { + return [ + 'keep original iat' => [false], + 'refresh iat' => [true], + ]; + } + public function testRejectsReservedRefreshClaims(): void { $this->expectException(JwtException::class); - $this->expectExceptionMessageIs('Custom JWT claims may not override reserved claims: exp, jti.'); + $this->expectExceptionMessageIs('Custom JWT claims may not override reserved claims: prv, sub.'); $this->factory()->refresh( payload: ['sub' => 42, 'iat' => 100], @@ -195,7 +295,7 @@ public function testRejectsReservedRefreshClaims(): void refreshIssuedAt: false, resetClaims: false, persistentClaims: [], - customClaims: ['exp' => 999, 'jti' => 'custom-jti'], + customClaims: ['sub' => 7, 'prv' => 'other-provider'], ); } diff --git a/tests/Jwt/JwtManagerTest.php b/tests/Jwt/JwtManagerTest.php index afaa6f9b18..41c28ee1d2 100644 --- a/tests/Jwt/JwtManagerTest.php +++ b/tests/Jwt/JwtManagerTest.php @@ -17,6 +17,8 @@ use Hypervel\Jwt\Providers\Lcobucci; use Hypervel\Jwt\Providers\Provider; use Hypervel\Jwt\Validations\ExpiredClaim; +use Hypervel\Jwt\Validations\IssuedAtClaim; +use Hypervel\Jwt\Validations\IssuerClaim; use Hypervel\Jwt\Validations\NotBeforeClaim; use Hypervel\Jwt\Validations\RequiredClaims; use Hypervel\Support\CarbonImmutable; @@ -351,18 +353,31 @@ public function testRefreshSkipsTemporalValidationsInsideRefreshWindow(): void $refreshedToken = 'baz.bar.foo'; $payload = [ 'sub' => 1, + 'iss' => 'http://example.com', 'exp' => $this->testNowTimestamp - 3600, - 'iat' => $this->testNowTimestamp, + 'nbf' => $this->testNowTimestamp - 7200, + 'iat' => $this->testNowTimestamp - 7200, ]; $refreshPayload = [ 'sub' => 1, - 'iat' => $this->testNowTimestamp, + 'iss' => 'http://example.com', + 'iat' => $this->testNowTimestamp - 7200, 'exp' => $this->testNowTimestamp + 7200, ]; $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('array')->with('jwt.validations')->andReturn([ + RequiredClaims::class, + ExpiredClaim::class, + IssuerClaim::class, + IssuedAtClaim::class, + NotBeforeClaim::class, + ]); + $this->config->shouldReceive('array')->with('jwt')->andReturn([ + 'required_claims' => ['iat', 'sub'], + 'issuer' => 'http://example.com', + 'leeway' => 0, + ]); $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(); @@ -381,27 +396,72 @@ public function testRefreshSkipsTemporalValidationsInsideRefreshWindow(): void $this->assertSame($refreshedToken, $this->createManager()->refresh($token)); } - public function testRefreshRejectsFutureNotBeforeClaim(): void + #[DataProvider('futureTimestampClaimProvider')] + public function testRefreshRejectsFutureTimestampClaimsBeyondTheLeeway(string $validation, string $claim): void { - $this->expectException(JwtException::class); - $this->expectExceptionMessageIs('Not Before (nbf) timestamp cannot be in the future'); + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIsOrContains("({$claim}) timestamp cannot be in the future"); $token = 'foo.bar.baz'; $payload = [ 'sub' => 1, 'iat' => $this->testNowTimestamp, - 'nbf' => $this->testNowTimestamp + 3600, + $claim => $this->testNowTimestamp + 61, ]; - $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('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class, $validation]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub'], 'leeway' => 60]); $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); $this->provider->shouldReceive('encode')->never(); + $this->blacklist->shouldReceive('add')->never(); $this->createManager()->refresh($token); } + #[DataProvider('futureTimestampClaimProvider')] + public function testRefreshAcceptsFutureTimestampClaimsWithinTheLeeway(string $validation, string $claim): void + { + $token = 'foo.bar.baz'; + $refreshedToken = 'baz.bar.foo'; + $payload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + $claim => $this->testNowTimestamp + 60, + ]; + $refreshPayload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations')->andReturn([RequiredClaims::class, $validation]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub'], 'leeway' => 60]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl')->andReturn(20160); + $this->config->shouldReceive('integer')->with('jwt.leeway')->andReturn(60); + $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')->once()->with($payload, 120, true, false, [], [])->andReturn($refreshPayload); + $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); + $this->provider->shouldReceive('encode')->once()->with($refreshPayload)->andReturn($refreshedToken); + + $this->assertSame($refreshedToken, $this->createManager()->refresh($token)); + } + + /** + * Provide validations that reject future timestamps during refresh. + * + * @return array + */ + public static function futureTimestampClaimProvider(): array + { + return [ + 'not before' => [NotBeforeClaim::class, 'nbf'], + 'issued at' => [IssuedAtClaim::class, 'iat'], + ]; + } + public function testRefreshAllowsPastNotBeforeClaim(): void { $token = 'foo.bar.baz'; @@ -476,7 +536,8 @@ public function testRefreshPassesResetClaimsCustomClaimsAndExplicitTtlToClaimFac )); } - public function testRefreshThrowsWhenRefreshWindowHasExpired(): void + #[DataProvider('expiredRefreshWindowLeewayProvider')] + public function testRefreshThrowsWhenRefreshWindowHasExpired(int $leeway): void { $this->expectException(TokenExpiredException::class); $this->expectExceptionMessageIs('Token has expired and can no longer be refreshed'); @@ -495,6 +556,7 @@ public function testRefreshThrowsWhenRefreshWindowHasExpired(): void $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(10); + $this->config->shouldReceive('integer')->with('jwt.leeway')->andReturn($leeway); $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); $this->provider->shouldReceive('encode')->never(); $this->blacklist->shouldReceive('has')->with($payload)->andReturn(false); @@ -503,6 +565,49 @@ public function testRefreshThrowsWhenRefreshWindowHasExpired(): void $this->createManager()->refresh($token); } + /** + * Provide leeways shorter than the 60-second overrun of the refresh window. + * + * @return array + */ + public static function expiredRefreshWindowLeewayProvider(): array + { + return [ + 'no leeway' => [0], + 'leeway shorter than the overrun' => [59], + ]; + } + + public function testRefreshWindowIncludesTheLeeway(): void + { + $token = 'foo.bar.baz'; + $refreshedToken = 'baz.bar.foo'; + $payload = [ + 'sub' => 1, + 'exp' => $this->testNowTimestamp - 3600, + 'iat' => $this->testNowTimestamp - 660, + ]; + $refreshPayload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp - 660, + 'exp' => $this->testNowTimestamp + 7200, + ]; + + $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')->andReturn(10); + $this->config->shouldReceive('integer')->with('jwt.leeway')->andReturn(60); + $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, false, false, [], [])->andReturn($refreshPayload); + $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); + $this->provider->shouldReceive('encode')->once()->with($refreshPayload)->andReturn($refreshedToken); + + $this->assertSame($refreshedToken, $this->createManager()->refresh($token)); + } + public function testRefreshWindowCanBeDisabled(): void { $token = 'foo.bar.baz'; @@ -746,6 +851,7 @@ private function mockClaimFactory(): void private function createManager(): JwtManager { $this->config->shouldReceive('string')->with('jwt.driver')->andReturn('dummy'); + $this->config->shouldReceive('integer')->with('jwt.leeway')->andReturn(0)->byDefault(); $manager = new JwtManager($this->container, $this->claimFactory); $provider = $this->provider; diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php index 962abc7397..60a8640ebb 100644 --- a/tests/Jwt/Providers/LcobucciTest.php +++ b/tests/Jwt/Providers/LcobucciTest.php @@ -4,6 +4,10 @@ namespace Hypervel\Tests\Jwt\Providers; +use DateInterval; +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\SecretMissingException; use Hypervel\Jwt\Exceptions\TokenExpiredException; @@ -64,6 +68,7 @@ public function testEncodeAndDecodeATokenUsingASymmetricKey(): void 'iat' => $iat = $this->testNowTimestamp, 'iss' => '/foo', 'custom_claim' => 'foobar', + 'nested_claim' => ['bar' => [0, 0, 0]], ]; $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256); @@ -74,6 +79,7 @@ public function testEncodeAndDecodeATokenUsingASymmetricKey(): void $this->assertEquals('1', $claims['sub']); $this->assertEquals('/foo', $claims['iss']); $this->assertEquals('foobar', $claims['custom_claim']); + $this->assertSame(['bar' => [0, 0, 0]], $claims['nested_claim']); $this->assertEquals($exp, $claims['exp']); $this->assertEquals($iat, $claims['iat']); } @@ -181,6 +187,52 @@ public function testEncodeAndDecodeATokenWithMultipleAudiences(): void $this->assertSame(['https://first.example.test', 'https://second.example.test'], $claims['aud']); } + #[DataProvider('dateClaimFormProvider')] + public function testEncodeAndDecodeSupportedDateClaimForms( + int|string|DateTimeInterface|DateInterval $exp, + int|string|DateTimeInterface|DateInterval $nbf, + int|string|DateTimeInterface|DateInterval $iat, + ): void { + $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256); + + $token = $provider->encode(['sub' => 1, 'exp' => $exp, 'nbf' => $nbf, 'iat' => $iat]); + $encodedClaims = json_decode(base64_decode(strtr(explode('.', $token)[1], '-_', '+/')), true); + $claims = $provider->decode($token); + + foreach ([$encodedClaims, $claims] as $dates) { + $this->assertSame($this->testNowTimestamp + 3600, $dates['exp']); + $this->assertSame($this->testNowTimestamp, $dates['nbf']); + $this->assertSame($this->testNowTimestamp, $dates['iat']); + } + } + + /** + * Provide expiration, not-before and issued-at values that represent the same dates. + * + * @return array + */ + public static function dateClaimFormProvider(): array + { + $now = 946684800; + + return [ + 'integer timestamps' => [$now + 3600, $now, $now], + 'integer string timestamps' => [(string) ($now + 3600), (string) $now, (string) $now], + 'Carbon dates with microseconds' => [ + CarbonImmutable::createFromTimestamp($now + 3600.5), + CarbonImmutable::createFromTimestamp($now + 0.5), + CarbonImmutable::createFromTimestamp($now), + ], + 'DateTime dates' => [new DateTime('@' . ($now + 3600)), new DateTime('@' . $now), new DateTime('@' . $now)], + 'DateTimeImmutable dates' => [ + new DateTimeImmutable('@' . ($now + 3600)), + new DateTimeImmutable('@' . $now), + new DateTimeImmutable('@' . $now), + ], + 'intervals from now' => [new DateInterval('PT1H'), new DateInterval('PT0S'), new DateInterval('PT0S')], + ]; + } + public function testShouldThrowAnInvalidExceptionWhenThePayloadCouldNotBeEncoded(): void { $this->expectException(JwtException::class); diff --git a/tests/Jwt/Validations/RequiredClaimsTest.php b/tests/Jwt/Validations/RequiredClaimsTest.php index f87b70bec6..089eb984bf 100644 --- a/tests/Jwt/Validations/RequiredClaimsTest.php +++ b/tests/Jwt/Validations/RequiredClaimsTest.php @@ -7,22 +7,40 @@ use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Jwt\Validations\RequiredClaims; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class RequiredClaimsTest extends TestCase { + private const array PAYLOAD = ['sub' => 1, 'iss' => 'http://example.com', 'exp' => 200, 'nbf' => 100, 'iat' => 100, 'jti' => 'foo']; + public function testValid(): void { $this->expectNotToPerformAssertions(); (new RequiredClaims([]))->validate([]); - (new RequiredClaims(['required_claims' => ['sub']]))->validate(['sub' => 'foo']); + (new RequiredClaims(['required_claims' => ['sub', 'iss']]))->validate(self::PAYLOAD); + (new RequiredClaims(['required_claims' => ['sub', 'iss', 'exp', 'nbf', 'iat', 'jti']]))->validate(self::PAYLOAD); } - public function testInvalid(): void + #[DataProvider('missingClaimsProvider')] + public function testInvalid(array $requiredClaims, string $message): void { $this->expectException(TokenInvalidException::class); - $this->expectExceptionMessageIs('Claims are missing: ["sub"]'); + $this->expectExceptionMessageIs($message); + + (new RequiredClaims(['required_claims' => $requiredClaims]))->validate(self::PAYLOAD); + } - (new RequiredClaims(['required_claims' => ['sub']]))->validate([]); + /** + * Provide required claims and the error each set produces. + * + * @return array, string}> + */ + public static function missingClaimsProvider(): array + { + return [ + 'one missing after present claims' => [['sub', 'iss', 'exp', 'nbf', 'iat', 'jti', 'abc'], 'Claims are missing: ["abc"]'], + 'several missing' => [['foo', 'bar'], 'Claims are missing: ["foo","bar"]'], + ]; } } From b56e9f36e0b9c12deb9c1b6513b6916d8484b5c8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:30:10 +0000 Subject: [PATCH 03/10] Let the JWT blacklist use any cache store The blacklist required a cache store with tag support, so the default database cache store made the provider throw as soon as the blacklist was enabled. Its keys were also the raw token identifiers, which a custom jti claim can make longer than the database store's key column. TaggedCache becomes CacheStorage and works on every store. Writes go through the blacklist tag when the store supports tags, and reads and deletes go through it only in all-mode, where tags namespace the keys. Other stores use plain keys. Every physical key is jwt_blacklist: plus the SHA-256 of the identifier, so its length is fixed and application-chosen identifiers cannot be crafted to collide. Clearing the blacklist still removes only blacklist entries. Both upstreams flush the whole cache when the store has no tags; Hypervel lets the cache's own tag error surface instead, so other cache entries are never lost. The new blacklist_store setting (JWT_BLACKLIST_STORE) picks the cache store used by the default storage, and null keeps the default store. Blacklist now takes the refresh TTL as a required parameter after the storage, keeping null as unlimited. Tests reconcile the blacklist and storage coverage of tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth main (1bdb72de5): - BlacklistTest's cases already had equivalents; only the constructor call changes. - The storage test's tagged cases map to the all-mode and any-mode cases, and its untagged put, forever, get and forget cases are ported. Its whole-cache flush case is replaced by a test that the untagged clear throws without flushing the store, and the TaggedStorage stub is not needed because tag support comes from the real store. - New tests cover clearing without touching unrelated entries, stores with tags disabled, long identifiers within a 255-character key, distinct identifiers that share a long prefix, and blacklist store selection. Upstream's setLaravelVersion() minutes conversion has no equivalent, because CacheStorage always converts minutes to seconds. The config comments, README and JWT guide describe the store setting, which stores share revocations between servers, the clearing requirement and what the grace period does. Validation: the Jwt suite passes; composer lint:fix changed no files and composer analyse reports no errors. --- src/docs/jwt.md | 22 ++- src/jwt/README.md | 1 + src/jwt/config/jwt.php | 27 ++- src/jwt/src/Blacklist.php | 8 +- src/jwt/src/JwtServiceProvider.php | 34 +--- src/jwt/src/Storage/CacheStorage.php | 119 ++++++++++++ src/jwt/src/Storage/TaggedCache.php | 106 ---------- tests/Jwt/BlacklistTest.php | 4 +- tests/Jwt/JwtConfigTest.php | 2 + tests/Jwt/JwtServiceProviderTest.php | 164 +++------------- tests/Jwt/Storage/CacheStorageTest.php | 258 +++++++++++++++++++++++++ tests/Jwt/Storage/TaggedCacheTest.php | 144 -------------- 12 files changed, 448 insertions(+), 441 deletions(-) create mode 100644 src/jwt/src/Storage/CacheStorage.php delete mode 100644 src/jwt/src/Storage/TaggedCache.php create mode 100644 tests/Jwt/Storage/CacheStorageTest.php delete mode 100644 tests/Jwt/Storage/TaggedCacheTest.php diff --git a/src/docs/jwt.md b/src/docs/jwt.md index 3e33eaec63..5f5864410d 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -378,23 +378,27 @@ The JWT blacklist lets the package invalidate tokens before they naturally expir Blacklisting is disabled by default. When enabled, newly issued tokens include a `jti` claim and authenticated blacklist checks require cache access. Enable it when your application needs server-side token invalidation. -The blacklist uses the configured storage provider: +Blacklist entries are kept in your default cache store. You may choose another store using the `blacklist_store` option: ```php -'providers' => [ - 'storage' => Hypervel\Jwt\Storage\TaggedCache::class, -], +'blacklist_store' => env('JWT_BLACKLIST_STORE'), ``` -If the provider members are omitted, Hypervel uses `Lcobucci` for token encoding and decoding and `TaggedCache` for blacklist storage. +Use a store that all of your servers share, such as Redis, so a revoked token is rejected everywhere. Stores that are not shared between servers, such as `file` or `swoole`, only see revocations made on the same server, and the `session` store only sees revocations made within the same session. If the blacklist store is a cache stack with a node-local tier, other servers may accept a revoked token until their local entry expires, so keep that tier's TTL short. -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. +The blacklist works with any cache store. Its `clear` method, which removes every blacklist entry, requires a store that supports tags, and it never removes other cache entries. + +The blacklist uses the configured storage provider: -If your cache store does not support tags, implement `Hypervel\Jwt\Contracts\StorageContract` and configure your implementation using `jwt.providers.storage`. +```php +'providers' => [ + 'storage' => Hypervel\Jwt\Storage\CacheStorage::class, +], +``` -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. +If the provider members are omitted, Hypervel uses `Lcobucci` for token encoding and decoding and `CacheStorage` for blacklist storage. To keep blacklist entries somewhere other than the cache, implement `Hypervel\Jwt\Contracts\StorageContract` and configure your implementation using `jwt.providers.storage`. The `blacklist_store` option only applies to `CacheStorage`. -You may configure a grace period for concurrent requests that are using the same token while a refresh is in progress: +A grace period keeps a revoked token usable for a number of seconds, allowing concurrent requests that use it to finish. Tokens invalidated with `forceForever` are revoked immediately: ```php 'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0), diff --git a/src/jwt/README.md b/src/jwt/README.md index 170adb06ce..a175015f3d 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -14,6 +14,7 @@ Documentation: https://hypervel.org/docs/jwt - Upstream sliding refresh middleware is not included; use an explicit refresh endpoint that calls `Auth::guard(...)->refresh()`. - The Namshi provider is not included. Lcobucci supports the same HMAC, RSA and ECDSA algorithms, and `tymondesigns/jwt-auth` no longer ships Namshi. - Lumen integrations are not included. +- Clearing the blacklist requires a cache store that supports tags. On other stores upstream flushes the whole cache, so Hypervel throws the cache's tag error instead and never removes other cache entries. - The `show_black_list_exception` option is not included; JWT exceptions fail normally. Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index 0772202e37..f2dac09994 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -237,6 +237,19 @@ 'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', false), + /* + |-------------------------------------------------------------------------- + | Blacklist Cache Store + |-------------------------------------------------------------------------- + | + | The cache store used by the default blacklist storage. Use a store that + | is shared by every server, such as Redis, so revocations apply on all + | of them. Set it to null to use the default cache store. + | + */ + + 'blacklist_store' => env('JWT_BLACKLIST_STORE'), + /* |-------------------------------------------------------------------------- | Refresh Issued At @@ -282,11 +295,9 @@ | Blacklist Grace Period | ------------------------------------------------------------------------- | - | When multiple concurrent requests are made with the same JWT, - | it is possible that some of them fail, due to token regeneration - | on every request. - | - | Set grace period in seconds to prevent parallel request failure. + | A revoked token remains usable for this many seconds, allowing + | concurrent requests that use it to finish. Tokens invalidated with + | `forceForever` are revoked immediately. | */ @@ -320,12 +331,10 @@ |-------------------------------------------------------------------------- | | Specify the provider that is used to store tokens in the blacklist. - | The default tagged-cache storage requires a taggable default cache - | store; with node-local stack tiers, blacklist visibility is bounded - | by the upper tier's TTL. + | The default cache storage uses the `blacklist_store` cache store. | */ - 'storage' => Hypervel\Jwt\Storage\TaggedCache::class, + 'storage' => Hypervel\Jwt\Storage\CacheStorage::class, ], ]; diff --git a/src/jwt/src/Blacklist.php b/src/jwt/src/Blacklist.php index 596b9527f7..eb97eb7fdc 100644 --- a/src/jwt/src/Blacklist.php +++ b/src/jwt/src/Blacklist.php @@ -12,10 +12,13 @@ class Blacklist implements BlacklistContract { + /** + * Create a new blacklist instance. + */ public function __construct( protected StorageContract $storage, + protected ?int $refreshTTL, protected int $gracePeriod = 0, - protected ?int $refreshTTL = 20160, protected int $leeway = 0, protected string $key = 'jti' ) { @@ -220,6 +223,9 @@ public function getRefreshTTL(): ?int return $this->refreshTTL; } + /** + * Create a date from a Unix timestamp. + */ protected function timestamp(int $timestamp): CarbonInterface { return Date::createFromTimestamp($timestamp); diff --git a/src/jwt/src/JwtServiceProvider.php b/src/jwt/src/JwtServiceProvider.php index 6ad0b45cc3..e0aeeef381 100644 --- a/src/jwt/src/JwtServiceProvider.php +++ b/src/jwt/src/JwtServiceProvider.php @@ -5,19 +5,15 @@ namespace Hypervel\Jwt; use Hypervel\Auth\AuthManager; -use Hypervel\Cache\Repository as CacheRepository; -use Hypervel\Contracts\Container\Container; use Hypervel\Jwt\Console\JwtGenerateCertsCommand; use Hypervel\Jwt\Console\JwtSecretCommand; use Hypervel\Jwt\Contracts\BlacklistContract; -use Hypervel\Jwt\Contracts\StorageContract; use Hypervel\Jwt\Http\Parser\Cookie; use Hypervel\Jwt\Http\Parser\InputSource; use Hypervel\Jwt\Http\Parser\Parser; -use Hypervel\Jwt\Storage\TaggedCache; +use Hypervel\Jwt\Storage\CacheStorage; use Hypervel\Support\ServiceProvider; use InvalidArgumentException; -use RuntimeException; class JwtServiceProvider extends ServiceProvider { @@ -56,12 +52,11 @@ public function register(): void $this->app->singleton(BlacklistContract::class, function ($app) { $config = $app->make('config'); - $storageClass = $config->string('jwt.providers.storage', TaggedCache::class); + $storageClass = $config->string('jwt.providers.storage', CacheStorage::class); $storage = match ($storageClass) { - TaggedCache::class => new TaggedCache($this->cacheStoreForJwtBlacklist( - $app, - $config->boolean('jwt.blacklist_enabled') - )), + CacheStorage::class => new CacheStorage( + $app->make('cache')->store($config->get('jwt.blacklist_store')) + ), default => $app->make($storageClass), }; @@ -133,23 +128,4 @@ protected function registerJwtGuard(): void }); }); } - - /** - * Resolve the cache store for JWT blacklist storage. - */ - protected function cacheStoreForJwtBlacklist(Container $app, bool $blacklistEnabled): CacheRepository - { - /** @var CacheRepository $repository */ - $repository = $app->make('cache')->store(); - - 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 configure a custom ' . StorageContract::class - . ' implementation in jwt.providers.storage.' - ); - } - - return $repository; - } } diff --git a/src/jwt/src/Storage/CacheStorage.php b/src/jwt/src/Storage/CacheStorage.php new file mode 100644 index 0000000000..9e8f634456 --- /dev/null +++ b/src/jwt/src/Storage/CacheStorage.php @@ -0,0 +1,119 @@ +getStore(); + + if ($store instanceof TaggableStore && $store->supportsTags()) { + $this->writesThroughTags = true; + $this->readsThroughTags = ! $store->getTagMode()->supportsDirectGet(); + } + } + + /** + * Add a new item into storage. + */ + public function add(string $key, mixed $value, int $minutes): bool + { + return $this->writeCache()->put($this->storageKey($key), $value, $minutes * 60); + } + + /** + * Add a new item into storage forever. + */ + public function forever(string $key, mixed $value): bool + { + return $this->writeCache()->forever($this->storageKey($key), $value); + } + + /** + * Get an item from storage. + */ + public function get(string $key): mixed + { + return $this->readCache()->get($this->storageKey($key)); + } + + /** + * Remove an item from storage. + */ + public function destroy(string $key): bool + { + return $this->readCache()->forget($this->storageKey($key)); + } + + /** + * Remove all items associated with the tag. + * + * Only a store with tag support can clear the blacklist without removing + * other cache entries, so on any other store the cache's tags() call throws. + */ + public function flush(): bool + { + // @phpstan-ignore method.notFound + return $this->cache->tags([$this->tag])->flush(); + } + + /** + * Get the cache that blacklist writes go through. + */ + protected function writeCache(): CacheContract + { + // @phpstan-ignore method.notFound + return $this->writesThroughTags ? $this->cache->tags([$this->tag]) : $this->cache; + } + + /** + * Get the cache that blacklist reads and deletes go through. + */ + protected function readCache(): CacheContract + { + // @phpstan-ignore method.notFound + return $this->readsThroughTags ? $this->cache->tags([$this->tag]) : $this->cache; + } + + /** + * Get the cache key for a logical blacklist key. + */ + protected function storageKey(string $key): string + { + // Hashing bounds application-chosen identifiers to fit limited key columns, + // such as the database store's. SHA-256 keeps distinct identifiers apart. + return self::KEY_PREFIX . hash('sha256', $key); + } +} diff --git a/src/jwt/src/Storage/TaggedCache.php b/src/jwt/src/Storage/TaggedCache.php deleted file mode 100644 index 893aa4e745..0000000000 --- a/src/jwt/src/Storage/TaggedCache.php +++ /dev/null @@ -1,106 +0,0 @@ -getStore(); - - $this->directKeyMode = $store instanceof TaggableStore - && $store->supportsTags() - && $store->getTagMode()->supportsDirectGet(); - } - - /** - * Add a new item into storage. - */ - public function add(string $key, mixed $value, int $minutes): bool - { - // @phpstan-ignore method.notFound - 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): bool - { - // @phpstan-ignore method.notFound - return $this->cache->tags([$this->tag])->forever($this->storageKey($key), $value); - } - - /** - * Get an item from storage. - */ - public function get(string $key): mixed - { - if ($this->directKeyMode) { - return $this->cache->get($this->storageKey($key)); - } - - // @phpstan-ignore method.notFound - return $this->cache->tags([$this->tag])->get($key); - } - - /** - * Remove an item from storage. - */ - public function destroy(string $key): bool - { - if ($this->directKeyMode) { - return $this->cache->forget($this->storageKey($key)); - } - - // @phpstan-ignore method.notFound - return $this->cache->tags([$this->tag])->forget($key); - } - - /** - * Remove all items associated with the tag. - */ - public function flush(): bool - { - // @phpstan-ignore method.notFound - return $this->cache->tags([$this->tag])->flush(); - } - - /** - * Get the storage key for a logical blacklist key. - */ - protected function storageKey(string $key): string - { - return $this->directKeyMode ? self::DIRECT_KEY_PREFIX . $key : $key; - } -} diff --git a/tests/Jwt/BlacklistTest.php b/tests/Jwt/BlacklistTest.php index 793d5f391a..eb6ed477a9 100644 --- a/tests/Jwt/BlacklistTest.php +++ b/tests/Jwt/BlacklistTest.php @@ -34,7 +34,7 @@ protected function setUp(): void $this->testNowTimestamp = Date::now()->timestamp; $this->storage = m::mock(StorageContract::class); - $this->blacklist = new Blacklist($this->storage); + $this->blacklist = new Blacklist($this->storage, refreshTTL: 20160); } public function testAddAValidTokenToTheBlacklist(): void @@ -281,7 +281,7 @@ public function testFiniteLifetimeStaysPositiveWhenTheClockAdvancesDuringTheStor ]; $blacklist = new Blacklist($this->storage, refreshTTL: 0); - // Model the clock advancing while the tagged-cache lookup is in flight. + // Model the clock advancing while the storage lookup is in flight. $this->storage->shouldReceive('get') ->with('foo') ->once() diff --git a/tests/Jwt/JwtConfigTest.php b/tests/Jwt/JwtConfigTest.php index 69d88c3661..30dd5d5f2a 100644 --- a/tests/Jwt/JwtConfigTest.php +++ b/tests/Jwt/JwtConfigTest.php @@ -146,6 +146,7 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void $originalValues = $this->setEnvironmentVariables([ 'JWT_ISSUER' => 'https://api.example.test', 'JWT_BLACKLIST_ENABLED' => '1', + 'JWT_BLACKLIST_STORE' => 'redis', 'JWT_REFRESH_IAT' => '1', 'JWT_LOCK_SUBJECT' => '0', 'JWT_TOKEN' => 'api_token', @@ -158,6 +159,7 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void $this->assertSame('https://api.example.test', $config['issuer']); $this->assertTrue($config['blacklist_enabled']); + $this->assertSame('redis', $config['blacklist_store']); $this->assertTrue($config['refresh_iat']); $this->assertFalse($config['lock_subject']); $this->assertSame('api_token', $config['token']); diff --git a/tests/Jwt/JwtServiceProviderTest.php b/tests/Jwt/JwtServiceProviderTest.php index 19c3c45ee2..a405590ab8 100644 --- a/tests/Jwt/JwtServiceProviderTest.php +++ b/tests/Jwt/JwtServiceProviderTest.php @@ -6,10 +6,6 @@ use Hypervel\Auth\AuthManager; use Hypervel\Cache\Repository as CacheRepository; -use Hypervel\Cache\StackStore; -use Hypervel\Cache\StackStoreProxy; -use Hypervel\Cache\TaggableStore; -use Hypervel\Cache\TagMode; use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Foundation\Application as ApplicationContract; @@ -23,14 +19,14 @@ use Hypervel\Jwt\JwtGuard; use Hypervel\Jwt\JwtServiceProvider; use Hypervel\Jwt\Providers\Lcobucci; -use Hypervel\Jwt\Storage\TaggedCache; +use Hypervel\Jwt\Storage\CacheStorage; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Date; use Hypervel\Testbench\TestCase; use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionProperty; -use RuntimeException; class JwtServiceProviderTest extends TestCase { @@ -141,152 +137,48 @@ public function testGuardRejectsUnsupportedTtlValue(): void $this->app->make(AuthManager::class)->guard('customers'); } - public function testTaggedCacheStorageUsesCacheStore(): void + #[DataProvider('blacklistStoreProvider')] + public function testCacheStorageUsesTheConfiguredBlacklistStore(?string $store): void { $config = $this->app->make('config'); - $config->set('jwt.providers.storage', TaggedCache::class); + $config->set('jwt.providers.storage', CacheStorage::class); $config->set('jwt.blacklist_enabled', true); - $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.refresh_ttl', 20160); - - $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); - - $blacklist = $this->app->make(BlacklistContract::class); - - $this->assertInstanceOf(Blacklist::class, $blacklist); - } - - public function testTaggedCacheStorageAcceptsAnyModeCacheStore(): void - { - $config = $this->app->make('config'); - $config->set('jwt.providers.storage', TaggedCache::class); - $config->set('jwt.blacklist_enabled', true); - $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.refresh_ttl', 20160); - - $repository = m::mock(CacheRepository::class); - $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); - $repository->shouldReceive('getStore')->once()->andReturn($this->taggableStore(TagMode::Any)); - $cache = m::mock(); - $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); - - $this->app->instance('cache', $cache); - $this->app->forgetInstance(BlacklistContract::class); - - $blacklist = $this->app->make(BlacklistContract::class); - - $this->assertInstanceOf(Blacklist::class, $blacklist); - } - - public function testTaggedCacheStorageAcceptsValidStackCacheStore(): void - { - $config = $this->app->make('config'); - $config->set('jwt.providers.storage', TaggedCache::class); - $config->set('jwt.blacklist_enabled', true); - $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.refresh_ttl', 20160); + $config->set('jwt.blacklist_store', $store); $repository = m::mock(CacheRepository::class); - $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); - $repository->shouldReceive('getStore')->once()->andReturn(new StackStore([ - new StackStoreProxy(m::mock(Store::class)), - new StackStoreProxy($this->taggableStore(TagMode::Any)), - ])); - $cache = m::mock(); - $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); - - $this->app->instance('cache', $cache); - $this->app->forgetInstance(BlacklistContract::class); - - $blacklist = $this->app->make(BlacklistContract::class); - - $this->assertInstanceOf(Blacklist::class, $blacklist); - } - - public function testDisabledBlacklistAllowsNonTaggableCacheStore(): void - { - $config = $this->app->make('config'); - $config->set('jwt.providers.storage', TaggedCache::class); - $config->set('jwt.blacklist_enabled', false); - $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.refresh_ttl', 20160); - - $repository = m::mock(CacheRepository::class); - $repository->shouldReceive('supportsTags')->never(); $repository->shouldReceive('getStore')->once()->andReturn(m::mock(Store::class)); $cache = m::mock(); - $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); + $cache->shouldReceive('store')->once()->with($store)->andReturn($repository); $this->app->instance('cache', $cache); $this->app->forgetInstance(BlacklistContract::class); $blacklist = $this->app->make(BlacklistContract::class); + $storage = (new ReflectionProperty($blacklist, 'storage'))->getValue($blacklist); - $this->assertInstanceOf(Blacklist::class, $blacklist); + $this->assertInstanceOf(CacheStorage::class, $storage); + $this->assertSame($repository, (new ReflectionProperty($storage, 'cache'))->getValue($storage)); } - public function testDisabledBlacklistAllowsInvalidTaggableCacheStore(): void + /** + * Provide blacklist cache store settings. + * + * @return array + */ + public static function blacklistStoreProvider(): array { - $config = $this->app->make('config'); - $config->set('jwt.providers.storage', TaggedCache::class); - $config->set('jwt.blacklist_enabled', false); - $config->set('jwt.blacklist_grace_period', 0); - $config->set('jwt.refresh_ttl', 20160); - - $store = m::mock(TaggableStore::class); - $store->shouldReceive('supportsTags')->once()->andReturnFalse(); - $store->shouldReceive('getTagMode')->never(); - - $repository = m::mock(CacheRepository::class); - $repository->shouldReceive('supportsTags')->never(); - $repository->shouldReceive('getStore')->once()->andReturn($store); - $cache = m::mock(); - $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); - - $this->app->instance('cache', $cache); - $this->app->forgetInstance(BlacklistContract::class); - - $blacklist = $this->app->make(BlacklistContract::class); - - $this->assertInstanceOf(Blacklist::class, $blacklist); - } - - public function testEnabledTaggedCacheBlacklistRequiresTaggableCacheStore(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessageIs( - 'The JWT blacklist requires a taggable cache store (all-mode or any-mode). ' - . 'Use a taggable store or configure a custom ' . StorageContract::class - . ' implementation in jwt.providers.storage.' - ); - - $config = $this->app->make('config'); - $config->set('jwt.providers.storage', TaggedCache::class); - $config->set('jwt.blacklist_enabled', true); - - $repository = m::mock(CacheRepository::class); - $repository->shouldReceive('supportsTags')->once()->andReturnFalse(); - $cache = m::mock(); - $cache->shouldReceive('store')->once()->withNoArgs()->andReturn($repository); - - $this->app->instance('cache', $cache); - $this->app->forgetInstance(BlacklistContract::class); - - $this->app->make(BlacklistContract::class); + return [ + 'default store' => [null], + 'named store' => ['redis'], + ]; } - public function testCustomBlacklistStorageBypassesTaggedCacheRequirement(): void + public function testCustomBlacklistStorageSkipsCacheStoreResolution(): void { $config = $this->app->make('config'); $config->set('jwt.providers.storage', JwtServiceProviderCustomStorage::class); $config->set('jwt.blacklist_enabled', true); + $config->set('jwt.blacklist_store', 'redis'); $config->set('jwt.blacklist_grace_period', 0); $config->set('jwt.refresh_ttl', 20160); @@ -361,20 +253,10 @@ public function testProviderImplementationsUsePackageDefaultsWhenOmitted(): void $this->assertInstanceOf(Lcobucci::class, $manager->driver()); $this->assertInstanceOf( - TaggedCache::class, + CacheStorage::class, (new ReflectionProperty($blacklist, 'storage'))->getValue($blacklist), ); } - - protected function taggableStore(TagMode $mode): TaggableStore - { - /** @var TaggableStore $store */ - $store = m::mock(TaggableStore::class); - $store->shouldReceive('supportsTags')->zeroOrMoreTimes()->andReturnTrue(); - $store->shouldReceive('getTagMode')->once()->andReturn($mode); - - return $store; - } } class JwtServiceProviderCustomStorage implements StorageContract diff --git a/tests/Jwt/Storage/CacheStorageTest.php b/tests/Jwt/Storage/CacheStorageTest.php new file mode 100644 index 0000000000..c5d9c3a17e --- /dev/null +++ b/tests/Jwt/Storage/CacheStorageTest.php @@ -0,0 +1,258 @@ +useStoreMode(TagMode::All); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('put')->with($this->storageKey('foo'), 'bar', 10 * 60)->once()->andReturnFalse(); + + $this->assertFalse($this->storage->add('foo', 'bar', 10)); + } + + public function testAddTheItemToAnyModeTaggedStorage(): void + { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('put')->with($this->storageKey('foo'), 'bar', 10 * 60)->once()->andReturnTrue(); + + $this->assertTrue($this->storage->add('foo', 'bar', 10)); + } + + public function testAddTheItemToUntaggedStorage(): void + { + $this->useUntaggedStore(); + $this->cache->shouldReceive('tags')->never(); + $this->cache->shouldReceive('put')->with($this->storageKey('foo'), 'bar', 10 * 60)->once()->andReturnTrue(); + + $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($this->storageKey('foo'), 'bar')->once()->andReturnFalse(); + + $this->assertFalse($this->storage->forever('foo', 'bar')); + } + + public function testAddTheItemToAnyModeTaggedStorageForever(): void + { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('forever')->with($this->storageKey('foo'), 'bar')->once()->andReturnTrue(); + + $this->assertTrue($this->storage->forever('foo', 'bar')); + } + + public function testAddTheItemToUntaggedStorageForever(): void + { + $this->useUntaggedStore(); + $this->cache->shouldReceive('tags')->never(); + $this->cache->shouldReceive('forever')->with($this->storageKey('foo'), 'bar')->once()->andReturnTrue(); + + $this->assertTrue($this->storage->forever('foo', 'bar')); + } + + public function testGetAnItemFromAllModeTaggedStorage(): void + { + $this->useStoreMode(TagMode::All); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('get')->with($this->storageKey('foo'))->once()->andReturn(['foo' => 'bar']); + + $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); + } + + public function testGetAnItemFromAnyModeStorageUsesPlainKey(): void + { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->never(); + $this->cache->shouldReceive('get')->with($this->storageKey('foo'))->once()->andReturn(['foo' => 'bar']); + + $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); + } + + public function testGetAnItemFromUntaggedStorage(): void + { + $this->useUntaggedStore(); + $this->cache->shouldReceive('tags')->never(); + $this->cache->shouldReceive('get')->with($this->storageKey('foo'))->once()->andReturn(['foo' => 'bar']); + + $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); + } + + public function testRemoveTheItemFromAllModeTaggedStorage(): void + { + $this->useStoreMode(TagMode::All); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('forget')->with($this->storageKey('foo'))->once()->andReturn(true); + + $this->assertTrue($this->storage->destroy('foo')); + } + + public function testRemoveTheItemFromAnyModeStorageUsesPlainKey(): void + { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->never(); + $this->cache->shouldReceive('forget')->with($this->storageKey('foo'))->once()->andReturn(true); + + $this->assertTrue($this->storage->destroy('foo')); + } + + public function testRemoveTheItemFromUntaggedStorage(): void + { + $this->useUntaggedStore(); + $this->cache->shouldReceive('tags')->never(); + $this->cache->shouldReceive('forget')->with($this->storageKey('foo'))->once()->andReturnFalse(); + + $this->assertFalse($this->storage->destroy('foo')); + } + + public function testRemoveAllAllModeTaggedItemsFromStorage(): void + { + $this->useStoreMode(TagMode::All); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('flush')->withNoArgs()->once()->andReturnFalse(); + + $this->assertFalse($this->storage->flush()); + } + + public function testRemoveAllAnyModeTaggedItemsFromStorage(): void + { + $this->useStoreMode(TagMode::Any); + $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); + $this->cache->shouldReceive('flush')->withNoArgs()->once()->andReturnTrue(); + + $this->assertTrue($this->storage->flush()); + } + + public function testRemoveAllItemsKeepsUnrelatedCacheEntries(): void + { + $cache = new Repository(new ArrayStore); + $storage = new CacheStorage($cache); + + $cache->forever('foo', 'unrelated'); + $storage->forever('foo', 'forever'); + + $this->assertTrue($storage->flush()); + $this->assertNull($storage->get('foo')); + $this->assertSame('unrelated', $cache->get('foo')); + } + + public function testRemoveAllItemsFromUntaggedStorageThrowsWithoutFlushingTheStore(): void + { + /** @var MockInterface|Store */ + $store = m::mock(Store::class); + $store->shouldReceive('flush')->never(); + $storage = new CacheStorage(new Repository($store)); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessageIs('This cache store does not support tagging.'); + + $storage->flush(); + } + + public function testStoreWithTagsDisabledUsesPlainKeysWithoutReadingTagMode(): void + { + /** @var CacheRepository|MockInterface */ + $cache = m::mock(CacheRepository::class); + /** @var MockInterface|TaggableStore */ + $store = m::mock(TaggableStore::class); + + $store->shouldReceive('supportsTags')->once()->andReturnFalse(); + $store->shouldReceive('getTagMode')->never(); + $cache->shouldReceive('getStore')->once()->andReturn($store); + $cache->shouldReceive('tags')->never(); + $cache->shouldReceive('put')->with($this->storageKey('foo'), 'bar', 10 * 60)->once()->andReturnTrue(); + $cache->shouldReceive('get')->with($this->storageKey('foo'))->once()->andReturn('bar'); + + $storage = new CacheStorage($cache); + + $this->assertTrue($storage->add('foo', 'bar', 10)); + $this->assertSame('bar', $storage->get('foo')); + } + + public function testLongIdentifiersFitLimitedKeyColumns(): void + { + $this->useUntaggedStore(); + $this->cache->shouldReceive('forever')->with(m::capture($key), 'forever')->once()->andReturnTrue(); + + $this->assertTrue($this->storage->forever(str_repeat('a', 1000), 'forever')); + $this->assertLessThanOrEqual(255, strlen($key)); + } + + public function testIdentifiersSharingALongPrefixStayDistinct(): void + { + $storage = new CacheStorage(new Repository(new ArrayStore)); + $prefix = str_repeat('a', 1000); + + $storage->forever($prefix . '1', 'forever'); + + $this->assertSame('forever', $storage->get($prefix . '1')); + $this->assertNull($storage->get($prefix . '2')); + } + + /** + * Use a mocked repository whose store supports tags in the given mode. + */ + protected function useStoreMode(TagMode $mode): void + { + /** @var CacheRepository|MockInterface */ + $cache = m::mock(CacheRepository::class); + /** @var MockInterface|TaggableStore */ + $store = m::mock(TaggableStore::class); + + $store->shouldReceive('supportsTags')->once()->andReturnTrue(); + $store->shouldReceive('getTagMode')->once()->andReturn($mode); + $cache->shouldReceive('getStore')->once()->andReturn($store); + + $this->cache = $cache; + $this->storage = new CacheStorage($this->cache); + } + + /** + * Use a mocked repository whose store has no tag support. + */ + protected function useUntaggedStore(): void + { + /** @var CacheRepository|MockInterface */ + $cache = m::mock(CacheRepository::class); + $cache->shouldReceive('getStore')->once()->andReturn(m::mock(Store::class)); + + $this->cache = $cache; + $this->storage = new CacheStorage($this->cache); + } + + /** + * Get the cache key that stores the given blacklist key. + */ + protected function storageKey(string $key): string + { + return 'jwt_blacklist:' . hash('sha256', $key); + } +} diff --git a/tests/Jwt/Storage/TaggedCacheTest.php b/tests/Jwt/Storage/TaggedCacheTest.php deleted file mode 100644 index fbc5df7a68..0000000000 --- a/tests/Jwt/Storage/TaggedCacheTest.php +++ /dev/null @@ -1,144 +0,0 @@ -useStoreMode(TagMode::All); - $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('put')->with('foo', 'bar', 10 * 60)->once()->andReturnFalse(); - - $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()->andReturnTrue(); - - $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()->andReturnFalse(); - - $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()->andReturnTrue(); - - $this->assertTrue($this->storage->forever('foo', 'bar')); - } - - public function testGetAnItemFromAllModeTaggedStorage(): void - { - $this->useStoreMode(TagMode::All); - $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('get')->with('foo')->once()->andReturn(['foo' => 'bar']); - - $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); - } - - public function testGetAnItemFromAnyModeStorageUsesPrefixedPlainKey(): void - { - $this->useStoreMode(TagMode::Any); - $this->cache->shouldReceive('tags')->never(); - $this->cache->shouldReceive('get')->with('jwt_blacklist:foo')->once()->andReturn(['foo' => 'bar']); - - $this->assertSame(['foo' => 'bar'], $this->storage->get('foo')); - } - - public function testRemoveTheItemFromAllModeTaggedStorage(): void - { - $this->useStoreMode(TagMode::All); - $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('forget')->with('foo')->once()->andReturn(true); - - $this->assertTrue($this->storage->destroy('foo')); - } - - public function testRemoveTheItemFromAnyModeStorageUsesPrefixedPlainKey(): void - { - $this->useStoreMode(TagMode::Any); - $this->cache->shouldReceive('tags')->never(); - $this->cache->shouldReceive('forget')->with('jwt_blacklist:foo')->once()->andReturn(true); - - $this->assertTrue($this->storage->destroy('foo')); - } - - public function testRemoveAllAllModeTaggedItemsFromStorage(): void - { - $this->useStoreMode(TagMode::All); - $this->cache->shouldReceive('tags')->with(['jwt_blacklist'])->once()->andReturnSelf(); - $this->cache->shouldReceive('flush')->withNoArgs()->once()->andReturnFalse(); - - $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()->andReturnTrue(); - - $this->assertTrue($this->storage->flush()); - } - - public function testConstructorDoesNotReadTagModeWhenStoreDoesNotSupportTags(): void - { - /** @var CacheRepository|MockInterface */ - $cache = m::mock(CacheRepository::class); - /** @var MockInterface|TaggableStore */ - $store = m::mock(TaggableStore::class); - - $store->shouldReceive('supportsTags')->once()->andReturnFalse(); - $store->shouldReceive('getTagMode')->never(); - $cache->shouldReceive('getStore')->once()->andReturn($store); - - $storage = new TaggedCache($cache); - - $this->assertInstanceOf(TaggedCache::class, $storage); - } - - protected function useStoreMode(TagMode $mode): void - { - /** @var CacheRepository|MockInterface */ - $cache = m::mock(CacheRepository::class); - /** @var MockInterface|TaggableStore */ - $store = m::mock(TaggableStore::class); - - $store->shouldReceive('supportsTags')->once()->andReturnTrue(); - $store->shouldReceive('getTagMode')->once()->andReturn($mode); - $cache->shouldReceive('getStore')->once()->andReturn($store); - - $this->cache = $cache; - $this->storage = new TaggedCache($this->cache); - } -} From 6ce36556de91fdeaa8ff58f3af72c9d45b269a17 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:51:32 +0000 Subject: [PATCH 04/10] Select JWT drivers through the manager and expose the blacklist The JWT provider could be chosen two ways: the driver setting picked a manager driver, and providers.jwt picked a class for the built-in lcobucci driver, which had to extend Lcobucci. The providers.jwt setting is removed. The driver setting and Jwt::extend() now cover both cases, the same way Laravel's driver managers work, and replacing the lcobucci driver is done by registering a driver under that name. Applications had no supported way to reach the blacklist, so removing one revocation or clearing them all meant resolving the contract by hand. JwtManager::blacklist() is now public, through Jwt::blacklist(), and resolves the blacklist on first use. This also removes a null check that normal use could never reach. Decoding and refreshing still skip the blacklist entirely when it is disabled. Tests reconcile the manager and refresh-claim coverage of tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth main (1bdb72de5): - A custom driver can replace lcobucci, a disabled blacklist is never resolved during decoding or refreshing, and blacklist() returns the same resolved instance. - The fork's repeated-refresh iat tests are ported to the claim factory: one factory refreshes the same payload twice, two minutes apart, and keeps or restamps iat exactly as configured. - Upstream's setter tests have no equivalent because these settings come from configuration. Upstream's getPayloadFactory() has no equivalent because payloads are arrays. The show_black_list_exception tests are not ported because the option is not included. The JWT guide gains a section on removing and clearing revocations, explains when to register custom drivers, and describes which guard methods throw for invalid tokens and how to keep those exceptions out of logs. Its list of upstream differences is removed so the package README is the only copy. The README now covers the facade's blacklist and driver access and the driver setting, and gives the correct reason show_black_list_exception is not included: disabling it in upstream makes revoked tokens valid. Validation: the Jwt suite passes, FacadeDocblocksTest passes, composer lint:fix changed no files and composer analyse reports no errors. --- src/docs/jwt.md | 42 +++++++++++++----------- src/jwt/README.md | 5 +-- src/jwt/config/jwt.php | 17 ++-------- src/jwt/src/JwtManager.php | 24 +++----------- src/support/src/Facades/Jwt.php | 1 + tests/Jwt/ClaimFactoryTest.php | 43 ++++++++++++++++++++---- tests/Jwt/JwtManagerTest.php | 58 +++++++++++++++++++++++++++------ 7 files changed, 120 insertions(+), 70 deletions(-) diff --git a/src/docs/jwt.md b/src/docs/jwt.md index 5f5864410d..23dc9fe1ba 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -21,9 +21,9 @@ - [Reading the Authenticated User](#reading-the-authenticated-user) - [Refreshing Tokens](#refreshing-tokens) - [Logging Out and Invalidating Tokens](#logging-out-and-invalidating-tokens) + - [Managing Revocations](#managing-revocations) - [Guard Methods](#guard-methods) - [Exceptions](#exceptions) -- [Differences From php-open-source-saver/jwt-auth](#differences-from-php-open-source-saver-jwt-auth) - [Credits](#credits) @@ -231,6 +231,8 @@ After registering the driver, you may select it using the `driver` configuration 'driver' => 'custom', ``` +To customize the bundled provider, such as with your own subclass of `Hypervel\Jwt\Providers\Lcobucci`, register it under the `lcobucci` name instead. The JWT manager creates each driver once and reuses it, so register drivers before the first token is encoded or decoded. Calling `extend` after a driver has been created does not replace it. + ### Token Lifetime @@ -386,7 +388,7 @@ Blacklist entries are kept in your default cache store. You may choose another s Use a store that all of your servers share, such as Redis, so a revoked token is rejected everywhere. Stores that are not shared between servers, such as `file` or `swoole`, only see revocations made on the same server, and the `session` store only sees revocations made within the same session. If the blacklist store is a cache stack with a node-local tier, other servers may accept a revoked token until their local entry expires, so keep that tier's TTL short. -The blacklist works with any cache store. Its `clear` method, which removes every blacklist entry, requires a store that supports tags, and it never removes other cache entries. +The blacklist works with any cache store. Removing every revocation at once requires a store that supports tags, as described in [managing revocations](#managing-revocations). The blacklist uses the configured storage provider: @@ -396,7 +398,7 @@ The blacklist uses the configured storage provider: ], ``` -If the provider members are omitted, Hypervel uses `Lcobucci` for token encoding and decoding and `CacheStorage` for blacklist storage. To keep blacklist entries somewhere other than the cache, implement `Hypervel\Jwt\Contracts\StorageContract` and configure your implementation using `jwt.providers.storage`. The `blacklist_store` option only applies to `CacheStorage`. +If the storage provider is omitted, Hypervel uses `CacheStorage`. To keep blacklist entries somewhere other than the cache, implement `Hypervel\Jwt\Contracts\StorageContract` and configure your implementation using `jwt.providers.storage`. The `blacklist_store` option only applies to `CacheStorage`. A grace period keeps a revoked token usable for a number of seconds, allowing concurrent requests that use it to finish. Tokens invalidated with `forceForever` are revoked immediately: @@ -581,6 +583,23 @@ You may pass `true` to blacklist the token forever. This also bypasses the confi Auth::guard('api')->invalidate(true); ``` + +### Managing Revocations + +The `Jwt` facade's `blacklist` method gives you access to the blacklist. Its `remove` method removes a single token's revocation, while the `clear` method removes every revocation: + +```php +use Hypervel\Support\Facades\Jwt; + +$payload = Jwt::decode($token, validate: false, checkBlacklist: false); + +Jwt::blacklist()->remove($payload); + +Jwt::blacklist()->clear(); +``` + +A token whose revocation is removed can authenticate again until it expires. The `clear` method requires a cache store that supports tags, such as Redis, and only removes blacklist entries. On other stores, it throws an exception instead. + ## Guard Methods @@ -627,22 +646,7 @@ Common exceptions include: - -## Differences From php-open-source-saver/jwt-auth - -Hypervel JWT differs from `php-open-source-saver/jwt-auth` in several ways: - -
- -- 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. -- 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. - -
+The guard's `user`, `check`, and `id` methods treat an invalid, expired, or blacklisted token as unauthenticated instead of throwing. Methods that need the token itself, such as `payload` and `refresh`, throw these exceptions. To keep them out of your logs, ignore them with the [`dontReport`](/docs/{{version}}/errors#ignoring-exceptions-by-type) exception method. ## Credits diff --git a/src/jwt/README.md b/src/jwt/README.md index a175015f3d..9f65a3dff7 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -6,7 +6,8 @@ Documentation: https://hypervel.org/docs/jwt ## 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 keeps the `Jwt` facade mapped to the array-based `JwtManager`, but does not include upstream `JwtAuth`, `JwtFactory`, or `JwtProvider` facades. Use `Jwt::blacklist()` for the blacklist and `Jwt::driver()` for the JWT provider. +- The JWT provider is chosen with the `driver` option and custom drivers are registered with `Jwt::extend()`, following Laravel's driver managers, instead of upstream's `providers.jwt` class setting. - Custom claims may set registered claims such as `exp`, `iss` and `jti`, but not `sub` or `prv`, which always come from the user and its provider. Customize the subject with `JwtSubject::getJwtIdentifier()`. - Hypervel adds a default `iss` only when `jwt.issuer` is set, rather than using the request URL, and a default `jti` only when the blacklist is enabled. - Cookie token parsing is available but not enabled by default. @@ -15,6 +16,6 @@ Documentation: https://hypervel.org/docs/jwt - The Namshi provider is not included. Lcobucci supports the same HMAC, RSA and ECDSA algorithms, and `tymondesigns/jwt-auth` no longer ships Namshi. - Lumen integrations are not included. - Clearing the blacklist requires a cache store that supports tags. On other stores upstream flushes the whole cache, so Hypervel throws the cache's tag error instead and never removes other cache entries. -- The `show_black_list_exception` option is not included; JWT exceptions fail normally. +- The `show_black_list_exception` option is not included. Upstream's option skips the blacklist check when disabled, so revoked tokens are accepted. Guards already treat blacklisted tokens as unauthenticated, and the exception handler's `dontReport` method keeps JWT exceptions out of logs. Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index f2dac09994..2830104d16 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -11,9 +11,9 @@ | JWT Driver |-------------------------------------------------------------------------- | - | The driver you are using to encode, decode and sign your - | JWT token, all the drivers must implement: - | Hypervel\Jwt\Contracts\ProviderContract::class + | The driver used to sign, encode and decode tokens. The "lcobucci" + | driver is built in. Register other drivers with `Jwt::extend()`; + | they must implement Hypervel\Jwt\Contracts\ProviderContract. | */ @@ -314,17 +314,6 @@ */ 'providers' => [ - /* - |-------------------------------------------------------------------------- - | JWT Provider - |-------------------------------------------------------------------------- - | - | Specify the provider that is used to create and decode the tokens. - | - */ - - 'jwt' => Hypervel\Jwt\Providers\Lcobucci::class, - /* |-------------------------------------------------------------------------- | Storage Provider diff --git a/src/jwt/src/JwtManager.php b/src/jwt/src/JwtManager.php index edbec2ed05..c3d793af03 100644 --- a/src/jwt/src/JwtManager.php +++ b/src/jwt/src/JwtManager.php @@ -17,11 +17,10 @@ use Hypervel\Support\Facades\Date; use Hypervel\Support\Manager; use Hypervel\Support\Str; -use RuntimeException; class JwtManager extends Manager implements ManagerContract { - protected ?BlacklistContract $blacklist; + protected ?BlacklistContract $blacklist = null; protected bool $blacklistEnabled = false; @@ -37,9 +36,6 @@ public function __construct( parent::__construct($container); $this->blacklistEnabled = $this->config->boolean('jwt.blacklist_enabled'); - $this->blacklist = $this->blacklistEnabled - ? $container->make(BlacklistContract::class) - : null; } /** @@ -47,13 +43,7 @@ public function __construct( */ public function createLcobucciDriver(): Lcobucci { - $class = $this->config->string('jwt.providers.jwt', Lcobucci::class); - - if (! is_a($class, Lcobucci::class, true)) { - throw new RuntimeException('JWT provider must be an instance of ' . Lcobucci::class); - } - - return new $class( + return new Lcobucci( (string) $this->config->get('jwt.secret'), $this->config->string('jwt.algo'), $this->config->array('jwt.keys'), @@ -239,14 +229,10 @@ public function hasBlacklistEnabled(): bool } /** - * Get the configured blacklist instance. + * Get the blacklist instance. */ - protected function blacklist(): BlacklistContract + public function blacklist(): BlacklistContract { - if ($this->blacklist === null) { - throw new JwtException('JWT blacklist is not configured.'); - } - - return $this->blacklist; + return $this->blacklist ??= $this->container->make(BlacklistContract::class); } } diff --git a/src/support/src/Facades/Jwt.php b/src/support/src/Facades/Jwt.php index 32c662990f..f5f8bb7d46 100644 --- a/src/support/src/Facades/Jwt.php +++ b/src/support/src/Facades/Jwt.php @@ -5,6 +5,7 @@ namespace Hypervel\Support\Facades; /** + * @method static \Hypervel\Jwt\Contracts\BlacklistContract blacklist() * @method static \Hypervel\Jwt\Providers\Lcobucci createLcobucciDriver() * @method static array decode(string $token, bool $validate = true, bool $checkBlacklist = true) * @method static mixed driver(\UnitEnum|string|null $driver = null) diff --git a/tests/Jwt/ClaimFactoryTest.php b/tests/Jwt/ClaimFactoryTest.php index ea2477c352..8e94a9f11e 100644 --- a/tests/Jwt/ClaimFactoryTest.php +++ b/tests/Jwt/ClaimFactoryTest.php @@ -202,19 +202,50 @@ public function testRefreshKeepsNonManagedClaimsWhenResetClaimsIsFalse(): void $this->assertSame('one', $claims['tenant']); } - public function testRefreshIssuedAtRestampsIat(): void - { + #[DataProvider('repeatedRefreshIssuedAtProvider')] + public function testRefreshIssuedAtSettingAppliesOnEveryRefresh( + bool $refreshIssuedAt, + int $firstIssuedAt, + int $secondIssuedAt, + ): void { CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-01 00:00:00')); - $claims = $this->factory()->refresh( - payload: ['sub' => 42, 'iat' => 100], + $factory = $this->factory(); + $payload = ['sub' => 42, 'iat' => 100]; + + $first = $factory->refresh( + payload: $payload, ttl: null, - refreshIssuedAt: true, + refreshIssuedAt: $refreshIssuedAt, resetClaims: false, persistentClaims: [], ); - $this->assertSame(1767225600, $claims['iat']); + CarbonImmutable::setTestNow(CarbonImmutable::now()->addMinutes(2)); + + $second = $factory->refresh( + payload: $payload, + ttl: null, + refreshIssuedAt: $refreshIssuedAt, + resetClaims: false, + persistentClaims: [], + ); + + $this->assertSame($firstIssuedAt, $first['iat']); + $this->assertSame($secondIssuedAt, $second['iat']); + } + + /** + * Provide each refresh issued-at setting and the iat of two refreshes two minutes apart. + * + * @return array + */ + public static function repeatedRefreshIssuedAtProvider(): array + { + return [ + 'keep original iat' => [false, 100, 100], + 'refresh iat' => [true, 1767225600, 1767225720], + ]; } #[DataProvider('refreshIssuerProvider')] diff --git a/tests/Jwt/JwtManagerTest.php b/tests/Jwt/JwtManagerTest.php index 41c28ee1d2..2dae6e07c3 100644 --- a/tests/Jwt/JwtManagerTest.php +++ b/tests/Jwt/JwtManagerTest.php @@ -125,9 +125,6 @@ public function testNullSecretSupportsAsymmetricSigning(): void 'jwt' => [ 'blacklist_enabled' => false, 'driver' => 'lcobucci', - 'providers' => [ - 'jwt' => Lcobucci::class, - ], 'secret' => null, 'algo' => Provider::ALGO_RS256, 'keys' => [ @@ -151,18 +148,45 @@ public function testNullSecretSupportsAsymmetricSigning(): void $this->assertSame('value', $payload['custom']); } - public function testConstructorDoesNotResolveBlacklistWhenBlacklistIsDisabled(): void + public function testCustomDriverCanReplaceTheLcobucciDriver(): void + { + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + $this->config->shouldReceive('string')->with('jwt.driver')->andReturn('lcobucci'); + + $manager = new JwtManager($this->container, $this->claimFactory); + $provider = $this->provider; + + $manager->extend('lcobucci', static fn (): Lcobucci => $provider); + + $this->assertSame($provider, $manager->driver()); + } + + public function testDisabledBlacklistIsNeverResolved(): void { - $container = m::mock(Container::class); - $config = m::mock(Repository::class); + $token = 'foo.bar.baz'; + $refreshedToken = 'baz.bar.foo'; + $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; + + $this->mockContainer(); + $this->mockConfig(); + $this->container->shouldReceive('make')->with(BlacklistContract::class)->never(); - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - $container->shouldReceive('make')->with(BlacklistContract::class)->never(); - $config->shouldReceive('boolean')->once()->with('jwt.blacklist_enabled')->andReturnFalse(); + $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')->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($payload); + $this->provider->shouldReceive('decode')->twice()->with($token)->andReturn($payload); + $this->provider->shouldReceive('encode')->once()->with($payload)->andReturn($refreshedToken); - $manager = new JwtManager($container, m::mock(ClaimFactory::class)); + $manager = $this->createManager(); $this->assertFalse($manager->hasBlacklistEnabled()); + $this->assertSame($payload, $manager->decode($token)); + $this->assertSame($refreshedToken, $manager->refresh($token)); } public function testDecodeAToken(): void @@ -812,6 +836,20 @@ public function testThrowAnExceptionWhenEnableBlacklistIsSetToFalse(): void $this->createManager()->invalidate($token); } + public function testGetTheBlacklist(): void + { + $this->mockContainer(); + $this->mockConfig(); + $this->container->shouldReceive('make')->once()->with(BlacklistContract::class)->andReturn($this->blacklist); + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnFalse(); + + $manager = $this->createManager(); + + $this->assertSame($this->blacklist, $manager->blacklist()); + $this->assertSame($this->blacklist, $manager->blacklist()); + } + private function setTestNow(): void { CarbonImmutable::setTestNow('2000-01-01T00:00:00.000000Z'); From 7ec0c12197e713d5f0d2e4d3fcd3f4ec4fa66cdf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:30:18 +0000 Subject: [PATCH 05/10] Protect JWT logins and make guard logout reliable Logging out through the JWT guard threw when the blacklist was disabled, which is the default. With the blacklist enabled, user() could still authenticate again from the same request header right after logout, during the blacklist grace period. Logout now revokes the token only when the blacklist is enabled, clears the guard's state and stops reading the request's token until login() or setToken() is called. The Logout event now carries the user who logged out. A failed blacklist write still throws, keeps the guard's state and dispatches no Logout event. attempt() had no timing protection, so response times showed whether an account existed. Like the session guard, it now runs inside a cloned Timebox using auth.timebox_duration, and returns early only on success. Passwords are rehashed on login when hashing.rehash_on_login is enabled, in attempt() and once(), but never in validate(), which only checks credentials. The guard gains a public fromUser() method that issues a token without logging the user in, replacing the protected makeTokenForUser(), and getUser() becomes public to return the user the guard has already resolved. The id() override is removed, so id() loads the user like Laravel's guards and returns null for a deleted user; getUserId() still reads the token subject without loading the user. Tests reconcile the guard, JWTAuth and auth-adapter coverage of tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth main (1bdb72de5): - Invalid, expired and blacklisted tokens leave the guard unauthenticated and make userOrFail() throw. A token whose user no longer exists also leaves it unauthenticated, while getUserId() still returns the subject. - The Authenticated event fires once when the token user resolves, as added by fork #298, and no event fires for a missing user, the null case fixed by fork #300. Both behaviors were already present. - Logout covers revocation, a disabled blacklist, an expired token, the forceForever flag, write failures and the Logout event's user. - Timing tests check that failures wait for the configured duration and that a success never leaves the shared timebox returning early. - byId(), onceUsingId(), fromUser(), tokenById() and macros are covered. Upstream tests for forwarding to the JWT object, request setters and token-validity check() have no equivalent because those APIs are not included. Fork #301 is not applicable: it scopes tymon.jwt for Octane, and the Hypervel guard already keeps request state in the coroutine context. Original #2292 is not applicable: it replaces user()'s docblock with {@inheritDoc}, and Hypervel uses title docblocks. The JWT guide documents fromUser(), getUser(), password rehashing, the id() and getUserId() difference and the new logout behavior. The README lists the guard's lack of method forwarding and the id() difference from upstream. Validation: the Jwt and Auth suites pass, composer lint:fix changed no files and composer analyse reports no errors. --- src/docs/jwt.md | 16 +- src/jwt/README.md | 2 + src/jwt/src/JwtGuard.php | 151 ++++---- src/jwt/src/JwtServiceProvider.php | 11 +- tests/Jwt/JwtGuardCoroutineSafetyTest.php | 14 +- tests/Jwt/JwtGuardEventTest.php | 179 ++++++++- tests/Jwt/JwtGuardSubjectLockTest.php | 14 +- tests/Jwt/JwtGuardTest.php | 423 +++++++++++++++++++--- 8 files changed, 674 insertions(+), 136 deletions(-) diff --git a/src/docs/jwt.md b/src/docs/jwt.md index 23dc9fe1ba..e038dd9187 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -443,15 +443,19 @@ return response()->json([ ]); ``` +Like Hypervel's session guard, the `attempt` and `once` methods [rehash the user's password](/docs/{{version}}/authentication#automatic-password-rehashing) when it was hashed with outdated settings. + You may issue a token for an existing user model using `login`: ```php $token = Auth::guard('api')->login($user); ``` -You may issue a token by user ID without setting the current guard user: +To issue a token for a user without making them the current guard user, use `fromUser`, or `tokenById` when you only have the user's ID: ```php +$token = Auth::guard('api')->fromUser($user); + $token = Auth::guard('api')->tokenById($userId); ``` @@ -477,7 +481,7 @@ $user = Auth::guard('api')->user(); $userId = Auth::guard('api')->id(); ``` -The `getUserId` method reads the token subject without loading the user model when no user is already cached: +The `id` method loads the user, like the other guards. When you only need the ID, the `getUserId` method reads the token subject without loading the user model, so it does not check that the user still exists: ```php $userId = Auth::guard('api')->getUserId(); @@ -563,13 +567,13 @@ Every refreshed token receives a new `nbf` claim. It also receives a new `exp` c ### Logging Out and Invalidating Tokens -The `logout` method invalidates the current token and then clears the guard's user, token, and decoded payload: +The `logout` method invalidates the current token when the blacklist is enabled, then clears the guard's user, token, and decoded payload. For the rest of the request, the guard no longer reads the token from the request: ```php Auth::guard('api')->logout(); ``` -Logout requires blacklisting when a current token exists. If blacklisting is disabled or the blacklist write fails, a `JwtException` is thrown. The guard keeps its current state and does not dispatch the `Logout` event. Calling `logout` without a current token remains harmless and clears local guard state. +Without the blacklist, tokens cannot be revoked, so a logged out token remains valid until it expires. If the blacklist write fails, a `JwtException` is thrown, the guard keeps its current state, and the `Logout` event is not dispatched. To invalidate a token directly, enable the blacklist and call `invalidate`: @@ -611,9 +615,11 @@ Auth::guard('api')->validate($credentials); // bool Auth::guard('api')->once($credentials); // bool Auth::guard('api')->onceUsingId($id); // Authenticatable|false Auth::guard('api')->login($user); // string +Auth::guard('api')->fromUser($user); // string Auth::guard('api')->tokenById($id); // string|null Auth::guard('api')->byId($id); // Authenticatable|false Auth::guard('api')->user(); // Authenticatable|null +Auth::guard('api')->getUser(); // Authenticatable|null Auth::guard('api')->userOrFail(); // Authenticatable Auth::guard('api')->id(); // int|string|null Auth::guard('api')->getUserId(); // int|string|null @@ -627,7 +633,7 @@ Auth::guard('api')->logout(); Auth::guard('api')->invalidate(); ``` -The `claims` and `setTTL` methods affect only the next token-producing operation. +The `claims` and `setTTL` methods affect only the next token-producing operation. The `getUser` method returns the user the guard has already resolved, without decoding the token or loading the user. ## Exceptions diff --git a/src/jwt/README.md b/src/jwt/README.md index 9f65a3dff7..fd694d53ed 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -7,6 +7,8 @@ Documentation: https://hypervel.org/docs/jwt - 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. Use `Jwt::blacklist()` for the blacklist and `Jwt::driver()` for the JWT provider. +- The guard does not forward unknown methods to an upstream `JWT` object. Token methods such as `fromUser`, `payload`, `refresh` and `invalidate` are defined on the guard, `check()` reports an authenticated user rather than a valid token, and manager features are available through the `Jwt` facade. +- The guard's `id()` loads the user like Laravel's guards, while upstream returns the token subject without loading the user. Use `getUserId()` for that. - The JWT provider is chosen with the `driver` option and custom drivers are registered with `Jwt::extend()`, following Laravel's driver managers, instead of upstream's `providers.jwt` class setting. - Custom claims may set registered claims such as `exp`, `iss` and `jti`, but not `sub` or `prv`, which always come from the user and its provider. Customize the subject with `JwtSubject::getJwtIdentifier()`. - Hypervel adds a default `iss` only when `jwt.issuer` is set, rather than using the request URL, and a default `jti` only when the blacklist is enabled. diff --git a/src/jwt/src/JwtGuard.php b/src/jwt/src/JwtGuard.php index 3b8e45fd8f..8ef6dd4a3b 100644 --- a/src/jwt/src/JwtGuard.php +++ b/src/jwt/src/JwtGuard.php @@ -26,7 +26,9 @@ use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Jwt\Exceptions\UserNotDefinedException; use Hypervel\Jwt\Http\Parser\Parser; +use Hypervel\Support\Timebox; use Hypervel\Support\Traits\Macroable; +use SensitiveParameter; use stdClass; class JwtGuard implements Guard @@ -53,6 +55,8 @@ class JwtGuard implements Guard /** * Create a new JWT authentication guard. * + * @param bool $rehashOnLogin indicates if passwords should be rehashed on login if needed + * @param int $timeboxDuration the number of microseconds that the timebox should wait for * @param null|int $ttl token time-to-live in minutes, or null for no expiration */ public function __construct( @@ -62,7 +66,10 @@ public function __construct( protected ClaimFactory $claimFactory, protected Parser $parser, protected Container $app, + protected bool $rehashOnLogin, + protected int $timeboxDuration, protected ?int $ttl = self::DEFAULT_TTL, + protected Timebox $timebox = new Timebox, ) { $this->provider = $provider; } @@ -70,22 +77,34 @@ public function __construct( /** * Attempt to authenticate a user using the given credentials. */ - public function attempt(array $credentials = [], bool $login = true): string|bool + public function attempt(#[SensitiveParameter] array $credentials = [], bool $login = true): string|bool { - $this->fireAttemptEvent($credentials); + return (clone $this->timebox)->call(function (Timebox $timebox) use ($credentials, $login): string|bool { + $this->fireAttemptEvent($credentials); - $user = $this->provider->retrieveByCredentials($credentials); - $this->setContextState('lastAttempted', $user); + $user = $this->provider->retrieveByCredentials($credentials); + $this->setContextState('lastAttempted', $user); - if ($user !== null && $this->provider->validateCredentials($user, $credentials)) { - $this->fireValidatedEvent($user); + if ($user !== null && $this->provider->validateCredentials($user, $credentials)) { + $this->fireValidatedEvent($user); - return $login ? $this->login($user) : true; - } + $result = true; - $this->fireFailedEvent($user, $credentials); + if ($login) { + $this->rehashPasswordIfRequired($user, $credentials); - return false; + $result = $this->login($user); + } + + $timebox->returnEarly(); + + return $result; + } + + $this->fireFailedEvent($user, $credentials); + + return false; + }, $this->timeboxDuration); } /** @@ -105,7 +124,7 @@ public function parseToken(): ?string */ public function login(AuthenticatableContract $user): string { - $token = $this->makeTokenForUser($user); + $token = $this->fromUser($user); $this->setToken($token); CoroutineContext::forget($this->getExplicitUserContextKey()); @@ -177,7 +196,7 @@ public function user(): ?AuthenticatableContract /** * Validate a user's credentials. */ - public function validate(array $credentials = []): bool + public function validate(#[SensitiveParameter] array $credentials = []): bool { return (bool) $this->attempt($credentials, false); } @@ -185,9 +204,11 @@ public function validate(array $credentials = []): bool /** * Log a user into the application using their credentials without persisting. */ - public function once(array $credentials = []): bool + public function once(#[SensitiveParameter] array $credentials = []): bool { if ($this->validate($credentials) && $user = $this->getLastAttempted()) { + $this->rehashPasswordIfRequired($user, $credentials); + $this->setUser($user); return true; @@ -210,6 +231,25 @@ public function onceUsingId(mixed $id): AuthenticatableContract|false return false; } + /** + * Create a new token for the given user without authenticating them. + */ + public function fromUser(AuthenticatableContract $user): string + { + $ttl = $this->getTTL(); + + try { + return $this->jwtManager->encode($this->claimFactory->make( + user: $user, + provider: $this->provider, + ttl: $ttl, + customClaims: $this->pullCustomClaims(), + )); + } finally { + $this->forgetContextState('ttl'); + } + } + /** * Create a new token by user ID. */ @@ -219,7 +259,7 @@ public function tokenById(mixed $id): ?string return null; } - return $this->makeTokenForUser($user); + return $this->fromUser($user); } /** @@ -245,11 +285,11 @@ public function userOrFail(): AuthenticatableContract } /** - * Get the ID for the currently authenticated user. + * Get the ID for the current user from the token without loading the user. */ public function getUserId(): int|string|null { - if ($user = $this->cachedUser()) { + if ($user = $this->getUser()) { return $user->getAuthIdentifier(); } @@ -266,14 +306,6 @@ public function getUserId(): int|string|null return $payload['sub'] ?? null; } - /** - * Get the ID for the currently authenticated user. - */ - public function id(): int|string|null - { - return $this->getUserId(); - } - /** * Add custom claims to the next JWT token. */ @@ -326,6 +358,11 @@ public function getToken(): ?string { $token = $this->getContextState('token'); + // Logout stores false so the guard stops reading the request's token. + if ($token === false) { + return null; + } + return is_string($token) && $token !== '' ? $token : $this->parseToken(); } @@ -384,24 +421,25 @@ public function refresh(bool $forceForever = false, bool $resetClaims = false): } /** - * Log the user out by invalidating the current token. + * Log the user out, invalidating the current token when the blacklist is enabled. */ public function logout(bool $forceForever = false): void { - $user = $this->cachedUser(); + $user = $this->user(); $token = $this->getToken(); - if ($token) { + if ($token && $this->jwtManager->hasBlacklistEnabled()) { $this->jwtManager->invalidate($token, $forceForever); } $this->forgetUser(); - $this->forgetContextState('token'); if ($token) { CoroutineContext::forget($this->getPayloadContextKey($token)); } + $this->setContextState('token', false); + $this->fireLogoutEvent($user); } @@ -419,12 +457,31 @@ public function invalidate(bool $forceForever = false): static return $this; } + /** + * Return the currently cached user. + */ + public function getUser(): ?AuthenticatableContract + { + self::$nullUserSentinel ??= new stdClass; + + /** @var null|AuthenticatableContract $explicitUser */ + $explicitUser = CoroutineContext::get($this->getExplicitUserContextKey()); + + if ($explicitUser !== null) { + return $explicitUser; + } + + $cached = CoroutineContext::get($this->getUserContextKey()); + + return ($cached === null || $cached === self::$nullUserSentinel) ? null : $cached; + } + /** * Determine if the guard has a user instance. */ public function hasUser(): bool { - return $this->cachedUser() !== null; + return $this->getUser() !== null; } /** @@ -501,21 +558,12 @@ public function getLastAttempted(): ?AuthenticatableContract } /** - * Create a token for the given user. + * Rehash the user's password if enabled and required. */ - protected function makeTokenForUser(AuthenticatableContract $user): string + protected function rehashPasswordIfRequired(AuthenticatableContract $user, #[SensitiveParameter] array $credentials): void { - $ttl = $this->getTTL(); - - try { - return $this->jwtManager->encode($this->claimFactory->make( - user: $user, - provider: $this->provider, - ttl: $ttl, - customClaims: $this->pullCustomClaims(), - )); - } finally { - $this->forgetContextState('ttl'); + if ($this->rehashOnLogin) { + $this->provider->rehashPasswordIfRequired($user, $credentials); } } @@ -530,25 +578,6 @@ protected function decodeToken(string $token): array ); } - /** - * Return the currently cached user. - */ - protected function cachedUser(): ?AuthenticatableContract - { - self::$nullUserSentinel ??= new stdClass; - - /** @var null|AuthenticatableContract $explicitUser */ - $explicitUser = CoroutineContext::get($this->getExplicitUserContextKey()); - - if ($explicitUser !== null) { - return $explicitUser; - } - - $cached = CoroutineContext::get($this->getUserContextKey()); - - return ($cached === null || $cached === self::$nullUserSentinel) ? null : $cached; - } - /** * Cache the current user without firing guard events. */ diff --git a/src/jwt/src/JwtServiceProvider.php b/src/jwt/src/JwtServiceProvider.php index e0aeeef381..9bb716a8b5 100644 --- a/src/jwt/src/JwtServiceProvider.php +++ b/src/jwt/src/JwtServiceProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Jwt; use Hypervel\Auth\AuthManager; +use Hypervel\Contracts\Container\Container; use Hypervel\Jwt\Console\JwtGenerateCertsCommand; use Hypervel\Jwt\Console\JwtSecretCommand; use Hypervel\Jwt\Contracts\BlacklistContract; @@ -100,11 +101,13 @@ public function boot(): void */ protected function registerJwtGuard(): void { - $this->callAfterResolving(AuthManager::class, function (AuthManager $authManager) { - $authManager->extend('jwt', function ($app, $name, $config) use ($authManager) { + $this->callAfterResolving(AuthManager::class, function (AuthManager $authManager): void { + $authManager->extend('jwt', function (Container $app, string $name, array $config) use ($authManager): JwtGuard { + $repository = $app->make('config'); + $ttl = array_key_exists('ttl', $config) ? $config['ttl'] - : $app->make('config')->get('jwt.ttl'); + : $repository->get('jwt.ttl'); if (! is_int($ttl) && $ttl !== null) { throw new InvalidArgumentException( @@ -119,6 +122,8 @@ protected function registerJwtGuard(): void claimFactory: $app->make(ClaimFactory::class), parser: $app->make(Parser::class), app: $app, + rehashOnLogin: $repository->boolean('hashing.rehash_on_login'), + timeboxDuration: $repository->integer('auth.timebox_duration'), ttl: $ttl, ); diff --git a/tests/Jwt/JwtGuardCoroutineSafetyTest.php b/tests/Jwt/JwtGuardCoroutineSafetyTest.php index 6763be78ba..c7e36decb9 100644 --- a/tests/Jwt/JwtGuardCoroutineSafetyTest.php +++ b/tests/Jwt/JwtGuardCoroutineSafetyTest.php @@ -106,17 +106,19 @@ function () use ($guard): int|string { private function createGuard(ManagerContract $manager, UserProvider $provider): JwtGuard { return new JwtGuard( - 'jwt', - $provider, - $manager, - new ClaimFactory(new Repository([ + name: 'jwt', + provider: $provider, + jwtManager: $manager, + claimFactory: new ClaimFactory(new Repository([ 'jwt' => [ 'issuer' => null, 'lock_subject' => false, ], ])), - new Parser([new AuthHeaders, new InputSource]), - $this->app, + parser: new Parser([new AuthHeaders, new InputSource]), + app: $this->app, + rehashOnLogin: false, + timeboxDuration: 0, ); } } diff --git a/tests/Jwt/JwtGuardEventTest.php b/tests/Jwt/JwtGuardEventTest.php index e10e42ef3d..710ebe3667 100644 --- a/tests/Jwt/JwtGuardEventTest.php +++ b/tests/Jwt/JwtGuardEventTest.php @@ -19,12 +19,14 @@ use Hypervel\Jwt\ClaimFactory; use Hypervel\Jwt\Contracts\ManagerContract; use Hypervel\Jwt\Exceptions\JwtException; +use Hypervel\Jwt\Exceptions\TokenExpiredException; use Hypervel\Jwt\Http\Parser\AuthHeaders; use Hypervel\Jwt\Http\Parser\InputSource; use Hypervel\Jwt\Http\Parser\Parser; use Hypervel\Jwt\JwtGuard; use Hypervel\Testbench\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; class JwtGuardEventTest extends TestCase { @@ -99,6 +101,42 @@ public function testFailedEventIsDispatchedWhenListening(): void $this->assertFalse($guard->attempt(['email' => 'foo@example.test'])); } + #[DataProvider('credentialCheckEventProvider')] + public function testCredentialChecksWithoutLoginDoNotDispatchTheLoginEvent(string $method, array $expectedEvents): void + { + $user = $this->user(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturn($user); + $provider->shouldReceive('validateCredentials')->once()->andReturnTrue(); + + $dispatched = []; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->andReturnTrue(); + $events->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$dispatched): void { + $dispatched[] = $event::class; + }); + + $guard = $this->createGuard(provider: $provider); + $guard->setDispatcher($events); + + $this->assertTrue($guard->{$method}(['email' => 'foo@example.test'])); + $this->assertSame($expectedEvents, $dispatched); + } + + /** + * Provide credential checks that do not log in and the events each dispatches. + * + * @return array}> + */ + public static function credentialCheckEventProvider(): array + { + return [ + 'validate' => ['validate', [Attempting::class, Validated::class]], + 'once' => ['once', [Attempting::class, Validated::class, Authenticated::class]], + ]; + } + public function testLoginAndAuthenticatedEventsAreDispatchedWhenListening(): void { $user = $this->user(1); @@ -130,21 +168,141 @@ function (object $event) use (&$dispatched): void { $this->assertSame($user, $dispatched[1]->user); } - public function testLogoutEventIsDispatchedWhenListening(): void + public function testAuthenticatedEventIsDispatchedOnceWhenTheTokenUserResolves(): void { $user = $this->user(1); + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($user); + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('token')->once()->andReturn(['sub' => 1]); + + $events = $this->dispatcherListeningFor(Authenticated::class, function (Authenticated $event) use ($user): bool { + return $event->guard === 'jwt' && $event->user === $user; + }); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->requestWithToken('token'), + ); + $guard->setDispatcher($events); + + $this->assertSame($user, $guard->user()); + $this->assertSame($user, $guard->user()); + } + + public function testNoEventIsDispatchedWhenTheTokenUserIsMissing(): void + { + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturnNull(); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('token')->once()->andReturn(['sub' => 1]); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners', 'dispatch')->never(); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->requestWithToken('token'), + ); + $guard->setDispatcher($events); + + $this->assertNull($guard->user()); + } + + public function testOnceUsingIdDispatchesTheAuthenticatedEvent(): void + { + $user = $this->user(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($user); + + $events = $this->dispatcherListeningFor(Authenticated::class, function (Authenticated $event) use ($user): bool { + return $event->guard === 'jwt' && $event->user === $user; + }); + + $guard = $this->createGuard(provider: $provider); + $guard->setDispatcher($events); + + $this->assertSame($user, $guard->onceUsingId(1)); + } + + public function testIssuingTokensWithoutLoginDispatchesNoEvents(): void + { + $user = $this->user(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($user); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->twice()->andReturn('token'); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners', 'dispatch')->never(); + + $guard = $this->createGuard(provider: $provider, jwtManager: $jwtManager); + $guard->setDispatcher($events); + + $this->assertSame('token', $guard->fromUser($user)); + $this->assertSame('token', $guard->tokenById(1)); + } + + public function testLogoutEventIncludesTheTokenUserWhenListening(): void + { + $user = $this->user(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($user); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('token')->once()->andReturn(['sub' => 1]); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnTrue(); $jwtManager->shouldReceive('invalidate')->with('token', false)->once()->andReturnTrue(); $events = $this->dispatcherListeningFor(Logout::class, function (Logout $event) use ($user): bool { return $event->guard === 'jwt' && $event->user === $user; }); - $guard = $this->createGuard(jwtManager: $jwtManager, request: $this->requestWithToken('token')); - $guard->setUser($user); + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->requestWithToken('token'), + ); + $guard->setDispatcher($events); + $guard->logout(); + } + + public function testLogoutWithAnExpiredTokenInvalidatesItAndDispatchesLogoutWithoutAUser(): void + { + $provider = m::mock(UserProvider::class); + $provider->shouldNotReceive('retrieveById'); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode') + ->with('token') + ->once() + ->andThrow(new TokenExpiredException('Token has expired')); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnTrue(); + $jwtManager->shouldReceive('invalidate')->with('token', false)->once()->andReturnTrue(); + + $events = $this->dispatcherListeningFor(Logout::class, function (Logout $event): bool { + return $event->guard === 'jwt' && $event->user === null; + }); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->requestWithToken('token'), + ); $guard->setDispatcher($events); $guard->logout(); + + $this->assertNull($guard->getToken()); + $this->assertNull($guard->user()); } public function testLogoutEventIsNotDispatchedWhenInvalidationFails(): void @@ -152,6 +310,7 @@ 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() @@ -214,17 +373,19 @@ private function createGuard( } return new JwtGuard( - 'jwt', - $provider ?? m::mock(UserProvider::class), - $jwtManager ?? m::mock(ManagerContract::class), - new ClaimFactory(new Repository([ + name: 'jwt', + provider: $provider ?? m::mock(UserProvider::class), + jwtManager: $jwtManager ?? m::mock(ManagerContract::class), + claimFactory: new ClaimFactory(new Repository([ 'jwt' => [ 'issuer' => null, 'lock_subject' => false, ], ])), - new Parser([new AuthHeaders, new InputSource]), - $this->app, + parser: new Parser([new AuthHeaders, new InputSource]), + app: $this->app, + rehashOnLogin: false, + timeboxDuration: 0, ); } diff --git a/tests/Jwt/JwtGuardSubjectLockTest.php b/tests/Jwt/JwtGuardSubjectLockTest.php index 056b38794f..87f4210daa 100644 --- a/tests/Jwt/JwtGuardSubjectLockTest.php +++ b/tests/Jwt/JwtGuardSubjectLockTest.php @@ -107,17 +107,19 @@ private function createGuard(UserProvider $provider, ManagerContract $jwtManager ])); return new JwtGuard( - 'jwt', - $provider, - $jwtManager, - new ClaimFactory(new Repository([ + name: 'jwt', + provider: $provider, + jwtManager: $jwtManager, + claimFactory: new ClaimFactory(new Repository([ 'jwt' => [ 'issuer' => null, 'lock_subject' => true, ], ])), - new Parser([new AuthHeaders, new InputSource]), - $this->app, + parser: new Parser([new AuthHeaders, new InputSource]), + app: $this->app, + rehashOnLogin: false, + timeboxDuration: 0, ); } } diff --git a/tests/Jwt/JwtGuardTest.php b/tests/Jwt/JwtGuardTest.php index 00c31b8b1b..e5d28c8d19 100644 --- a/tests/Jwt/JwtGuardTest.php +++ b/tests/Jwt/JwtGuardTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Jwt; +use Carbon\CarbonInterval; use Hypervel\Auth\AuthManager; use Hypervel\Auth\AuthServiceProvider; use Hypervel\Config\Repository; @@ -17,6 +18,7 @@ use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\SecretMissingException; use Hypervel\Jwt\Exceptions\TokenBlacklistedException; +use Hypervel\Jwt\Exceptions\TokenExpiredException; use Hypervel\Jwt\Exceptions\TokenInvalidException; use Hypervel\Jwt\Exceptions\UserNotDefinedException; use Hypervel\Jwt\Http\Parser\AuthHeaders; @@ -24,9 +26,11 @@ use Hypervel\Jwt\Http\Parser\Parser; use Hypervel\Jwt\JwtGuard; use Hypervel\Jwt\JwtServiceProvider; +use Hypervel\Support\Sleep; use Hypervel\Testbench\TestCase; use Mockery as m; use Mockery\MockInterface; +use PHPUnit\Framework\Attributes\DataProvider; class JwtGuardTest extends TestCase { @@ -59,6 +63,8 @@ public function testParseTokenReturnsNullWhenNoRequestContext(): void public function testUserReturnsUserFromJwtPayload(): void { $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(42); + $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveById')->with(42)->once()->andReturn($user); @@ -72,6 +78,8 @@ public function testUserReturnsUserFromJwtPayload(): void ); $this->assertSame($user, $guard->user()); + $this->assertTrue($guard->check()); + $this->assertSame(42, $guard->id()); } public function testUserReturnsNullWhenNoToken(): void @@ -80,6 +88,7 @@ public function testUserReturnsNullWhenNoToken(): void RequestContext::forget(); $this->assertNull($guard->user()); + $this->assertFalse($guard->check()); } public function testUserCachesResultInContext(): void @@ -101,7 +110,7 @@ public function testUserCachesResultInContext(): void $this->assertSame($user, $guard->user()); // Should not call decode again } - public function testUserCachesNullViaSentinel(): void + public function testMissingTokenUserIsCachedAsUnauthenticated(): void { $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveById')->with(42)->once()->andReturn(null); @@ -116,7 +125,48 @@ public function testUserCachesNullViaSentinel(): void ); $this->assertNull($guard->user()); - $this->assertNull($guard->user()); // Should not call decode again + $this->assertNull($guard->user()); + $this->assertFalse($guard->check()); + $this->assertNull($guard->id()); + $this->assertSame(42, $guard->getUserId()); + } + + #[DataProvider('unusableTokenExceptionProvider')] + public function testUnusableTokenIsCachedAsUnauthenticated(JwtException $exception): void + { + $provider = m::mock(UserProvider::class); + $provider->shouldNotReceive('retrieveById'); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('unusable-token')->once()->andThrow($exception); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('unusable-token'), + ); + + $this->assertNull($guard->user()); + $this->assertFalse($guard->check()); + $this->assertNull($guard->id()); + + $this->expectException(UserNotDefinedException::class); + + $guard->userOrFail(); + } + + /** + * Provide the token exceptions that leave the guard unauthenticated. + * + * @return array + */ + public static function unusableTokenExceptionProvider(): array + { + return [ + 'invalid' => [new TokenInvalidException], + 'expired' => [new TokenExpiredException], + 'blacklisted' => [new TokenBlacklistedException], + ]; } public function testAttemptReturnsTokenOnValidCredentials(): void @@ -124,11 +174,12 @@ public function testAttemptReturnsTokenOnValidCredentials(): void $user = m::mock(Authenticatable::class); $user->shouldReceive('getAuthIdentifier')->andReturn(1); + $credentials = ['email' => 'foo@bar.com', 'password' => 'secret']; + $provider = m::mock(UserProvider::class); - $provider->shouldReceive('retrieveByCredentials') - ->with(['email' => 'foo@bar.com', 'password' => 'secret']) - ->andReturn($user); - $provider->shouldReceive('validateCredentials')->with($user, m::type('array'))->andReturnTrue(); + $provider->shouldReceive('retrieveByCredentials')->with($credentials)->andReturn($user); + $provider->shouldReceive('validateCredentials')->with($user, $credentials)->andReturnTrue(); + $provider->shouldReceive('rehashPasswordIfRequired')->with($user, $credentials)->once(); $jwtManager = m::mock(ManagerContract::class); $jwtManager->shouldReceive('encode')->once()->andReturn('new-token'); @@ -139,7 +190,8 @@ public function testAttemptReturnsTokenOnValidCredentials(): void request: $this->createRequestWithBearer(null), ); - $this->assertSame('new-token', $guard->attempt(['email' => 'foo@bar.com', 'password' => 'secret'])); + $this->assertSame('new-token', $guard->attempt($credentials)); + $this->assertSame($user, $guard->getLastAttempted()); } public function testAttemptReturnsFalseOnInvalidCredentials(): void @@ -161,6 +213,7 @@ public function testValidateDoesNotLoginUser(): void $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveByCredentials')->andReturn($user); $provider->shouldReceive('validateCredentials')->andReturnTrue(); + $provider->shouldNotReceive('rehashPasswordIfRequired'); $jwtManager = m::mock(ManagerContract::class); $jwtManager->shouldNotReceive('encode'); @@ -180,6 +233,7 @@ public function testAttemptWithoutLoginReturnsTrueAndDoesNotMintToken(): void $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveByCredentials')->once()->andReturn($user); $provider->shouldReceive('validateCredentials')->once()->andReturnTrue(); + $provider->shouldNotReceive('rehashPasswordIfRequired'); $jwtManager = m::mock(ManagerContract::class); $jwtManager->shouldNotReceive('encode'); @@ -585,14 +639,16 @@ public function testRefreshReturnsNullWhenNoToken(): void $this->assertNull($guard->refresh()); } - public function testLogoutInvalidatesTokenAndClearsContext(): void + public function testLogoutInvalidatesTheRequestTokenAndStopsUsingIt(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); - $provider->shouldReceive('retrieveById')->with(1)->andReturn($user); + $provider->shouldReceive('retrieveById')->with(1)->twice()->andReturn($user); + // The grace period keeps the revoked token decodable during this request. $jwtManager = m::mock(ManagerContract::class); - $jwtManager->shouldReceive('decode')->with('valid-token')->andReturn(['sub' => 1]); + $jwtManager->shouldReceive('decode')->with('valid-token')->twice()->andReturn(['sub' => 1]); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnTrue(); $jwtManager->shouldReceive('invalidate')->with('valid-token', false)->once()->andReturnTrue(); $guard = $this->createGuard( @@ -601,51 +657,68 @@ public function testLogoutInvalidatesTokenAndClearsContext(): void request: $this->createRequestWithBearer('valid-token'), ); - // Resolve user first $this->assertSame($user, $guard->user()); $guard->logout(); - // After logout, hasUser should be false + $this->assertNull($guard->getToken()); $this->assertFalse($guard->hasUser()); + $this->assertNull($guard->user()); + $this->assertFalse($guard->check()); + $this->assertNull($guard->id()); + + $this->assertSame($user, $guard->setToken('valid-token')->user()); } - public function testLogoutWithBlacklistDisabledPropagatesAndRetainsContext(): void + public function testLogoutWithBlacklistDisabledStopsUsingTheRequestTokenWithoutInvalidating(): void { $user = m::mock(Authenticatable::class); + $nextUser = m::mock(Authenticatable::class); + $nextUser->shouldReceive('getAuthIdentifier')->andReturn(2); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($user); + $jwtManager = m::mock(ManagerContract::class); $jwtManager->shouldReceive('decode')->with('valid-token')->once()->andReturn(['sub' => 1]); - $jwtManager->shouldReceive('invalidate') - ->with('valid-token', false) - ->once() - ->andThrow(new JwtException('You must have the blacklist enabled to invalidate a token.')); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnFalse(); + $jwtManager->shouldNotReceive('invalidate'); + $jwtManager->shouldReceive('encode')->once()->andReturn('next-token'); - $guard = $this->createGuard(jwtManager: $jwtManager, request: null) - ->setToken('valid-token') - ->setUser($user); + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); - $this->assertSame(['sub' => 1], $guard->getPayload()); + $this->assertSame($user, $guard->user()); - try { - $guard->logout(); + $guard->logout(); - $this->fail('Expected logout to fail when blacklisting is disabled.'); - } catch (JwtException $exception) { - $this->assertSame('You must have the blacklist enabled to invalidate a token.', $exception->getMessage()); - } + $this->assertNull($guard->getToken()); + $this->assertSame([], $guard->getPayload()); + $this->assertNull($guard->user()); + $this->assertFalse($guard->check()); + $this->assertNull($guard->id()); - $this->assertSame('valid-token', $guard->getToken()); - $this->assertTrue($guard->hasUser()); - $this->assertSame(['sub' => 1], $guard->getPayload()); + $this->assertSame('next-token', $guard->login($nextUser)); + $this->assertSame('next-token', $guard->getToken()); + $this->assertSame($nextUser, $guard->user()); } public function testLogoutClearsDecodedPayloadCache(): void { + $user = m::mock(Authenticatable::class); + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($user); + $jwtManager = m::mock(ManagerContract::class); $this->stubDecodeThenBlacklisted($jwtManager, 'valid-token', ['sub' => 1]); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnTrue(); $jwtManager->shouldReceive('invalidate')->with('valid-token', false)->once()->andReturnTrue(); $guard = $this->createGuard( + provider: $provider, jwtManager: $jwtManager, request: $this->createRequestWithBearer('valid-token'), ); @@ -654,17 +727,56 @@ public function testLogoutClearsDecodedPayloadCache(): void $guard->logout(); + $this->assertSame([], $guard->getPayload()); + $this->expectException(TokenBlacklistedException::class); - $guard->getPayload(); + $guard->setToken('valid-token')->getPayload(); + } + + public function testLogoutFailureRetainsTheTokenAndUser(): 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 the blacklist write fails.'); + } catch (JwtException $exception) { + $this->assertSame('blacklist write failed', $exception->getMessage()); + } + + $this->assertSame('valid-token', $guard->getToken()); + $this->assertSame($user, $guard->user()); + $this->assertSame(['sub' => 1], $guard->getPayload()); } public function testLogoutPassesForceForeverFlag(): void { + $user = m::mock(Authenticatable::class); + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($user); + $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', true)->once()->andReturnTrue(); $guard = $this->createGuard( + provider: $provider, jwtManager: $jwtManager, request: $this->createRequestWithBearer('valid-token'), ); @@ -688,7 +800,7 @@ public function testLogoutWithoutTokenClearsLocalUserState(): void $this->assertFalse($guard->hasUser()); } - public function testHasUserReturnsTrueAfterUserResolved(): void + public function testGetUserReturnsTheResolvedUser(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); @@ -705,15 +817,21 @@ public function testHasUserReturnsTrueAfterUserResolved(): void $guard->user(); + $this->assertSame($user, $guard->getUser()); $this->assertTrue($guard->hasUser()); } - public function testHasUserReturnsFalseBeforeResolution(): void + public function testGetUserDoesNotResolveTheTokenUser(): void { + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldNotReceive('decode'); + $guard = $this->createGuard( + jwtManager: $jwtManager, request: $this->createRequestWithBearer('valid-token'), ); + $this->assertNull($guard->getUser()); $this->assertFalse($guard->hasUser()); } @@ -734,6 +852,32 @@ public function testGetUserIdUsesCachedUserWithoutDecodingToken(): void $this->assertSame(42, $guard->getUserId()); } + public function testGetUserIdReadsTheTokenSubjectWithoutLoadingTheUser(): void + { + $provider = m::mock(UserProvider::class); + $provider->shouldNotReceive('retrieveById'); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('valid-token')->once()->andReturn(['sub' => 42]); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); + + $this->assertSame(42, $guard->getUserId()); + $this->assertFalse($guard->hasUser()); + } + + public function testGetUserIdReturnsNullWithoutToken(): void + { + $guard = $this->createGuard(request: null); + RequestContext::forget(); + + $this->assertNull($guard->getUserId()); + } + public function testExplicitUserOverridesAnUnrelatedTokenUntilForgotten(): void { $explicitUser = m::mock(Authenticatable::class); @@ -821,6 +965,7 @@ public function testLogoutClearsDefaultUserCacheWhenTokenOverrideIsActive(): voi $user = m::mock(Authenticatable::class); $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnTrue(); $jwtManager->shouldReceive('invalidate')->with('active-token', false)->once()->andReturnTrue(); $guard = $this->createGuard( @@ -863,11 +1008,11 @@ public function testSwitchingTokensResolvesDifferentUsersInSameCoroutine(): void $this->assertSame($firstUser, $guard->setToken('first-token')->user()); } - public function testOnceUsingIdReturnsUserWhenUserExists(): void + public function testOnceUsingIdAndByIdReturnUserWhenUserExists(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); - $provider->shouldReceive('retrieveById')->with(1)->andReturn($user); + $provider->shouldReceive('retrieveById')->with(1)->twice()->andReturn($user); $jwtManager = m::mock(ManagerContract::class); $jwtManager->shouldNotReceive('encode'); @@ -879,15 +1024,19 @@ public function testOnceUsingIdReturnsUserWhenUserExists(): void ); $this->assertSame($user, $guard->onceUsingId(1)); + $this->assertSame($user, $guard->byId(1)); + $this->assertSame($user, $guard->user()); } public function testOnceDoesNotMintTokenAndSetsUser(): void { $user = m::mock(Authenticatable::class); + $credentials = ['email' => 'foo@bar.com', 'password' => 'secret']; $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveByCredentials')->once()->andReturn($user); $provider->shouldReceive('validateCredentials')->once()->andReturnTrue(); + $provider->shouldReceive('rehashPasswordIfRequired')->with($user, $credentials)->once(); $jwtManager = m::mock(ManagerContract::class); $jwtManager->shouldNotReceive('encode'); @@ -898,14 +1047,101 @@ public function testOnceDoesNotMintTokenAndSetsUser(): void request: $this->createRequestWithBearer(null), ); - $this->assertTrue($guard->once(['email' => 'foo@bar.com', 'password' => 'secret'])); + $this->assertTrue($guard->once($credentials)); $this->assertSame($user, $guard->user()); } - public function testOnceUsingIdReturnsFalseWhenUserNotFound(): void + public function testOnceReturnsFalseForInvalidCredentials(): void { + $user = m::mock(Authenticatable::class); + $provider = m::mock(UserProvider::class); - $provider->shouldReceive('retrieveById')->with(999)->andReturn(null); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturn($user); + $provider->shouldReceive('validateCredentials')->once()->andReturnFalse(); + $provider->shouldNotReceive('rehashPasswordIfRequired'); + + $guard = $this->createGuard(provider: $provider, request: $this->createRequestWithBearer(null)); + + $this->assertFalse($guard->once(['email' => 'foo@bar.com', 'password' => 'wrong'])); + $this->assertFalse($guard->hasUser()); + } + + public function testDisabledRehashOnLoginNeverRehashesPasswords(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->twice()->andReturn($user); + $provider->shouldReceive('validateCredentials')->twice()->andReturnTrue(); + $provider->shouldNotReceive('rehashPasswordIfRequired'); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturn('new-token'); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer(null), + rehashOnLogin: false, + ); + + $this->assertSame('new-token', $guard->attempt(['email' => 'foo@bar.com', 'password' => 'secret'])); + $this->assertTrue($guard->once(['email' => 'foo@bar.com', 'password' => 'secret'])); + } + + public function testFailedAttemptWaitsForTheTimebox(): void + { + Sleep::fake(); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturnNull(); + + $guard = $this->createGuard( + provider: $provider, + request: $this->createRequestWithBearer(null), + timeboxDuration: 200000, + ); + + $this->assertFalse($guard->attempt(['email' => 'missing@bar.com', 'password' => 'secret'])); + + Sleep::assertSlept( + static fn (CarbonInterval $duration): bool => $duration->totalMicroseconds > 0 + && $duration->totalMicroseconds <= 200000 + ); + } + + public function testSuccessfulAttemptReturnsWithoutWaitingForTheTimebox(): void + { + Sleep::fake(); + + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturn($user); + $provider->shouldReceive('validateCredentials')->once()->andReturnTrue(); + $provider->shouldReceive('rehashPasswordIfRequired')->once(); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturn('new-token'); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer(null), + timeboxDuration: 200000, + ); + + $this->assertSame('new-token', $guard->attempt(['email' => 'foo@bar.com', 'password' => 'secret'])); + + Sleep::assertNeverSlept(); + } + + public function testOnceUsingIdAndByIdReturnFalseWhenUserNotFound(): void + { + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(999)->twice()->andReturn(null); $guard = $this->createGuard( provider: $provider, @@ -913,6 +1149,7 @@ public function testOnceUsingIdReturnsFalseWhenUserNotFound(): void ); $this->assertFalse($guard->onceUsingId(999)); + $this->assertFalse($guard->byId(999)); } public function testTokenByIdReturnsTokenWithoutSettingCurrentUserOrToken(): void @@ -938,6 +1175,42 @@ public function testTokenByIdReturnsTokenWithoutSettingCurrentUserOrToken(): voi $this->assertFalse($guard->hasUser()); } + public function testTokenByIdReturnsNullWhenUserIsNotFound(): void + { + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturnNull(); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldNotReceive('encode'); + + $guard = $this->createGuard(provider: $provider, jwtManager: $jwtManager, request: null); + + $this->assertNull($guard->tokenById(1)); + } + + public function testFromUserReturnsTokenWithoutSettingCurrentUserOrToken(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $capturedPayload = null; + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturnUsing(function (array $payload) use (&$capturedPayload): string { + $capturedPayload = $payload; + + return 'token-for-user'; + }); + + $guard = $this->createGuard(jwtManager: $jwtManager, request: null); + RequestContext::forget(); + + $this->assertSame('token-for-user', $guard->claims(['role' => 'admin'])->fromUser($user)); + $this->assertSame(1, $capturedPayload['sub']); + $this->assertSame('admin', $capturedPayload['role']); + $this->assertNull($guard->getToken()); + $this->assertFalse($guard->hasUser()); + } + public function testInvalidateReturnsGuardInstance(): void { $jwtManager = m::mock(ManagerContract::class); @@ -1144,6 +1417,13 @@ public function testDecodedPayloadIsCachedBetweenUserAndGetPayload(): void $this->assertSame(1, $payload['sub']); } + public function testGuardIsMacroable(): void + { + JwtGuard::macro('foo', static fn (): string => 'bar'); + + $this->assertSame('bar', $this->createGuard()->foo()); + } + public function testServiceProviderRegistersJwtGuardWhenAuthManagerResolvesAfterBoot(): void { $provider = m::mock(UserProvider::class); @@ -1179,6 +1459,46 @@ public function testServiceProviderRegistersJwtGuardWhenAuthManagerIsAlreadyReso $this->assertInstanceOf(JwtGuard::class, $authManager->guard('jwt')); } + public function testRegisteredGuardUsesTheTimeboxAndRehashSettings(): void + { + Sleep::fake(); + + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->with(['email' => 'missing@bar.com'])->once()->andReturnNull(); + $provider->shouldReceive('retrieveByCredentials')->with(['email' => 'foo@bar.com'])->once()->andReturn($user); + $provider->shouldReceive('validateCredentials')->once()->andReturnTrue(); + $provider->shouldNotReceive('rehashPasswordIfRequired'); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturn('new-token'); + + $container = $this->createAuthTestContainer(rehashOnLogin: false, timeboxDuration: 300000); + $jwtServiceProvider = new JwtServiceProvider($container); + $jwtServiceProvider->register(); + $container->instance('jwt', $jwtManager); + $jwtServiceProvider->boot(); + + /** @var AuthManager $authManager */ + $authManager = $container->make(AuthManager::class); + $authManager->provider('jwt-test-provider', static fn (): UserProvider => $provider); + $guard = $authManager->guard('jwt'); + + // Succeeding first shows a later failure on the same guard still waits. + $this->assertSame('new-token', $guard->attempt(['email' => 'foo@bar.com'])); + Sleep::assertNeverSlept(); + + $this->assertFalse($guard->attempt(['email' => 'missing@bar.com'])); + + // A wait above the framework's 200000 microsecond default shows the configured duration is used. + Sleep::assertSlept( + static fn (CarbonInterval $duration): bool => $duration->totalMicroseconds > 200000 + && $duration->totalMicroseconds <= 300000 + ); + } + /** * Stub decoding to succeed once and then report a blacklisted token. * @@ -1211,24 +1531,28 @@ protected function createGuard( ?ManagerContract $jwtManager = null, ?Request $request = null, ?int $ttl = 120, + bool $rehashOnLogin = true, + int $timeboxDuration = 0, ): JwtGuard { if ($request !== null) { RequestContext::set($request); } return new JwtGuard( - 'jwt', - $provider ?? m::mock(UserProvider::class), - $jwtManager ?? m::mock(ManagerContract::class), - new ClaimFactory(new Repository([ + name: 'jwt', + provider: $provider ?? m::mock(UserProvider::class), + jwtManager: $jwtManager ?? m::mock(ManagerContract::class), + claimFactory: new ClaimFactory(new Repository([ 'jwt' => [ 'issuer' => null, 'lock_subject' => true, ], ])), - new Parser([new AuthHeaders, new InputSource]), - $this->app, - $ttl, + parser: new Parser([new AuthHeaders, new InputSource]), + app: $this->app, + rehashOnLogin: $rehashOnLogin, + timeboxDuration: $timeboxDuration, + ttl: $ttl, ); } @@ -1244,7 +1568,10 @@ protected function createRequestWithBearer(?string $token): Request ); } - protected function createAuthTestContainer(): Application + /** + * Create an application container for guard registration tests. + */ + protected function createAuthTestContainer(bool $rehashOnLogin = true, int $timeboxDuration = 200000): Application { $container = new Application; $container->instance('config', new Repository([ @@ -1264,6 +1591,10 @@ protected function createAuthTestContainer(): Application 'driver' => 'jwt-test-provider', ], ], + 'timebox_duration' => $timeboxDuration, + ], + 'hashing' => [ + 'rehash_on_login' => $rehashOnLogin, ], 'jwt' => [ 'ttl' => 120, From 29c3c31cf917de9d39582f6962ab1fb5f370bee3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:55:45 +0000 Subject: [PATCH 06/10] Give the JWT cookie its own name and test token sources end to end The Cookie parser read the cookie named by jwt.token, the same key InputSource reads from request input, so an application could not name the cookie and the input key separately. A new cookie_key_name setting (JWT_COOKIE_KEY_NAME, default "token") names the cookie, following PHP-Open-Source-Saver/jwt-auth's option of the same name. AuthHeaders no longer falls back to the HTTP_AUTHORIZATION and REDIRECT_HTTP_AUTHORIZATION server values. The request bridge and Symfony's request already copy both into the Authorization header, so the fallbacks never ran. Cookie decryption stays with the framework's EncryptCookies middleware. Upstream's decrypt_cookies option decrypts the cookie itself, which cannot read cookies written by that middleware, so it is not included. Tests reconcile the parser and middleware coverage of tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth main (1bdb72de5): - Bearer parsing covers other schemes, an empty token, a tab after the scheme, surrounding whitespace and trailing hyphens. - InputSource reads form and JSON bodies, and the body wins over the query string, like the request's input() method. - The parser chain skips extractors that return null or an empty string, and stops at the first token. A custom extractor configured in jwt.parser is resolved from the container and used. - The provider passes jwt.token to InputSource and cookie_key_name to Cookie. - The auth middleware accepts a valid token and rejects a missing token, an invalid token and a token whose user no longer exists, which routes without the middleware treat as guests. These replace upstream's jwt.auth and jwt.check middleware tests. - An encrypted cookie authenticates after EncryptCookies decrypts it, and a cookie that cannot be decrypted is ignored. The query-string-over-input, route-parameter, Lumen, mutable parser chain and refresh middleware tests have no equivalent because those parsers, setters and middleware are not included. The JWT guide explains the parser order, input precedence, the cookie setting, how EncryptCookies and CSRF protection apply to cookie tokens, custom extractors and routes that allow guests. The README lists the token-source, decrypt_cookies and middleware differences from upstream. The Testbench .env.example lists JWT_COOKIE_KEY_NAME. Validation: the Jwt suite passes, composer lint:fix changed no files and composer analyse reports no errors. --- src/docs/jwt.md | 39 +++++++-- src/jwt/README.md | 7 +- src/jwt/config/jwt.php | 6 +- src/jwt/src/Http/Parser/AuthHeaders.php | 4 +- src/jwt/src/JwtServiceProvider.php | 9 +- src/testbench/hypervel/.env.example | 1 + tests/Jwt/Http/ParserTest.php | 109 ++++++++++++++++++++++-- tests/Jwt/JwtConfigTest.php | 6 +- tests/Jwt/JwtGuardContextTest.php | 95 +++++++++++++++++++++ tests/Jwt/JwtServiceProviderTest.php | 42 ++++++++- 10 files changed, 288 insertions(+), 30 deletions(-) diff --git a/src/docs/jwt.md b/src/docs/jwt.md index e038dd9187..2f7a881e0b 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -309,13 +309,7 @@ Request input parsing is available but is not enabled by default because URL tok /api/user?token=eyJhbGciOi... ``` -The input key defaults to `token`: - -```php -'token' => env('JWT_TOKEN', 'token'), -``` - -You may customize the parser chain: +Cookie parsing is also available but is not enabled by default. You may customize the parser chain: ```php use Hypervel\Jwt\Http\Parser\AuthHeaders; @@ -329,9 +323,34 @@ use Hypervel\Jwt\Http\Parser\InputSource; ], ``` -Cookie parsing is also available but is not enabled by default. If you add the `InputSource` or `Cookie` parser, it reads the same key configured by `jwt.token`. +The parsers run in order, and the first token found is used. The `InputSource` parser reads the input key named by `token` from the query string and request body. When both contain a token, the body wins, just like the request's `input` method. The `Cookie` parser reads the cookie named by `cookie_key_name`: + +```php +'token' => env('JWT_TOKEN', 'token'), + +'cookie_key_name' => env('JWT_COOKIE_KEY_NAME', 'token'), +``` + +Hypervel [encrypts cookies](/docs/{{version}}/responses#cookies-and-encryption) by default, so the `EncryptCookies` middleware must decrypt the token cookie before the guard reads it. The `web` middleware group includes this middleware and runs it before the `auth` middleware, but the `api` group does not. Cookies that cannot be decrypted are ignored. You may instead exclude the token cookie from encryption using the `encryptCookies` method's `except` argument. This only configures the middleware; it does not add the middleware to any routes. The token is still signed, so leaving the cookie unencrypted only exposes its claims. -For a non-standard header or token scheme, implement `Hypervel\Jwt\Contracts\TokenExtractor` and add that class to `jwt.parser`. +Since browsers send cookies automatically, cookie authentication also needs [CSRF protection](/docs/{{version}}/csrf), even though the token is signed and the cookie may be encrypted. The `web` middleware group includes CSRF protection, but the `api` group does not. + +For another header, token scheme, or a route parameter, implement `Hypervel\Jwt\Contracts\TokenExtractor` and add that class to `jwt.parser`: + +```php +use Hypervel\Http\Request; +use Hypervel\Jwt\Contracts\TokenExtractor; + +class AccessTokenHeader implements TokenExtractor +{ + public function parseToken(Request $request): ?string + { + return $request->headers->get('X-Access-Token'); + } +} +``` + +Custom parsers are resolved from the container once and shared by every request, so they must only read the request they are given and never store request or token state. ### Validations and Leeway @@ -470,6 +489,8 @@ Route::middleware('auth:api')->get('/profile', function () { }); ``` +Routes that allow guests don't need any middleware. Calling `Auth::guard('api')->user()` reads the token when it is first needed, and returns `null` when the request has no usable token. + ### Reading the Authenticated User diff --git a/src/jwt/README.md b/src/jwt/README.md index fd694d53ed..27cbffbb14 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -12,9 +12,10 @@ Documentation: https://hypervel.org/docs/jwt - The JWT provider is chosen with the `driver` option and custom drivers are registered with `Jwt::extend()`, following Laravel's driver managers, instead of upstream's `providers.jwt` class setting. - Custom claims may set registered claims such as `exp`, `iss` and `jti`, but not `sub` or `prv`, which always come from the user and its provider. Customize the subject with `JwtSubject::getJwtIdentifier()`. - Hypervel adds a default `iss` only when `jwt.issuer` is set, rather than using the request URL, and a default `jti` only when the blacklist is enabled. -- 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()`. +- Only the `Authorization` header is read by default, while upstream also reads the query string, request input, route parameters and cookies. Add `InputSource` to `jwt.parser` to read the query string and request body, where the body wins when both contain a token, or `Cookie` to read the cookie named by `cookie_key_name`. +- Upstream's separate query-string, route-parameter and Lumen parsers are not included. `InputSource` reads query input, and other sources such as route parameters use a custom `TokenExtractor`. The parser chain is set in `jwt.parser` instead of with upstream's `setChain()` and `addParser()`, because one parser is shared by every request. +- The `decrypt_cookies` option is not included. Upstream decrypts the cookie itself, which can't read cookies written by the `EncryptCookies` middleware, so that middleware decrypts the token cookie instead. +- Upstream's middleware is not included. Use `auth:api` instead of `jwt.auth`, and call `Auth::guard('api')->user()` on routes that allow guests instead of using `jwt.check`. Instead of the sliding refresh middleware (`jwt.refresh` and `jwt.renew`), use an explicit refresh endpoint that calls `Auth::guard(...)->refresh()`. - The Namshi provider is not included. Lcobucci supports the same HMAC, RSA and ECDSA algorithms, and `tymondesigns/jwt-auth` no longer ships Namshi. - Lumen integrations are not included. - Clearing the blacklist requires a cache store that supports tags. On other stores upstream flushes the whole cache, so Hypervel throws the cache's tag error instead and never removes other cache entries. diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index 2830104d16..cd37b76602 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -279,13 +279,15 @@ | Token Parser |-------------------------------------------------------------------------- | - | Configure the request input key and ordered parser chain used to extract - | JWT tokens from incoming requests. + | Configure the request input key, cookie name and ordered parser chain + | used to extract JWT tokens from incoming requests. | */ 'token' => env('JWT_TOKEN', 'token'), + 'cookie_key_name' => env('JWT_COOKIE_KEY_NAME', 'token'), + 'parser' => [ \Hypervel\Jwt\Http\Parser\AuthHeaders::class, ], diff --git a/src/jwt/src/Http/Parser/AuthHeaders.php b/src/jwt/src/Http/Parser/AuthHeaders.php index 52424908bb..35e444811f 100644 --- a/src/jwt/src/Http/Parser/AuthHeaders.php +++ b/src/jwt/src/Http/Parser/AuthHeaders.php @@ -14,9 +14,7 @@ class AuthHeaders implements TokenExtractor */ public function parseToken(Request $request): ?string { - $header = $request->header('Authorization') - ?: $request->server('HTTP_AUTHORIZATION') - ?: $request->server('REDIRECT_HTTP_AUTHORIZATION'); + $header = $request->header('Authorization'); if (! is_string($header)) { return null; diff --git a/src/jwt/src/JwtServiceProvider.php b/src/jwt/src/JwtServiceProvider.php index 9bb716a8b5..4c3318330b 100644 --- a/src/jwt/src/JwtServiceProvider.php +++ b/src/jwt/src/JwtServiceProvider.php @@ -9,6 +9,7 @@ use Hypervel\Jwt\Console\JwtGenerateCertsCommand; use Hypervel\Jwt\Console\JwtSecretCommand; use Hypervel\Jwt\Contracts\BlacklistContract; +use Hypervel\Jwt\Contracts\TokenExtractor; use Hypervel\Jwt\Http\Parser\Cookie; use Hypervel\Jwt\Http\Parser\InputSource; use Hypervel\Jwt\Http\Parser\Parser; @@ -33,13 +34,13 @@ public function register(): void $app->make(ClaimFactory::class), )); - $this->app->singleton(Parser::class, function ($app) { + $this->app->singleton(Parser::class, function (Container $app): Parser { $config = $app->make('config'); - $tokenKey = $config->string('jwt.token'); $chain = array_map( - fn (string $extractor) => match ($extractor) { - InputSource::class, Cookie::class => new $extractor($tokenKey), + fn (string $extractor): TokenExtractor => match ($extractor) { + InputSource::class => new InputSource($config->string('jwt.token')), + Cookie::class => new Cookie($config->string('jwt.cookie_key_name')), default => $app->make($extractor), }, $config->array('jwt.parser'), diff --git a/src/testbench/hypervel/.env.example b/src/testbench/hypervel/.env.example index 31b67fa9ec..0dfadb196a 100644 --- a/src/testbench/hypervel/.env.example +++ b/src/testbench/hypervel/.env.example @@ -38,6 +38,7 @@ SESSION_DOMAIN=null # JWT_REFRESH_IAT=false # JWT_LOCK_SUBJECT=true # JWT_TOKEN=token +# JWT_COOKIE_KEY_NAME=token BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/tests/Jwt/Http/ParserTest.php b/tests/Jwt/Http/ParserTest.php index 506d49ab1a..75f29001be 100644 --- a/tests/Jwt/Http/ParserTest.php +++ b/tests/Jwt/Http/ParserTest.php @@ -5,11 +5,14 @@ namespace Hypervel\Tests\Jwt\Http; use Hypervel\Http\Request; +use Hypervel\Jwt\Contracts\TokenExtractor; use Hypervel\Jwt\Http\Parser\AuthHeaders; use Hypervel\Jwt\Http\Parser\Cookie; use Hypervel\Jwt\Http\Parser\InputSource; use Hypervel\Jwt\Http\Parser\Parser; use Hypervel\Tests\TestCase; +use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; class ParserTest extends TestCase { @@ -57,26 +60,75 @@ public function testParsesRedirectAuthorizationHeader(): void $this->assertSame('redirect-token', $parser->parseToken($request)); } - public function testReturnsNullWhenBearerHeaderIsMissing(): void + #[DataProvider('headerWithoutBearerTokenProvider')] + public function testReturnsNullWhenTheHeaderHasNoBearerToken(string $header): void { $parser = new Parser([new AuthHeaders]); $request = Request::create('/', 'GET', server: [ - 'HTTP_AUTHORIZATION' => 'Basic token', + 'HTTP_AUTHORIZATION' => $header, ]); $this->assertNull($parser->parseToken($request)); } - public function testReturnsNullWhenBearerIsNotTheAuthScheme(): void + /** + * Provide authorization headers that do not carry a bearer token. + * + * @return array + */ + public static function headerWithoutBearerTokenProvider(): array + { + return [ + 'basic scheme' => ['Basic OnBhc3N3b3Jk'], + 'bearer inside another credential' => ['Basic not-a-Bearer token'], + 'no scheme' => ['eyJhbGciOiJIUzI1NiIsInR5'], + 'empty bearer credential' => ['Bearer '], + 'tab after the scheme' => ["Bearer\tfoobar"], + ]; + } + + #[DataProvider('bearerHeaderWhitespaceProvider')] + public function testTrimsWhitespaceAroundTheBearerToken(string $header): void { $parser = new Parser([new AuthHeaders]); $request = Request::create('/', 'GET', server: [ - 'HTTP_AUTHORIZATION' => 'Basic not-a-Bearer token', + 'HTTP_AUTHORIZATION' => $header, ]); - $this->assertNull($parser->parseToken($request)); + $this->assertSame('foobar', $parser->parseToken($request)); + } + + /** + * Provide bearer headers with extra whitespace around the token. + * + * @return array + */ + public static function bearerHeaderWhitespaceProvider(): array + { + return [ + 'space' => ['Bearer foobar '], + 'multiple spaces' => ['Bearer foobar '], + 'trailing tab' => ["Bearer foobar\t"], + 'trailing tabs' => ["Bearer foobar\t\t\t"], + 'trailing new line' => ["Bearer foobar\n"], + 'trailing new lines' => ["Bearer foobar\n\n\n"], + 'trailing carriage return' => ["Bearer foobar\r"], + 'trailing carriage returns' => ["Bearer foobar\r\r\r"], + 'trailing mixture of whitespace' => ["Bearer foobar\t \n \r \t \n"], + ]; + } + + public function testKeepsTrailingHyphensInTheBearerToken(): void + { + $parser = new Parser([new AuthHeaders]); + + $request = Request::create('/', 'GET', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer foobar--', + ]); + + $this->assertSame('foobar--', $parser->parseToken($request)); } public function testParsesQueryInput(): void @@ -88,6 +140,32 @@ public function testParsesQueryInput(): void $this->assertSame('query-token', $parser->parseToken($request)); } + #[DataProvider('bodyInputRequestProvider')] + public function testBodyInputTakesPrecedenceOverQueryInput(Request $request): void + { + $parser = new Parser([new InputSource]); + + $this->assertSame('body-token', $parser->parseToken($request)); + } + + /** + * Provide requests with different tokens in the query and the body. + * + * @return array + */ + public static function bodyInputRequestProvider(): array + { + return [ + 'form body' => [Request::create('/?token=query-token', 'POST', ['token' => 'body-token'])], + 'json body' => [Request::create( + '/?token=query-token', + 'POST', + server: ['CONTENT_TYPE' => 'application/json'], + content: json_encode(['token' => 'body-token']), + )], + ]; + } + public function testParsesCookieWhenCookieExtractorIsConfigured(): void { $parser = new Parser([new Cookie]); @@ -131,4 +209,25 @@ public function testParserDoesNotRetainRequestBetweenCalls(): void $this->assertSame('first-token', $parser->parseToken($firstRequest)); $this->assertNull($parser->parseToken($secondRequest)); } + + public function testUsesTheFirstExtractorInTheChainThatFindsAToken(): void + { + $request = Request::create('/'); + + $missing = m::mock(TokenExtractor::class); + $missing->shouldReceive('parseToken')->with($request)->once()->andReturnNull(); + + $empty = m::mock(TokenExtractor::class); + $empty->shouldReceive('parseToken')->with($request)->once()->andReturn(''); + + $found = m::mock(TokenExtractor::class); + $found->shouldReceive('parseToken')->with($request)->once()->andReturn('custom-token'); + + $unused = m::mock(TokenExtractor::class); + $unused->shouldNotReceive('parseToken'); + + $parser = new Parser([$missing, $empty, $found, $unused]); + + $this->assertSame('custom-token', $parser->parseToken($request)); + } } diff --git a/tests/Jwt/JwtConfigTest.php b/tests/Jwt/JwtConfigTest.php index 30dd5d5f2a..5b540d8d39 100644 --- a/tests/Jwt/JwtConfigTest.php +++ b/tests/Jwt/JwtConfigTest.php @@ -150,6 +150,7 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void 'JWT_REFRESH_IAT' => '1', 'JWT_LOCK_SUBJECT' => '0', 'JWT_TOKEN' => 'api_token', + 'JWT_COOKIE_KEY_NAME' => 'jwt_cookie', ]); try { @@ -163,18 +164,21 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void $this->assertTrue($config['refresh_iat']); $this->assertFalse($config['lock_subject']); $this->assertSame('api_token', $config['token']); + $this->assertSame('jwt_cookie', $config['cookie_key_name']); } finally { $this->restoreEnvironmentVariables($originalValues); Env::flushRepository(); } } - public function testDefaultParserOnlyIncludesAuthorizationHeaders(): void + public function testDefaultTokenSourcesOnlyIncludeAuthorizationHeaders(): void { $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; $this->assertSame([AuthHeaders::class], $config['parser']); $this->assertNotContains(InputSource::class, $config['parser']); + $this->assertSame('token', $config['token']); + $this->assertSame('token', $config['cookie_key_name']); } public function testNotBeforeClaimClassIsUsedInConfiguration(): void diff --git a/tests/Jwt/JwtGuardContextTest.php b/tests/Jwt/JwtGuardContextTest.php index 14e93757be..556fba93a4 100644 --- a/tests/Jwt/JwtGuardContextTest.php +++ b/tests/Jwt/JwtGuardContextTest.php @@ -8,7 +8,12 @@ use Hypervel\Auth\GenericUser; use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Cookie\Middleware\EncryptCookies; +use Hypervel\Http\JsonResponse; use Hypervel\Jwt\Contracts\ManagerContract; +use Hypervel\Jwt\Exceptions\TokenInvalidException; +use Hypervel\Jwt\Http\Parser\AuthHeaders; +use Hypervel\Jwt\Http\Parser\Cookie; use Hypervel\Jwt\JwtServiceProvider; use Hypervel\Support\Facades\Route; use Hypervel\Testbench\TestCase; @@ -39,6 +44,18 @@ protected function setUp(): void ]); }); + Route::middleware('auth:jwt')->get('/jwt-context/protected', static function (): JsonResponse { + return response()->json([ + 'id' => auth('jwt')->id(), + ]); + }); + + Route::middleware(EncryptCookies::class)->get('/jwt-context/cookie-user', static function (): JsonResponse { + return response()->json([ + 'id' => auth('jwt')->user()?->getAuthIdentifier(), + ]); + }); + Route::post('/jwt-context/login', static function () { return response()->json([ 'token' => auth('jwt')->login(new GenericUser([ @@ -72,6 +89,7 @@ protected function defineEnvironment(ApplicationContract $app): void 'driver' => 'jwt-context', ], 'jwt.lock_subject' => false, + 'jwt.parser' => [AuthHeaders::class, Cookie::class], ]); } @@ -91,6 +109,71 @@ public function testBearerResolutionRunsAgainForEachRequest(): void ->assertJson(['id' => 1]); } + public function testAuthMiddlewareAcceptsAValidToken(): void + { + $this->jwtManager->shouldReceive('decode')->with('valid-token')->once()->andReturn(['sub' => 1]); + $this->provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($this->user(1)); + + $this->withHeader('Authorization', 'Bearer valid-token') + ->getJson('/jwt-context/protected') + ->assertOk() + ->assertJson(['id' => 1]); + } + + public function testRequestWithoutATokenIsRejectedByAuthMiddlewareAndAGuestElsewhere(): void + { + $this->jwtManager->shouldNotReceive('decode'); + $this->provider->shouldNotReceive('retrieveById'); + + $this->assertRejectedByAuthMiddlewareAndAGuestElsewhere(); + } + + public function testInvalidTokenIsRejectedByAuthMiddlewareAndAGuestElsewhere(): void + { + $this->jwtManager->shouldReceive('decode') + ->with('invalid-token') + ->twice() + ->andThrow(new TokenInvalidException('Token Signature could not be verified.')); + $this->provider->shouldNotReceive('retrieveById'); + + $this->withHeader('Authorization', 'Bearer invalid-token'); + + $this->assertRejectedByAuthMiddlewareAndAGuestElsewhere(); + } + + public function testTokenForAMissingUserIsRejectedByAuthMiddlewareAndAGuestElsewhere(): void + { + $this->jwtManager->shouldReceive('decode')->with('orphan-token')->twice()->andReturn(['sub' => 404]); + $this->provider->shouldReceive('retrieveById')->with(404)->twice()->andReturnNull(); + + $this->withHeader('Authorization', 'Bearer orphan-token'); + + $this->assertRejectedByAuthMiddlewareAndAGuestElsewhere(); + } + + public function testCookieTokenIsReadAfterEncryptCookiesDecryptsIt(): void + { + $this->jwtManager->shouldReceive('decode')->with('cookie-token')->once()->andReturn(['sub' => 1]); + $this->provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($this->user(1)); + + $this->withCredentials() + ->withCookie('token', 'cookie-token') + ->getJson('/jwt-context/cookie-user') + ->assertOk() + ->assertJson(['id' => 1]); + } + + public function testCookieThatCannotBeDecryptedLeavesTheRequestAGuest(): void + { + $this->jwtManager->shouldNotReceive('decode'); + + $this->withCredentials() + ->withUnencryptedCookie('token', 'not-encrypted') + ->getJson('/jwt-context/cookie-user') + ->assertOk() + ->assertExactJson(['id' => null]); + } + public function testExplicitUserSurvivesAcrossRequestsWithoutResolvingBearerTokens(): void { $this->jwtManager->shouldNotReceive('decode'); @@ -130,6 +213,18 @@ public function testRequestDrivenLoginDoesNotAuthenticateParentWithoutBearerToke $this->assertGuest('jwt'); } + /** + * Assert that the protected route rejects the request and the open route treats it as a guest. + */ + private function assertRejectedByAuthMiddlewareAndAGuestElsewhere(): void + { + $this->getJson('/jwt-context/protected')->assertUnauthorized(); + + $this->getJson('/jwt-context/user') + ->assertOk() + ->assertExactJson(['id' => null]); + } + /** * Create a generic authenticatable user. */ diff --git a/tests/Jwt/JwtServiceProviderTest.php b/tests/Jwt/JwtServiceProviderTest.php index a405590ab8..baa4985f2c 100644 --- a/tests/Jwt/JwtServiceProviderTest.php +++ b/tests/Jwt/JwtServiceProviderTest.php @@ -14,7 +14,10 @@ use Hypervel\Jwt\Contracts\BlacklistContract; use Hypervel\Jwt\Contracts\ManagerContract; use Hypervel\Jwt\Contracts\StorageContract; +use Hypervel\Jwt\Contracts\TokenExtractor; +use Hypervel\Jwt\Http\Parser\AuthHeaders; use Hypervel\Jwt\Http\Parser\Cookie; +use Hypervel\Jwt\Http\Parser\InputSource; use Hypervel\Jwt\Http\Parser\Parser; use Hypervel\Jwt\JwtGuard; use Hypervel\Jwt\JwtServiceProvider; @@ -37,19 +40,41 @@ protected function getPackageProviders(ApplicationContract $app): array ]; } - public function testParserUsesConfiguredTokenKeyAndExtractorChain(): void + public function testParserUsesSeparateInputAndCookieNames(): void { - $this->app->make('config')->set('jwt.token', 'api_token'); - $this->app->make('config')->set('jwt.parser', [Cookie::class]); + config([ + 'jwt.token' => 'api_token', + 'jwt.cookie_key_name' => 'jwt_cookie', + 'jwt.parser' => [InputSource::class, Cookie::class], + ]); /** @var Parser $parser */ $parser = $this->app->make(Parser::class); + $this->assertSame('input-token', $parser->parseToken(Request::create('/?api_token=input-token'))); $this->assertSame('cookie-token', $parser->parseToken(Request::create('/', 'GET', cookies: [ + 'jwt_cookie' => 'cookie-token', + ]))); + $this->assertNull($parser->parseToken(Request::create('/?jwt_cookie=input-token', 'GET', cookies: [ 'api_token' => 'cookie-token', ]))); } + public function testParserResolvesCustomExtractorsFromTheContainer(): void + { + config([ + 'jwt.parser' => [AuthHeaders::class, JwtServiceProviderAccessTokenHeader::class], + ]); + + /** @var Parser $parser */ + $parser = $this->app->make(Parser::class); + + $this->assertSame('custom-token', $parser->parseToken(Request::create('/', 'GET', server: [ + 'HTTP_X_ACCESS_TOKEN' => 'custom-token', + ]))); + $this->assertNull($parser->parseToken(Request::create('/'))); + } + public function testJwtMiddlewareAliasesAreNotRegistered(): void { $middleware = $this->app->make('router')->getMiddleware(); @@ -294,3 +319,14 @@ public function flush(): bool return true; } } + +class JwtServiceProviderAccessTokenHeader implements TokenExtractor +{ + /** + * Read the token from the custom header. + */ + public function parseToken(Request $request): ?string + { + return $request->headers->get('X-Access-Token'); + } +} From e0b2c02a27e98c702ed68789d6ba4ea62e26fa8e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:31:01 +0000 Subject: [PATCH 07/10] Enable the JWT blacklist by default and fix env writing for JWT commands The blacklist is now enabled by default, as in both upstream packages, so logout and refresh revoke the old token. New tokens include a jti claim and authenticated requests check the blacklist in the cache. Applications that don't need to revoke tokens before they expire can set JWT_BLACKLIST_ENABLED to false. Env::writeVariable() and Env::writeVariables() found existing assignments by matching raw lines, so they missed assignments the dotenv loader accepts, such as "KEY = value", "export KEY=value" and quoted names. Without overwrite they appended a second assignment, which the loader then used. With overwrite, an exported or later duplicate kept the old value. Text inside a multiline value could also be taken for an assignment. The writer now reads entries with phpdotenv's parser, replaces the last assignment of the key across all of its lines and keeps an export prefix. An empty value is filled without overwrite, and a value that references other variables is never treated as equal to the new value. jwt:generate-certs, Sentry's publish command and install:broadcasting use the same writer. jwt:secret now detects an existing secret in every form the loader accepts, including an unresolved reference such as "${OTHER}", and replaces an empty secret without asking. --always-no takes precedence over --force. After writing a new secret, the command warns that the server and other long-running processes must be restarted. jwt:generate-certs --algo=ec failed with its default options, because the curve defaulted to prime256v1 while --sha defaulted to 512. The curve now defaults to the one the SHA variant requires, and an explicit curve that does not match still fails. The provider's binding closures are typed, and the config's TTL note is reworded. Tests reconcile LaravelServiceProviderTest, DefaultConfigValuesTest and EnvHelperTraitTest from PHP-Open-Source-Saver/jwt-auth main (1bdb72de5); tymondesigns/jwt-auth 2.x (6c70930a9) has no matching tests: - Every config default is asserted with the JWT environment variables cleared. - The provider passes the grace period, refresh TTL and leeway to the blacklist, and the secret, algorithm and keys to the Lcobucci driver. - Both Env writers update the assignment the loader uses for spaced, exported, quoted-name, duplicate, empty, referenced and multiline entries. - jwt:secret covers plain, spaced, exported and referenced secrets with --always-no and --force, confirmation, empty secrets and the restart warning. - jwt:generate-certs covers the curve derived from each SHA variant and the default EC options. The Laravel 13 support changes in both upstreams (PHP-Open-Source-Saver/jwt-auth#297, tymondesigns/jwt-auth#2299) only update their own Composer constraints and CI, so nothing applies here. The JWT guide explains the blacklist default and how to disable it, that invalidate() needs the blacklist, the --always-no precedence, restarting after a new secret, the EC curve defaults, and choosing a shared --dir because certificate paths in .env are absolute. Validation: the Jwt suite, the Env writer tests and the Sentry publish and broadcasting install command tests pass, composer lint:fix changed no files and composer analyse reports no errors. --- src/docs/jwt.md | 14 +-- src/jwt/composer.json | 3 +- src/jwt/config/jwt.php | 4 +- .../src/Console/JwtGenerateCertsCommand.php | 6 +- src/jwt/src/Console/JwtSecretCommand.php | 28 +++--- src/jwt/src/JwtServiceProvider.php | 4 +- src/support/src/Env.php | 82 ++++++++++++----- .../Console/JwtGenerateCertsCommandTest.php | 17 +++- tests/Jwt/Console/JwtSecretCommandTest.php | 49 +++++++++-- tests/Jwt/JwtConfigTest.php | 73 ++++++++++++---- tests/Jwt/JwtGuardContextTest.php | 2 +- tests/Jwt/JwtServiceProviderTest.php | 22 ++++- tests/Support/EnvFileTest.php | 87 +++++++++++++++++++ 13 files changed, 314 insertions(+), 77 deletions(-) diff --git a/src/docs/jwt.md b/src/docs/jwt.md index 2f7a881e0b..c030ae03d5 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -72,7 +72,7 @@ You may display a generated secret without writing to `.env`: php artisan jwt:secret --show ``` -If a secret already exists, the command asks before replacing it. You may skip the prompt with `--force`, or skip generation when a secret exists using `--always-no`. +If a secret already exists, the command asks before replacing it. You may skip the prompt with `--force`, or keep an existing secret using `--always-no`, which takes precedence over `--force`. Like new certificates, a new secret requires [restarting the server and other long-running processes](#generating-certificates). ### Generating Certificates @@ -93,12 +93,12 @@ You may customize the algorithm and key options: ```shell php artisan jwt:generate-certs --force --algo=rsa --bits=4096 --sha=512 -php artisan jwt:generate-certs --force --algo=ec --curve=prime256v1 --sha=256 +php artisan jwt:generate-certs --force --algo=ec --sha=256 ``` -RSA keys must be at least 2048 bits. +RSA keys must be at least 2048 bits. EC keys use the curve their SHA variant requires: `prime256v1` for 256, `secp384r1` for 384, and `secp521r1` for 512. -You may change the output directory using `--dir`. The directory may be absolute or relative to your application's base path. +You may change the output directory using `--dir`. The directory may be absolute or relative to your application's base path. The command writes absolute `file://` paths to `.env`, so if each deployment uses a new release directory, choose a `--dir` that is shared between releases. You may protect the private key with a passphrase using `--passphrase`, or prompt for it interactively using `--ask-passphrase`: @@ -394,10 +394,10 @@ If your application uses timestamp validations and your servers have small clock The JWT blacklist lets the package invalidate tokens before they naturally expire: ```php -'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', false), +'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', true), ``` -Blacklisting is disabled by default. When enabled, newly issued tokens include a `jti` claim and authenticated blacklist checks require cache access. Enable it when your application needs server-side token invalidation. +Blacklisting is enabled by default, so logging out or refreshing revokes the old token. Newly issued tokens include a `jti` claim, and each authenticated request checks the blacklist in the cache. If your application doesn't need to revoke tokens before they expire, you may set `JWT_BLACKLIST_ENABLED` to `false`; tokens then remain valid until they expire, even after logout. Blacklist entries are kept in your default cache store. You may choose another store using the `blacklist_store` option: @@ -596,7 +596,7 @@ Auth::guard('api')->logout(); Without the blacklist, tokens cannot be revoked, so a logged out token remains valid until it expires. If the blacklist write fails, a `JwtException` is thrown, the guard keeps its current state, and the `Logout` event is not dispatched. -To invalidate a token directly, enable the blacklist and call `invalidate`: +While the blacklist is enabled, you may invalidate a token directly using the `invalidate` method: ```php Auth::guard('api')->invalidate(); diff --git a/src/jwt/composer.json b/src/jwt/composer.json index 992590d3cb..47998191d6 100644 --- a/src/jwt/composer.json +++ b/src/jwt/composer.json @@ -38,7 +38,8 @@ "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", "hypervel/support": "^0.4", - "symfony/console": "^8.1.2" + "symfony/console": "^8.1.2", + "vlucas/phpdotenv": "^5.6.1" }, "autoload": { "psr-4": { diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index cd37b76602..45c8fd4d53 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -102,7 +102,7 @@ | Some people may want this behavior for e.g. a mobile app. | This is not particularly recommended, so make sure you have appropriate | systems in place to revoke the token if necessary. - | Notice: If you set this to null you should remove 'exp' element from 'required_claims' list. + | Notice: If you set this to null, 'exp' must not be listed in 'required_claims'. | */ @@ -235,7 +235,7 @@ | */ - 'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', false), + 'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', true), /* |-------------------------------------------------------------------------- diff --git a/src/jwt/src/Console/JwtGenerateCertsCommand.php b/src/jwt/src/Console/JwtGenerateCertsCommand.php index b257d5e461..7f570301c2 100644 --- a/src/jwt/src/Console/JwtGenerateCertsCommand.php +++ b/src/jwt/src/Console/JwtGenerateCertsCommand.php @@ -31,7 +31,7 @@ class JwtGenerateCertsCommand extends Command {--bits=4096 : RSA key length} {--sha=512 : SHA variant} {--dir=storage/certs : Directory where certificates should be written} - {--curve=prime256v1 : EC curve name} + {--curve= : EC curve name, defaults to the curve the SHA variant requires} {--passphrase= : Passphrase} {--ask-passphrase : Prompt for the passphrase}'; @@ -49,7 +49,7 @@ public function handle(Filesystem $files): int $algorithm = strtolower((string) $this->option('algo')); $bits = (int) $this->option('bits'); $sha = (int) $this->option('sha'); - $curve = (string) $this->option('curve'); + $curve = $this->option('curve'); $passphrase = $this->resolvePassphrase(); $environmentFile = $this->hypervel->environmentFilePath(); @@ -72,6 +72,8 @@ public function handle(Filesystem $files): int } if ($keyType === OPENSSL_KEYTYPE_EC) { + $curve = (string) ($curve ?? self::EC_CURVES[$sha]); + $this->validateEcCurve($sha, $curve); } diff --git a/src/jwt/src/Console/JwtSecretCommand.php b/src/jwt/src/Console/JwtSecretCommand.php index 4e3357f66b..28fb204916 100644 --- a/src/jwt/src/Console/JwtSecretCommand.php +++ b/src/jwt/src/Console/JwtSecretCommand.php @@ -4,10 +4,11 @@ namespace Hypervel\Jwt\Console; +use Dotenv\Parser\Parser; use Hypervel\Console\Command; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Env; use Hypervel\Support\Str; -use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'jwt:secret')] @@ -29,7 +30,7 @@ class JwtSecretCommand extends Command /** * Execute the console command. */ - public function handle(): int + public function handle(Filesystem $files): int { $key = Str::random(64); @@ -47,14 +48,14 @@ public function handle(): int return self::FAILURE; } - if ($this->envHasKey($environmentFile, 'JWT_SECRET') && ! $this->option('force')) { + if ($this->hasSecret($files->get($environmentFile))) { if ($this->option('always-no')) { $this->comment('JWT secret already exists. Skipping...'); return self::SUCCESS; } - if (! $this->confirm('This will invalidate all existing tokens. Are you sure you want to override the JWT secret?')) { + if (! $this->option('force') && ! $this->confirm('This will invalidate all existing tokens. Are you sure you want to override the JWT secret?')) { $this->comment('No changes were made to your JWT secret.'); return self::SUCCESS; @@ -66,21 +67,28 @@ public function handle(): int ], $environmentFile, overwrite: true); $this->components->info('JWT secret set successfully.'); + $this->components->warn( + 'Restart the server and every other long-running application process, including queue workers and custom server processes, ' + . 'before issuing tokens with the new secret. The [php artisan server:reload] command only replaces server workers and is not sufficient.' + ); return self::SUCCESS; } /** - * Determine if an environment variable exists. + * Determine if the environment file assigns a JWT secret. */ - protected function envHasKey(string $environmentFile, string $key): bool + protected function hasSecret(string $contents): bool { - $contents = file_get_contents($environmentFile); + $secret = null; - if ($contents === false) { - throw new RuntimeException('Unable to read environment file.'); + // Values are not resolved, so a reference such as "${OTHER}" still counts as a secret. + foreach ((new Parser)->parse($contents) as $entry) { + if ($entry->getName() === 'JWT_SECRET') { + $secret = $entry->getValue()->getOrElse(null); + } } - return preg_match('/^' . preg_quote($key, '/') . '=/m', $contents) === 1; + return $secret !== null && $secret->getChars() !== ''; } } diff --git a/src/jwt/src/JwtServiceProvider.php b/src/jwt/src/JwtServiceProvider.php index 4c3318330b..8df1b78b8e 100644 --- a/src/jwt/src/JwtServiceProvider.php +++ b/src/jwt/src/JwtServiceProvider.php @@ -29,7 +29,7 @@ public function register(): void // Hypervel intentionally keeps JWT as an array-based manager/guard package. // Upstream object/facade bindings hold mutable request state that does not // fit worker-lifetime singleton guards. - $this->app->singleton('jwt', fn ($app) => new JwtManager( + $this->app->singleton('jwt', fn (Container $app): JwtManager => new JwtManager( $app, $app->make(ClaimFactory::class), )); @@ -51,7 +51,7 @@ public function register(): void return new Parser($chain); }); - $this->app->singleton(BlacklistContract::class, function ($app) { + $this->app->singleton(BlacklistContract::class, function (Container $app): Blacklist { $config = $app->make('config'); $storageClass = $config->string('jwt.providers.storage', CacheStorage::class); diff --git a/src/support/src/Env.php b/src/support/src/Env.php index 6bcbab5a89..caa00063dd 100644 --- a/src/support/src/Env.php +++ b/src/support/src/Env.php @@ -5,6 +5,9 @@ namespace Hypervel\Support; use Closure; +use Dotenv\Parser\Entry; +use Dotenv\Parser\EntryParser; +use Dotenv\Parser\Lines; use Dotenv\Repository\Adapter\AdapterInterface; use Dotenv\Repository\Adapter\PutenvAdapter; use Dotenv\Repository\RepositoryBuilder; @@ -286,47 +289,47 @@ protected static function addVariableToEnvContents(string $key, mixed $value, ar { $prefix = explode('_', $key)[0] . '_'; $lastPrefixIndex = -1; + $assignment = null; $stringValue = (string) $value; $shouldQuote = preg_match('/^[a-zA-Z0-9]+$/', $stringValue) === 0; - $lineToAddVariations = [ - $key . '=' . (is_string($value) ? self::prepareQuotedValue($value) : $value), - $key . '=' . $value, - ]; - - $lineToAdd = $shouldQuote ? $lineToAddVariations[0] : $lineToAddVariations[1]; + $lineToAdd = $key . '=' . ($shouldQuote && is_string($value) ? self::prepareQuotedValue($value) : $value); if ($value === '') { $lineToAdd = $key . '='; } - foreach ($envLines as $index => $line) { - if (str_starts_with($line, $prefix)) { - $lastPrefixIndex = $index; + foreach (self::parseEnvEntries($envLines) as $entry) { + $name = $entry['entry']->getName(); + + if (str_starts_with($name, $prefix)) { + $lastPrefixIndex = $entry['end']; } - if (in_array($line, $lineToAddVariations)) { - // This exact line already exists, so we don't need to add it again. - return $envLines; + // The loader uses the last assignment, so later duplicates replace earlier matches. + if ($name === $key) { + $assignment = $entry; } + } - if ($line === $key . '=') { - // If the value is empty, we can replace it with the new value. - $envLines[$index] = $lineToAdd; + if ($assignment !== null) { + $currentValue = $assignment['entry']->getValue()->getOrElse(null); + // A value that references other variables is never equal to a literal value. + if ($currentValue !== null && $currentValue->getVars() === [] && $currentValue->getChars() === $stringValue) { return $envLines; } - if (str_starts_with($line, $key . '=')) { - if (! $overwrite) { - return $envLines; - } - - $envLines[$index] = $lineToAdd; - + if ($currentValue !== null && $currentValue->getChars() !== '' && ! $overwrite) { return $envLines; } + + $export = preg_match('/^\s*export\s/', $envLines[$assignment['start']]) === 1 ? 'export ' : ''; + + array_splice($envLines, $assignment['start'], $assignment['end'] - $assignment['start'] + 1, [$export . $lineToAdd]); + + return $envLines; } if ($lastPrefixIndex === -1) { @@ -344,6 +347,41 @@ protected static function addVariableToEnvContents(string $key, mixed $value, ar ); } + /** + * Parse the environment file lines into entries with the line span of each entry. + * + * Multiline values are grouped by the dotenv parser, so text inside a value is + * never mistaken for an assignment. Entries the parser rejects are skipped. + * + * @param array $envLines + * @return list + */ + protected static function parseEnvEntries(array $envLines): array + { + $entries = []; + $index = 0; + + foreach (Lines::process($envLines) as $rawEntry) { + $rawLines = explode("\n", $rawEntry); + + // Only comments and blank lines are skipped, and an entry never starts with one. + while ($envLines[$index] !== $rawLines[0]) { + ++$index; + } + + $end = $index + count($rawLines) - 1; + $entry = EntryParser::parse($rawEntry)->success(); + + if ($entry->isDefined()) { + $entries[] = ['start' => $index, 'end' => $end, 'entry' => $entry->get()]; + } + + $index = $end + 1; + } + + return $entries; + } + /** * Get the possible option for this environment variable. */ diff --git a/tests/Jwt/Console/JwtGenerateCertsCommandTest.php b/tests/Jwt/Console/JwtGenerateCertsCommandTest.php index 3a4eb6b947..c39623702c 100644 --- a/tests/Jwt/Console/JwtGenerateCertsCommandTest.php +++ b/tests/Jwt/Console/JwtGenerateCertsCommandTest.php @@ -138,7 +138,7 @@ public function testGeneratesEcCertificates(): void $this->assertStringContainsString('JWT_PUBLIC_KEY="file://' . $publicKeyPath . '"', $contents); } - public function testGeneratesAllEcCertificateVariantsWithMatchingCurves(): void + public function testEcCurveDefaultsToTheCurveTheShaVariantRequires(): void { foreach ([256 => 'prime256v1', 384 => 'secp384r1', 512 => 'secp521r1'] as $sha => $curve) { $directory = $this->temporaryDirectory("ec-{$sha}"); @@ -148,7 +148,6 @@ public function testGeneratesAllEcCertificateVariantsWithMatchingCurves(): void '--algo' => 'ec', '--sha' => $sha, '--dir' => $directory, - '--curve' => $curve, ])->assertSuccessful(); $this->assertFileExists($directory . "/jwt-ec-{$curve}-private.pem"); @@ -157,6 +156,20 @@ public function testGeneratesAllEcCertificateVariantsWithMatchingCurves(): void } } + public function testEcDefaultsGenerateEs512Certificates(): void + { + $directory = $this->temporaryDirectory('ec-defaults'); + + $this->artisan('jwt:generate-certs', [ + '--algo' => 'ec', + '--dir' => $directory, + ])->assertSuccessful(); + + $this->assertFileExists($directory . '/jwt-ec-secp521r1-private.pem'); + $this->assertFileExists($directory . '/jwt-ec-secp521r1-public.pem'); + $this->assertStringContainsString('JWT_ALGO=ES512', file_get_contents($this->app->environmentFilePath())); + } + public function testRejectsMismatchedEcCurve(): void { $this->expectException(RuntimeException::class); diff --git a/tests/Jwt/Console/JwtSecretCommandTest.php b/tests/Jwt/Console/JwtSecretCommandTest.php index 4928b9f370..e107706a0c 100644 --- a/tests/Jwt/Console/JwtSecretCommandTest.php +++ b/tests/Jwt/Console/JwtSecretCommandTest.php @@ -54,6 +54,7 @@ public function testShowPrintsSecretWithoutWritingEnvironmentFile(): void $originalContents = file_get_contents($environmentFile); $this->artisan('jwt:secret', ['--show' => true]) + ->doesntExpectOutputToContain('Restart the server') ->assertSuccessful(); $this->assertSame($originalContents, file_get_contents($environmentFile)); @@ -64,6 +65,10 @@ public function testForceWritesSecretWithoutAddingAlgorithm(): void $environmentFile = $this->app->environmentFilePath(); $this->artisan('jwt:secret', ['--force' => true]) + ->expectsOutputToContain( + 'Restart the server and every other long-running application process, including queue workers and custom server processes, ' + . 'before issuing tokens with the new secret. The [php artisan server:reload] command only replaces server workers and is not sufficient.' + ) ->assertSuccessful(); $contents = file_get_contents($environmentFile); @@ -97,17 +102,35 @@ public static function algorithmProvider(): array ]; } - public function testAlwaysNoSkipsExistingSecret(): void + #[DataProvider('existingSecretProvider')] + public function testAlwaysNoSkipsExistingSecret(string $assignment, bool $force): void { $environmentFile = $this->app->environmentFilePath(); - file_put_contents($environmentFile, "JWT_SECRET=existing-secret\n"); + file_put_contents($environmentFile, "{$assignment}\n"); - $this->artisan('jwt:secret', ['--always-no' => true]) + $this->artisan('jwt:secret', ['--always-no' => true, '--force' => $force]) ->expectsOutputToContain('JWT secret already exists. Skipping...') + ->doesntExpectOutputToContain('Restart the server') ->assertSuccessful(); - $this->assertSame("JWT_SECRET=existing-secret\n", file_get_contents($environmentFile)); + $this->assertSame("{$assignment}\n", file_get_contents($environmentFile)); + } + + /** + * Provide existing secret assignments and whether to pass --force. + * + * @return array + */ + public static function existingSecretProvider(): array + { + return [ + 'plain' => ['JWT_SECRET=existing-secret', false], + 'plain with force' => ['JWT_SECRET=existing-secret', true], + 'spaced with force' => ['JWT_SECRET = existing-secret', true], + 'exported with force' => ['export JWT_SECRET=existing-secret', true], + 'reference with force' => ['JWT_SECRET="${DEPLOYED_JWT_SECRET}"', true], + ]; } public function testConfirmationNoSkipsExistingSecret(): void @@ -128,7 +151,7 @@ public function testConfirmationYesOverwritesExistingSecret(): void { $environmentFile = $this->app->environmentFilePath(); - file_put_contents($environmentFile, "JWT_SECRET=existing-secret\nJWT_ALGO=RS256\n"); + file_put_contents($environmentFile, "JWT_ALGO=RS256\nexport JWT_SECRET = existing-secret\n"); $this->artisan('jwt:secret') ->expectsConfirmation('This will invalidate all existing tokens. Are you sure you want to override the JWT secret?', 'yes') @@ -136,9 +159,19 @@ public function testConfirmationYesOverwritesExistingSecret(): void $contents = file_get_contents($environmentFile); - $this->assertStringNotContainsString('JWT_SECRET=existing-secret', $contents); - $this->assertMatchesRegularExpression('/^JWT_SECRET=.{64}$/m', $contents); - $this->assertStringContainsString('JWT_ALGO=RS256', $contents); + $this->assertMatchesRegularExpression('/\AJWT_ALGO=RS256\nexport JWT_SECRET=.{64}\n\z/', $contents); + } + + public function testEmptySecretIsReplacedWithoutConfirmation(): void + { + $environmentFile = $this->app->environmentFilePath(); + + file_put_contents($environmentFile, "JWT_SECRET=\n"); + + $this->artisan('jwt:secret') + ->assertSuccessful(); + + $this->assertMatchesRegularExpression('/\AJWT_SECRET=.{64}\n\z/', file_get_contents($environmentFile)); } public function testFailsWhenEnvironmentFileIsMissing(): void diff --git a/tests/Jwt/JwtConfigTest.php b/tests/Jwt/JwtConfigTest.php index 5b540d8d39..c93842de47 100644 --- a/tests/Jwt/JwtConfigTest.php +++ b/tests/Jwt/JwtConfigTest.php @@ -5,8 +5,8 @@ namespace Hypervel\Tests\Jwt; use Hypervel\Jwt\Http\Parser\AuthHeaders; -use Hypervel\Jwt\Http\Parser\InputSource; use Hypervel\Jwt\JwtGuard; +use Hypervel\Jwt\Storage\CacheStorage; use Hypervel\Jwt\Validations\ExpiredClaim; use Hypervel\Jwt\Validations\IssuedAtClaim; use Hypervel\Jwt\Validations\IssuerClaim; @@ -16,11 +16,56 @@ class JwtConfigTest extends TestCase { - public function testTtlMatchesTheGuardDefault(): void + public function testDefaultConfigurationValues(): void { - $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; + $originalValues = $this->setEnvironmentVariables(array_fill_keys([ + 'JWT_DRIVER', + 'JWT_SECRET', + 'JWT_PUBLIC_KEY', + 'JWT_PRIVATE_KEY', + 'JWT_PASSPHRASE', + 'JWT_TTL', + 'JWT_REFRESH_TTL', + 'JWT_ISSUER', + 'JWT_ALGO', + 'JWT_LEEWAY', + 'JWT_BLACKLIST_ENABLED', + 'JWT_BLACKLIST_STORE', + 'JWT_REFRESH_IAT', + 'JWT_LOCK_SUBJECT', + 'JWT_TOKEN', + 'JWT_COOKIE_KEY_NAME', + 'JWT_BLACKLIST_GRACE_PERIOD', + ], null)); - $this->assertSame(JwtGuard::DEFAULT_TTL, $config['ttl']); + try { + Env::flushRepository(); + + $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; + + $this->assertSame('lcobucci', $config['driver']); + $this->assertNull($config['secret']); + $this->assertSame(['public' => null, 'private' => null, 'passphrase' => null], $config['keys']); + $this->assertSame(JwtGuard::DEFAULT_TTL, $config['ttl']); + $this->assertSame(20160, $config['refresh_ttl']); + $this->assertNull($config['issuer']); + $this->assertSame('HS256', $config['algo']); + $this->assertSame(['iat', 'sub'], $config['required_claims']); + $this->assertSame([], $config['persistent_claims']); + $this->assertSame(0, $config['leeway']); + $this->assertTrue($config['blacklist_enabled']); + $this->assertNull($config['blacklist_store']); + $this->assertFalse($config['refresh_iat']); + $this->assertTrue($config['lock_subject']); + $this->assertSame('token', $config['token']); + $this->assertSame('token', $config['cookie_key_name']); + $this->assertSame([AuthHeaders::class], $config['parser']); + $this->assertSame(0, $config['blacklist_grace_period']); + $this->assertSame(['storage' => CacheStorage::class], $config['providers']); + } finally { + $this->restoreEnvironmentVariables($originalValues); + Env::flushRepository(); + } } public function testBlacklistGracePeriodIsLoadedAsIntegerFromEnvironment(): void @@ -145,7 +190,7 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void { $originalValues = $this->setEnvironmentVariables([ 'JWT_ISSUER' => 'https://api.example.test', - 'JWT_BLACKLIST_ENABLED' => '1', + 'JWT_BLACKLIST_ENABLED' => '0', 'JWT_BLACKLIST_STORE' => 'redis', 'JWT_REFRESH_IAT' => '1', 'JWT_LOCK_SUBJECT' => '0', @@ -159,7 +204,7 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; $this->assertSame('https://api.example.test', $config['issuer']); - $this->assertTrue($config['blacklist_enabled']); + $this->assertFalse($config['blacklist_enabled']); $this->assertSame('redis', $config['blacklist_store']); $this->assertTrue($config['refresh_iat']); $this->assertFalse($config['lock_subject']); @@ -171,16 +216,6 @@ public function testNewJwtOptionsAreLoadedFromEnvironment(): void } } - public function testDefaultTokenSourcesOnlyIncludeAuthorizationHeaders(): void - { - $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; - - $this->assertSame([AuthHeaders::class], $config['parser']); - $this->assertNotContains(InputSource::class, $config['parser']); - $this->assertSame('token', $config['token']); - $this->assertSame('token', $config['cookie_key_name']); - } - public function testNotBeforeClaimClassIsUsedInConfiguration(): void { $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; @@ -202,9 +237,9 @@ public function testDefaultConfigurationValidatesStandardTemporalClaimsAndIssuer } /** - * Set the given environment variables. + * Set the given environment variables, unsetting those given a null value. * - * @param array $values + * @param array $values * @return array */ private function setEnvironmentVariables(array $values): array @@ -221,7 +256,7 @@ private function setEnvironmentVariables(array $values): array ]; unset($_SERVER[$key], $_ENV[$key]); - putenv("{$key}={$value}"); + putenv($value === null ? $key : "{$key}={$value}"); } return $originalValues; diff --git a/tests/Jwt/JwtGuardContextTest.php b/tests/Jwt/JwtGuardContextTest.php index 556fba93a4..acf71d492a 100644 --- a/tests/Jwt/JwtGuardContextTest.php +++ b/tests/Jwt/JwtGuardContextTest.php @@ -38,7 +38,7 @@ protected function setUp(): void fn (): UserProvider => $this->provider, ); - Route::get('/jwt-context/user', static function () { + Route::get('/jwt-context/user', static function (): JsonResponse { return response()->json([ 'id' => auth('jwt')->user()?->getAuthIdentifier(), ]); diff --git a/tests/Jwt/JwtServiceProviderTest.php b/tests/Jwt/JwtServiceProviderTest.php index baa4985f2c..49afc8f987 100644 --- a/tests/Jwt/JwtServiceProviderTest.php +++ b/tests/Jwt/JwtServiceProviderTest.php @@ -218,12 +218,13 @@ public function testCustomBlacklistStorageSkipsCacheStoreResolution(): void $this->assertInstanceOf(Blacklist::class, $blacklist); } - public function testBlacklistReceivesFiniteRefreshTtlAndLeeway(): void + public function testBlacklistReceivesGracePeriodFiniteRefreshTtlAndLeeway(): void { CarbonImmutable::setTestNow('2000-01-01T00:00:00.000000Z'); $config = $this->app->make('config'); $config->set('jwt.providers.storage', JwtServiceProviderCustomStorage::class); + $config->set('jwt.blacklist_grace_period', 30); $config->set('jwt.refresh_ttl', 5); $config->set('jwt.leeway', 120); @@ -234,6 +235,7 @@ public function testBlacklistReceivesFiniteRefreshTtlAndLeeway(): void $storage = $this->app->make(JwtServiceProviderCustomStorage::class); $now = Date::now()->timestamp; + $this->assertSame(30, $blacklist->getGracePeriod()); $this->assertSame(5, $blacklist->getRefreshTTL()); $this->assertTrue($blacklist->add([ 'exp' => $now + 600, @@ -264,6 +266,24 @@ public function testBlacklistReceivesNullRefreshTtl(): void $this->assertTrue($storage->foreverCalled); } + public function testLcobucciDriverReceivesTheConfiguredSecretAlgorithmAndKeys(): void + { + $keys = ['public' => 'public-key', 'private' => 'private-key', 'passphrase' => 'passphrase']; + + config([ + 'jwt.secret' => 'some-secret', + 'jwt.algo' => 'ES512', + 'jwt.keys' => $keys, + ]); + + $driver = $this->app->make('jwt')->driver(); + + $this->assertInstanceOf(Lcobucci::class, $driver); + $this->assertSame('some-secret', $driver->getSecret()); + $this->assertSame('ES512', $driver->getAlgo()); + $this->assertSame($keys, $driver->getKeys()); + } + public function testProviderImplementationsUsePackageDefaultsWhenOmitted(): void { $config = $this->app->make('config'); diff --git a/tests/Support/EnvFileTest.php b/tests/Support/EnvFileTest.php index 44779a22e1..20a42871c4 100644 --- a/tests/Support/EnvFileTest.php +++ b/tests/Support/EnvFileTest.php @@ -8,6 +8,7 @@ use Hypervel\Support\Env; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class EnvFileTest extends TestCase { @@ -49,4 +50,90 @@ public function testWriteVariablesQuotesPunctuationOutsideTheAlphanumericRange() $this->assertSame('APP_NAME="name_with_underscore"', file_get_contents($this->envPath)); } + + #[DataProvider('existingAssignmentProvider')] + public function testWritersUpdateTheAssignmentTheLoaderUses(array $lines, string $value, bool $overwrite, array $expected): void + { + $writers = [ + function () use ($value, $overwrite): void { + Env::writeVariable('APP_NAME', $value, $this->envPath, $overwrite); + }, + function () use ($value, $overwrite): void { + Env::writeVariables(['APP_NAME' => $value], $this->envPath, $overwrite); + }, + ]; + + foreach ($writers as $write) { + file_put_contents($this->envPath, implode(PHP_EOL, $lines)); + + $write(); + + $this->assertSame(implode(PHP_EOL, $expected), file_get_contents($this->envPath)); + } + } + + /** + * Provide existing assignments and the file contents expected after writing APP_NAME. + * + * @return array, string, bool, list}> + */ + public static function existingAssignmentProvider(): array + { + return [ + 'spaced assignment is overwritten in place' => [ + ['APP_NAME = old', 'APP_ENV=local'], + 'new', + true, + ['APP_NAME=new', 'APP_ENV=local'], + ], + 'spaced assignment is kept without overwrite' => [ + ['APP_NAME = old', 'APP_ENV=local'], + 'new', + false, + ['APP_NAME = old', 'APP_ENV=local'], + ], + 'exported assignment keeps its prefix' => [ + ['export APP_NAME=old'], + 'new', + true, + ['export APP_NAME=new'], + ], + 'quoted name' => [ + ['"APP_NAME"=old'], + 'new', + true, + ['APP_NAME=new'], + ], + 'last duplicate is replaced' => [ + ['APP_NAME=new', 'APP_NAME=old'], + 'new', + true, + ['APP_NAME=new', 'APP_NAME=new'], + ], + 'literal empty value is filled without overwrite' => [ + ['APP_NAME=""'], + 'new', + false, + ['APP_NAME=new'], + ], + 'value with a reference is kept without overwrite' => [ + ['APP_NAME="${OTHER}"'], + 'new', + false, + ['APP_NAME="${OTHER}"'], + ], + 'assignment-like text inside a multiline value' => [ + ['APP_CERT="-----BEGIN-----', 'APP_NAME=inside', '-----END-----"', 'DB_HOST=localhost'], + 'new', + true, + ['APP_CERT="-----BEGIN-----', 'APP_NAME=inside', '-----END-----"', 'APP_NAME=new', 'DB_HOST=localhost'], + ], + 'multiline value is replaced entirely' => [ + ['APP_NAME="first', 'second"', 'APP_ENV=local'], + 'new', + true, + ['APP_NAME=new', 'APP_ENV=local'], + ], + ]; + } } From 3db0d83e3f73d71671e991a3d66380af23f9e3eb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:14:02 +0000 Subject: [PATCH 08/10] Keep JWT secrets out of traces and verify tokens with a public key Bearer tokens and signing credentials could appear in exception traces. Error trackers such as Sentry report frame arguments when PHP keeps them, so a failed decode, refresh or invalidation could send the raw token, and a provider failure could send the secret, private key or passphrase. Token parameters on the manager contract and JwtManager (decode, refresh, invalidate and the refresh decode), the provider contract, Lcobucci::decode() and the guard's token decode, plus the Provider and Lcobucci constructors, setSecret(), setKeys() and Lcobucci::getKey(), are now marked #[SensitiveParameter]. An asymmetric Lcobucci provider without a private key failed when it was created, so a service that only verifies tokens from a separate authentication server needed the private key too. Such a provider now verifies tokens with the public key, using lcobucci's parser, validator and a SignedWith constraint, and issuing or refreshing a token still fails with the existing "Private key is not set." error. Other providers build their configuration when created, as before, so constraints added by a buildConfig() subclass still apply when decoding. A Configuration passed to the constructor signs and verifies tokens instead of one built from the secret and keys, and the setters replace it with a rebuilt one. JwtManager now resolves validation classes through the container, passing the jwt configuration as the config argument, so custom validations can inject services. Each class is resolved once and reused for the worker. The unused PayloadException is removed. The README now credits both upstream repositories, explains the worker-shared guard and manager design, and lists the differences with their equivalents: array payloads, the missing JWTAuth, JWTFactory and JWTProvider facades, an optional JwtSubject, no method forwarding or runtime setters, the no-token refresh() and payload() results, the fork's id() behavior, the required claims and 120-minute TTL, and subject locking. The JWT guide covers public-key-only verification and custom validations, including why they must not keep per-request state. The sync notes for PHP-Open-Source-Saver/jwt-auth main (1bdb72de5) and tymondesigns/jwt-auth 2.x (6c70930a9) record the source, test and docs mapping, the processing order and the required adaptations, and add the original repository's entry. Tests cover RSA and ECDSA verification with only the public key, including rejecting a forged payload, the missing-private-key error now raised when issuing a token, a buildConfig() subclass constraint during decoding, a given Configuration, container-resolved validations that are resolved once, and credentials and tokens kept out of exception traces. Validation: the Jwt suite and FacadeDocblocksTest pass, composer lint:fix only moved attributes onto their own lines, and composer analyse reports no errors. --- docs/upstream-sync/sync.yaml | 8 ++ src/docs/jwt.md | 33 +++++ src/jwt/README.md | 22 ++- src/jwt/src/Contracts/ManagerContract.php | 7 +- src/jwt/src/Contracts/ProviderContract.php | 4 +- src/jwt/src/Exceptions/PayloadException.php | 9 -- src/jwt/src/JwtGuard.php | 2 +- src/jwt/src/JwtManager.php | 10 +- src/jwt/src/Providers/Lcobucci.php | 102 ++++++++++--- src/jwt/src/Providers/Provider.php | 7 +- tests/Jwt/JwtManagerTest.php | 110 ++++++++++++++ tests/Jwt/Providers/LcobucciTest.php | 151 ++++++++++++++++++++ 12 files changed, 424 insertions(+), 41 deletions(-) delete mode 100644 src/jwt/src/Exceptions/PayloadException.php diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 60fd2ca3ba..59e1df2dd4 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -176,6 +176,14 @@ PHP-Open-Source-Saver/jwt-auth: checked_through: null last_reviewed_pr: null sync_date: null + notes: Primary reference for the first-party hypervel/jwt package, which tracks useful capabilities, fixes and tested behavior rather than an exact API; tymondesigns/jwt-auth below is processed after it. Source mapping into src/jwt/src - JWT, JWTAuth, JWTGuard and Providers/Auth to JwtGuard; Manager to JwtManager and the Jwt facade; Factory, Claims, Payload and Support to ClaimFactory and array payloads; Validators/PayloadValidator and claim checks to Validations, which JwtManager runs; Validators/TokenValidator to the Lcobucci provider's parsing; Providers/JWT to Providers; Providers/Storage to Storage/CacheStorage; Http/Parser to Http/Parser and TokenExtractor; the service providers to JwtServiceProvider; Console and EnvHelperTrait to Console and Hypervel\Support\Env; config/config.php to config/jwt.php. Upstream middleware has no class. Tests map to tests/Jwt by owning class - Providers/JWT, TokenTest and Validators/TokenValidatorTest to Providers; Claims, FactoryTest, PayloadTest and Validators/PayloadValidatorTest to ClaimFactoryTest, JwtManagerTest and Validations; BlacklistTest and Providers/Storage to BlacklistTest and Storage/CacheStorageTest; ManagerTest to JwtManagerTest; JWTGuardTest, JWTAuthTest and Providers/Auth to the JwtGuard*Test files; Http/ParserTest and Middleware to Http/ParserTest, JwtServiceProviderTest and JwtGuardContextTest; LaravelServiceProviderTest to JwtServiceProviderTest; DefaultConfigValuesTest to JwtConfigTest; Console/EnvHelperTraitTest to tests/Support/EnvFileTest and Console. Upstream docs map to src/docs/jwt.md. Adapt changes to array payloads, configuration in place of runtime setters on shared services, and per-request guard state in CoroutineContext. src/jwt/README.md lists the deliberate differences and their equivalents; check them before applying an upstream change they affect, and keep it current. + +tymondesigns/jwt-auth: + branch: 2.x + checked_through: null + last_reviewed_pr: null + sync_date: null + notes: Original package behind hypervel/jwt, processed after PHP-Open-Source-Saver/jwt-auth with the same source, test and docs mapping. It still receives independent fixes; compare each change with the fork's version and the Hypervel code before adapting it, without duplicating the fork's tests. saloonphp/saloon: branch: v4 diff --git a/src/docs/jwt.md b/src/docs/jwt.md index c030ae03d5..9ab07ee160 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -208,6 +208,8 @@ For RSA and EC algorithms, configure `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, and `J The key values may be key contents or a `file://` URI. +An application that only verifies tokens issued by another service, such as an API behind a separate authentication server, only needs the public key. Without a private key, tokens are still verified, but issuing or refreshing a token throws a `JwtException`. + ### Custom Drivers @@ -388,6 +390,37 @@ If your application uses timestamp validations and your servers have small clock 'leeway' => (int) env('JWT_LEEWAY', 0), ``` +You may add your own validation classes to the `validations` option. A validation implements the `Hypervel\Jwt\Contracts\ValidationContract` contract and throws a `TokenInvalidException` to reject a token: + +```php +versions->current($payload['sub'])) { + throw new TokenInvalidException('Token version is outdated.'); + } + } +} +``` + +Validations are resolved from the container once and shared by every request, so their dependencies must be safe to share, like a repository or cache that looks up the current value inside `validate`. Never keep the current request, tenant, or other per-request state in a validation. A constructor that accepts a `$config` argument receives the `jwt` configuration array. Refreshing skips validations that also implement `Hypervel\Jwt\Contracts\TemporalValidation`, as it does the expiration check, since the refresh window replaces them. + ### Blacklist diff --git a/src/jwt/README.md b/src/jwt/README.md index 27cbffbb14..c9989205ac 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -3,15 +3,22 @@ JWT for Hypervel Documentation: https://hypervel.org/docs/jwt -## Differences From php-open-source-saver/jwt-auth +## Differences From 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. Use `Jwt::blacklist()` for the blacklist and `Jwt::driver()` for the JWT provider. -- The guard does not forward unknown methods to an upstream `JWT` object. Token methods such as `fromUser`, `payload`, `refresh` and `invalidate` are defined on the guard, `check()` reports an authenticated user rather than a valid token, and manager features are available through the `Jwt` facade. -- The guard's `id()` loads the user like Laravel's guards, while upstream returns the token subject without loading the user. Use `getUserId()` for that. +Hypervel JWT takes behavior, fixes and tests from both jwt-auth repositories below, with its own design: one guard and one manager serve every request in a worker, so per-request state lives in coroutine context, payloads are plain arrays, and settings come from configuration rather than setters on shared services. + +- Payloads are arrays instead of upstream `Payload`, `Token` and claim objects. Read claims with array access, such as `payload()['sub']`, in place of `getClaim()` and the `Payload` methods. +- The `JWTAuth`, `JWTFactory` and `JWTProvider` facades are not included. Use the guard's methods, such as `Auth::guard('api')->fromUser($user)` or `login($user)`, to issue tokens for users. The `Jwt` facade's `encode()` signs the given payload without the claims the guard adds, except for a `jti` when the blacklist is enabled and the payload has none. `decode()` verifies a token and returns its claims. `Jwt::blacklist()` returns the blacklist and `Jwt::driver()` the JWT provider. +- Any `Authenticatable` user can receive tokens. Implement `JwtSubject`, upstream's `JWTSubject`, only to change the `sub` claim or add custom claims. +- The guard does not forward unknown methods to an upstream `JWT` object. In place of `JWTAuth::check()`, which reports whether the token is valid, use the guard's `check()` for an authenticated user or `Jwt::decode()` to validate a token. +- The guard always reads the current request, so it has no `setRequest()`; use `setToken()` to authenticate a specific token. Runtime setters such as `lockSubject()`, `setBlacklistEnabled()`, `setPersistentClaims()` and `setRefreshIat()` are not included because the guard and manager are shared by every request; set these options in `config/jwt.php`. +- Without a token, the guard's `refresh()` returns `null` and `payload()` returns an empty array, while upstream throws. +- The guard's `id()` loads the user like Laravel's guards and the original repository, while the PHP-Open-Source-Saver fork returns the token subject without loading the user. Use `getUserId()` for that. - The JWT provider is chosen with the `driver` option and custom drivers are registered with `Jwt::extend()`, following Laravel's driver managers, instead of upstream's `providers.jwt` class setting. - Custom claims may set registered claims such as `exp`, `iss` and `jti`, but not `sub` or `prv`, which always come from the user and its provider. Customize the subject with `JwtSubject::getJwtIdentifier()`. - Hypervel adds a default `iss` only when `jwt.issuer` is set, rather than using the request URL, and a default `jti` only when the blacklist is enabled. +- Only `iat` and `sub` are required claims by default, because `iss`, `exp` and `jti` are only added when an issuer, a TTL or the blacklist is configured. The default `ttl` is 120 minutes rather than 60. +- Subject locking hashes the user provider's model class. While it is enabled, a guard whose provider exposes its model rejects tokens without a matching `prv` claim, including tokens that have none, and disabling it turns the check off. Upstream hashes the user's class, accepts tokens without `prv`, and still checks a `prv` claim while locking is disabled. - Only the `Authorization` header is read by default, while upstream also reads the query string, request input, route parameters and cookies. Add `InputSource` to `jwt.parser` to read the query string and request body, where the body wins when both contain a token, or `Cookie` to read the cookie named by `cookie_key_name`. - Upstream's separate query-string, route-parameter and Lumen parsers are not included. `InputSource` reads query input, and other sources such as route parameters use a custom `TokenExtractor`. The parser chain is set in `jwt.parser` instead of with upstream's `setChain()` and `addParser()`, because one parser is shared by every request. - The `decrypt_cookies` option is not included. Upstream decrypts the cookie itself, which can't read cookies written by the `EncryptCookies` middleware, so that middleware decrypts the token cookie instead. @@ -21,4 +28,7 @@ Documentation: https://hypervel.org/docs/jwt - Clearing the blacklist requires a cache store that supports tags. On other stores upstream flushes the whole cache, so Hypervel throws the cache's tag error instead and never removes other cache entries. - The `show_black_list_exception` option is not included. Upstream's option skips the blacklist check when disabled, so revoked tokens are accepted. Guards already treat blacklisted tokens as unauthenticated, and the exception handler's `dontReport` method keeps JWT exceptions out of logs. -Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth +Ported from: + +- https://github.com/PHP-Open-Source-Saver/jwt-auth +- https://github.com/tymondesigns/jwt-auth diff --git a/src/jwt/src/Contracts/ManagerContract.php b/src/jwt/src/Contracts/ManagerContract.php index 9fa5104154..2e48f99395 100644 --- a/src/jwt/src/Contracts/ManagerContract.php +++ b/src/jwt/src/Contracts/ManagerContract.php @@ -4,6 +4,8 @@ namespace Hypervel\Jwt\Contracts; +use SensitiveParameter; + interface ManagerContract { /** @@ -14,12 +16,13 @@ public function encode(array $payload): string; /** * Decode a token into its payload. */ - public function decode(string $token, bool $validate = true, bool $checkBlacklist = true): array; + public function decode(#[SensitiveParameter] string $token, bool $validate = true, bool $checkBlacklist = true): array; /** * Refresh a token. */ public function refresh( + #[SensitiveParameter] string $token, bool $forceForever = false, bool $resetClaims = false, @@ -30,7 +33,7 @@ public function refresh( /** * Invalidate a token. */ - public function invalidate(string $token, bool $forceForever = false): bool; + public function invalidate(#[SensitiveParameter] string $token, bool $forceForever = false): bool; /** * Determine if the blacklist is enabled. diff --git a/src/jwt/src/Contracts/ProviderContract.php b/src/jwt/src/Contracts/ProviderContract.php index 01e0360cc1..9dc1347cdc 100644 --- a/src/jwt/src/Contracts/ProviderContract.php +++ b/src/jwt/src/Contracts/ProviderContract.php @@ -4,6 +4,8 @@ namespace Hypervel\Jwt\Contracts; +use SensitiveParameter; + interface ProviderContract { /** @@ -14,5 +16,5 @@ public function encode(array $payload): string; /** * Decode a JSON Web Token. */ - public function decode(string $token): array; + public function decode(#[SensitiveParameter] string $token): array; } diff --git a/src/jwt/src/Exceptions/PayloadException.php b/src/jwt/src/Exceptions/PayloadException.php deleted file mode 100644 index 7f86d28a3f..0000000000 --- a/src/jwt/src/Exceptions/PayloadException.php +++ /dev/null @@ -1,9 +0,0 @@ -getPayloadContextKey($token), diff --git a/src/jwt/src/JwtManager.php b/src/jwt/src/JwtManager.php index c3d793af03..83eead8657 100644 --- a/src/jwt/src/JwtManager.php +++ b/src/jwt/src/JwtManager.php @@ -17,6 +17,7 @@ use Hypervel\Support\Facades\Date; use Hypervel\Support\Manager; use Hypervel\Support\Str; +use SensitiveParameter; class JwtManager extends Manager implements ManagerContract { @@ -73,7 +74,7 @@ public function encode(array $payload): string /** * Decode a token into its payload. */ - public function decode(string $token, bool $validate = true, bool $checkBlacklist = true): array + public function decode(#[SensitiveParameter] string $token, bool $validate = true, bool $checkBlacklist = true): array { $payload = $this->driver()->decode($token); @@ -113,13 +114,14 @@ protected function getValidation(string $class): ValidationContract return $validation; } - return $this->validations[$class] = new $class($this->config->array('jwt')); + return $this->validations[$class] = $this->container->make($class, ['config' => $this->config->array('jwt')]); } /** * Refresh a token. */ public function refresh( + #[SensitiveParameter] string $token, bool $forceForever = false, bool $resetClaims = false, @@ -155,7 +157,7 @@ public function refresh( /** * Decode a token for refresh. */ - protected function decodeForRefresh(string $token): array + protected function decodeForRefresh(#[SensitiveParameter] string $token): array { $payload = $this->driver()->decode($token); @@ -171,7 +173,7 @@ protected function decodeForRefresh(string $token): array /** * Invalidate a token. */ - public function invalidate(string $token, bool $forceForever = false): bool + public function invalidate(#[SensitiveParameter] string $token, bool $forceForever = false): bool { if (! $this->blacklistEnabled) { throw new JwtException('You must have the blacklist enabled to invalidate a token.'); diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index 9f614d64c6..a86f6279ec 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -15,15 +15,22 @@ use Hypervel\Support\Facades\Date; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Encoding\JoseEncoder; +use Lcobucci\JWT\Parser; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Signer\Ecdsa; use Lcobucci\JWT\Signer\Ecdsa\ConversionFailed; use Lcobucci\JWT\Signer\Key; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa; +use Lcobucci\JWT\Token\Parser as TokenParser; use Lcobucci\JWT\Token\Plain; use Lcobucci\JWT\Token\RegisteredClaims; +use Lcobucci\JWT\Validation\Constraint; use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\Validator as TokenValidator; +use Lcobucci\JWT\Validator; +use SensitiveParameter; use Throwable; class Lcobucci extends Provider implements ProviderContract @@ -34,19 +41,47 @@ class Lcobucci extends Provider implements ProviderContract protected Signer $signer; /** - * \Lcobucci\JWT\Configuration. + * The configuration used to sign tokens. + * + * An asymmetric provider without a private key builds it only when asked + * to sign a token, so it can still verify tokens with the public key. */ - protected Configuration $config; + protected ?Configuration $config; + + /** + * The parser that reads tokens. + */ + protected Parser $parser; + + /** + * The validator that checks tokens against the validation constraints. + */ + protected Validator $validator; + + /** + * The constraints a token must satisfy, including its signature. + * + * @var array + */ + protected array $validationConstraints; /** * Create the Lcobucci provider. + * + * A given configuration signs and verifies tokens instead of one built from the secret and keys. */ - public function __construct(string $secret, string $algo, array $keys, ?Configuration $config = null) - { + public function __construct( + #[SensitiveParameter] + string $secret, + string $algo, + #[SensitiveParameter] + array $keys, + ?Configuration $config = null, + ) { parent::__construct($secret, $algo, $keys); - $this->signer = $this->getSigner(); - $this->config = $config ?: $this->buildConfig(); + $this->config = $config; + $this->configure(); } /** @@ -71,11 +106,12 @@ public function __construct(string $secret, string $algo, array $keys, ?Configur */ public function encode(array $payload): string { + $config = $this->getSigningConfig(); $builder = $this->getBuilderFromClaims($payload); try { return $builder - ->getToken($this->config->signer(), $this->config->signingKey()) + ->getToken($config->signer(), $config->signingKey()) ->toString(); } catch (Exception $e) { throw new JwtException('Could not create token: ' . $e->getMessage(), $e->getCode(), $e); @@ -87,11 +123,11 @@ public function encode(array $payload): string * * @throws JwtException */ - public function decode(string $token): array + public function decode(#[SensitiveParameter] string $token): array { try { /** @var Plain */ - $token = $this->config->parser()->parse($token); + $token = $this->parser->parse($token); } catch (Throwable $exception) { throw new TokenInvalidException( 'Could not decode token: ' . $exception->getMessage(), @@ -101,7 +137,7 @@ public function decode(string $token): array } try { - $verified = $this->config->validator()->validate($token, ...$this->config->validationConstraints()); + $verified = $this->validator->validate($token, ...$this->validationConstraints); } catch (ConversionFailed) { // ECDSA verification rejects a signature of the wrong length before comparing it. $verified = false; @@ -122,7 +158,7 @@ public function decode(string $token): array */ protected function getBuilderFromClaims(array $payload): Builder { - $builder = $this->config->builder(); + $builder = $this->getSigningConfig()->builder(); foreach ($payload as $key => $value) { switch ($key) { @@ -192,16 +228,50 @@ protected function buildConfig(): Configuration } /** - * Rebuild cached derived state after a configuration change. + * Build the signer and the objects that verify tokens. + * + * @throws JwtException + */ + protected function configure(): void + { + $this->signer = $this->getSigner(); + + // Signing needs the private key, but verifying a token only needs the public key. + if ($this->config === null && $this->isAsymmetric() && ! $this->getPrivateKey()) { + $this->parser = new TokenParser(new JoseEncoder); + $this->validator = new TokenValidator; + $this->validationConstraints = [new SignedWith($this->signer, $this->getVerificationKey())]; + + return; + } + + $this->config ??= $this->buildConfig(); + $this->parser = $this->config->parser(); + $this->validator = $this->config->validator(); + $this->validationConstraints = $this->config->validationConstraints(); + } + + /** + * Get the configuration used to sign tokens. * - * Signer is rebuilt before config because buildConfig() reads $this->signer. + * @throws JwtException + */ + protected function getSigningConfig(): Configuration + { + return $this->config ??= $this->buildConfig(); + } + + /** + * Rebuild the signer and configuration after a setting changes. + * + * A configuration given to the constructor is replaced by one built from the new settings. * * @throws JwtException */ protected function onConfigurationChanged(): void { - $this->signer = $this->getSigner(); - $this->config = $this->buildConfig(); + $this->config = null; + $this->configure(); } /** @@ -276,7 +346,7 @@ protected function getVerificationKey(): Key /** * Get the signing key instance. */ - protected function getKey(string $contents, string $passphrase = ''): Key + protected function getKey(#[SensitiveParameter] string $contents, #[SensitiveParameter] string $passphrase = ''): Key { if (str_starts_with($contents, 'file://')) { return InMemory::file($contents, $passphrase); diff --git a/src/jwt/src/Providers/Provider.php b/src/jwt/src/Providers/Provider.php index f997e3820d..f5bf2ec283 100644 --- a/src/jwt/src/Providers/Provider.php +++ b/src/jwt/src/Providers/Provider.php @@ -5,6 +5,7 @@ namespace Hypervel\Jwt\Providers; use Hypervel\Support\Arr; +use SensitiveParameter; abstract class Provider { @@ -30,8 +31,10 @@ abstract class Provider * Constructor. */ public function __construct( + #[SensitiveParameter] protected string $secret, protected string $algo, + #[SensitiveParameter] protected array $keys ) { } @@ -68,7 +71,7 @@ public function getAlgo(): string * * @return $this */ - public function setSecret(string $secret): static + public function setSecret(#[SensitiveParameter] string $secret): static { $this->secret = $secret; $this->onConfigurationChanged(); @@ -92,7 +95,7 @@ public function getSecret(): string * * @return $this */ - public function setKeys(array $keys): static + public function setKeys(#[SensitiveParameter] array $keys): static { $this->keys = $keys; $this->onConfigurationChanged(); diff --git a/tests/Jwt/JwtManagerTest.php b/tests/Jwt/JwtManagerTest.php index 2dae6e07c3..79426f2aad 100644 --- a/tests/Jwt/JwtManagerTest.php +++ b/tests/Jwt/JwtManagerTest.php @@ -9,6 +9,7 @@ use Hypervel\Foundation\Application; use Hypervel\Jwt\ClaimFactory; use Hypervel\Jwt\Contracts\BlacklistContract; +use Hypervel\Jwt\Contracts\ValidationContract; use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\TokenBlacklistedException; use Hypervel\Jwt\Exceptions\TokenExpiredException; @@ -29,6 +30,7 @@ use Mockery as m; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; +use SensitiveParameterValue; use Symfony\Component\Uid\Uuid; class JwtManagerTest extends TestCase @@ -371,6 +373,44 @@ public function testDecodeStillRejectsExpiredTokensWhenExpiredClaimValidationIsE $this->createManager()->decode('foo.bar.baz'); } + public function testCustomValidationsAreResolvedFromTheContainerOnceAndReused(): void + { + $token = 'foo.bar.baz'; + $payload = ['sub' => 1, 'ver' => 1]; + $versions = new JwtManagerTokenVersions([1 => 1]); + $resolutions = 0; + + $application = new Application; + $application->instance('config', new Repository([ + 'jwt' => [ + 'blacklist_enabled' => false, + 'driver' => 'dummy', + 'validations' => [JwtManagerTokenVersionValidation::class], + ], + ])); + $application->instance(JwtManagerTokenVersions::class, $versions); + $application->resolving(JwtManagerTokenVersionValidation::class, function () use (&$resolutions): void { + ++$resolutions; + }); + + $provider = $this->provider; + $provider->shouldReceive('decode')->times(3)->with($token)->andReturn($payload); + + $manager = new JwtManager($application, $this->claimFactory); + $manager->extend('dummy', static fn (): Lcobucci => $provider); + + $this->assertSame($payload, $manager->decode($token)); + $this->assertSame($payload, $manager->decode($token)); + $this->assertSame(1, $resolutions); + + $versions->current[1] = 2; + + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIs('Token version is outdated.'); + + $manager->decode($token); + } + public function testRefreshSkipsTemporalValidationsInsideRefreshWindow(): void { $token = 'foo.bar.baz'; @@ -804,6 +844,38 @@ public static function failedInvalidationProvider(): array ]; } + public function testFailedInvalidationKeepsTheTokenOutOfTheExceptionTrace(): void + { + $token = 'header.payload.signature'; + $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp, 'jti' => 'foo']; + $exception = null; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled')->andReturnTrue(); + $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); + $this->blacklist->shouldReceive('add')->once()->with($payload)->andReturnFalse(); + + $manager = $this->createManager(); + $ignoreArguments = ini_set('zend.exception_ignore_args', '0'); + + try { + $manager->invalidate($token); + } catch (JwtException $exception) { + } finally { + ini_set('zend.exception_ignore_args', $ignoreArguments); + } + + $this->assertInstanceOf(JwtException::class, $exception); + + $frames = array_values(array_filter( + $exception->getTrace(), + static fn (array $frame): bool => ($frame['class'] ?? null) === JwtManager::class && $frame['function'] === 'invalidate', + )); + + $this->assertCount(1, $frames); + $this->assertInstanceOf(SensitiveParameterValue::class, $frames[0]['args'][0]); + $this->assertSame($token, $frames[0]['args'][0]->getValue()); + } + public function testInvalidateDoesNotReadTheBlacklistBeforeWriting(): void { $token = 'foo.bar.baz'; @@ -860,6 +932,10 @@ private function setTestNow(): void private function mockContainer(): void { $this->container = m::mock(Container::class); + + $this->container->shouldReceive('make') + ->with(m::type('string'), m::hasKey('config')) + ->andReturnUsing(static fn (string $class, array $parameters): object => new $class($parameters['config'])); } private function mockConfig(): void @@ -904,3 +980,37 @@ private function mockUuid(string $value): void Str::createUuidsUsing(fn () => Uuid::fromString($value)); } } + +class JwtManagerTokenVersions +{ + /** + * Create a new token version store. + * + * @param array $current + */ + public function __construct( + public array $current = [], + ) { + } +} + +class JwtManagerTokenVersionValidation implements ValidationContract +{ + /** + * Create a new token version validation. + */ + public function __construct( + protected JwtManagerTokenVersions $versions, + ) { + } + + /** + * Validate that the token carries the user's current token version. + */ + public function validate(array $payload): void + { + if ($payload['ver'] !== $this->versions->current[$payload['sub']]) { + throw new TokenInvalidException('Token version is outdated.'); + } + } +} diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php index 60a8640ebb..7b95252820 100644 --- a/tests/Jwt/Providers/LcobucciTest.php +++ b/tests/Jwt/Providers/LcobucciTest.php @@ -4,10 +4,12 @@ namespace Hypervel\Tests\Jwt\Providers; +use Closure; use DateInterval; use DateTime; use DateTimeImmutable; use DateTimeInterface; +use Exception; use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\SecretMissingException; use Hypervel\Jwt\Exceptions\TokenExpiredException; @@ -18,7 +20,13 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Date; use Hypervel\Tests\TestCase; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer\Hmac\Sha256; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Validation\Constraint\IssuedBy; +use Lcobucci\JWT\Validation\Constraint\SignedWith; use PHPUnit\Framework\Attributes\DataProvider; +use SensitiveParameterValue; use TypeError; class LcobucciTest extends TestCase @@ -387,6 +395,40 @@ public function testShouldThrowAnExceptionWhenNoAsymmetricPrivateKeyIsProvided() )->encode(['sub' => 1]); } + #[DataProvider('asymmetricKeyPairProvider')] + public function testThePublicKeyAloneVerifiesTokens(string $algo, string $privateKeyFile, string $publicKeyFile): void + { + $publicKey = file_get_contents(__DIR__ . "/../Fixtures/keys/{$publicKeyFile}"); + $token = $this->getProvider('does_not_matter', $algo, [ + 'private' => file_get_contents(__DIR__ . "/../Fixtures/keys/{$privateKeyFile}"), + 'public' => $publicKey, + ])->encode(['sub' => 1, 'iat' => $this->testNowTimestamp]); + + $verifier = $this->getProvider('does_not_matter', $algo, ['public' => $publicKey]); + + $this->assertSame('1', $verifier->decode($token)['sub']); + + [$header, , $signature] = explode('.', $token); + + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIs('Token Signature could not be verified.'); + + $verifier->decode("{$header}." . $this->base64UrlEncode('{"sub":"2"}') . ".{$signature}"); + } + + /** + * Provide asymmetric algorithms with their key pair fixtures. + * + * @return array + */ + public static function asymmetricKeyPairProvider(): array + { + return [ + 'RSA' => [Provider::ALGO_RS256, 'id_rsa', 'id_rsa.pub'], + 'ECDSA' => [Provider::ALGO_ES256, 'id_ecdsa', 'id_ecdsa.pub'], + ]; + } + public function testShouldThrowASecretMissingExceptionWhenNoSymmetricSecretIsProvided(): void { $this->expectException(SecretMissingException::class); @@ -515,6 +557,100 @@ public function testSetKeysTakesEffectOnSigning(): void $this->getProvider('does_not_matter', Provider::ALGO_RS256, $keyPair1)->decode($token); } + public function testConstraintsAddedByBuildConfigApplyWhenDecoding(): void + { + $provider = new LcobucciWithIssuerConstraint($this->getRandomString(), Provider::ALGO_HS256, []); + + $claims = $provider->decode($provider->encode(['sub' => 1, 'iss' => 'https://issuer.example.test'])); + + $this->assertSame('https://issuer.example.test', $claims['iss']); + + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIs('Token Signature could not be verified.'); + + $provider->decode($provider->encode(['sub' => 1, 'iss' => 'https://other.example.test'])); + } + + public function testAGivenConfigurationSignsAndVerifiesTokensInsteadOfTheSecret(): void + { + $secret = $this->getRandomString(); + $key = InMemory::plainText($this->getRandomString()); + $config = Configuration::forSymmetricSigner(new Sha256, $key) + ->withValidationConstraints(new SignedWith(new Sha256, $key)); + + $provider = new Lcobucci($secret, Provider::ALGO_HS256, [], $config); + $token = $provider->encode(['sub' => 1, 'iat' => $this->testNowTimestamp]); + + $this->assertSame('1', $provider->decode($token)['sub']); + + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIs('Token Signature could not be verified.'); + + $this->getProvider($secret, Provider::ALGO_HS256)->decode($token); + } + + #[DataProvider('credentialFailureProvider')] + public function testSigningCredentialsAreKeptOutOfExceptionTraces(Closure $configure, string $function, array $positions): void + { + $exception = null; + $ignoreArguments = ini_set('zend.exception_ignore_args', '0'); + + try { + $configure(); + } catch (Exception $exception) { + } finally { + ini_set('zend.exception_ignore_args', $ignoreArguments); + } + + $this->assertInstanceOf(Exception::class, $exception); + + $frames = array_values(array_filter( + $exception->getTrace(), + static fn (array $frame): bool => is_a($frame['class'] ?? '', Provider::class, true) && $frame['function'] === $function, + )); + + $this->assertCount(1, $frames); + + foreach ($positions as $position) { + $this->assertInstanceOf(SensitiveParameterValue::class, $frames[0]['args'][$position]); + } + } + + /** + * Provide failures that happen while the given credentials are function arguments. + * + * @return array}> + */ + public static function credentialFailureProvider(): array + { + $keys = __DIR__ . '/../Fixtures/keys'; + + return [ + 'constructor' => [ + static fn (): Lcobucci => new Lcobucci('signing-secret', 'INVALID_ALGO', ['private' => 'private-key']), + '__construct', + [0, 2], + ], + 'new keys' => [ + static fn (): Lcobucci => (new Lcobucci('does_not_matter', Provider::ALGO_RS256, [ + 'private' => file_get_contents("{$keys}/id_rsa"), + 'public' => file_get_contents("{$keys}/id_rsa.pub"), + ]))->setKeys(['private' => file_get_contents("{$keys}/id_rsa_alt")]), + 'setKeys', + [0], + ], + 'key file' => [ + static fn (): Lcobucci => new Lcobucci('does_not_matter', Provider::ALGO_RS256, [ + 'private' => "file://{$keys}/missing", + 'public' => file_get_contents("{$keys}/id_rsa.pub"), + 'passphrase' => 'key-passphrase', + ]), + 'getKey', + [0, 1], + ], + ]; + } + private function getProvider(string $secret, string $algo, array $keys = []): Lcobucci { return new Lcobucci($secret, $algo, $keys); @@ -586,3 +722,18 @@ private function getDummyEcPublicKey(): string return file_get_contents(__DIR__ . '/../Fixtures/keys/id_ecdsa.pub'); } } + +class LcobucciWithIssuerConstraint extends Lcobucci +{ + /** + * Build the configuration, also requiring the expected issuer. + */ + protected function buildConfig(): Configuration + { + $config = parent::buildConfig(); + + return $config->withValidationConstraints( + ...[...$config->validationConstraints(), new IssuedBy('https://issuer.example.test')], + ); + } +} From 22950bd8473460b448ef0697382a3cefb06c7e6b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:29:13 +0000 Subject: [PATCH 09/10] Complete the JWT guide and add jwt-auth porting guidance The JWT guide now covers the remaining guard features from both jwt-auth repositories' docs: making the JWT guard the application's default with AUTH_GUARD, reading the current token's claims with payload(), authenticating a specific token with setToken() and revoking a token forever through logout(true). The method list adds check() and getTTL(). It also says that passing a curve that does not match the SHA variant to jwt:generate-certs fails. The User Models example extended the base Eloquent model, which does not implement Authenticatable, so the guard could not log in or issue tokens for it. It now extends Hypervel\Foundation\Auth\User, matching both upstream guides. The credits now name both upstream repositories without claiming which one the package started from. The Laravel porting guide gains a section for applications moving from tymon/jwt-auth or php-open-source-saver/jwt-auth. It covers publishing the new config file instead of reusing the old one, replacing the facades, runtime setters and middleware, enabling the query, body or cookie parsers when clients send tokens there, and rotating the secret or key pair, since revocations stored by the old package use different cache keys. The sync registry records PHP-Open-Source-Saver/jwt-auth main checked through 1bdb72de5 (last reviewed PR 301) and tymondesigns/jwt-auth 2.x through 6c70930a9 (last reviewed PR 2292). Validation: composer test:parallel and composer test:testbench pass. --- docs/upstream-sync/sync.yaml | 12 +++++------ src/docs/jwt.md | 36 ++++++++++++++++++++++++++------ src/docs/porting-from-laravel.md | 10 +++++++++ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 59e1df2dd4..c515b5db31 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -173,16 +173,16 @@ aimeos/laravel-nestedset: PHP-Open-Source-Saver/jwt-auth: branch: main - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: 1bdb72de5e60fcb02264d7aba4bd4608939e3798 + last_reviewed_pr: 301 + sync_date: '2026-10-07' notes: Primary reference for the first-party hypervel/jwt package, which tracks useful capabilities, fixes and tested behavior rather than an exact API; tymondesigns/jwt-auth below is processed after it. Source mapping into src/jwt/src - JWT, JWTAuth, JWTGuard and Providers/Auth to JwtGuard; Manager to JwtManager and the Jwt facade; Factory, Claims, Payload and Support to ClaimFactory and array payloads; Validators/PayloadValidator and claim checks to Validations, which JwtManager runs; Validators/TokenValidator to the Lcobucci provider's parsing; Providers/JWT to Providers; Providers/Storage to Storage/CacheStorage; Http/Parser to Http/Parser and TokenExtractor; the service providers to JwtServiceProvider; Console and EnvHelperTrait to Console and Hypervel\Support\Env; config/config.php to config/jwt.php. Upstream middleware has no class. Tests map to tests/Jwt by owning class - Providers/JWT, TokenTest and Validators/TokenValidatorTest to Providers; Claims, FactoryTest, PayloadTest and Validators/PayloadValidatorTest to ClaimFactoryTest, JwtManagerTest and Validations; BlacklistTest and Providers/Storage to BlacklistTest and Storage/CacheStorageTest; ManagerTest to JwtManagerTest; JWTGuardTest, JWTAuthTest and Providers/Auth to the JwtGuard*Test files; Http/ParserTest and Middleware to Http/ParserTest, JwtServiceProviderTest and JwtGuardContextTest; LaravelServiceProviderTest to JwtServiceProviderTest; DefaultConfigValuesTest to JwtConfigTest; Console/EnvHelperTraitTest to tests/Support/EnvFileTest and Console. Upstream docs map to src/docs/jwt.md. Adapt changes to array payloads, configuration in place of runtime setters on shared services, and per-request guard state in CoroutineContext. src/jwt/README.md lists the deliberate differences and their equivalents; check them before applying an upstream change they affect, and keep it current. tymondesigns/jwt-auth: branch: 2.x - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: 6c70930a92710d97e8e52b182fca2176097f33be + last_reviewed_pr: 2292 + sync_date: '2026-10-07' notes: Original package behind hypervel/jwt, processed after PHP-Open-Source-Saver/jwt-auth with the same source, test and docs mapping. It still receives independent fixes; compare each change with the fork's version and the Hypervel code before adapting it, without duplicating the fork's tests. saloonphp/saloon: diff --git a/src/docs/jwt.md b/src/docs/jwt.md index 9ab07ee160..005fab23bb 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -96,7 +96,7 @@ php artisan jwt:generate-certs --force --algo=rsa --bits=4096 --sha=512 php artisan jwt:generate-certs --force --algo=ec --sha=256 ``` -RSA keys must be at least 2048 bits. EC keys use the curve their SHA variant requires: `prime256v1` for 256, `secp384r1` for 384, and `secp521r1` for 512. +RSA keys must be at least 2048 bits. EC keys use the curve their SHA variant requires: `prime256v1` for 256, `secp384r1` for 384, and `secp521r1` for 512. Passing a different curve with `--curve` fails. You may change the output directory using `--dir`. The directory may be absolute or relative to your application's base path. The command writes absolute `file://` paths to `.env`, so if each deployment uses a new release directory, choose a `--dir` that is shared between releases. @@ -123,6 +123,12 @@ To use JWT authentication, configure an auth guard that uses the `jwt` driver: ], ``` +If JWT is your application's main way of authenticating users, you may make this guard the default by setting the `AUTH_GUARD` environment variable. Calls such as `Auth::user()` and the `auth` middleware then use it without naming the guard: + +```ini +AUTH_GUARD=api +``` + You may then protect routes using Hypervel's normal authentication middleware: ```php @@ -141,10 +147,10 @@ JWT can authenticate any model supported by your configured user provider. If yo namespace App\Models; -use Hypervel\Database\Eloquent\Model; +use Hypervel\Foundation\Auth\User as Authenticatable; use Hypervel\Jwt\Contracts\JwtSubject; -class User extends Model implements JwtSubject +class User extends Authenticatable implements JwtSubject { /** * Get the identifier that will be stored in the subject claim. @@ -541,12 +547,26 @@ The `id` method loads the user, like the other guards. When you only need the ID $userId = Auth::guard('api')->getUserId(); ``` -Use `userOrFail` when a missing user should throw: +The `userOrFail` method throws a `UserNotDefinedException` when there is no authenticated user: ```php $user = Auth::guard('api')->userOrFail(); ``` +The `payload` method returns the current token's claims as an array, or an empty array when the request has no token: + +```php +$payload = Auth::guard('api')->payload(); + +$tenantId = $payload['tenant_id'] ?? null; +``` + +To authenticate a token that is not in the current request, pass it to the `setToken` method. The guard uses that token for the rest of the request: + +```php +$user = Auth::guard('api')->setToken($token)->user(); +``` + ### Refreshing Tokens @@ -635,10 +655,12 @@ While the blacklist is enabled, you may invalidate a token directly using the `i Auth::guard('api')->invalidate(); ``` -You may pass `true` to blacklist the token forever. This also bypasses the configured grace period, so the revocation takes effect immediately: +You may pass `true` to blacklist the token forever. This also bypasses the configured grace period, so the revocation takes effect immediately. The `logout` method accepts the same argument: ```php Auth::guard('api')->invalidate(true); + +Auth::guard('api')->logout(true); ``` @@ -675,10 +697,12 @@ Auth::guard('api')->byId($id); // Authenticatable|false Auth::guard('api')->user(); // Authenticatable|null Auth::guard('api')->getUser(); // Authenticatable|null Auth::guard('api')->userOrFail(); // Authenticatable +Auth::guard('api')->check(); // bool Auth::guard('api')->id(); // int|string|null Auth::guard('api')->getUserId(); // int|string|null Auth::guard('api')->claims(['role' => 'admin']); Auth::guard('api')->setTTL(15); +Auth::guard('api')->getTTL(); // int|null Auth::guard('api')->setToken($token); Auth::guard('api')->getToken(); Auth::guard('api')->payload(); // array @@ -711,4 +735,4 @@ The guard's `user`, `check`, and `id` methods treat an invalid, expired, or blac ## Credits -Hypervel JWT began as a port of [PHP Open Source Saver JWT Auth](https://github.com/PHP-Open-Source-Saver/jwt-auth) and has been adapted for Hypervel's framework architecture and coroutine runtime. +Hypervel JWT draws fixes and improvements from [jwt-auth](https://github.com/tymondesigns/jwt-auth) and its [PHP Open Source Saver fork](https://github.com/PHP-Open-Source-Saver/jwt-auth), adapted for Hypervel's framework architecture and coroutine runtime. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 1790156ff0..a373c18a02 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -31,6 +31,7 @@ - [JSON:API Resources](#jsonapi-resources) - [CSRF Protection](#csrf-protection) - [Fortify](#fortify) + - [JWT Authentication](#jwt-authentication) - [Scout](#scout) - [Socialite](#socialite) - [JSON Schema](#json-schema) @@ -544,6 +545,15 @@ User models that use Fortify's `TwoFactorAuthenticatable` trait must also implem Fortify ignores Laravel's `fortify.passwords` setting. Declare the password reset broker with the guard's `passwords` key in `config/auth.php` instead. See [password resets](/docs/{{version}}/fortify#password-resets). Laravel's deprecated `Laravel\Fortify\Rules\Password` rule is not available; use `Hypervel\Validation\Rules\Password`. + +### JWT Authentication + +Applications using `tymon/jwt-auth` or `php-open-source-saver/jwt-auth` can switch to `hypervel/jwt`. Publish its `config/jwt.php` file and copy your values into it instead of reusing the old file, since some options have been renamed or removed. Replace the `JWTAuth` and `JWTFactory` facades with guard methods such as `fromUser` and `payload`, which returns the claims as an array. Set options such as subject locking and the blacklist in configuration instead of calling setters at runtime. Replace the `jwt.auth` middleware with `auth:api`, remove `jwt.check` from routes that allow guests, and replace `jwt.refresh` and `jwt.renew` with a [refresh endpoint](/docs/{{version}}/jwt#refreshing-tokens). + +Only the `Authorization` header is read by default. If clients send tokens in the query string, request body, or a cookie, add the matching parser to the `parser` option. See [token sources](/docs/{{version}}/jwt#token-sources). + +Rotate `JWT_SECRET` or your key pair when you switch, so clients sign in again. Hypervel stores revocations under different cache keys, so tokens revoked by the old application would be accepted again if they still verified. + ### Scout From 7b747f190b30680a692f7ebee6671c54437d780e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:08:05 +0000 Subject: [PATCH 10/10] Apply custom JWT constraints when verifying with only a public key An asymmetric Lcobucci provider without a private key built its own parser, validator and SignedWith constraint instead of calling buildConfig(). A subclass that added validation constraints in buildConfig(), such as a required issuer, had them silently skipped on services that only hold the public key, so tokens that failed those checks were accepted there. The provider now keeps one Configuration in every mode. Without a private key, buildConfig() puts the public key in lcobucci's signing-key slot, which lcobucci requires even for verification, and encode() fails with "Private key is not set." before building a token. Parsing, validation and constraints all come from that configuration, so the separate verification-only properties and getSigningConfig() are gone. A Configuration passed to the constructor is still used as given and can sign without local keys, and the setters still replace it with one built from the new settings. New tests cover constraints added by buildConfig() with only the public key for RSA and ECDSA, switching a provider between verifying and signing through setKeys(), and signing with a given asymmetric configuration and no local keys. The JWT guide now says that while the blacklist is enabled tokens must include a jti claim, so enabling it rejects existing or externally issued tokens without one. Validation: the JWT test suite, composer lint:fix and composer analyse pass. --- src/docs/jwt.md | 2 +- src/jwt/src/Providers/Lcobucci.php | 81 ++++++++-------------------- tests/Jwt/Providers/LcobucciTest.php | 54 +++++++++++++++++++ 3 files changed, 76 insertions(+), 61 deletions(-) diff --git a/src/docs/jwt.md b/src/docs/jwt.md index 005fab23bb..34dce571df 100644 --- a/src/docs/jwt.md +++ b/src/docs/jwt.md @@ -436,7 +436,7 @@ The JWT blacklist lets the package invalidate tokens before they naturally expir 'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', true), ``` -Blacklisting is enabled by default, so logging out or refreshing revokes the old token. Newly issued tokens include a `jti` claim, and each authenticated request checks the blacklist in the cache. If your application doesn't need to revoke tokens before they expire, you may set `JWT_BLACKLIST_ENABLED` to `false`; tokens then remain valid until they expire, even after logout. +Blacklisting is enabled by default, so logging out or refreshing revokes the old token. Newly issued tokens include a `jti` claim, and each authenticated request checks the blacklist in the cache. While the blacklist is enabled, tokens must include a `jti` claim so they can be revoked. Enabling it rejects existing or externally issued tokens that lack this claim. If your application doesn't need to revoke tokens before they expire, you may set `JWT_BLACKLIST_ENABLED` to `false`; tokens then remain valid until they expire, even after logout. Blacklist entries are kept in your default cache store. You may choose another store using the `blacklist_store` option: diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index a86f6279ec..aab802a171 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -15,21 +15,15 @@ use Hypervel\Support\Facades\Date; use Lcobucci\JWT\Builder; use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Encoding\JoseEncoder; -use Lcobucci\JWT\Parser; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Signer\Ecdsa; use Lcobucci\JWT\Signer\Ecdsa\ConversionFailed; use Lcobucci\JWT\Signer\Key; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa; -use Lcobucci\JWT\Token\Parser as TokenParser; use Lcobucci\JWT\Token\Plain; use Lcobucci\JWT\Token\RegisteredClaims; -use Lcobucci\JWT\Validation\Constraint; use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\Validation\Validator as TokenValidator; -use Lcobucci\JWT\Validator; use SensitiveParameter; use Throwable; @@ -41,29 +35,14 @@ class Lcobucci extends Provider implements ProviderContract protected Signer $signer; /** - * The configuration used to sign tokens. - * - * An asymmetric provider without a private key builds it only when asked - * to sign a token, so it can still verify tokens with the public key. - */ - protected ?Configuration $config; - - /** - * The parser that reads tokens. + * The configuration that signs and verifies tokens. */ - protected Parser $parser; + protected Configuration $config; /** - * The validator that checks tokens against the validation constraints. + * Whether the provider has no private key, so it can only verify tokens. */ - protected Validator $validator; - - /** - * The constraints a token must satisfy, including its signature. - * - * @var array - */ - protected array $validationConstraints; + protected bool $verifiesOnly = false; /** * Create the Lcobucci provider. @@ -80,8 +59,7 @@ public function __construct( ) { parent::__construct($secret, $algo, $keys); - $this->config = $config; - $this->configure(); + $this->configure($config); } /** @@ -106,12 +84,15 @@ public function __construct( */ public function encode(array $payload): string { - $config = $this->getSigningConfig(); + if ($this->verifiesOnly) { + throw new JwtException('Private key is not set.'); + } + $builder = $this->getBuilderFromClaims($payload); try { return $builder - ->getToken($config->signer(), $config->signingKey()) + ->getToken($this->config->signer(), $this->config->signingKey()) ->toString(); } catch (Exception $e) { throw new JwtException('Could not create token: ' . $e->getMessage(), $e->getCode(), $e); @@ -127,7 +108,7 @@ public function decode(#[SensitiveParameter] string $token): array { try { /** @var Plain */ - $token = $this->parser->parse($token); + $token = $this->config->parser()->parse($token); } catch (Throwable $exception) { throw new TokenInvalidException( 'Could not decode token: ' . $exception->getMessage(), @@ -137,7 +118,7 @@ public function decode(#[SensitiveParameter] string $token): array } try { - $verified = $this->validator->validate($token, ...$this->validationConstraints); + $verified = $this->config->validator()->validate($token, ...$this->config->validationConstraints()); } catch (ConversionFailed) { // ECDSA verification rejects a signature of the wrong length before comparing it. $verified = false; @@ -158,7 +139,7 @@ public function decode(#[SensitiveParameter] string $token): array */ protected function getBuilderFromClaims(array $payload): Builder { - $builder = $this->getSigningConfig()->builder(); + $builder = $this->config->builder(); foreach ($payload as $key => $value) { switch ($key) { @@ -217,7 +198,9 @@ protected function buildConfig(): Configuration $config = $this->isAsymmetric() ? Configuration::forAsymmetricSigner( $this->signer, - $this->getSigningKey(), + // lcobucci needs a signing key even to verify, so a provider without a private + // key fills the slot with the public key, and encode() refuses to sign. + $this->verifiesOnly ? $this->getVerificationKey() : $this->getSigningKey(), $this->getVerificationKey() ) : Configuration::forSymmetricSigner($this->signer, $this->getSigningKey()); @@ -228,37 +211,16 @@ protected function buildConfig(): Configuration } /** - * Build the signer and the objects that verify tokens. + * Build the signer, and the configuration unless one is given. * * @throws JwtException */ - protected function configure(): void + protected function configure(?Configuration $config = null): void { + // buildConfig() reads the signer and the verification-only flag. $this->signer = $this->getSigner(); - - // Signing needs the private key, but verifying a token only needs the public key. - if ($this->config === null && $this->isAsymmetric() && ! $this->getPrivateKey()) { - $this->parser = new TokenParser(new JoseEncoder); - $this->validator = new TokenValidator; - $this->validationConstraints = [new SignedWith($this->signer, $this->getVerificationKey())]; - - return; - } - - $this->config ??= $this->buildConfig(); - $this->parser = $this->config->parser(); - $this->validator = $this->config->validator(); - $this->validationConstraints = $this->config->validationConstraints(); - } - - /** - * Get the configuration used to sign tokens. - * - * @throws JwtException - */ - protected function getSigningConfig(): Configuration - { - return $this->config ??= $this->buildConfig(); + $this->verifiesOnly = $config === null && $this->isAsymmetric() && ! $this->getPrivateKey(); + $this->config = $config ?? $this->buildConfig(); } /** @@ -270,7 +232,6 @@ protected function getSigningConfig(): Configuration */ protected function onConfigurationChanged(): void { - $this->config = null; $this->configure(); } diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php index 7b95252820..1e05754d43 100644 --- a/tests/Jwt/Providers/LcobucciTest.php +++ b/tests/Jwt/Providers/LcobucciTest.php @@ -23,6 +23,7 @@ use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Hmac\Sha256; use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Signer\Rsa\Sha256 as RsaSha256; use Lcobucci\JWT\Validation\Constraint\IssuedBy; use Lcobucci\JWT\Validation\Constraint\SignedWith; use PHPUnit\Framework\Attributes\DataProvider; @@ -557,6 +558,27 @@ public function testSetKeysTakesEffectOnSigning(): void $this->getProvider('does_not_matter', Provider::ALGO_RS256, $keyPair1)->decode($token); } + public function testSetKeysSwitchesBetweenVerifyingAndSigning(): void + { + $keyPair = ['private' => $this->getDummyPrivateKey(), 'public' => $this->getDummyPublicKey()]; + + $provider = $this->getProvider('does_not_matter', Provider::ALGO_RS256, ['public' => $keyPair['public']]); + $provider->setKeys($keyPair); + + $token = $provider->encode(['sub' => 1, 'iat' => $this->testNowTimestamp]); + + $this->assertSame('1', $provider->decode($token)['sub']); + + $provider->setKeys(['public' => $keyPair['public']]); + + $this->assertSame('1', $provider->decode($token)['sub']); + + $this->expectException(JwtException::class); + $this->expectExceptionMessageIs('Private key is not set.'); + + $provider->encode(['sub' => 1]); + } + public function testConstraintsAddedByBuildConfigApplyWhenDecoding(): void { $provider = new LcobucciWithIssuerConstraint($this->getRandomString(), Provider::ALGO_HS256, []); @@ -571,6 +593,26 @@ public function testConstraintsAddedByBuildConfigApplyWhenDecoding(): void $provider->decode($provider->encode(['sub' => 1, 'iss' => 'https://other.example.test'])); } + #[DataProvider('asymmetricKeyPairProvider')] + public function testConstraintsAddedByBuildConfigApplyWithOnlyThePublicKey(string $algo, string $privateKeyFile, string $publicKeyFile): void + { + $publicKey = file_get_contents(__DIR__ . "/../Fixtures/keys/{$publicKeyFile}"); + $issuer = $this->getProvider('does_not_matter', $algo, [ + 'private' => file_get_contents(__DIR__ . "/../Fixtures/keys/{$privateKeyFile}"), + 'public' => $publicKey, + ]); + $verifier = new LcobucciWithIssuerConstraint('does_not_matter', $algo, ['public' => $publicKey]); + + $claims = $verifier->decode($issuer->encode(['sub' => 1, 'iss' => 'https://issuer.example.test'])); + + $this->assertSame('https://issuer.example.test', $claims['iss']); + + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessageIs('Token Signature could not be verified.'); + + $verifier->decode($issuer->encode(['sub' => 1, 'iss' => 'https://other.example.test'])); + } + public function testAGivenConfigurationSignsAndVerifiesTokensInsteadOfTheSecret(): void { $secret = $this->getRandomString(); @@ -589,6 +631,18 @@ public function testAGivenConfigurationSignsAndVerifiesTokensInsteadOfTheSecret( $this->getProvider($secret, Provider::ALGO_HS256)->decode($token); } + public function testAGivenAsymmetricConfigurationSignsWithoutLocalKeys(): void + { + $publicKey = InMemory::plainText($this->getDummyPublicKey()); + $config = Configuration::forAsymmetricSigner(new RsaSha256, InMemory::plainText($this->getDummyPrivateKey()), $publicKey) + ->withValidationConstraints(new SignedWith(new RsaSha256, $publicKey)); + + $provider = new Lcobucci('does_not_matter', Provider::ALGO_RS256, [], $config); + $token = $provider->encode(['sub' => 1, 'iat' => $this->testNowTimestamp]); + + $this->assertSame('1', $provider->decode($token)['sub']); + } + #[DataProvider('credentialFailureProvider')] public function testSigningCredentialsAreKeptOutOfExceptionTraces(Closure $configure, string $function, array $positions): void {