From c078a81d97294a2d6edabe1b1b9a01408b01a873 Mon Sep 17 00:00:00 2001 From: Igor Filippov Date: Thu, 20 Aug 2026 15:44:14 +0200 Subject: [PATCH] Support Laravel 13 and firebase/php-jwt 7, drop Laravel 11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency support: - laravel/framework ^12|^13 — Laravel 11 is dropped because every 11.x release is blocked by security advisories under Composer's default policy, so it cannot be installed anyway - firebase/php-jwt ^6.8|^7.0 — all 6.x releases are affected by CVE-2025-45769, so Composer cannot resolve a php-jwt 6 only constraint without an explicit advisory ignore in every consuming application. 6.8+ stays allowed so applications already locked on php-jwt 6 can adopt Laravel 13 without rotating signing keys first - orchestra/testbench ^10.0|^11.0, phpunit/phpunit ^11.3|^12.0 Guard registration on Laravel 13: Laravel 13 rebinds the AuthManager::extend() callback to the AuthManager instance (Illuminate\Support\RebindsCallbacksToSelf), which changes both $this and the closure scope. Calling $this->createStatelessGuard() therefore resolves against the AuthManager and every request through either guard fails with a 500. Guard creation now goes through first-class callables, which keep their own $this and scope on Laravel 12 and 13 alike. PHP 8.4: - explicit nullable types for $previous and $timebox, which were implicitly nullable Static analysis: - phpstan/phpstan ^2.2. PHPStan 1.12 has been EOL since July 2025 and reported Command::SUCCESS as undefined on Laravel 13 (symfony/console 8 declares it just fine) while missing the actual AuthManager problem above - checkGenericClassInNonGenericObjectType was removed in PHPStan 2, so Payload declares its Arrayable generics instead Tests and CI: - GuardTest covers guard resolution, both drivers over HTTP, token blacklisting on logout and password-change invalidation. The suite previously only covered JwtDecoder, which is why broken guards passed CI - tests and PHPStan now run against a Laravel 12/13 x php-jwt 6/7 matrix Docs: - document supported versions and the minimum signing key size that firebase/php-jwt 7 enforces for HMAC algorithms Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/php-code-analyzing.yml | 11 +- .github/workflows/php-tests.yml | 16 +- README.md | 16 +- composer.json | 14 +- phpstan.neon | 4 +- .../NonAuthenticatableModelException.php | 2 +- src/LaravelJwtServiceProvider.php | 19 +- src/Payload.php | 6 + src/StatefulGuard.php | 2 +- tests/Fixtures/GuardTestUser.php | 26 +++ tests/GuardTest.php | 210 ++++++++++++++++++ 11 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 tests/Fixtures/GuardTestUser.php create mode 100644 tests/GuardTest.php diff --git a/.github/workflows/php-code-analyzing.yml b/.github/workflows/php-code-analyzing.yml index df154fc..3993928 100644 --- a/.github/workflows/php-code-analyzing.yml +++ b/.github/workflows/php-code-analyzing.yml @@ -9,6 +9,13 @@ jobs: container: image: ubuntu:latest + strategy: + fail-fast: false + matrix: + laravel: [ '12', '13' ] + + name: PHPStan / Laravel ${{ matrix.laravel }} + steps: - name: Checkout code (git) uses: actions/checkout@v3 @@ -20,7 +27,9 @@ jobs: tools: composer - name: Install dependencies with composer - run: composer install --no-progress + run: > + composer update --with-all-dependencies --no-progress + --with "laravel/framework:^${{ matrix.laravel }}.0" - name: Run PHPStan run: ./vendor/bin/phpstan analyze --memory-limit 2G diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml index c792117..ac6ee7c 100644 --- a/.github/workflows/php-tests.yml +++ b/.github/workflows/php-tests.yml @@ -9,6 +9,15 @@ jobs: container: image: ubuntu:latest + strategy: + fail-fast: false + matrix: + php-version: [ '8.3', '8.4' ] + laravel: [ '12', '13' ] + php-jwt: [ '6', '7' ] + + name: PHP ${{ matrix.php-version }} / Laravel ${{ matrix.laravel }} / php-jwt ${{ matrix.php-jwt }} + steps: - name: Checkout code (git) uses: actions/checkout@v3 @@ -16,11 +25,14 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ matrix.php-version }} tools: composer - name: Install dependencies with composer - run: composer install --no-progress + run: > + composer update --with-all-dependencies --no-progress + --with "laravel/framework:^${{ matrix.laravel }}.0" + --with "firebase/php-jwt:^${{ matrix.php-jwt }}.0" - name: Run PHPUnit run: ./vendor/bin/phpunit diff --git a/README.md b/README.md index be0ada8..d1c367e 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,20 @@ This package provides a simple way to use JWT (JSON Web Tokens) as an authentication guard in a Laravel application. +## Requirements + +| Package | Supported versions | +|---------------------|--------------------| +| PHP | 8.2+ (8.3+ on Laravel 13) | +| `laravel/framework` | 12, 13 | +| `firebase/php-jwt` | 6.8+, 7 | + +> **`firebase/php-jwt` 7 enforces a minimum signing key size.** With HMAC algorithms +> (`HS256`/`HS384`/`HS512`) a key shorter than 32 bytes makes token issuing fail with +> `DomainException: Provided key is too short`. Laravel's `APP_KEY` is long enough; a custom +> `LARAVEL_JWT_ENCODE_KEY` may not be. Check your keys before upgrading `firebase/php-jwt` +> from 6 to 7, or pin `firebase/php-jwt` to `^6.8` until the keys are rotated. + ## Installation Require package `zendrop/laravel-jwt` @@ -108,4 +122,4 @@ For stateful JWT: ], ] -``` \ No newline at end of file +``` diff --git a/composer.json b/composer.json index ed4d745..d1002ab 100644 --- a/composer.json +++ b/composer.json @@ -2,8 +2,8 @@ "name": "zendrop/laravel-jwt", "type": "library", "require": { - "firebase/php-jwt": "^6.8", - "laravel/framework": "^11|^12" + "firebase/php-jwt": "^6.8|^7.0", + "laravel/framework": "^12|^13" }, "license": "MIT", "autoload": { @@ -30,12 +30,12 @@ } }, "require-dev": { - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^2.2", "laravel/pint": "^1.13", "squizlabs/php_codesniffer": "^3.7", "slevomat/coding-standard": "^8.14", - "orchestra/testbench": "^9.5|^10.0", - "phpunit/phpunit": "^11.3" + "orchestra/testbench": "^10.0|^11.0", + "phpunit/phpunit": "^11.3|^12.0" }, "config": { "allow-plugins": { @@ -43,7 +43,9 @@ }, "policy": { "advisories": { - "ignore-id": ["PKSA-y2cr-5h3j-g3ys"] + "ignore-id": [ + "PKSA-y2cr-5h3j-g3ys" + ] } } } diff --git a/phpstan.neon b/phpstan.neon index 534f9ee..48d33fa 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,4 +10,6 @@ parameters: maximumNumberOfProcesses: 4 jobSize: 20 - checkGenericClassInNonGenericObjectType: false + ignoreErrors: + # HasJwt is a trait for consuming applications, so it has no usage inside src/. + - identifier: trait.unused diff --git a/src/Exceptions/NonAuthenticatableModelException.php b/src/Exceptions/NonAuthenticatableModelException.php index 6453515..be38cad 100644 --- a/src/Exceptions/NonAuthenticatableModelException.php +++ b/src/Exceptions/NonAuthenticatableModelException.php @@ -7,7 +7,7 @@ class NonAuthenticatableModelException extends LaravelJwtException public function __construct( string $message = 'The model must implement the Authenticatable interface to use JWT.', int $code = 0, - \Throwable $previous = null + ?\Throwable $previous = null ) { if ($message) { parent::__construct($message, $code, $previous); diff --git a/src/LaravelJwtServiceProvider.php b/src/LaravelJwtServiceProvider.php index 8bff7c8..30649ce 100644 --- a/src/LaravelJwtServiceProvider.php +++ b/src/LaravelJwtServiceProvider.php @@ -99,12 +99,19 @@ protected function bindDependencies(): void */ protected function configureGuardDrivers(): void { + // Laravel 13 rebinds the extend() callback to the AuthManager instance + // (Illuminate\Support\RebindsCallbacksToSelf), which changes both $this and the + // closure scope. First-class callables keep their own $this and scope, so guard + // creation keeps working on Laravel 11, 12 and 13. + $createStatelessGuard = $this->createStatelessGuard(...); + $createStatefulGuard = $this->createStatefulGuard(...); + // laravel-jwt - Auth::resolved(function (AuthManager $auth) { + Auth::resolved(function (AuthManager $auth) use ($createStatelessGuard) { $auth->extend( driver: static::GUARD_DRIVER_STATELESS, - callback: function ($app, $name, array $config) use ($auth) { - $guard = $this->createStatelessGuard($auth, $config); + callback: function ($app, $name, array $config) use ($auth, $createStatelessGuard) { + $guard = $createStatelessGuard($auth, $config); $app->refresh('request', $guard, 'setRequest'); return $guard; @@ -113,11 +120,11 @@ protected function configureGuardDrivers(): void }); // laravel-jwt-stateful - Auth::resolved(function (AuthManager $auth) { + Auth::resolved(function (AuthManager $auth) use ($createStatefulGuard) { $auth->extend( driver: static::GUARD_DRIVER_STATEFUL, - callback: function ($app, $name, array $config) use ($auth) { - $guard = $this->createStatefulGuard($auth, $name, $config); + callback: function ($app, $name, array $config) use ($auth, $createStatefulGuard) { + $guard = $createStatefulGuard($auth, $name, $config); $app->refresh('request', $guard, 'setRequest'); return $guard; diff --git a/src/Payload.php b/src/Payload.php index 18f9b55..da640d8 100644 --- a/src/Payload.php +++ b/src/Payload.php @@ -4,6 +4,9 @@ use Illuminate\Contracts\Support\Arrayable; +/** + * @implements Arrayable + */ class Payload implements Arrayable { public function __construct( @@ -15,6 +18,9 @@ public function __construct( ) { } + /** + * @return array + */ public function toArray(): array { $reflectionClass = new \ReflectionClass($this); diff --git a/src/StatefulGuard.php b/src/StatefulGuard.php index bf5a17e..9af5e61 100644 --- a/src/StatefulGuard.php +++ b/src/StatefulGuard.php @@ -86,7 +86,7 @@ public function __construct( BlacklistDriverInterface $blacklist, UserProvider $provider, EventDispatcher $eventDispatcher, - Timebox $timebox = null, + ?Timebox $timebox = null, ) { $this->name = $name; $this->request = $request; diff --git a/tests/Fixtures/GuardTestUser.php b/tests/Fixtures/GuardTestUser.php new file mode 100644 index 0000000..a3c5a3f --- /dev/null +++ b/tests/Fixtures/GuardTestUser.php @@ -0,0 +1,26 @@ + + */ + protected $fillable = ['email', 'password']; +} diff --git a/tests/GuardTest.php b/tests/GuardTest.php new file mode 100644 index 0000000..a548d69 --- /dev/null +++ b/tests/GuardTest.php @@ -0,0 +1,210 @@ + + */ + protected function getPackageProviders($app): array + { + return [LaravelJwtServiceProvider::class]; + } + + /** + * @param \Illuminate\Foundation\Application $app + */ + protected function defineEnvironment($app): void + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('laravel-jwt.payload.iss', 'https://jwt.test'); + $app['config']->set('laravel-jwt.keys.encode', self::KEY); + $app['config']->set('laravel-jwt.keys.decode', self::KEY); + $app['config']->set('auth.providers.users.model', GuardTestUser::class); + $app['config']->set('auth.guards.' . LaravelJwtServiceProvider::GUARD_DRIVER_STATEFUL, [ + 'driver' => LaravelJwtServiceProvider::GUARD_DRIVER_STATEFUL, + 'provider' => 'users', + ]); + } + + protected function defineDatabaseMigrations(): void + { + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('email')->unique(); + $table->string('password'); + }); + + Schema::create(config('laravel-jwt.blacklist-database-table'), function (Blueprint $table): void { + $table->id(); + $table->string('hash')->unique(); + $table->dateTime('expired_at')->nullable(); + $table->timestamps(); + }); + } + + protected function defineRoutes($router): void + { + /** @var Router $router */ + $router->middleware('auth:' . LaravelJwtServiceProvider::GUARD_DRIVER_STATELESS) + ->get('/_test/stateless', fn () => ['id' => Auth::guard(LaravelJwtServiceProvider::GUARD_DRIVER_STATELESS)->id()]); + + $router->middleware('auth:' . LaravelJwtServiceProvider::GUARD_DRIVER_STATEFUL) + ->get('/_test/stateful', fn () => ['id' => Auth::guard(LaravelJwtServiceProvider::GUARD_DRIVER_STATEFUL)->id()]); + + $router->post('/_test/login', fn () => [ + 'ok' => Auth::guard(LaravelJwtServiceProvider::GUARD_DRIVER_STATEFUL)->attempt([ + 'email' => 'jwt@example.com', + 'password' => self::PASSWORD, + ]), + ]); + + $router->post('/_test/logout', function () { + Auth::guard(LaravelJwtServiceProvider::GUARD_DRIVER_STATEFUL)->logout(); + + return ['blacklisted' => BlacklistTokenModel::query()->count()]; + }); + } + + public function testBothGuardDriversAreResolvable(): void + { + $this->assertInstanceOf( + RequestGuard::class, + Auth::guard(LaravelJwtServiceProvider::GUARD_DRIVER_STATELESS) + ); + $this->assertInstanceOf( + StatefulGuard::class, + Auth::guard(LaravelJwtServiceProvider::GUARD_DRIVER_STATEFUL) + ); + } + + public function testStatelessGuardAuthenticatesUserFromBearerToken(): void + { + $user = $this->createUser(); + + $this->withToken((string) $user->makeJwt()) + ->getJson('/_test/stateless') + ->assertOk() + ->assertJson(['id' => $user->id]); + } + + public function testStatelessGuardRejectsMalformedToken(): void + { + $this->createUser(); + + $this->withToken('not.a.jwt') + ->getJson('/_test/stateless') + ->assertUnauthorized(); + } + + public function testStatelessGuardRejectsRequestWithoutToken(): void + { + $this->createUser(); + + $this->getJson('/_test/stateless')->assertUnauthorized(); + } + + public function testStatelessGuardRejectsTokenAfterPasswordChange(): void + { + $user = $this->createUser(); + $token = (string) $user->makeJwt(); + + $user->password = Hash::make('a-brand-new-password'); + $user->save(); + + $this->withToken($token)->getJson('/_test/stateless')->assertUnauthorized(); + } + + public function testStatefulGuardAuthenticatesUserFromBearerToken(): void + { + $user = $this->createUser(); + + $this->withToken((string) $user->makeJwt()) + ->getJson('/_test/stateful') + ->assertOk() + ->assertJson(['id' => $user->id]); + } + + public function testStatefulGuardAttemptQueuesJwtCookie(): void + { + $user = $this->createUser(); + + $this->postJson('/_test/login') + ->assertOk() + ->assertJson(['ok' => true]); + + $cookieName = config('laravel-jwt.token-cookie-name'); + $this->assertTrue(Cookie::hasQueued($cookieName)); + + $jwt = $this->app->make(JwtDecoderInterface::class)->decode(Cookie::queued($cookieName)->getValue()); + + $this->assertNotNull($jwt); + $this->assertSame($user->id, $jwt->payload->sub); + } + + public function testStatefulGuardRejectsInvalidCredentials(): void + { + $user = $this->createUser(); + $user->password = Hash::make('some-other-password'); + $user->save(); + + $this->postJson('/_test/login')->assertOk()->assertJson(['ok' => false]); + } + + public function testStatefulGuardBlacklistsTokenOnLogout(): void + { + $user = $this->createUser(); + $token = (string) $user->makeJwt(); + + $this->withToken($token) + ->postJson('/_test/logout') + ->assertOk() + ->assertJson(['blacklisted' => 1]); + + $this->withToken($token)->getJson('/_test/stateful')->assertUnauthorized(); + $this->withToken($token)->getJson('/_test/stateless')->assertUnauthorized(); + } + + public function testRemoveExpiredBlacklistedTokensCommandSucceeds(): void + { + $this->artisan('laravel-jwt:remove-expired-blacklisted-tokens')->assertSuccessful(); + } + + private function createUser(): GuardTestUser + { + $user = new GuardTestUser(); + $user->email = 'jwt@example.com'; + $user->password = Hash::make(self::PASSWORD); + $user->save(); + + return $user; + } +}