diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 60fd2ca3ba..c515b5db31 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -173,9 +173,17 @@ 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: 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: branch: v4 diff --git a/src/docs/jwt.md b/src/docs/jwt.md index d8399e116e..34dce571df 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) @@ -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. 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. +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`: @@ -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. @@ -166,6 +172,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 @@ -195,6 +214,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 @@ -218,6 +239,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 @@ -294,13 +317,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; @@ -314,9 +331,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'), +``` -For a non-standard header or token scheme, implement `Hypervel\Jwt\Contracts\TokenExtractor` and add that class to `jwt.parser`. +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. + +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 @@ -348,40 +390,75 @@ 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), ``` +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 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. 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. -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. Removing every revocation at once requires a store that supports tags, as described in [managing revocations](#managing-revocations). -If your cache store does not support tags, implement `Hypervel\Jwt\Contracts\StorageContract` and configure your implementation using `jwt.providers.storage`. +The blacklist uses the configured storage provider: -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. +```php +'providers' => [ + 'storage' => Hypervel\Jwt\Storage\CacheStorage::class, +], +``` -You may configure a grace period for concurrent requests that are using the same token while a refresh is in progress: +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: ```php 'blacklist_grace_period' => (int) env('JWT_BLACKLIST_GRACE_PERIOD', 0), @@ -424,15 +501,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); ``` @@ -447,6 +528,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 @@ -458,18 +541,32 @@ $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(); ``` -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 @@ -539,31 +636,50 @@ 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 -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`: +While the blacklist is enabled, you may invalidate a token directly using the `invalidate` method: ```php 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); +``` + + +### 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 @@ -575,14 +691,18 @@ 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')->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 @@ -591,7 +711,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 @@ -610,24 +730,9 @@ 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 -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 diff --git a/src/jwt/README.md b/src/jwt/README.md index 2a9e0c8cee..c9989205ac 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -3,14 +3,32 @@ 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. -- 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. +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. -Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth +- 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. +- 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. +- 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 +- https://github.com/tymondesigns/jwt-auth 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 f56f419170..45c8fd4d53 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. | */ @@ -99,10 +99,10 @@ | 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. + | Notice: If you set this to null, 'exp' must not be listed in 'required_claims'. | */ @@ -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. | @@ -233,7 +235,20 @@ | */ - 'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', false), + 'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | 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'), /* |-------------------------------------------------------------------------- @@ -264,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, ], @@ -280,11 +297,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. | */ @@ -301,29 +316,16 @@ */ 'providers' => [ - /* - |-------------------------------------------------------------------------- - | JWT Provider - |-------------------------------------------------------------------------- - | - | Specify the provider that is used to create and decode the tokens. - | - */ - - 'jwt' => Hypervel\Jwt\Providers\Lcobucci::class, - /* |-------------------------------------------------------------------------- | Storage Provider |-------------------------------------------------------------------------- | | 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 394e824493..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' ) { @@ -43,15 +46,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; } @@ -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/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/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/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 8fc434f613..9dc1347cdc 100644 --- a/src/jwt/src/Contracts/ProviderContract.php +++ b/src/jwt/src/Contracts/ProviderContract.php @@ -4,9 +4,17 @@ namespace Hypervel\Jwt\Contracts; +use SensitiveParameter; + interface ProviderContract { + /** + * Create a JSON Web Token. + */ public function encode(array $payload): string; - public function decode(string $token): array; + /** + * Decode a JSON Web Token. + */ + public function decode(#[SensitiveParameter] string $token): array; } 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/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 @@ -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/JwtGuard.php b/src/jwt/src/JwtGuard.php index 3b8e45fd8f..3128c117f4 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,28 +558,19 @@ 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); } } /** * Decode a JWT token, caching the result per coroutine. */ - protected function decodeToken(string $token): array + protected function decodeToken(#[SensitiveParameter] string $token): array { return CoroutineContext::getOrSet( $this->getPayloadContextKey($token), @@ -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/JwtManager.php b/src/jwt/src/JwtManager.php index 2f57825e47..83eead8657 100644 --- a/src/jwt/src/JwtManager.php +++ b/src/jwt/src/JwtManager.php @@ -17,11 +17,11 @@ use Hypervel\Support\Facades\Date; use Hypervel\Support\Manager; use Hypervel\Support\Str; -use RuntimeException; +use SensitiveParameter; class JwtManager extends Manager implements ManagerContract { - protected ?BlacklistContract $blacklist; + protected ?BlacklistContract $blacklist = null; protected bool $blacklistEnabled = false; @@ -37,9 +37,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 +44,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'), @@ -83,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); @@ -98,6 +89,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,19 +105,23 @@ 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)) { 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, @@ -159,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); @@ -175,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.'); @@ -213,7 +211,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'); } } @@ -227,14 +231,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/jwt/src/JwtServiceProvider.php b/src/jwt/src/JwtServiceProvider.php index 6ad0b45cc3..8df1b78b8e 100644 --- a/src/jwt/src/JwtServiceProvider.php +++ b/src/jwt/src/JwtServiceProvider.php @@ -5,19 +5,17 @@ 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\Contracts\TokenExtractor; 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 { @@ -31,18 +29,18 @@ 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), )); - $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'), @@ -53,15 +51,14 @@ 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', 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), }; @@ -105,11 +102,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( @@ -124,6 +123,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, ); @@ -133,23 +134,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/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index c8b19c6c54..aab802a171 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,17 +12,19 @@ use Hypervel\Jwt\Exceptions\JwtException; use Hypervel\Jwt\Exceptions\SecretMissingException; use Hypervel\Jwt\Exceptions\TokenInvalidException; -use Hypervel\Support\Collection; +use Hypervel\Support\Facades\Date; 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; use Lcobucci\JWT\Token\Plain; use Lcobucci\JWT\Token\RegisteredClaims; use Lcobucci\JWT\Validation\Constraint\SignedWith; +use SensitiveParameter; use Throwable; class Lcobucci extends Provider implements ProviderContract @@ -32,19 +35,31 @@ class Lcobucci extends Provider implements ProviderContract protected Signer $signer; /** - * \Lcobucci\JWT\Configuration. + * The configuration that signs and verifies tokens. */ protected Configuration $config; + /** + * Whether the provider has no private key, so it can only verify tokens. + */ + protected bool $verifiesOnly = false; + /** * 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->configure($config); } /** @@ -69,6 +84,10 @@ public function __construct(string $secret, string $algo, array $keys, ?Configur */ public function encode(array $payload): string { + if ($this->verifiesOnly) { + throw new JwtException('Private key is not set.'); + } + $builder = $this->getBuilderFromClaims($payload); try { @@ -85,7 +104,7 @@ public function encode(array $payload): string * * @throws JwtException */ - public function decode(string $token): array + public function decode(#[SensitiveParameter] string $token): array { try { /** @var Plain */ @@ -98,21 +117,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(), + ); } /** @@ -128,13 +147,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 +174,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. */ @@ -163,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()); @@ -174,16 +211,28 @@ protected function buildConfig(): Configuration } /** - * Rebuild cached derived state after a configuration change. + * Build the signer, and the configuration unless one is given. + * + * @throws JwtException + */ + protected function configure(?Configuration $config = null): void + { + // buildConfig() reads the signer and the verification-only flag. + $this->signer = $this->getSigner(); + $this->verifiesOnly = $config === null && $this->isAsymmetric() && ! $this->getPrivateKey(); + $this->config = $config ?? $this->buildConfig(); + } + + /** + * Rebuild the signer and configuration after a setting changes. * - * Signer is rebuilt before config because buildConfig() reads $this->signer. + * 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->configure(); } /** @@ -202,6 +251,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) @@ -255,7 +307,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/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/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/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/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/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/BlacklistTest.php b/tests/Jwt/BlacklistTest.php index 0a591e7c01..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() @@ -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..8e94a9f11e 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), @@ -169,25 +202,123 @@ 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')); + + $factory = $this->factory(); + $payload = ['sub' => 42, 'iat' => 100]; + + $first = $factory->refresh( + payload: $payload, + ttl: null, + refreshIssuedAt: $refreshIssuedAt, + resetClaims: false, + persistentClaims: [], + ); + + 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')] + 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], - ttl: null, - refreshIssuedAt: true, + 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(1767225600, $claims['iat']); + $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 +326,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/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/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/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 69d88c3661..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,10 +190,12 @@ 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', 'JWT_TOKEN' => 'api_token', + 'JWT_COOKIE_KEY_NAME' => 'jwt_cookie', ]); try { @@ -157,24 +204,18 @@ 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']); $this->assertSame('api_token', $config['token']); + $this->assertSame('jwt_cookie', $config['cookie_key_name']); } finally { $this->restoreEnvironmentVariables($originalValues); Env::flushRepository(); } } - public function testDefaultParserOnlyIncludesAuthorizationHeaders(): void - { - $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; - - $this->assertSame([AuthHeaders::class], $config['parser']); - $this->assertNotContains(InputSource::class, $config['parser']); - } - public function testNotBeforeClaimClassIsUsedInConfiguration(): void { $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; @@ -196,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 @@ -215,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 14e93757be..acf71d492a 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; @@ -33,7 +38,19 @@ 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(), + ]); + }); + + 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(), ]); @@ -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/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, diff --git a/tests/Jwt/JwtManagerTest.php b/tests/Jwt/JwtManagerTest.php index afaa6f9b18..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; @@ -17,6 +18,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; @@ -27,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 @@ -123,9 +127,6 @@ public function testNullSecretSupportsAsymmetricSigning(): void 'jwt' => [ 'blacklist_enabled' => false, 'driver' => 'lcobucci', - 'providers' => [ - 'jwt' => Lcobucci::class, - ], 'secret' => null, 'algo' => Provider::ALGO_RS256, 'keys' => [ @@ -149,18 +150,45 @@ public function testNullSecretSupportsAsymmetricSigning(): void $this->assertSame('value', $payload['custom']); } - public function testConstructorDoesNotResolveBlacklistWhenBlacklistIsDisabled(): void + public function testCustomDriverCanReplaceTheLcobucciDriver(): void { - $container = m::mock(Container::class); - $config = m::mock(Repository::class); + $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; - $container->shouldReceive('make')->once()->with('config')->andReturn($config); - $container->shouldReceive('make')->with(BlacklistContract::class)->never(); - $config->shouldReceive('boolean')->once()->with('jwt.blacklist_enabled')->andReturnFalse(); + $manager->extend('lcobucci', static fn (): Lcobucci => $provider); - $manager = new JwtManager($container, m::mock(ClaimFactory::class)); + $this->assertSame($provider, $manager->driver()); + } + + public function testDisabledBlacklistIsNeverResolved(): void + { + $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(); + + $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 = $this->createManager(); $this->assertFalse($manager->hasBlacklistEnabled()); + $this->assertSame($payload, $manager->decode($token)); + $this->assertSame($refreshedToken, $manager->refresh($token)); } public function testDecodeAToken(): void @@ -345,24 +373,75 @@ 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'; $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 +460,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 +600,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 +620,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 +629,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'; @@ -675,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'; @@ -707,6 +908,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'); @@ -717,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 @@ -746,6 +965,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; @@ -760,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/JwtServiceProviderTest.php b/tests/Jwt/JwtServiceProviderTest.php index 19c3c45ee2..49afc8f987 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; @@ -18,19 +14,22 @@ 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; 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 { @@ -41,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(); @@ -141,152 +162,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); - - $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); + $config->set('jwt.blacklist_store', $store); $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); @@ -301,12 +218,13 @@ public function testCustomBlacklistStorageBypassesTaggedCacheRequirement(): 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); @@ -317,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, @@ -347,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'); @@ -361,20 +298,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 @@ -412,3 +339,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'); + } +} diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php index 07decd660f..1e05754d43 100644 --- a/tests/Jwt/Providers/LcobucciTest.php +++ b/tests/Jwt/Providers/LcobucciTest.php @@ -4,6 +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; @@ -14,7 +20,14 @@ 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\Signer\Rsa\Sha256 as RsaSha256; +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 @@ -64,6 +77,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 +88,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']); } @@ -108,6 +123,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 = [ @@ -123,6 +196,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); @@ -160,12 +279,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')] @@ -238,6 +396,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); @@ -327,6 +519,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]; @@ -349,6 +558,153 @@ 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, []); + + $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'])); + } + + #[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(); + $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); + } + + 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 + { + $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); @@ -403,4 +759,35 @@ 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'); + } +} + +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')], + ); + } } 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); - } -} 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"]'], + ]; } } 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'], + ], + ]; + } }