diff --git a/docs/plans/2026-06-28-0709-jwt-hypervel-native-parity-refactor.md b/docs/plans/2026-06-28-0709-jwt-hypervel-native-parity-refactor.md new file mode 100644 index 000000000..3816a37da --- /dev/null +++ b/docs/plans/2026-06-28-0709-jwt-hypervel-native-parity-refactor.md @@ -0,0 +1,1993 @@ +# Hypervel JWT Native Parity Refactor Plan + +## Goal + +Refactor `src/jwt` into a complete, clean, Hypervel-native JWT package that keeps the current array-based, coroutine-safe architecture while bringing over the useful behavior from `php-open-source-saver/jwt-auth`. + +The final codebase reads as if it was designed this way from the start: + +- no stale comments or docs +- no dead exception/classes +- no upstream object model copied back in +- no mutable request/token/custom-claim state on worker-lifetime singletons +- no avoidable hot-path overhead +- no behavior that silently differs from the documented API + +## Source References + +Current Hypervel package: + +- `src/jwt/src/JwtGuard.php` +- `src/jwt/src/JWTManager.php` +- `src/jwt/src/JWTServiceProvider.php` +- `src/jwt/src/Providers/Lcobucci.php` +- `src/jwt/src/Validations/*` +- `src/jwt/config/jwt.php` +- `src/jwt/README.md` +- `tests/JWT/*` + +Current Hypervel auth patterns: + +- `src/auth/src/SessionGuard.php` +- `src/auth/src/AuthManager.php` +- `src/auth/src/EloquentUserProvider.php` +- `src/auth/src/Events/*` + +Upstream JWT package clone: + +- `/tmp/claude-20000/-home-binaryfire-workspace-monorepo/eeff203a-4261-4d58-80c6-bab17f200296/scratchpad/jwt-auth-fork` + +Important upstream files: + +- `src/JWTGuard.php` +- `src/JWT.php` +- `src/JWTAuth.php` +- `src/Manager.php` +- `src/Factory.php` +- `src/Claims/*` +- `src/Http/Parser/*` +- `src/Http/Middleware/*` +- `src/Console/JWTGenerateSecretCommand.php` +- `src/Console/JWTGenerateCertCommand.php` +- `src/Providers/AbstractServiceProvider.php` +- `src/Providers/LaravelServiceProvider.php` +- `config/config.php` +- `docs/auth-guard.md` +- `docs/quick-start.md` + +## Research Summary + +The current Hypervel package is a slim reimplementation, not a direct port. It uses: + +- array payloads instead of upstream `Payload`, `Token`, `Claim`, and `Collection` objects +- `JWTManager` for encode/decode/refresh/invalidate +- `JwtGuard` with `CoroutineContext` for per-coroutine user and payload caches +- a simple validation pipeline under `src/jwt/src/Validations` + +That core design is correct for Hypervel. Upstream stores mutable request/token/custom-claim/last-attempted state on objects such as `JWT`, `JWTAuth`, `JWTGuard`, and `Parser`. In Laravel those instances are request-scoped or refreshed with the request. In Hypervel they would be worker-lifetime singletons, so copying them directly would leak state between coroutines. + +The missing pieces are useful behavior, not a reason to restore the upstream object graph. + +## Final Design Decisions + +### Keep the Array-Based Payload Model + +Do not port upstream `Payload`, `Token`, `Claims`, `Claims\Factory`, or claim DTO classes. + +Why: + +- A JWT payload is a flat claim map. +- The upstream object graph adds per-decode allocations and indirection on the hot path. +- The object graph does not add capability that cannot be represented by arrays plus validation. +- The array model is easier to keep coroutine-safe. + +How: + +- Keep `JWTManager::encode(array $payload): string`. +- Keep `JWTManager::decode(...): array`. +- Keep `JwtGuard::getPayload(): array`. +- Add a small array-based claim builder so default claim construction is centralized without restoring claim objects. + +`JWTManager` receives the claim factory explicitly because refresh claim construction moves there: + +```php +public function __construct( + protected Container $container, + protected ClaimFactory $claimFactory, +) { + parent::__construct($container); + + $this->blacklist = $container->make(BlacklistContract::class); + $this->blacklistEnabled = $this->config->boolean('jwt.blacklist_enabled', false); +} + +public function encode(array $payload): string +{ + if ($this->blacklistEnabled && ! array_key_exists('jti', $payload)) { + $payload['jti'] = (string) Str::uuid(); + } + + return $this->driver()->encode($payload); +} +``` + +`JWTManager::encode()` owns blacklist `jti` stamping so the public `JWT::encode([...])` path still produces invalidatable tokens when blacklist is enabled. It preserves caller-provided `jti` values and adds none when blacklist is disabled. + +### Add Central Claim Building, Not Claim Objects + +Current Hypervel claim construction is split across: + +- `JwtGuard::login()` +- `JWTManager::encode()` +- `JWTManager::buildRefreshClaims()` + +This causes drift and made refresh behavior incomplete. Add a dedicated array-based builder. + +New file: + +- `src/jwt/src/ClaimFactory.php` + +Shape: + +```php +get('jwt.issuer'); + $this->issuer = ($issuer === null || $issuer === '') ? null : $issuer; + + $this->lockSubject = $config->boolean('jwt.lock_subject', true); + } + + /** + * Build claims for a newly issued token. + */ + public function make( + Authenticatable $user, + UserProvider $provider, + ?int $ttl, + array $customClaims = [], + ): array { + $claims = [ + 'sub' => $this->subjectIdentifier($user), + ]; + + if ($this->lockSubject && method_exists($provider, 'getModel')) { + /** @phpstan-ignore-next-line method.notFound */ + $claims['prv'] = $this->subjectModelHash($provider->getModel()); + } + + if ($user instanceof JWTSubject) { + $claims = array_merge($claims, $user->getJWTCustomClaims()); + } + + return $this->withDefaults(array_merge($claims, $customClaims), $ttl); + } + + /** + * Build claims for a refreshed token. + */ + public function refresh( + array $payload, + ?int $ttl, + bool $refreshIssuedAt, + bool $resetClaims, + 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); + + $claims = array_merge($claims, $persistent, $customClaims, [ + 'sub' => $payload['sub'], + ]); + + if (! $refreshIssuedAt) { + $claims['iat'] = $payload['iat']; + } + + if (array_key_exists('prv', $payload)) { + $claims['prv'] = $payload['prv']; + } + + return $this->withDefaults($claims, $ttl); + } + + /** + * Stamp standard claims, then apply caller claims on top. + */ + protected function withDefaults(array $claims, ?int $ttl): array + { + $now = Date::now(); + + $defaults = [ + 'iat' => $now->getTimestamp(), + 'nbf' => $now->getTimestamp(), + ]; + + if ($ttl !== null) { + $defaults['exp'] = $now->addMinutes($ttl)->getTimestamp(); + } + + if ($this->issuer !== null) { + $defaults['iss'] = $this->issuer; + } + + return array_merge($defaults, $claims); + } + + /** + * Determine the subject identifier for a user. + */ + public function subjectIdentifier(Authenticatable $user): mixed + { + return $user instanceof JWTSubject + ? $user->getJWTIdentifier() + : $user->getAuthIdentifier(); + } + + /** + * Check whether a decoded token belongs to the configured provider model. + */ + public function subjectMatchesProvider(array $payload, UserProvider $provider): bool + { + if (! $this->lockSubject || ! method_exists($provider, 'getModel')) { + return true; + } + + /** @phpstan-ignore-next-line method.notFound */ + $model = $provider->getModel(); + + return isset($payload['prv']) + && hash_equals($this->subjectModelHash($model), (string) $payload['prv']); + } + + /** + * Hash the subject model class. + */ + protected function subjectModelHash(string|object $model): string + { + $class = is_object($model) ? $model::class : $model; + + return static::$subjectModelHashes[$class] ??= hash('xxh128', $class); + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$subjectModelHashes = []; + } +} +``` + +Implementation notes: + +- `prv` uses `xxh128`, not upstream `sha1`, because this is an internal signed-token model discriminator and Hypervel already uses `xxh128` for internal non-crypto hashes. +- Static `$subjectModelHashes` is safe because model class strings are immutable worker-lifetime metadata. +- Add `ClaimFactory::flushState()` to `tests/AfterEachTestSubscriber.php`. +- `jwt.issuer` is config-driven. Do not use upstream's request URL issuer because JWTs can be issued from CLI/jobs and request URL issuer adds request-dependent behavior. +- `jti` remains owned by `JWTManager::encode()` so direct `JWT::encode([...])` callers still get invalidatable tokens when blacklist is enabled. + +### Add JWTSubject as an Optional Contract + +New file: + +- `src/jwt/src/Contracts/JWTSubject.php` + +Shape: + +```php +provider = $provider; +} + +protected ?Dispatcher $events = null; + +/** + * Register an authentication attempt event listener. + */ +public function attempting(callable $callback): void +{ + $this->events?->listen(Attempting::class, $callback); +} + +/** + * Get the event dispatcher instance. + */ +public function getDispatcher(): ?Dispatcher +{ + return $this->events; +} + +/** + * Set the event dispatcher instance. + */ +public function setDispatcher(Dispatcher $events): void +{ + $this->events = $events; +} + +/** + * Dispatch the given event if listeners are registered. + */ +protected function dispatchIfListening(string $eventClass, Closure $event): void +{ + if ($this->events?->hasListeners($eventClass)) { + $this->events->dispatch($event()); + } +} +``` + +Then add JWT-specific fire helpers: + +```php +protected function fireAttemptEvent(array $credentials): void +{ + $this->dispatchIfListening(Attempting::class, fn () => new Attempting($this->name, $credentials, false)); +} + +protected function fireValidatedEvent(AuthenticatableContract $user): void +{ + $this->dispatchIfListening(Validated::class, fn () => new Validated($this->name, $user)); +} + +protected function fireFailedEvent(?AuthenticatableContract $user, array $credentials): void +{ + $this->dispatchIfListening(Failed::class, fn () => new Failed($this->name, $user, $credentials)); +} + +protected function fireLoginEvent(AuthenticatableContract $user): void +{ + $this->dispatchIfListening(Login::class, fn () => new Login($this->name, $user, false)); +} + +protected function fireAuthenticatedEvent(AuthenticatableContract $user): void +{ + $this->dispatchIfListening(Authenticated::class, fn () => new Authenticated($this->name, $user)); +} + +protected function fireLogoutEvent(?AuthenticatableContract $user): void +{ + $this->dispatchIfListening(Logout::class, fn () => new Logout($this->name, $user)); +} +``` + +Performance: + +- `hasListeners()` is the existing Hypervel event guard pattern. +- Event objects are constructed only when listeners exist. +- The no-listener path adds only a cached listener lookup. + +### Fix JwtGuard Authentication API + +Current bugs: + +- `attempt()` returns `bool`, while upstream returns token string or `false`. +- `once()` calls `attempt(..., true)` and mints a token. +- `onceUsingId()` calls `login()`, mints a token, and returns `true` instead of the resolved user. +- `login()` can cache the new user under a new-token key while `user()` keeps parsing the old request token. +- `claims()` persists after the token is minted despite the "next token" docblock. + +Final API: + +```php +public function attempt(array $credentials = [], bool $login = true): string|bool +{ + $this->fireAttemptEvent($credentials); + + $user = $this->provider->retrieveByCredentials($credentials); + $this->setContextState('lastAttempted', $user); + + if ($user !== null && $this->provider->validateCredentials($user, $credentials)) { + $this->fireValidatedEvent($user); + + return $login ? $this->login($user) : true; + } + + $this->fireFailedEvent($user, $credentials); + + return false; +} + +public function validate(array $credentials = []): bool +{ + return (bool) $this->attempt($credentials, false); +} + +public function once(array $credentials = []): bool +{ + if ($this->validate($credentials)) { + $this->setUser($this->getLastAttempted()); + + return true; + } + + return false; +} + +public function onceUsingId(mixed $id): AuthenticatableContract|false +{ + if ($user = $this->provider->retrieveById($id)) { + $this->setUser($user); + + return $user; + } + + return false; +} +``` + +Add context helpers in `JwtGuard`, mirroring the current `SessionGuard` idea for simple state while keeping user and payload caches token-keyed: + +```php +protected const string GUARD_CONTEXT_KEY_PREFIX = '__auth.guards.'; + +private const string NO_EXPIRY = '__jwt.ttl.no_expiry'; + +protected function getContextState(string $key, mixed $default = null): mixed +{ + return CoroutineContext::get($this->getContextStateKey($key), $default); +} + +protected function setContextState(string $key, mixed $value): void +{ + CoroutineContext::set($this->getContextStateKey($key), $value); +} + +protected function forgetContextState(string $key): void +{ + CoroutineContext::forget($this->getContextStateKey($key)); +} + +protected function getContextStateKey(string $key): string +{ + return static::GUARD_CONTEXT_KEY_PREFIX . $this->name . '.' . $key; +} + +protected function getUserContextKey(?string $token = null): string +{ + $token ??= $this->getToken(); + + if ($token === null || $token === '') { + return $this->getContextStateKey('user.default'); + } + + return $this->getContextStateKey('user.' . hash('xxh128', $token)); +} + +protected function getPayloadContextKey(string $token): string +{ + return $this->getContextStateKey('payload.' . hash('xxh128', $token)); +} + +public function getLastAttempted(): ?AuthenticatableContract +{ + return $this->getContextState('lastAttempted'); +} + +public function hasUser(): bool +{ + self::$nullUserSentinel ??= new stdClass; + + $cached = CoroutineContext::get($this->getUserContextKey()); + + return $cached !== null && $cached !== self::$nullUserSentinel; +} + +public function setUser(AuthenticatableContract $user): static +{ + CoroutineContext::set($this->getUserContextKey(), $user); + $this->fireAuthenticatedEvent($user); + + return $this; +} + +public function forgetUser(): static +{ + CoroutineContext::forget($this->getUserContextKey()); + + return $this; +} + +protected function cachedUser(): ?AuthenticatableContract +{ + self::$nullUserSentinel ??= new stdClass; + + $cached = CoroutineContext::get($this->getUserContextKey()); + + return ($cached === null || $cached === self::$nullUserSentinel) ? null : $cached; +} +``` + +Current token override: + +```php +public function setToken(string $token): static +{ + $this->setContextState('token', $token); + + return $this; +} + +public function getToken(): ?string +{ + return $this->getContextState('token') ?: $this->parseToken(); +} + +protected function requireToken(): string +{ + if (! $token = $this->getToken()) { + throw new JWTException('Token could not be parsed from the request.'); + } + + return $token; +} +``` + +Use `getToken()` in `user()`, `getPayload()`, `refresh()`, `logout()`, and `invalidate()`. This fixes login/setToken behavior when a request already has an older bearer token. + +User cache keys must be derived from `getToken()`, not a single guard-wide user slot. This preserves the current token-keyed behavior while fixing the login bug: `login()` sets the new token first, then `setUser()` writes to the new token's user cache. It also keeps `setToken($a)->user()` and `setToken($b)->user()` correct inside the same coroutine. + +Payload cache keys also stay token-keyed: + +```php +protected function decodeToken(string $token): array +{ + return CoroutineContext::getOrSet( + $this->getPayloadContextKey($token), + fn () => $this->jwtManager->decode($token) + ); +} +``` + +`login()` sets the token and user: + +```php +public function login(AuthenticatableContract $user): string +{ + $token = $this->makeTokenForUser($user); + + $this->setToken($token); + $this->setUser($user); + $this->fireLoginEvent($user); + + return $token; +} +``` + +`claims()` is "next token" only: + +```php +public function claims(array $claims): static +{ + $contextKey = $this->getContextStateKey('claims'); + $existing = CoroutineContext::get($contextKey, []); + + CoroutineContext::set($contextKey, array_merge($existing, $claims)); + + return $this; +} + +protected function pullCustomClaims(): array +{ + $contextKey = $this->getContextStateKey('claims'); + $claims = CoroutineContext::get($contextKey, []); + CoroutineContext::forget($contextKey); + + return $claims; +} +``` + +### Add Per-Guard and Per-Call TTL + +Current bug: + +- `JWTServiceProvider` ignores `auth.guards.*.ttl`. + +Final service provider behavior: + +```php +$ttl = array_key_exists('ttl', $config) + ? $config['ttl'] + : $app->make('config')->get('jwt.ttl', 120); +``` + +Use `array_key_exists()` so `ttl => null` means "no expiration". + +Runtime per-call TTL: + +```php +public function setTTL(?int $ttl): static +{ + $this->setContextState('ttl', $ttl ?? self::NO_EXPIRY); + + return $this; +} + +public function getTTL(): ?int +{ + $ttl = $this->getContextState('ttl'); + + if ($ttl === null) { + return $this->ttl; + } + + return $ttl === self::NO_EXPIRY ? null : (int) $ttl; +} +``` + +After `login()`, clear only the per-call override: + +```php +$ttl = $this->getTTL(); +$token = $this->jwtManager->encode($this->claimFactory->make(..., ttl: $ttl, ...)); +$this->forgetContextState('ttl'); +``` + +Why: + +- Guard instances are worker-lifetime singletons. +- Runtime TTL overrides must not live on `$this->ttl`. +- Per-guard default TTL belongs in the guard constructor and is safe. +- Per-call override belongs in `CoroutineContext`. +- Refresh uses the same effective TTL as minting. `setTTL()` is a one-shot override for the next token-producing operation, including refresh. +- `getTTL()` uses a non-null sentinel because `CoroutineContext::get()` and `CoroutineContext::has()` both collapse stored `null` into "absent". + +### Add Subject Locking + +Current bug: + +- A token with `sub = 5` minted for one Eloquent provider can authenticate against another Eloquent provider that also has id `5`. + +Final behavior: + +- Add `jwt.lock_subject` config, default `true`. +- When minting through `JwtGuard`, include `prv` when the provider exposes `getModel()`. +- When resolving `user()`, reject payloads whose `prv` does not match the provider model. +- When `lock_subject` is true and the provider exposes `getModel()`, missing `prv` fails. Hypervel 0.4 has no legacy token compatibility requirement, and requiring the claim gives the clean security behavior. +- When provider does not expose `getModel()`, skip the check, matching upstream's safe fallback for non-Eloquent providers. + +Full `user()` shape: + +```php +public function user(): ?AuthenticatableContract +{ + self::$nullUserSentinel ??= new stdClass; + + $token = $this->getToken(); + $contextKey = $this->getUserContextKey($token); + + $cached = CoroutineContext::get($contextKey); + + if ($cached === self::$nullUserSentinel) { + return null; + } + + if ($cached !== null) { + return $cached; + } + + if (! $token) { + CoroutineContext::set($contextKey, self::$nullUserSentinel); + + return null; + } + + try { + $payload = $this->decodeToken($token); + } catch (JWTException) { + CoroutineContext::set($contextKey, self::$nullUserSentinel); + + return null; + } + + $sub = $this->claimFactory->subjectMatchesProvider($payload, $this->provider) + ? ($payload['sub'] ?? null) + : null; + + $user = $sub !== null ? $this->provider->retrieveById($sub) : null; + + if ($user === null) { + CoroutineContext::set($contextKey, self::$nullUserSentinel); + + return null; + } + + $this->setUser($user); + + return $user; +} +``` + +### Add `userOrFail()`, `getUserId()`, `id()`, `tokenById()`, `byId()`, `payload()`, and `invalidate()` + +These are useful upstream APIs and map cleanly to Hypervel. + +```php +public function userOrFail(): AuthenticatableContract +{ + if (! $user = $this->user()) { + throw new UserNotDefinedException; + } + + return $user; +} + +public function getUserId(): int|string|null +{ + if ($user = $this->cachedUser()) { + return $user->getAuthIdentifier(); + } + + try { + $payload = $this->getPayload(); + } catch (JWTException) { + return null; + } + + if (! $this->claimFactory->subjectMatchesProvider($payload, $this->provider)) { + return null; + } + + return $payload['sub'] ?? null; +} + +public function id(): int|string|null +{ + return $this->getUserId(); +} + +public function tokenById(mixed $id): ?string +{ + if (! $user = $this->provider->retrieveById($id)) { + return null; + } + + return $this->makeTokenForUser($user); +} + +public function byId(mixed $id): AuthenticatableContract|false +{ + return $this->onceUsingId($id); +} + +public function payload(): array +{ + return $this->getPayload(); +} + +public function getPayload(): array +{ + if (! $token = $this->getToken()) { + return []; + } + + return $this->decodeToken($token); +} + +public function invalidate(bool $forceForever = false): static +{ + $this->jwtManager->invalidate($this->requireToken(), $forceForever); + + return $this; +} +``` + +`login()` and `tokenById()` must share one token-building helper. This is justified reuse: both paths need the same claim construction, one-shot custom claims, one-shot TTL handling, and cleanup. + +```php +protected function makeTokenForUser(AuthenticatableContract $user): string +{ + $token = $this->jwtManager->encode( + $this->claimFactory->make($user, $this->provider, $this->getTTL(), $this->pullCustomClaims()) + ); + + $this->forgetContextState('ttl'); + + return $token; +} +``` + +`getPayload()` returns an empty array when no token is present, preserving current behavior. It remains a strict inspection API for present tokens and throws JWT exceptions for invalid tokens. `payload()` is a direct alias. + +`user()` and `getUserId()` translate invalid, expired, malformed, or blacklisted tokens into `null`. That is guard resolution behavior, not exception hiding: `auth:jwt` then follows the normal `AuthenticationException` / 401 path. `getPayload()` and `payload()` remain strict inspection APIs and continue throwing JWT exceptions for invalid tokens. + +`getUserId()` returns the already-set user's auth identifier when the guard has one, otherwise decodes the payload and returns `sub` without loading the user record. It must still enforce subject locking, matching `user()` and upstream's `validateSubject()` path. Override `id()` to call it so `Auth::id()` remains cheap on JWT requests. + +### Fix Logout Without Swallowing Real Errors + +Current bug: + +- `jwt.blacklist_enabled` defaults to false. +- `JwtGuard::logout()` always calls `JWTManager::invalidate()`. +- `JWTManager::invalidate()` throws when blacklist is disabled. + +Do not catch and swallow all JWT exceptions like upstream. Hypervel's fail-fast rule is better. + +Add this to `ManagerContract` and `JWTManager`: + +```php +public function hasBlacklistEnabled(): bool; +``` + +```php +public function hasBlacklistEnabled(): bool +{ + return $this->blacklistEnabled; +} +``` + +Then: + +```php +public function logout(bool $forceForever = false): void +{ + $user = $this->cachedUser(); + $token = $this->getToken(); + + $this->forgetUser(); + if ($token) { + CoroutineContext::forget($this->getPayloadContextKey($token)); + } + + $this->forgetContextState('token'); + + if ($token && $this->jwtManager->hasBlacklistEnabled()) { + $this->jwtManager->invalidate($token, $forceForever); + } + + $this->fireLogoutEvent($user); +} +``` + +Why: + +- Local guard state is cleared either way. +- Server-side token invalidation runs only when configured. +- Misconfigured blacklist storage still fails naturally. +- Default logout no longer throws just because blacklist is disabled. + +### Add Refresh-Aware Decoding + +Current bug: + +- `JWTManager::refresh()` calls `decode($token)` through the normal validation pipeline. +- If `ExpiredClaim` validation is enabled, expired tokens cannot be refreshed even when they are inside `refresh_ttl`. + +Final behavior: + +- Add `Hypervel\JWT\Contracts\TemporalValidation` as a marker contract for timestamp/lifetime checks. +- Add a refresh validation mode to `JWTManager`. +- Required-claims validation still runs. +- Temporal validations are skipped for refresh. +- Refresh window validation uses original `iat`. +- Blacklist checks still run for the old token before refresh. + +Simple shape: + +```php +interface TemporalValidation +{ +} +``` + +```php +protected function decodeForRefresh(string $token, bool $checkBlacklist = true): array +{ + $payload = $this->driver()->decode($token); + + $this->validatePayload($payload, refreshFlow: true); + + if ($this->blacklistEnabled && $checkBlacklist && $this->blacklist->has($payload)) { + throw new TokenBlacklistedException('The token has been blacklisted'); + } + + return $payload; +} + +protected function validatePayload(array $payload, bool $refreshFlow = false): void +{ + foreach ($this->config->array('jwt.validations', []) as $validation) { + $validation = $this->getValidation($validation); + + if ($refreshFlow && $validation instanceof TemporalValidation) { + continue; + } + + $validation->validate($payload); + } +} +``` + +Validation behavior: + +- `RequiredClaims` does not implement `TemporalValidation`, so it always runs. +- `ExpiredClaim`, `IssuedAtClaim`, and `NotBeforeClaim` implement `TemporalValidation`, so they are skipped only during refresh. +- `ValidationContract` stays simple: `public function validate(array $payload): void;`. + +`JWTManager::refresh()`: + +```php +public function refresh( + string $token, + bool $forceForever = false, + bool $resetClaims = false, + array $customClaims = [], + int|null|false $ttl = false, +): string +{ + $payload = $this->decodeForRefresh($token); + $this->validateRefreshWindow($payload); + + $claims = $this->claimFactory->refresh( + payload: $payload, + ttl: $ttl === false ? $this->config->get('jwt.ttl', 120) : $ttl, + refreshIssuedAt: $this->config->boolean('jwt.refresh_iat', false), + resetClaims: $resetClaims, + persistentClaims: $this->config->array('jwt.persistent_claims', []), + customClaims: $customClaims, + ); + + if ($this->blacklistEnabled) { + $this->invalidate($token, $forceForever); + } + + return $this->encode($claims); +} +``` + +`ManagerContract::refresh()` must accept `$resetClaims` and inline custom claims: + +```php +public function refresh( + string $token, + bool $forceForever = false, + bool $resetClaims = false, + array $customClaims = [], + int|null|false $ttl = false, +): string; +``` + +`JwtGuard::refresh()` passes both arguments: + +```php +public function refresh(bool $forceForever = false, bool $resetClaims = false): ?string +{ + if (! $token = $this->getToken()) { + return null; + } + + $customClaims = $this->pullCustomClaims(); + $ttl = $this->getTTL(); + $this->forgetContextState('ttl'); + $this->forgetUser(); + CoroutineContext::forget($this->getPayloadContextKey($token)); + + $newToken = $this->jwtManager->refresh($token, $forceForever, $resetClaims, $customClaims, $ttl); + $this->setToken($newToken); + + return $newToken; +} +``` + +### Fix NotBeforeCliam + +Rename: + +- `src/jwt/src/Validations/NotBeforeCliam.php` +- `tests/JWT/Validations/NotBeforeCliamTest.php` + +To: + +- `src/jwt/src/Validations/NotBeforeClaim.php` +- `tests/JWT/Validations/NotBeforeClaimTest.php` + +Update: + +- namespace imports +- config commented class reference +- tests + +No compatibility alias is needed. Hypervel 0.4 is greenfield. + +### Add SecretMissingException + +New file: + +- `src/jwt/src/Exceptions/SecretMissingException.php` + +```php + $chain + */ + public function __construct( + protected array $chain, + ) { + } + + public function parseToken(Request $request): ?string + { + foreach ($this->chain as $extractor) { + $token = $extractor->parseToken($request); + + if (is_string($token) && $token !== '') { + return $token; + } + } + + return null; + } +} +``` + +Register parser as a singleton containing only parser objects and config-derived immutable settings. + +Default chain: + +- `AuthHeaders` +- `InputSource` + +Parser config: + +- `jwt.token` controls the request input and cookie key name. +- `jwt.parser` controls the ordered extractor class list. +- `Cookie` is shipped but opt-in. Upstream enables cookie parsing in Laravel; Hypervel does not enable it by default because the current Hypervel guard only reads the bearer header and request input, and enabling cookie auth by default broadens the auth source. +- Cookie decryption is handled by Hypervel's normal cookie middleware; the JWT parser must not manually decrypt cookies. + +Extractor shapes: + +```php +class AuthHeaders implements TokenExtractor +{ + public function parseToken(Request $request): ?string + { + $header = $request->header('Authorization') + ?: $request->server('HTTP_AUTHORIZATION') + ?: $request->server('REDIRECT_HTTP_AUTHORIZATION'); + + if (! is_string($header)) { + return null; + } + + $position = strripos($header, 'Bearer'); + + if ($position === false) { + return null; + } + + $token = substr($header, $position + strlen('Bearer')); + + return trim(str_contains($token, ',') ? strstr($token, ',', true) : $token) ?: null; + } +} + +class InputSource implements TokenExtractor +{ + public function __construct( + protected string $key = 'token', + ) { + } + + public function parseToken(Request $request): ?string + { + $token = $request->input($this->key); + + return is_string($token) && $token !== '' ? $token : null; + } +} + +class Cookie implements TokenExtractor +{ + public function __construct( + protected string $key = 'token', + ) { + } + + public function parseToken(Request $request): ?string + { + $token = $request->cookie($this->key); + + return is_string($token) && $token !== '' ? $token : null; + } +} +``` + +`JwtGuard::parseToken()`: + +```php +public function parseToken(): ?string +{ + if (! RequestContext::has()) { + return null; + } + + return $this->parser->parseToken($this->app->make('request')); +} +``` + +Do not store the request on the parser. + +Do not port upstream route-param or Lumen parsers. Hypervel has no matching route-token JWT path, and keeping the parser surface request-passed and stateless is the correct Swoole shape. Record the decision in the README under differences and with a concise source comment in parser registration so future porting does not re-add mutable request parser behavior. + +### Add Middleware + +Port only the useful missing sliding-token middleware to Hypervel style: + +- `src/jwt/src/Http/Middleware/RefreshToken.php` +- `src/jwt/src/Http/Middleware/AuthenticateAndRenew.php` + +Do not add `jwt.auth` or `jwt.check`. Normal authentication is already covered by Hypervel's `auth:jwt` middleware, and optional JWT auth is handled by reading the guard lazily. + +Register aliases in `JWTServiceProvider::boot()`: + +```php +$router = $this->app->make('router'); + +$router->aliasMiddleware('jwt.refresh', RefreshToken::class); +$router->aliasMiddleware('jwt.renew', AuthenticateAndRenew::class); +``` + +Middleware uses the auth manager / guard, not upstream `JWTAuth`. + +Base behavior: + +```php +protected function guard(?string $guard = null): JwtGuard +{ + $resolved = $this->auth->guard($guard); + + if (! $resolved instanceof JwtGuard) { + throw new RuntimeException('JWT middleware requires a JWT guard.'); + } + + return $resolved; +} +``` + +`RefreshToken` and `AuthenticateAndRenew` set `Authorization: Bearer ` on the response. + +All middleware must be stateless. Do not store token/user/request on middleware properties. + +### Add Commands + +Port command behavior, not upstream `EnvHelperTrait`. + +New files: + +- `src/jwt/src/Console/JwtSecretCommand.php` +- `src/jwt/src/Console/JwtGenerateCertsCommand.php` + +Add `"hypervel/console": "^0.4"` to `src/jwt/composer.json`; JWT commands extend `Hypervel\Console\Command` and the package does not currently require it. + +Use Hypervel command patterns: + +- `#[AsCommand(name: 'jwt:secret')]` +- `#[AsCommand(name: 'jwt:generate-certs')]` +- `Hypervel\Support\Env::writeVariable()` / `writeVariables()` +- `$this->hypervel->environmentFilePath()` + +Register in `JWTServiceProvider::register()`: + +```php +if ($this->app->runningInConsole()) { + $this->commands([ + JwtSecretCommand::class, + JwtGenerateCertsCommand::class, + ]); +} +``` + +`jwt:secret` behavior: + +- generate 64-character secret +- `--show` prints without writing +- `--force` overwrites without confirmation +- `--always-no` skips if key exists +- write `JWT_SECRET` +- write `JWT_ALGO=HS256` + +`jwt:generate-certs` behavior: + +- generate RSA or EC cert pair +- support force, algo, bits, sha, dir, curve, passphrase, ask-passphrase +- write `JWT_ALGO`, `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, `JWT_PASSPHRASE` +- use typed returns and fail fast on OpenSSL failure + +### Service Provider Wiring + +Update `JWTServiceProvider`: + +- inject config via `$app->make('config')` +- use `->make()`, not array access +- let `ClaimFactory` auto-singleton as an unbound concrete +- bind stateless parser with its configured extractor chain +- register commands +- register middleware aliases +- pass dispatcher to `JwtGuard` +- pass per-guard TTL with `array_key_exists` + +Manager and parser bindings: + +```php +$this->app->singleton('jwt', fn ($app) => new JWTManager( + $app, + $app->make(ClaimFactory::class), +)); + +$this->app->singleton(Parser::class, function ($app) { + $config = $app->make('config'); + $tokenKey = $config->string('jwt.token', 'token'); + + $chain = array_map( + fn (string $extractor) => match ($extractor) { + InputSource::class, Cookie::class => new $extractor($tokenKey), + default => $app->make($extractor), + }, + $config->array('jwt.parser', [AuthHeaders::class, InputSource::class]), + ); + + // The parser chain is stateless; request instances are passed per parse so + // coroutine requests cannot leak through a singleton parser. + return new Parser($chain); +}); +``` + +Guard construction: + +```php +return new JwtGuard( + name: $name, + provider: $authManager->createUserProvider($config['provider'] ?? null), + jwtManager: $app->make('jwt'), + claimFactory: $app->make(ClaimFactory::class), + parser: $app->make(Parser::class), + app: $app, + ttl: array_key_exists('ttl', $config) + ? $config['ttl'] + : $app->make('config')->get('jwt.ttl', 120), +); +``` + +Then: + +```php +$guard->setDispatcher($app->make('events')); +``` + +### Remove Replaced Code + +Delete stale code after the new shape is in place: + +- `JwtGuard::getContextKeyForToken()`; user caching is still token-keyed, but now through `getUserContextKey()` based on `getToken()`. +- `JWTManager::buildRefreshClaims()`; refresh claim construction belongs to `ClaimFactory::refresh()`. +- `Date` and `Str` imports from `JwtGuard`; claim stamping and parsing move to `ClaimFactory` / parser classes. +- `Collection` import from `JWTManager`; refresh claim construction moves to `ClaimFactory`. +- `Str` import and `$blacklistEnabled` property from `ClaimFactory`; blacklist-gated `jti` generation stays in `JWTManager::encode()`. + +Do not leave compatibility wrappers or comments for these removed internals. + +Keep `JwtGuard::flushState()`. It still flushes `Macroable` macros and is already registered in `tests/AfterEachTestSubscriber.php`. Static subject model hashes move to `ClaimFactory::flushState()`, which must also be registered there. + +### Config Updates + +Update `src/jwt/config/jwt.php`: + +- fix `ttl` comment to 2 hours +- add `refresh_iat` +- add `issuer` +- add `lock_subject` +- add parser config +- update `NotBeforeClaim` spelling +- add command mention in secret comment + +Add commented reference entries to the relevant `.env.example` files for: + +- `JWT_ISSUER` +- `JWT_REFRESH_IAT` +- `JWT_LOCK_SUBJECT` +- `JWT_TOKEN` + +Config shape: + +```php +'issuer' => env('JWT_ISSUER'), + +'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', false), + +'refresh_iat' => env('JWT_REFRESH_IAT', false), + +'lock_subject' => env('JWT_LOCK_SUBJECT', true), + +'token' => env('JWT_TOKEN', 'token'), + +'parser' => [ + \Hypervel\JWT\Http\Parser\AuthHeaders::class, + \Hypervel\JWT\Http\Parser\InputSource::class, +], +``` + +Use normal Hypervel config style for boolean env values. Typed config getters will fail fast if unsupported string values are provided; broader env boolean parsing would be a framework-wide policy, not a JWT-only config workaround. + +Decision: + +- Keep `jwt.blacklist_enabled` default false for performance. Blacklist adds cache I/O on authenticated requests. +- Keep `jti` blacklist-gated. +- Keep `ttl` default 120 minutes. +- Keep default validations safe: `RequiredClaims`, `ExpiredClaim`, `IssuerClaim`, `IssuedAtClaim`, and `NotBeforeClaim` are enabled. `IssuerClaim` is a no-op until `jwt.issuer` is configured. +- Keep `required_claims` default as `iat` and `sub`. Do not require `exp` when `ttl` can be null. + +### README and Boost Docs + +Keep `src/jwt/README.md` as the short package landing page used by the other component packages. + +It must include: + +- `Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth` +- a concise description of the package +- a pointer to `src/boost/docs/jwt.md` for full usage docs + +Add `src/boost/docs/jwt.md` as the full user-facing documentation surface, matching the style of the other Boost docs. + +It must cover: + +- installation/config publishing +- `jwt:secret` +- `jwt:generate-certs` +- configuring the jwt guard +- user model requirements and optional `JWTSubject` +- signing keys and algorithms +- token lifetime and per-call TTL +- subject locking +- header/input token parsing and opt-in cookie parsing +- validations, leeway, blacklist, refresh window, and `refresh_iat` +- login with `attempt()` +- `login()` +- `once()` / `onceUsingId()` +- `tokenById()` +- `user()`, `userOrFail()`, `getUserId()`, `id()` +- `claims()` +- `refresh()` +- `logout()` / `invalidate()` and blacklist requirements +- middleware aliases +- exceptions +- differences from upstream + +Differences section: + +- Hypervel uses arrays, not `Payload`, `Token`, or claim DTO objects. +- Hypervel keeps the existing `JWT` facade mapped to the array-based `JWTManager`, but does not ship upstream `JWTAuth`, `JWTFactory`, or `JWTProvider` facades. +- Hypervel does not include Namshi or Lumen integration. +- Hypervel parser is stateless and request is passed per parse. +- Hypervel does not enable cookie token parsing by default; upstream enables it in Laravel. The `Cookie` parser is available and can be added to `jwt.parser` when an application explicitly wants cookie-based JWT auth. +- Hypervel does not support upstream route-param/Lumen parser shortcuts because they are framework-specific and not part of Hypervel's intended JWT path. +- `show_black_list_exception` is not included; exceptions fail normally. + +Also update the Boost docs index and authentication docs: + +- add `jwt.md` to `src/boost/docs/documentation.md` +- mention JWT as a stateless API-auth option from `src/boost/docs/authentication.md` +- keep the custom guard example generic so it does not imply users should build their own JWT guard + +### Source Comments for Intentional Omissions + +Add only concise comments where future porting will naturally look. + +`JWTServiceProvider` near omitted upstream object/facade binding surface: + +```php +// Hypervel intentionally keeps JWT as an array-based manager/guard package. +// Upstream JWT/JWTAuth/Payload/Token/Claim/facade bindings store mutable request +// state and do not fit worker-lifetime singleton guards. +``` + +Parser registration: + +```php +// The parser chain is stateless; request instances are passed per parse so +// coroutine requests cannot leak through a singleton parser. +``` + +Do not add noisy comments to routine methods. + +### Tests + +Keep existing `tests/JWT` tests and update them to the new behavior. Add new tests in the same package directory. No external service tests are needed for this package. + +#### Guard API Tests + +File: + +- `tests/JWT/JwtGuardTest.php` + +Add/replace tests: + +- `attempt()` returns token string on successful login. +- `attempt($credentials, false)` returns true and does not call encode. +- invalid attempt returns false and fires failed event when listeners exist. +- `validate()` returns bool. +- `login()` sets current token and current user even when request contains an older bearer token. +- `once()` does not call encode and sets current user only. +- `onceUsingId()` does not call encode, sets current user only, and returns the user or `false`. +- `tokenById()` returns token without setting current user/token. +- `getUserId()` and `id()` return the token subject without fetching the user and reject subject-lock mismatches. +- `claims()` affects only the next minted token and is cleared afterward. +- `setTTL()` affects only the next token and is cleared afterward. +- `setTTL(null)` omits `exp` and does not leak into a later token. +- per-guard `ttl => null` omits `exp`. +- per-guard `ttl` is honored on refresh. +- `setTTL(n)->refresh()` produces an `n` minute token and does not leak the override into a later token. +- `userOrFail()` throws `UserNotDefinedException`. +- `payload()` aliases `getPayload()`. +- `getPayload()` returns `[]` when no token is present. +- `getPayload()` throws when a present token is invalid. +- `setToken()` overrides request token for `user()` and `getPayload()`. +- `setToken($a)->user()` then `setToken($b)->user()` resolves different token-keyed users in the same coroutine. +- `logout()` clears current user and token. +- `logout()` clears the decoded payload cache for the active token. +- `logout()` does not call invalidate when blacklist is disabled. +- `logout(true)` passes force flag when blacklist is enabled. +- `invalidate()` calls the manager with the current token and force flag. +- `userOrFail()` returns the resolved user on success. + +#### Auth Event Tests + +File: + +- `tests/JWT/JwtGuardEventTest.php` or merge into `JwtGuardTest.php` if size stays readable. + +Tests: + +- no event object is dispatched when `hasListeners()` returns false. +- `Attempting` fires only when listener exists. +- `Validated` fires on valid credentials. +- `Failed` fires on invalid credentials. +- `Login` fires on login. +- `Authenticated` fires on `setUser()` / successful `user()` resolution. +- `Logout` fires on logout. +- `attempting()` registers listener through dispatcher. + +#### Subject Locking and JWTSubject Tests + +File: + +- `tests/JWT/JWTSubjectTest.php` + +Fixtures: + +- `tests/JWT/Fixtures/JWTSubjectUser.php` +- `tests/JWT/Fixtures/JWTSubjectAdmin.php` + +Tests: + +- `JWTSubject::getJWTIdentifier()` is used for `sub`. +- `JWTSubject::getJWTCustomClaims()` are merged. +- inline `claims()` override model claims. +- `prv` is included when `jwt.lock_subject` is true and provider has `getModel()`. +- `prv` is omitted when lock subject is false. +- matching provider model authenticates. +- mismatched provider model returns null and caches null sentinel. +- mismatched provider model makes `getUserId()` / `id()` return null. +- missing `prv` fails when lock subject is true and provider has `getModel()`. +- provider without `getModel()` skips subject locking. + +#### Coroutine Safety Tests + +File: + +- `tests/JWT/JwtGuardCoroutineSafetyTest.php` + +Use `parallel()` and `usleep()`: + +- concurrent `claims()` calls do not bleed. +- concurrent `setTTL()` calls do not bleed. +- concurrent `setToken()` calls do not bleed. +- decoded payload cache is per token and per coroutine. +- token switching inside one coroutine does not reuse another token's user or payload cache. +- login in one coroutine does not set user in another coroutine. + +#### Manager Refresh Tests + +File: + +- `tests/JWT/JWTManagerTest.php` + +Add/update: + +- refresh with `ExpiredClaim` enabled succeeds when inside refresh window. +- normal decode with `ExpiredClaim` enabled still rejects expired token. +- refresh fails when `refresh_ttl` window expired. +- `refresh_iat=false` keeps original `iat`. +- `refresh_iat=true` stamps a fresh `iat`. +- `resetClaims=false` keeps original non-managed custom claims, configured persistent claims, `sub`, and existing `prv`, while stamping fresh managed defaults. +- `resetClaims=true` keeps configured persistent claims, `sub`, and existing `prv`, while dropping non-persistent custom claims and stamping fresh managed defaults. +- configured persistent claims cannot preserve stale managed claims such as old `exp` or `jti`. +- inline `claims()` are applied to the next refresh and cleared afterward. +- guard `refresh()` sets the returned token as the active token and clears old user/payload caches. +- guard `refresh()` consumes the effective TTL, including explicit `null` for no expiry. +- refresh invalidates old token only when blacklist enabled. +- `hasBlacklistEnabled()` returns config-derived value. +- `JWTManager::encode()` adds `jti` when blacklist is enabled and preserves caller-provided `jti`. + +#### Claim Factory Tests + +File: + +- `tests/JWT/ClaimFactoryTest.php` + +Tests: + +- builds default `sub`, `iat`, `exp`. +- omits `exp` when TTL is null. +- includes config issuer only when configured. +- includes `nbf`. +- uses `xxh128` `prv` with static model-hash cache. +- `flushState()` clears model-hash cache. +- refresh builder handles persistent claims, custom claims, `refresh_iat`, `resetClaims`. + +#### Parser Tests + +File: + +- `tests/JWT/Http/ParserTest.php` + +Tests: + +- bearer header parses. +- request input parses, including query-string input. +- cookie parses through the normal request cookie bag when `Cookie` is included in `jwt.parser`. +- default parser config does not include `Cookie`. +- non-string token input returns null. +- parser does not retain request between calls. + +#### Middleware Tests + +Files: + +- `tests/JWT/Http/Middleware/RefreshTokenTest.php` +- `tests/JWT/Http/Middleware/AuthenticateAndRenewTest.php` + +Tests: + +- missing token throws unauthorized. +- invalid token throws unauthorized. +- refresh sets Authorization response header. +- renew authenticates then sets refreshed Authorization response header. +- middleware resolves the configured guard and fails clearly if it is not `JwtGuard`. + +#### JWT Provider Tests + +File: + +- `tests/JWT/Providers/LcobucciTest.php` + +Tests: + +- missing symmetric secret throws `SecretMissingException` when signing. +- missing symmetric secret throws `SecretMissingException` when verification needs a symmetric key. +- missing asymmetric keys keep their existing specific key-related exception messages. + +#### Command Tests + +Files: + +- `tests/JWT/Console/JwtSecretCommandTest.php` +- `tests/JWT/Console/JwtGenerateCertsCommandTest.php` + +Use `Hypervel\Testbench\TestCase` because commands write `.env` and cert files under the runtime skeleton. + +Tests: + +- `jwt:secret --show` prints key and does not write `.env`. +- `jwt:secret --force` writes `JWT_SECRET` and `JWT_ALGO=HS256`. +- existing key without force prompts/skips based on option. +- missing `.env` reports the problem without writing. +- cert command writes private/public key files. +- cert command writes `JWT_ALGO`, `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, `JWT_PASSPHRASE`. +- cert command refuses to overwrite existing files without `--force`. +- invalid algo fails. + +#### Provider Tests + +File: + +- `tests/JWT/JWTServiceProviderTest.php` or existing `JwtGuardTest` provider section. + +Tests: + +- guard receives per-guard TTL. +- guard receives explicit `ttl => null`. +- dispatcher is set. +- parser singleton is stateless. +- only `jwt.refresh` and `jwt.renew` middleware aliases are registered. +- commands are registered in console. +- `TaggedCache` storage uses `$app->make('cache')->store()`, not array access. + +#### Validation Rename Tests + +Rename and update: + +- `tests/JWT/Validations/NotBeforeClaimTest.php` + +Tests: + +- token before `nbf` throws. +- token at or after `nbf` passes. + +### Verification Commands + +After each test file or logical group: + +```bash +cd /home/binaryfire/workspace/monorepo/contrib/hypervel/components +./vendor/bin/phpunit --no-progress tests/JWT/JwtGuardTest.php +``` + +After source changes: + +```bash +cd /home/binaryfire/workspace/monorepo/contrib/hypervel/components +composer fix +``` + +`composer fix` runs: + +1. php-cs-fixer +2. phpstan +3. test:parallel + +After green checks: + +- self-review all changed code +- re-read current upstream files for each ported feature and confirm no useful behavior was missed +- re-check worker-lifetime and coroutine state +- request Claude review and loop until signoff + +## Implementation Order + +1. Add `JWTSubject`, `TemporalValidation`, `ClaimFactory`, `SecretMissingException`, parser contract/classes, middleware classes, and command classes. +2. Update `JWTManager` and `ManagerContract` for claim factory, refresh-aware decode, resetClaims, refresh_iat, blacklist state, and `SecretMissingException`. +3. Update validations, including `NotBeforeClaim` rename and `TemporalValidation` support. +4. Update `JwtGuard` for events, token override, fixed attempt/once/login/logout behavior, per-call TTL, subject matching, and added methods. +5. Update `JWTServiceProvider` registration, middleware aliases, parser registration, command registration, per-guard TTL, and dispatcher wiring. +6. Update config. +7. Update README. +8. Update and add tests in the groups above, running each file as it is completed. +9. Run `composer fix`. +10. Self-review against Hypervel source, upstream source, Swoole/coroutine constraints, and the plan. +11. Request Claude code review and loop until signoff. + +## Performance Review + +Hot path additions: + +- `JwtGuard::getToken()` checks one coroutine key before parsing request. +- `JwtGuard::user()` checks subject hash only when `lock_subject` is enabled and provider has `getModel()`. +- Auth events use `hasListeners()` before constructing events. +- Parser chain loops over a small list of stateless parsers. +- `JWTSubject` check is a cheap `instanceof`. + +Avoided overhead: + +- no `Payload` object +- no `Token` object +- no per-claim DTOs +- no mutable singleton parser/request object +- no always-on `jti` generation when blacklist is disabled +- no blacklist cache lookup when blacklist is disabled + +Worker-lifetime caching: + +- subject model hash map in `ClaimFactory` +- existing validation instances in `JWTManager` +- existing provider signer/config caching in `Lcobucci` + +Coroutine state: + +- current token override +- current user/null sentinel +- last attempted user +- per-call custom claims +- per-call TTL +- decoded payload cache + +This is the cleanest performance shape: immutable metadata is cached for the worker lifetime; request/per-call state stays in `CoroutineContext`; feature checks are cheap; optional cache I/O only happens when blacklist is enabled. + +## Final State Checklist + +- `attempt()` returns token string or false. +- `validate()` remains boolean. +- `login()` sets current token and current user. +- `once()` and `onceUsingId()` do not mint tokens. +- `claims()` is one-token-only. +- per-guard TTL works, including explicit null. +- per-call TTL is coroutine-local. +- subject locking prevents cross-provider id collisions. +- `JWTSubject` works but is not required. +- refresh works for expired tokens inside refresh window. +- `refresh_iat` works. +- `resetClaims` works. +- logout works with blacklist disabled and invalidates when enabled. +- events are guarded with `hasListeners()`. +- parser is stateless. +- commands exist. +- middleware aliases exist. +- `NotBeforeClaim` is spelled correctly everywhere. +- README has upstream attribution and real docs. +- intentional upstream omissions are recorded concisely. +- tests cover behavior and coroutine isolation. +- `composer fix` is green. + +## Addendum: Tymon Follow-Up Review + +After the main implementation plan was written, Tymon's current `tymon/jwt-auth` package was reviewed as an additional source: + +- `/tmp/claude-20000/-home-binaryfire-workspace-monorepo/eeff203a-4261-4d58-80c6-bab17f200296/scratchpad/jwt-auth-tymon` + +Fold these final corrections into the implementation. This addendum supersedes the earlier middleware-related plan items, including the "Add Middleware" section, middleware test plan, implementation-order middleware references, and final checklist item that says middleware aliases exist. + +### Remove Sliding Refresh Middleware + +Delete the sliding-token middleware and do not register `jwt.refresh` or `jwt.renew` aliases: + +- `src/jwt/src/Http/Middleware/RefreshToken.php` +- `src/jwt/src/Http/Middleware/AuthenticateAndRenew.php` +- `tests/JWT/Http/Middleware/RefreshTokenTest.php` +- `tests/JWT/Http/Middleware/AuthenticateAndRenewTest.php` + +Why: + +- Route protection already belongs to Hypervel's normal auth middleware plus the JWT guard. +- The useful refresh primitive is `Auth::guard('api')->refresh()`. +- Refreshing on every request turns normal traffic into token rotation traffic, requires blacklist writes for each request using the middleware, creates concurrent-request races, and forces clients to replace their stored token from every response header. + +Document the explicit refresh endpoint pattern in `src/boost/docs/jwt.md` instead. The refresh route must not be protected with `auth:api`, because normal auth validation rejects expired access tokens before `JwtGuard::refresh()` can run its refresh-window validation. + +```php +use Hypervel\JWT\Exceptions\JWTException; +use Hypervel\Support\Facades\Auth; + +Route::post('/token/refresh', function () { + try { + $token = Auth::guard('api')->refresh(); + } catch (JWTException) { + abort(401, 'Token cannot be refreshed.'); + } + + abort_if($token === null, 401, 'No token provided.'); + + return response()->json(['token' => $token]); +}); +``` + +Keep `blacklist_grace_period`; explicit refresh can still race when clients submit concurrent refresh requests or make concurrent requests around refresh time. + +### Lazily Resolve Blacklist Storage and Fail Fast When Enabled + +`jwt.blacklist_enabled` defaults to `false`. When it is disabled, `JWTManager` should not resolve `BlacklistContract` at all: + +```php +$this->blacklistEnabled = $this->config->boolean('jwt.blacklist_enabled', false); +$this->blacklist = $this->blacklistEnabled + ? $container->make(BlacklistContract::class) + : null; +``` + +Keep the existing nullable blacklist property and the existing `$this->blacklistEnabled &&` guards before blacklist use. + +The default blacklist storage remains `Hypervel\JWT\Storage\TaggedCache`. This matches the old Hypervel 0.3 package and gives blacklist entries an isolated tag so `clear()` flushes only JWT blacklist entries. + +Add a fail-fast check in the `BlacklistContract` binding, not inside `TaggedCache`, so disabled-blacklist applications with non-taggable default cache stores do not fail during manager construction: + +```php +$repository = $app->make('cache')->store(); + +if ($config->boolean('jwt.blacklist_enabled', false) && ! $repository->supportsTags()) { + throw new RuntimeException( + 'The JWT blacklist requires a taggable cache store. Use a taggable store or set a custom jwt.providers.storage.' + ); +} + +$storage = new TaggedCache($repository); +``` + +Only run this check when `jwt.providers.storage` is `TaggedCache::class`; custom storage providers are responsible for their own storage requirements. + +### Use the Current Lcobucci Validation API + +`lcobucci/jwt` 5.6.0 deprecates `Configuration::setValidationConstraints()`. Use `withValidationConstraints()` directly with no compatibility shim: + +```php +return $config->withValidationConstraints( + new SignedWith($this->signer, $this->getVerificationKey()) +); +``` + +This keeps `buildConfig()` immutable and preserves `onConfigurationChanged()`, which assigns the returned configuration instance back onto the provider. + +### Round Blacklist TTL Up + +Carbon 3 returns fractional minute differences. Blacklist entries must not expire fractionally early, so round the TTL up: + +```php +return (int) ceil(abs( + $exp->max($iat->addMinutes($this->refreshTTL)) + ->addMinute() + ->diffInMinutes() +)); +``` + +### Do Not Add Mutable Header / Prefix Parser Configuration + +Do not port Tymon's `AuthHeaders::setHeaderName()` or `setHeaderPrefix()` methods. + +Why: + +- `Authorization: Bearer` is the standard JWT request header. +- `AuthHeaders` stays stateless and safe as a worker-lifetime singleton. +- Applications that need a custom header or scheme can add a custom `TokenExtractor` class to `jwt.parser`. + +### Addendum Test Coverage + +Add or update tests for: + +- `JWTManager` does not resolve `BlacklistContract` when `jwt.blacklist_enabled` is false. +- disabled blacklist with a non-taggable default cache store does not throw. +- enabled blacklist with `TaggedCache` and a non-taggable cache store throws a clear exception. +- enabled blacklist with `TaggedCache` and a taggable cache store works. +- custom blacklist storage bypasses the tag-support check. +- blacklist TTL uses ceiling rounding for fractional minute differences. +- Lcobucci signed-token validation still works after switching to `withValidationConstraints()`. +- `JWTServiceProvider` no longer registers `jwt.refresh` or `jwt.renew` aliases. diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index 94d93687d..8849593b5 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -112,7 +112,7 @@ public function createSessionDriver(string $name, array $config): SessionGuard $guard = new SessionGuard( $name, $this->createUserProvider($config['provider'] ?? null), - $this->app['session.store'], + $this->app->make('session.store'), $this->app, rehashOnLogin: $repository->boolean('hashing.rehash_on_login', true), timeboxDuration: $repository->integer('auth.timebox_duration', 200000), @@ -122,9 +122,9 @@ public function createSessionDriver(string $name, array $config): SessionGuard // When using the remember me functionality of the authentication services we // will need to be set the encryption instance of the guard, which allows // secure, encrypted cookie values to get generated for those cookies. - $guard->setCookieJar($this->app['cookie']); + $guard->setCookieJar($this->app->make('cookie')); - $guard->setDispatcher($this->app['events']); + $guard->setDispatcher($this->app->make('events')); if (isset($config['remember'])) { $guard->setRememberDuration($config['remember']); diff --git a/src/auth/src/AuthServiceProvider.php b/src/auth/src/AuthServiceProvider.php index 0d4dc5db7..0a3deb982 100755 --- a/src/auth/src/AuthServiceProvider.php +++ b/src/auth/src/AuthServiceProvider.php @@ -102,12 +102,17 @@ protected function registerRequestUserResolver(): void protected function registerEventRebindHandler(): void { $this->app->rebinding('events', function ($app, $dispatcher) { - if (! $app->resolved('auth') - || $app['auth']->hasResolvedGuards() === false) { + if (! $app->resolved('auth')) { return; } - foreach ($app['auth']->getGuards() as $guard) { + $auth = $app->make('auth'); + + if ($auth->hasResolvedGuards() === false) { + return; + } + + foreach ($auth->getGuards() as $guard) { if (method_exists($guard, 'setDispatcher')) { $guard->setDispatcher($dispatcher); } diff --git a/src/auth/src/GuardHelpers.php b/src/auth/src/GuardHelpers.php index 3abaeb027..2b8012389 100644 --- a/src/auth/src/GuardHelpers.php +++ b/src/auth/src/GuardHelpers.php @@ -68,6 +68,9 @@ public function getProvider(): ?UserProvider /** * Set the user provider used by the guard. + * + * Boot or tests only. The provider is stored on the worker-lifetime guard + * and affects every subsequent request. */ public function setProvider(UserProvider $provider): void { diff --git a/src/auth/src/SessionGuard.php b/src/auth/src/SessionGuard.php index b5fb097b5..fa10d4596 100644 --- a/src/auth/src/SessionGuard.php +++ b/src/auth/src/SessionGuard.php @@ -669,6 +669,9 @@ protected function rehashUserPasswordForDeviceLogout(#[SensitiveParameter] strin /** * Register an authentication attempt event listener. + * + * Boot-only. Listener registrations persist on the worker-lifetime dispatcher + * and affect every subsequent request. */ public function attempting(callable $callback): void { @@ -775,6 +778,9 @@ protected function getRememberDuration(): int /** * Set the number of minutes the remember me cookie should be valid for. + * + * Boot-only. The duration is stored on the worker-lifetime guard and affects + * every subsequent request. */ public function setRememberDuration(int $minutes): static { @@ -799,6 +805,9 @@ public function getCookieJar(): CookieJar /** * Set the cookie creator instance used by the guard. + * + * Boot or tests only. The cookie jar is stored on the worker-lifetime guard + * and affects every subsequent request. */ public function setCookieJar(CookieJar $cookie): void { @@ -815,6 +824,9 @@ public function getDispatcher(): ?Dispatcher /** * Set the event dispatcher instance. + * + * Boot or tests only. The dispatcher is stored on the worker-lifetime guard + * and affects every subsequent request. */ public function setDispatcher(Dispatcher $events): void { diff --git a/src/boost/docs/authentication.md b/src/boost/docs/authentication.md index 36f0711c1..278c43a17 100644 --- a/src/boost/docs/authentication.md +++ b/src/boost/docs/authentication.md @@ -83,7 +83,7 @@ As discussed in this documentation, you can interact with these authentication s #### Hypervel's API Authentication Services -Hypervel provides two optional packages to assist you in managing API tokens and authenticating requests made with API tokens: [Passport](/docs/{{version}}/passport) and [Sanctum](/docs/{{version}}/sanctum). Please note that these libraries and Hypervel's built-in cookie based authentication libraries are not mutually exclusive. These libraries primarily focus on API token authentication while the built-in authentication services focus on cookie based browser authentication. Many applications will use both Hypervel's built-in cookie based authentication services and one of Hypervel's API authentication packages. +Hypervel provides optional packages to assist you in managing API tokens and authenticating requests made with API tokens, including [Sanctum](/docs/{{version}}/sanctum) and [JWT authentication](/docs/{{version}}/jwt). Please note that these libraries and Hypervel's built-in cookie based authentication libraries are not mutually exclusive. These libraries primarily focus on API token authentication while the built-in authentication services focus on cookie based browser authentication. Many applications will use both Hypervel's built-in cookie based authentication services and one of Hypervel's API authentication packages. **Passport** @@ -95,16 +95,22 @@ In response to the complexity of OAuth2 and developer confusion, we set out to b Hypervel Sanctum is a hybrid web / API authentication package that can manage your application's entire authentication process. This is possible because when Sanctum based applications receive a request, Sanctum will first determine if the request includes a session cookie that references an authenticated session. Sanctum accomplishes this by calling Hypervel's built-in authentication services which we discussed earlier. If the request is not being authenticated via a session cookie, Sanctum will inspect the request for an API token. If an API token is present, Sanctum will authenticate the request using that token. To learn more about this process, please consult Sanctum's ["how it works"](/docs/{{version}}/sanctum#how-it-works) documentation. +**JWT Authentication** + +[Hypervel JWT](/docs/{{version}}/jwt) provides stateless bearer token authentication using signed JSON Web Tokens. JWT authentication is useful when your application needs signed tokens for API, mobile, or service-to-service requests and does not need Sanctum's database-backed personal access tokens or OAuth2 grant flows. + #### Summary and Choosing Your Stack In summary, if your application will be accessed using a browser and you are building a monolithic Hypervel application, your application will use Hypervel's built-in authentication services. -Next, if your application offers an API that will be consumed by third parties, you will choose between [Passport](/docs/{{version}}/passport) or [Sanctum](/docs/{{version}}/sanctum) to provide API token authentication for your application. In general, Sanctum should be preferred when possible since it is a simple, complete solution for API authentication, SPA authentication, and mobile authentication, including support for "scopes" or "abilities". +Next, if your application offers an API that will be consumed by third parties, you will choose between [Sanctum](/docs/{{version}}/sanctum), [JWT authentication](/docs/{{version}}/jwt), or an OAuth2 server to provide API token authentication for your application. In general, Sanctum should be preferred when possible since it is a simple, complete solution for API authentication, SPA authentication, and mobile authentication, including support for "scopes" or "abilities". + +If you are building a single-page application (SPA) that will be powered by a Hypervel backend, you should use [Hypervel Sanctum](/docs/{{version}}/sanctum). When using Sanctum, you will need to [manually implement your own backend authentication routes](#authenticating-users) or use another headless authentication backend service that provides routes and controllers for features such as registration, password reset, email verification, and more. -If you are building a single-page application (SPA) that will be powered by a Hypervel backend, you should use [Hypervel Sanctum](/docs/{{version}}/sanctum). When using Sanctum, you will either need to [manually implement your own backend authentication routes](#authenticating-users) or utilize [Hypervel Fortify](/docs/{{version}}/fortify) as a headless authentication backend service that provides routes and controllers for features such as registration, password reset, email verification, and more. +An OAuth2 server may be chosen when your application absolutely needs all of the features provided by the OAuth2 specification. -Passport may be chosen when your application absolutely needs all of the features provided by the OAuth2 specification. +JWT authentication may be chosen when your application wants stateless signed bearer tokens without storing each issued token in the database. And, if you would like to get started quickly, we are pleased to recommend [our application starter kits](/docs/{{version}}/starter-kits) as a quick way to start a new Hypervel application that already uses our preferred authentication stack of Hypervel's built-in authentication services. @@ -746,12 +752,15 @@ Route::post('/settings', function () { You may define your own authentication guards using the `extend` method on the `Auth` facade. You should place your call to the `extend` method within a [service provider](/docs/{{version}}/providers). Since Hypervel already ships with an `AppServiceProvider`, we can place the code in that provider: +> [!NOTE] +> If you want to authenticate requests using JSON Web Tokens, use Hypervel's [JWT authentication](/docs/{{version}}/jwt) package. This section is for custom authentication systems that are not already provided by Hypervel. + ```php [ 'api' => [ - 'driver' => 'jwt', + 'driver' => 'token', 'provider' => 'users', ], ], diff --git a/src/boost/docs/documentation.md b/src/boost/docs/documentation.md index 1d0e9426c..07316c5f8 100644 --- a/src/boost/docs/documentation.md +++ b/src/boost/docs/documentation.md @@ -59,6 +59,7 @@ - [Task Scheduling](/docs/{{version}}/scheduling) - ## Security - [Authentication](/docs/{{version}}/authentication) + - [JWT Authentication](/docs/{{version}}/jwt) - [Authorization](/docs/{{version}}/authorization) - [Permission](/docs/{{version}}/permission) - [Email Verification](/docs/{{version}}/verification) diff --git a/src/boost/docs/jwt.md b/src/boost/docs/jwt.md new file mode 100644 index 000000000..9443e4230 --- /dev/null +++ b/src/boost/docs/jwt.md @@ -0,0 +1,570 @@ +# JWT Authentication + +- [Introduction](#introduction) +- [Installation](#installation) + - [Publishing Configuration](#publishing-configuration) + - [Generating Secrets](#generating-secrets) + - [Generating Certificates](#generating-certificates) +- [Configuration](#configuration) + - [Configuring the Guard](#configuring-the-guard) + - [User Models](#user-models) + - [Signing Keys and Algorithms](#signing-keys-and-algorithms) + - [Token Lifetime](#token-lifetime) + - [Subject Locking](#subject-locking) + - [Token Sources](#token-sources) + - [Validations and Leeway](#validations-and-leeway) + - [Blacklist](#blacklist) +- [Authenticating Requests](#authenticating-requests) + - [Issuing Tokens](#issuing-tokens) + - [Protecting Routes](#protecting-routes) + - [Reading the Authenticated User](#reading-the-authenticated-user) + - [Refreshing Tokens](#refreshing-tokens) + - [Logging Out and Invalidating Tokens](#logging-out-and-invalidating-tokens) +- [Guard Methods](#guard-methods) +- [Exceptions](#exceptions) +- [Differences From "php-open-source-saver/jwt-auth"](#differences-from-php-open-source-saver-jwt-auth) + + +## Introduction + +Hypervel JWT provides stateless bearer token authentication using Hypervel's authentication guard system. It is based on the `php-open-source-saver/jwt-auth` package and adapted for Hypervel's long-lived Swoole workers and coroutine runtime. + +JWT authentication is useful when your application needs signed tokens that can be sent with API, mobile, or service-to-service requests. If you need first-party SPA session authentication or database-backed personal access tokens, consider [Sanctum](/docs/{{version}}/sanctum) instead. + + +## Installation + +You may install the package using Composer: + +```shell +composer require hypervel/jwt +``` + +The package service provider is discovered automatically. + + +### Publishing Configuration + +You may publish the JWT configuration file using the `vendor:publish` command: + +```shell +php artisan vendor:publish --provider="Hypervel\JWT\JWTServiceProvider" +``` + +This publishes a `config/jwt.php` file where you may configure signing keys, token lifetime, parser sources, validation, and blacklist behavior. + + +### Generating Secrets + +For HMAC algorithms such as `HS256`, generate a signing secret using the `jwt:secret` command: + +```shell +php artisan jwt:secret +``` + +This command writes `JWT_SECRET` and `JWT_ALGO=HS256` to your `.env` file. + +You may display a generated secret without writing to `.env`: + +```shell +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`. + + +### Generating Certificates + +For RSA or EC algorithms, generate a public / private key pair using the `jwt:generate-certs` command: + +```shell +php artisan jwt:generate-certs +``` + +The command writes the generated certificates to `storage/certs` by default and updates `JWT_ALGO`, `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, and `JWT_PASSPHRASE` in your `.env` file. + +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 +``` + +You may change the output directory using `--dir`. The directory may be absolute or relative to your application's base path. + +You may protect the private key with a passphrase using `--passphrase`, or prompt for it interactively using `--ask-passphrase`: + +```shell +php artisan jwt:generate-certs --ask-passphrase +``` + + +## Configuration + + +### Configuring the Guard + +To use JWT authentication, configure an auth guard that uses the `jwt` driver: + +```php +'guards' => [ + 'api' => [ + 'driver' => 'jwt', + 'provider' => 'users', + ], +], +``` + +You may then protect routes using Hypervel's normal authentication middleware: + +```php +Route::middleware('auth:api')->get('/user', function () { + return Auth::guard('api')->user(); +}); +``` + + +### User Models + +JWT can authenticate any model supported by your configured user provider. If you need to customize the `sub` claim or add model-defined custom claims, implement the `Hypervel\JWT\Contracts\JWTSubject` contract: + +```php +getKey(); + } + + /** + * Return custom claims to add to the token. + */ + public function getJWTCustomClaims(): array + { + return []; + } +} +``` + +Inline claims passed with the guard's `claims` method override model-defined custom claims for the next token. + + +### Signing Keys and Algorithms + +The JWT driver defaults to the bundled Lcobucci provider: + +```php +'driver' => env('JWT_DRIVER', 'lcobucci'), +``` + +For HMAC algorithms, configure `JWT_SECRET` and `JWT_ALGO`: + +```php +'secret' => env('JWT_SECRET'), + +'algo' => env('JWT_ALGO', Hypervel\JWT\Providers\Provider::ALGO_HS256), +``` + +For RSA and EC algorithms, configure `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, and `JWT_PASSPHRASE`: + +```php +'keys' => [ + 'public' => env('JWT_PUBLIC_KEY'), + 'private' => env('JWT_PRIVATE_KEY'), + 'passphrase' => env('JWT_PASSPHRASE'), +], +``` + +The key values may be key contents or `file://` paths. + + +### Token Lifetime + +The `ttl` configuration option controls how long newly issued tokens remain valid, in minutes: + +```php +'ttl' => env('JWT_TTL', 120), +``` + +Set this value to `null` to issue tokens without an `exp` claim: + +```php +'ttl' => null, +``` + +You may also configure a different TTL per guard: + +```php +'guards' => [ + 'customers' => [ + 'driver' => 'jwt', + 'provider' => 'customers', + 'ttl' => 15, + ], + + 'devices' => [ + 'driver' => 'jwt', + 'provider' => 'devices', + 'ttl' => null, + ], +], +``` + +For one token-producing operation, use `setTTL`: + +```php +$token = Auth::guard('api') + ->setTTL(15) + ->attempt($credentials); +``` + +The override is cleared after the token is generated. + + +### Subject Locking + +Subject locking is enabled by default: + +```php +'lock_subject' => env('JWT_LOCK_SUBJECT', true), +``` + +When subject locking is enabled and the user provider exposes its model class, JWT adds a provider hash to each token. This prevents a token issued for one provider model from authenticating against another provider model that happens to have the same ID. + + +### Token Sources + +By default, JWT reads tokens from the `Authorization` bearer header: + +```http +Authorization: Bearer eyJhbGciOi... +``` + +Request input parsing is available but is not enabled by default because URL tokens can leak through logs, browser history, and referrer headers: + +```http +/api/user?token=eyJhbGciOi... +``` + +The input key defaults to `token`: + +```php +'token' => env('JWT_TOKEN', 'token'), +``` + +You may customize the parser chain: + +```php +use Hypervel\JWT\Http\Parser\AuthHeaders; +use Hypervel\JWT\Http\Parser\Cookie; +use Hypervel\JWT\Http\Parser\InputSource; + +'parser' => [ + AuthHeaders::class, + InputSource::class, + Cookie::class, +], +``` + +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`. + +For a non-standard header or token scheme, implement `Hypervel\JWT\Contracts\TokenExtractor` and add that class to `jwt.parser`. + + +### Validations and Leeway + +The `validations` option controls which validation classes run when a token is decoded: + +```php +'validations' => [ + Hypervel\JWT\Validations\RequiredClaims::class, + Hypervel\JWT\Validations\ExpiredClaim::class, + Hypervel\JWT\Validations\IssuerClaim::class, + Hypervel\JWT\Validations\IssuedAtClaim::class, + Hypervel\JWT\Validations\NotBeforeClaim::class, +], +``` + +The default configuration enables required-claim, expiration, issuer, issued-at, and not-before validation. Issuer validation only enforces a value when `jwt.issuer` is configured: + +```php +'issuer' => env('JWT_ISSUER'), +``` + +The `required_claims` option controls which claims must exist in every token: + +```php +'required_claims' => [ + 'iat', + 'sub', +], +``` + +If your application uses timestamp validations and your servers have small clock differences, configure `leeway` in seconds: + +```php +'leeway' => (int) env('JWT_LEEWAY', 0), +``` + + +### Blacklist + +The JWT blacklist lets the package invalidate tokens before they naturally expire: + +```php +'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', false), +``` + +Blacklisting is disabled by default. When enabled, newly issued tokens include a `jti` claim and authenticated blacklist checks require cache access. Enable it when your application needs server-side token invalidation. + +The blacklist uses the configured storage provider: + +```php +'providers' => [ + 'storage' => Hypervel\JWT\Storage\TaggedCache::class, +], +``` + +You may configure a grace period for concurrent requests that are using the same token while a refresh is in progress: + +```php +'blacklist_grace_period' => env('JWT_BLACKLIST_GRACE_PERIOD', 0), +``` + +The `blacklist_refresh_ttl` option keeps blacklist entries long enough to cover the token's refresh window: + +```php +'blacklist_refresh_ttl' => env('JWT_BLACKLIST_REFRESH_TTL', 20160), +``` + + +## Authenticating Requests + + +### Issuing Tokens + +The `attempt` method validates credentials and returns a JWT string when authentication succeeds: + +```php +use Hypervel\Support\Facades\Auth; + +$credentials = $request->only(['email', 'password']); +$guard = Auth::guard('api'); +$ttl = $guard->getTTL(); + +if (! $token = $guard->attempt($credentials)) { + return response()->json(['message' => 'Invalid credentials.'], 401); +} + +return response()->json([ + 'access_token' => $token, + 'token_type' => 'bearer', + 'expires_in' => $ttl === null ? null : $ttl * 60, +]); +``` + +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: + +```php +$token = Auth::guard('api')->tokenById($userId); +``` + + +### Protecting Routes + +Use Hypervel's normal authentication middleware: + +```php +Route::middleware('auth:api')->get('/profile', function () { + return Auth::guard('api')->user(); +}); +``` + + +### Reading the Authenticated User + +Use the usual auth APIs to read the authenticated user or ID: + +```php +$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: + +```php +$userId = Auth::guard('api')->getUserId(); +``` + +Use `userOrFail` when a missing user should throw: + +```php +$user = Auth::guard('api')->userOrFail(); +``` + + +### Refreshing Tokens + +The `refresh` method creates a new token from the current token: + +```php +$newToken = Auth::guard('api')->refresh(); +``` + +Expose refresh through a dedicated endpoint: + +```php +use Hypervel\JWT\Exceptions\JWTException; +use Hypervel\Support\Facades\Auth; + +Route::post('/token/refresh', function () { + try { + $token = Auth::guard('api')->refresh(); + } catch (JWTException) { + abort(401, 'Token cannot be refreshed.'); + } + + abort_if($token === null, 401, 'No token provided.'); + + return response()->json(['token' => $token]); +}); +``` + +Do not protect the refresh route with `auth:api`. Refresh must be able to read an expired token that is still inside the refresh window, and normal auth middleware rejects expired access tokens before the handler runs. + +The refresh window is controlled by `refresh_ttl`, in minutes: + +```php +'refresh_ttl' => env('JWT_REFRESH_TTL', 20160), +``` + +If `refresh_iat` is `false`, refreshed tokens keep the original `iat` claim. If `refresh_iat` is `true`, refreshed tokens receive a fresh `iat` claim: + +```php +'refresh_iat' => env('JWT_REFRESH_IAT', false), +``` + +You may force the old token to remain blacklisted forever when blacklist is enabled: + +```php +$newToken = Auth::guard('api')->refresh(forceForever: true); +``` + +You may also reset non-persistent custom claims during refresh: + +```php +$newToken = Auth::guard('api')->refresh(resetClaims: true); +``` + +Claims listed in `persistent_claims` are preserved during refresh when they are present on the old token: + +```php +'persistent_claims' => [ + 'tenant_id', +], +``` + +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. + + +### Logging Out and Invalidating Tokens + +The `logout` method clears the current guard user and token. If blacklist is enabled, it also invalidates the current token: + +```php +Auth::guard('api')->logout(); +``` + +To invalidate a token directly, enable the blacklist and call `invalidate`: + +```php +Auth::guard('api')->invalidate(); +``` + +You may pass `true` to blacklist the token forever: + +```php +Auth::guard('api')->invalidate(true); +``` + + +## Guard Methods + +The JWT guard supports these methods: + +```php +Auth::guard('api')->attempt($credentials); // string|false +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')->tokenById($id); // string|null +Auth::guard('api')->byId($id); // Authenticatable|false +Auth::guard('api')->user(); // Authenticatable|null +Auth::guard('api')->userOrFail(); // Authenticatable +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')->setToken($token); +Auth::guard('api')->getToken(); +Auth::guard('api')->payload(); // array +Auth::guard('api')->refresh(); +Auth::guard('api')->logout(); +Auth::guard('api')->invalidate(); +``` + +The `claims` and `setTTL` methods affect only the next token-producing operation. + + +## Exceptions + +JWT exceptions extend `Hypervel\JWT\Exceptions\JWTException`. + +Common exceptions include: + +
+ +- `SecretMissingException` +- `TokenBlacklistedException` +- `TokenExpiredException` +- `TokenInvalidException` +- `UserNotDefinedException` + +
+ + +## Differences From "php-open-source-saver/jwt-auth" + +Hypervel JWT is based on `php-open-source-saver/jwt-auth`, but its internals are adapted for Hypervel: + +
+ +- Hypervel uses array payloads instead of upstream `Payload`, `Token`, and claim DTO objects. +- Hypervel keeps the `JWT` facade mapped to the array-based `JWTManager`, but does not include upstream `JWTAuth`, `JWTFactory`, or `JWTProvider` facades. +- The parser chain is stateless. Request instances are passed to the parser for each parse so coroutine requests cannot leak through singleton services. +- Cookie token parsing is available but not enabled by default. +- Upstream route-parameter and Lumen parser shortcuts are not included. +- Upstream sliding refresh middleware is not included; use an explicit refresh endpoint that calls `Auth::guard(...)->refresh()`. +- Namshi and Lumen integrations are not included. +- The `show_black_list_exception` option is not included; JWT exceptions fail normally. + +
diff --git a/src/jwt/README.md b/src/jwt/README.md index bb3b646ac..35a943586 100644 --- a/src/jwt/README.md +++ b/src/jwt/README.md @@ -1,4 +1,21 @@ JWT for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/jwt) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/jwt) + +Ported from: https://github.com/PHP-Open-Source-Saver/jwt-auth + +This package provides stateless JWT authentication for Hypervel applications, adapted for long-lived Swoole workers and coroutine-safe request state. + +## Differences From "php-open-source-saver/jwt-auth" + +- Hypervel uses array payloads instead of upstream `Payload`, `Token`, and claim DTO objects. +- Hypervel keeps the `JWT` facade mapped to the array-based `JWTManager`, but does not include upstream `JWTAuth`, `JWTFactory`, or `JWTProvider` facades. +- Hypervel's parser chain is stateless and receives the request for each parse so coroutine requests cannot leak through singleton services. +- Cookie token parsing is available but not enabled by default. +- Upstream route-parameter and Lumen parser shortcuts are not included. +- Upstream sliding refresh middleware is not included; use an explicit refresh endpoint that calls `Auth::guard(...)->refresh()`. +- Namshi and Lumen integrations are not included. +- The `show_black_list_exception` option is not included; JWT exceptions fail normally. + +Full usage docs are available in `src/boost/docs/jwt.md`. diff --git a/src/jwt/composer.json b/src/jwt/composer.json index 78f62c0b0..3a455da20 100644 --- a/src/jwt/composer.json +++ b/src/jwt/composer.json @@ -30,6 +30,8 @@ "hypervel/auth": "^0.4", "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", + "hypervel/config": "^0.4", + "hypervel/console": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/http": "^0.4", diff --git a/src/jwt/config/jwt.php b/src/jwt/config/jwt.php index 26f46c84c..5d72abbc4 100644 --- a/src/jwt/config/jwt.php +++ b/src/jwt/config/jwt.php @@ -25,7 +25,7 @@ |-------------------------------------------------------------------------- | | Don't forget to set this in your .env file, as it will be used to sign - | your tokens. + | your tokens. You may generate it using the jwt:secret command. | | Note: This will be used for Symmetric algorithms only (HMAC), | since RSA and ECDSA use a private/public key combo (See below). @@ -96,7 +96,7 @@ |-------------------------------------------------------------------------- | | Specify the length of time (in minutes) that the token will be valid for. - | Defaults to 1 hour. + | 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. @@ -127,6 +127,17 @@ 'refresh_ttl' => $refreshTtl === null ? null : (int) $refreshTtl, + /* + |-------------------------------------------------------------------------- + | Issuer + |-------------------------------------------------------------------------- + | + | The issuer to add to newly generated tokens. + | + */ + + 'issuer' => env('JWT_ISSUER'), + /* |-------------------------------------------------------------------------- | JWT hashing algorithm @@ -148,9 +159,10 @@ */ 'validations' => [ \Hypervel\JWT\Validations\RequiredClaims::class, - // \Hypervel\JWT\Validations\ExpiredClaim::class, - // \Hypervel\JWT\Validations\IssuedAtClaim::class, - // \Hypervel\JWT\Validations\NotBeforeCliam::class, + \Hypervel\JWT\Validations\ExpiredClaim::class, + \Hypervel\JWT\Validations\IssuerClaim::class, + \Hypervel\JWT\Validations\IssuedAtClaim::class, + \Hypervel\JWT\Validations\NotBeforeClaim::class, ], /* @@ -206,7 +218,7 @@ | */ - 'leeway' => env('JWT_LEEWAY', 0), + 'leeway' => (int) env('JWT_LEEWAY', 0), /* |-------------------------------------------------------------------------- @@ -220,6 +232,46 @@ 'blacklist_enabled' => env('JWT_BLACKLIST_ENABLED', false), + /* + |-------------------------------------------------------------------------- + | Refresh Issued At + |-------------------------------------------------------------------------- + | + | When enabled, refreshed tokens receive a fresh iat claim. When disabled, + | refreshed tokens keep the original iat claim. + | + */ + + 'refresh_iat' => env('JWT_REFRESH_IAT', false), + + /* + |-------------------------------------------------------------------------- + | Subject Locking + |-------------------------------------------------------------------------- + | + | When enabled, tokens include a provider model hash to prevent the same + | subject ID from authenticating against a different provider model. + | + */ + + 'lock_subject' => env('JWT_LOCK_SUBJECT', true), + + /* + |-------------------------------------------------------------------------- + | Token Parser + |-------------------------------------------------------------------------- + | + | Configure the request input key and ordered parser chain used to extract + | JWT tokens from incoming requests. + | + */ + + 'token' => env('JWT_TOKEN', 'token'), + + 'parser' => [ + \Hypervel\JWT\Http\Parser\AuthHeaders::class, + ], + /* | ------------------------------------------------------------------------- | Blacklist Grace Period diff --git a/src/jwt/src/Blacklist.php b/src/jwt/src/Blacklist.php index 549e2e3ee..6c48b12c9 100644 --- a/src/jwt/src/Blacklist.php +++ b/src/jwt/src/Blacklist.php @@ -56,9 +56,11 @@ protected function getMinutesUntilExpired(array $payload): int // get the latter of the two expiration dates and find // the number of minutes until the expiration date, // plus 1 minute to avoid overlap - return (int) abs($exp->max($iat->addMinutes($this->refreshTTL)) - ->addMinute() - ->diffInMinutes()); + return (int) ceil(abs( + $exp->max($iat->addMinutes($this->refreshTTL)) + ->addMinute() + ->diffInMinutes() + )); } /** diff --git a/src/jwt/src/ClaimFactory.php b/src/jwt/src/ClaimFactory.php new file mode 100644 index 000000000..86dbc0d3d --- /dev/null +++ b/src/jwt/src/ClaimFactory.php @@ -0,0 +1,192 @@ +get('jwt.issuer'); + + $this->issuer = ($issuer === null || $issuer === '') ? null : $issuer; + $this->lockSubject = $config->boolean('jwt.lock_subject', true); + } + + /** + * Build claims for a newly issued token. + */ + public function make( + Authenticatable $user, + UserProvider $provider, + ?int $ttl, + array $customClaims = [], + ): array { + $claims = [ + 'sub' => $this->subjectIdentifier($user), + ]; + + if ($this->lockSubject && method_exists($provider, 'getModel')) { + /* @phpstan-ignore-next-line method.notFound */ + $claims['prv'] = $this->subjectModelHash($provider->getModel()); + } + + if ($user instanceof JWTSubject) { + $subjectClaims = $user->getJWTCustomClaims(); + $this->rejectReservedCustomClaims($subjectClaims); + + $claims = array_merge($claims, $subjectClaims); + } + + $this->rejectReservedCustomClaims($customClaims); + + return $this->withDefaults(array_merge($claims, $customClaims), $ttl); + } + + /** + * Build claims for a refreshed token. + */ + public function refresh( + array $payload, + ?int $ttl, + bool $refreshIssuedAt, + bool $resetClaims, + 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); + + $this->rejectReservedCustomClaims($customClaims); + + $claims = array_merge($claims, $persistent, $customClaims, [ + 'sub' => $payload['sub'], + ]); + + if (! $refreshIssuedAt) { + $claims['iat'] = $payload['iat']; + } + + if (array_key_exists('prv', $payload)) { + $claims['prv'] = $payload['prv']; + } + + return $this->withDefaults($claims, $ttl); + } + + /** + * Determine the subject identifier for a user. + */ + public function subjectIdentifier(Authenticatable $user): mixed + { + return $user instanceof JWTSubject + ? $user->getJWTIdentifier() + : $user->getAuthIdentifier(); + } + + /** + * Check whether a decoded token belongs to the configured provider model. + */ + public function subjectMatchesProvider(array $payload, UserProvider $provider): bool + { + if (! $this->lockSubject || ! method_exists($provider, 'getModel')) { + return true; + } + + /* @phpstan-ignore-next-line method.notFound */ + $model = $provider->getModel(); + + return isset($payload['prv']) + && hash_equals($this->subjectModelHash($model), (string) $payload['prv']); + } + + /** + * Reject custom claims owned by the package. + */ + protected function rejectReservedCustomClaims(array $claims): void + { + $reserved = array_intersect(array_keys($claims), self::RESERVED_CUSTOM_CLAIMS); + + if ($reserved !== []) { + sort($reserved); + + throw new JWTException('Custom JWT claims may not override reserved claims: ' . implode(', ', $reserved) . '.'); + } + } + + /** + * Stamp standard claims, then apply caller claims on top. + */ + protected function withDefaults(array $claims, ?int $ttl): array + { + $now = Date::now(); + + $defaults = [ + 'iat' => $now->getTimestamp(), + 'nbf' => $now->getTimestamp(), + ]; + + if ($ttl !== null) { + $defaults['exp'] = $now->addMinutes($ttl)->getTimestamp(); + } + + if ($this->issuer !== null) { + $defaults['iss'] = $this->issuer; + } + + return array_merge($defaults, $claims); + } + + /** + * Hash the subject model class. + */ + protected function subjectModelHash(string|object $model): string + { + $class = is_object($model) ? $model::class : $model; + + return static::$subjectModelHashes[$class] ??= hash('xxh128', $class); + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$subjectModelHashes = []; + } +} diff --git a/src/jwt/src/Console/JwtGenerateCertsCommand.php b/src/jwt/src/Console/JwtGenerateCertsCommand.php new file mode 100644 index 000000000..4a38697bd --- /dev/null +++ b/src/jwt/src/Console/JwtGenerateCertsCommand.php @@ -0,0 +1,185 @@ + 'prime256v1', + 384 => 'secp384r1', + 512 => 'secp521r1', + ]; + + /** + * The name and signature of the console command. + */ + protected ?string $signature = 'jwt:generate-certs + {--force : Override certificates if they already exist} + {--algo=rsa : Algorithm: rsa or ec} + {--bits=4096 : RSA key length} + {--sha=512 : SHA variant} + {--dir=storage/certs : Directory where certificates should be written} + {--curve=prime256v1 : EC curve name} + {--passphrase= : Passphrase} + {--ask-passphrase : Prompt for the passphrase}'; + + /** + * The console command description. + */ + protected string $description = 'Generate a JWT certificate pair'; + + /** + * Execute the console command. + */ + public function handle(): int + { + $directory = $this->resolvePath((string) $this->option('dir')); + $algorithm = strtolower((string) $this->option('algo')); + $bits = (int) $this->option('bits'); + $sha = (int) $this->option('sha'); + $curve = (string) $this->option('curve'); + $passphrase = $this->resolvePassphrase(); + $environmentFile = $this->hypervel->environmentFilePath(); + + if (! file_exists($environmentFile)) { + $this->error("The file [{$environmentFile}] does not exist."); + + return self::FAILURE; + } + + $this->validateSha($sha); + + [$keyType, $algorithmIdentifier] = match ($algorithm) { + 'rsa' => [OPENSSL_KEYTYPE_RSA, sprintf('RS%d', $sha)], + 'ec' => [OPENSSL_KEYTYPE_EC, sprintf('ES%d', $sha)], + default => throw new RuntimeException('Unknown JWT certificate algorithm.'), + }; + + if ($keyType === OPENSSL_KEYTYPE_EC) { + $this->validateEcCurve($sha, $curve); + } + + $keyIdentifier = $keyType === OPENSSL_KEYTYPE_EC ? $curve : (string) $bits; + $privateKeyPath = sprintf('%s/jwt-%s-%s-private.pem', $directory, $algorithm, $keyIdentifier); + $publicKeyPath = sprintf('%s/jwt-%s-%s-public.pem', $directory, $algorithm, $keyIdentifier); + + if (! $this->option('force') && (file_exists($privateKeyPath) || file_exists($publicKeyPath))) { + $this->error('JWT certificates already exist. Use --force to overwrite them.'); + + return self::FAILURE; + } + + $options = [ + 'digest_alg' => sprintf('sha%d', $sha), + 'private_key_type' => $keyType, + ]; + + if ($keyType === OPENSSL_KEYTYPE_RSA) { + $options['private_key_bits'] = $bits; + } else { + $options['curve_name'] = $curve; + } + + $key = openssl_pkey_new($options); + + if ($key === false) { + throw new RuntimeException('Unable to create JWT key pair.'); + } + + $privateKey = ''; + + if (! openssl_pkey_export($key, $privateKey, $passphrase)) { + throw new RuntimeException('Unable to export JWT private key.'); + } + + $details = openssl_pkey_get_details($key); + + if ($details === false || ! is_string($details['key'] ?? null)) { + throw new RuntimeException('Unable to export JWT public key.'); + } + + if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new RuntimeException("Unable to create directory [{$directory}]."); + } + + if (file_put_contents($privateKeyPath, $privateKey) === false) { + throw new RuntimeException("Unable to write private key to [{$privateKeyPath}]."); + } + + if (! chmod($privateKeyPath, 0600)) { + throw new RuntimeException("Unable to secure private key [{$privateKeyPath}]."); + } + + if (file_put_contents($publicKeyPath, $details['key']) === false) { + throw new RuntimeException("Unable to write public key to [{$publicKeyPath}]."); + } + + Env::writeVariables([ + 'JWT_ALGO' => $algorithmIdentifier, + 'JWT_PRIVATE_KEY' => 'file://' . $privateKeyPath, + 'JWT_PUBLIC_KEY' => 'file://' . $publicKeyPath, + 'JWT_PASSPHRASE' => $passphrase ?? '', + ], $environmentFile, overwrite: true); + + $this->components->info('JWT certificates generated successfully.'); + + return self::SUCCESS; + } + + /** + * Validate the SHA variant. + */ + protected function validateSha(int $sha): void + { + if (! in_array($sha, [256, 384, 512], true)) { + throw new RuntimeException('JWT certificate SHA variant must be 256, 384, or 512.'); + } + } + + /** + * Validate the EC curve against the chosen ES algorithm. + */ + protected function validateEcCurve(int $sha, string $curve): void + { + $requiredCurve = self::EC_CURVES[$sha]; + + if ($curve !== $requiredCurve) { + throw new RuntimeException("ES{$sha} requires the [{$requiredCurve}] curve."); + } + } + + /** + * Resolve the passphrase option. + */ + protected function resolvePassphrase(): ?string + { + if ($this->option('ask-passphrase')) { + return $this->secret('Passphrase') ?: null; + } + + $passphrase = $this->option('passphrase'); + + return is_string($passphrase) && $passphrase !== '' ? $passphrase : null; + } + + /** + * Resolve a path relative to the application base path. + */ + protected function resolvePath(string $path): string + { + return str_starts_with($path, DIRECTORY_SEPARATOR) + ? $path + : $this->hypervel->basePath($path); + } +} diff --git a/src/jwt/src/Console/JwtSecretCommand.php b/src/jwt/src/Console/JwtSecretCommand.php new file mode 100644 index 000000000..c456a39ff --- /dev/null +++ b/src/jwt/src/Console/JwtSecretCommand.php @@ -0,0 +1,87 @@ +option('show')) { + $this->comment($key); + + return self::SUCCESS; + } + + $environmentFile = $this->hypervel->environmentFilePath(); + + if (! file_exists($environmentFile)) { + $this->error("The file [{$environmentFile}] does not exist."); + + return self::FAILURE; + } + + if ($this->envHasKey($environmentFile, 'JWT_SECRET') && ! $this->option('force')) { + 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?')) { + $this->comment('No changes were made to your JWT secret.'); + + return self::SUCCESS; + } + } + + Env::writeVariables([ + 'JWT_SECRET' => $key, + 'JWT_ALGO' => 'HS256', + ], $environmentFile, overwrite: true); + + $this->components->info('JWT secret set successfully.'); + + return self::SUCCESS; + } + + /** + * Determine if an environment variable exists. + */ + protected function envHasKey(string $environmentFile, string $key): bool + { + $contents = file_get_contents($environmentFile); + + if ($contents === false) { + throw new RuntimeException('Unable to read environment file.'); + } + + return preg_match('/^' . preg_quote($key, '/') . '=/m', $contents) === 1; + } +} diff --git a/src/jwt/src/Contracts/JWTSubject.php b/src/jwt/src/Contracts/JWTSubject.php new file mode 100644 index 000000000..8ff69d0f3 --- /dev/null +++ b/src/jwt/src/Contracts/JWTSubject.php @@ -0,0 +1,18 @@ +header('Authorization') + ?: $request->server('HTTP_AUTHORIZATION') + ?: $request->server('REDIRECT_HTTP_AUTHORIZATION'); + + if (! is_string($header)) { + return null; + } + + foreach (explode(',', $header) as $segment) { + $segment = trim($segment); + + if (strncasecmp($segment, 'Bearer ', 7) === 0) { + return trim(substr($segment, 7)) ?: null; + } + } + + return null; + } +} diff --git a/src/jwt/src/Http/Parser/Cookie.php b/src/jwt/src/Http/Parser/Cookie.php new file mode 100644 index 000000000..e3e05fd07 --- /dev/null +++ b/src/jwt/src/Http/Parser/Cookie.php @@ -0,0 +1,29 @@ +cookie($this->key); + + return is_string($token) && $token !== '' ? $token : null; + } +} diff --git a/src/jwt/src/Http/Parser/InputSource.php b/src/jwt/src/Http/Parser/InputSource.php new file mode 100644 index 000000000..56f1775e9 --- /dev/null +++ b/src/jwt/src/Http/Parser/InputSource.php @@ -0,0 +1,29 @@ +input($this->key); + + return is_string($token) && $token !== '' ? $token : null; + } +} diff --git a/src/jwt/src/Http/Parser/Parser.php b/src/jwt/src/Http/Parser/Parser.php new file mode 100644 index 000000000..5473746f5 --- /dev/null +++ b/src/jwt/src/Http/Parser/Parser.php @@ -0,0 +1,37 @@ + $chain + */ + public function __construct( + protected array $chain + ) { + } + + /** + * Parse a token from the request. + */ + public function parseToken(Request $request): ?string + { + foreach ($this->chain as $parser) { + $token = $parser->parseToken($request); + + if (is_string($token) && $token !== '') { + return $token; + } + } + + return null; + } +} diff --git a/src/jwt/src/JWTManager.php b/src/jwt/src/JWTManager.php index 7014c7235..5573b22f9 100644 --- a/src/jwt/src/JWTManager.php +++ b/src/jwt/src/JWTManager.php @@ -7,12 +7,12 @@ use Hypervel\Contracts\Container\Container; use Hypervel\JWT\Contracts\BlacklistContract; use Hypervel\JWT\Contracts\ManagerContract; +use Hypervel\JWT\Contracts\TemporalValidation; use Hypervel\JWT\Contracts\ValidationContract; use Hypervel\JWT\Exceptions\JWTException; use Hypervel\JWT\Exceptions\TokenBlacklistedException; use Hypervel\JWT\Exceptions\TokenExpiredException; use Hypervel\JWT\Providers\Lcobucci; -use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; use Hypervel\Support\Manager; use Hypervel\Support\Str; @@ -30,11 +30,15 @@ class JWTManager extends Manager implements ManagerContract * Create a new manager instance. */ public function __construct( - protected Container $container + Container $container, + protected ClaimFactory $claimFactory, ) { parent::__construct($container); - $this->blacklist = $container->make(BlacklistContract::class); + $this->blacklistEnabled = $this->config->boolean('jwt.blacklist_enabled', false); + $this->blacklist = $this->blacklistEnabled + ? $container->make(BlacklistContract::class) + : null; } /** @@ -63,15 +67,21 @@ public function getDefaultDriver(): string return $this->config->string('jwt.driver', 'lcobucci'); } + /** + * Encode a payload into a token. + */ public function encode(array $payload): string { - if ($this->blacklistEnabled) { + if ($this->blacklistEnabled && ! array_key_exists('jti', $payload)) { $payload['jti'] = (string) Str::uuid(); } return $this->driver()->encode($payload); } + /** + * Decode a token into its payload. + */ public function decode(string $token, bool $validate = true, bool $checkBlacklist = true): array { $payload = $this->driver()->decode($token); @@ -80,18 +90,23 @@ public function decode(string $token, bool $validate = true, bool $checkBlacklis $this->validatePayload($payload); } - if ($this->blacklistEnabled && $checkBlacklist && $this->blacklist->has($payload)) { + if ($this->blacklistEnabled && $checkBlacklist && $this->blacklist()->has($payload)) { throw new TokenBlacklistedException('The token has been blacklisted'); } return $payload; } - protected function validatePayload(array $payload): void + protected function validatePayload(array $payload, bool $refresh = false): void { foreach ($this->config->array('jwt.validations', []) as $validation) { - $this->getValidation($validation) - ->validate($payload); + $validation = $this->getValidation($validation); + + if ($refresh && $validation instanceof TemporalValidation) { + continue; + } + + $validation->validate($payload); } } @@ -104,22 +119,61 @@ protected function getValidation(string $class): ValidationContract return $this->validations[$class] = new $class($this->config->array('jwt')); } - public function refresh(string $token, bool $forceForever = false): string - { - $payload = $this->decode($token); + /** + * Refresh a token. + */ + public function refresh( + string $token, + bool $forceForever = false, + bool $resetClaims = false, + array $customClaims = [], + int|false|null $ttl = false, + ): string { + $payload = $this->decodeForRefresh($token); $this->validateRefreshWindow($payload); - $claims = $this->buildRefreshClaims($payload); + if ($ttl === false) { + /** @var null|int $ttl */ + $ttl = $this->config->get('jwt.ttl', 120); + } + + $claims = $this->claimFactory->refresh( + payload: $payload, + ttl: $ttl, + refreshIssuedAt: $this->config->boolean('jwt.refresh_iat', false), + resetClaims: $resetClaims, + persistentClaims: $this->config->array('jwt.persistent_claims', []), + customClaims: $customClaims, + ); + + $newToken = $this->encode($claims); if ($this->blacklistEnabled) { - // Invalidate old token $this->invalidate($token, $forceForever); } - // Return the new token - return $this->encode($claims); + return $newToken; } + /** + * Decode a token for refresh. + */ + protected function decodeForRefresh(string $token): array + { + $payload = $this->driver()->decode($token); + + $this->validatePayload($payload, refresh: true); + + if ($this->blacklistEnabled && $this->blacklist()->has($payload)) { + throw new TokenBlacklistedException('The token has been blacklisted'); + } + + return $payload; + } + + /** + * Invalidate a token. + */ public function invalidate(string $token, bool $forceForever = false): bool { if (! $this->blacklistEnabled) { @@ -127,8 +181,8 @@ public function invalidate(string $token, bool $forceForever = false): bool } return call_user_func( - [$this->blacklist, $forceForever ? 'addForever' : 'add'], - $this->decode($token, false) + [$this->blacklist(), $forceForever ? 'addForever' : 'add'], + $this->decode($token, false, false) ); } @@ -150,31 +204,22 @@ protected function validateRefreshWindow(array $payload): void } /** - * Build the claims for a refreshed token. + * Determine if the blacklist is enabled. */ - protected function buildRefreshClaims(array $payload): array + public function hasBlacklistEnabled(): bool { - // Get the claims to be persisted from the payload - $persistentClaims = Collection::make($payload) - ->only($this->config->array('jwt.persistent_claims', [])) - ->toArray(); - - // persist the relevant claims - $claims = array_merge( - $persistentClaims, - [ - 'sub' => $payload['sub'], - 'iat' => $payload['iat'], - ] - ); - - /** @var null|int $ttl */ - $ttl = $this->config->get('jwt.ttl', 120); + return $this->blacklistEnabled; + } - if ($ttl !== null) { - $claims['exp'] = Date::now()->addMinutes($ttl)->getTimestamp(); + /** + * Get the configured blacklist instance. + */ + protected function blacklist(): BlacklistContract + { + if ($this->blacklist === null) { + throw new JWTException('JWT blacklist is not configured.'); } - return $claims; + return $this->blacklist; } } diff --git a/src/jwt/src/JWTServiceProvider.php b/src/jwt/src/JWTServiceProvider.php index 192485993..e54355eaf 100644 --- a/src/jwt/src/JWTServiceProvider.php +++ b/src/jwt/src/JWTServiceProvider.php @@ -5,9 +5,18 @@ 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\Http\Parser\AuthHeaders; +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\Support\ServiceProvider; +use RuntimeException; class JWTServiceProvider extends ServiceProvider { @@ -18,12 +27,40 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/jwt.php', 'jwt'); + // 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( + $app, + $app->make(ClaimFactory::class), + )); + + $this->app->singleton(Parser::class, function ($app) { + $config = $app->make('config'); + $tokenKey = $config->string('jwt.token', 'token'); + + $chain = array_map( + fn (string $extractor) => match ($extractor) { + InputSource::class, Cookie::class => new $extractor($tokenKey), + default => $app->make($extractor), + }, + $config->array('jwt.parser', [AuthHeaders::class]), + ); + + // The parser chain is stateless; request instances are passed per parse so + // coroutine requests cannot leak through a singleton parser. + return new Parser($chain); + }); + $this->app->singleton(BlacklistContract::class, function ($app) { $config = $app->make('config'); $storageClass = $config->string('jwt.providers.storage'); $storage = match ($storageClass) { - TaggedCache::class => new TaggedCache($app['cache']->store()), + TaggedCache::class => new TaggedCache($this->cacheStoreForJwtBlacklist( + $app, + $config->boolean('jwt.blacklist_enabled', false) + )), default => $app->make($storageClass), }; @@ -34,7 +71,12 @@ public function register(): void ); }); - $this->app->singleton('jwt', fn ($app) => new JWTManager($app)); + if ($this->app->runningInConsole()) { + $this->commands([ + JwtGenerateCertsCommand::class, + JwtSecretCommand::class, + ]); + } } /** @@ -44,6 +86,8 @@ public function boot(): void { $this->registerJwtGuard(); + // Sliding refresh middleware is intentionally not registered; refresh + // belongs in an explicit endpoint via JwtGuard::refresh(). if ($this->app->runningInConsole()) { $this->publishes([ __DIR__ . '/../config/jwt.php' => config_path('jwt.php'), @@ -59,16 +103,41 @@ protected function registerJwtGuard(): void $this->callAfterResolving(AuthManager::class, function (AuthManager $authManager) { $authManager->extend('jwt', function ($app, $name, $config) use ($authManager) { /** @var null|int $ttl */ - $ttl = $app->make('config')->get('jwt.ttl', 120); + $ttl = array_key_exists('ttl', $config) + ? $config['ttl'] + : $app->make('config')->get('jwt.ttl', 120); - return new JwtGuard( + $guard = new JwtGuard( name: $name, provider: $authManager->createUserProvider($config['provider'] ?? null), jwtManager: $app->make('jwt'), + claimFactory: $app->make(ClaimFactory::class), + parser: $app->make(Parser::class), app: $app, ttl: $ttl, ); + + $guard->setDispatcher($app->make('events')); + + return $guard; }); }); } + + /** + * 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. Use a taggable store or set a custom jwt.providers.storage.' + ); + } + + return $repository; + } } diff --git a/src/jwt/src/JwtGuard.php b/src/jwt/src/JwtGuard.php index 806a2a01e..c9af1de6d 100644 --- a/src/jwt/src/JwtGuard.php +++ b/src/jwt/src/JwtGuard.php @@ -4,6 +4,13 @@ namespace Hypervel\JWT; +use Closure; +use Hypervel\Auth\Events\Attempting; +use Hypervel\Auth\Events\Authenticated; +use Hypervel\Auth\Events\Failed; +use Hypervel\Auth\Events\Login; +use Hypervel\Auth\Events\Logout; +use Hypervel\Auth\Events\Validated; use Hypervel\Auth\GuardHelpers; use Hypervel\Context\CoroutineContext; use Hypervel\Context\RequestContext; @@ -11,9 +18,14 @@ use Hypervel\Contracts\Auth\Guard; use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Contracts\Container\Container; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\JWT\Contracts\ManagerContract; -use Hypervel\Support\Facades\Date; -use Hypervel\Support\Str; +use Hypervel\JWT\Exceptions\JWTException; +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\Parser; use Hypervel\Support\Traits\Macroable; use stdClass; @@ -22,11 +34,20 @@ class JwtGuard implements Guard use GuardHelpers; use Macroable; + protected const string GUARD_CONTEXT_KEY_PREFIX = '__auth.guards.'; + + private const string NO_EXPIRY = '__jwt.ttl.no_expiry'; + /** * Sentinel value indicating "user was resolved but not found". */ private static object $nullUserSentinel; + /** + * The event dispatcher instance. + */ + protected ?Dispatcher $events = null; + /** * Create a new JWT authentication guard. * @@ -36,6 +57,8 @@ public function __construct( protected string $name, UserProvider $provider, protected ManagerContract $jwtManager, + protected ClaimFactory $claimFactory, + protected Parser $parser, protected Container $app, protected ?int $ttl = 120, ) { @@ -45,17 +68,22 @@ public function __construct( /** * Attempt to authenticate a user using the given credentials. */ - public function attempt(array $credentials = [], bool $login = true): bool + public function attempt(array $credentials = [], bool $login = true): string|bool { + $this->fireAttemptEvent($credentials); + $user = $this->provider->retrieveByCredentials($credentials); + $this->setContextState('lastAttempted', $user); - $validated = ! is_null($user) && $this->provider->validateCredentials($user, $credentials); + if ($user !== null && $this->provider->validateCredentials($user, $credentials)) { + $this->fireValidatedEvent($user); - if ($validated && $login) { - $this->login($user); + return $login ? $this->login($user) : true; } - return $validated; + $this->fireFailedEvent($user, $credentials); + + return false; } /** @@ -67,18 +95,7 @@ public function parseToken(): ?string return null; } - $request = $this->app->make('request'); - - $header = $request->header('Authorization', ''); - if ($header && Str::startsWith($header, 'Bearer ')) { - return Str::substr($header, 7); - } - - if ($request->has('token')) { - return $request->input('token'); - } - - return null; + return $this->parser->parseToken($this->app->make('request')); } /** @@ -86,23 +103,11 @@ public function parseToken(): ?string */ public function login(AuthenticatableContract $user): string { - $now = Date::now(); - $claims = CoroutineContext::get("__auth.guards.{$this->name}.claims", []); - $payload = [ - 'sub' => $user->getAuthIdentifier(), - 'iat' => $now->getTimestamp(), - ]; + $token = $this->makeTokenForUser($user); - if ($this->ttl !== null) { - $payload['exp'] = $now->addMinutes($this->ttl)->getTimestamp(); - } - - $token = $this->jwtManager->encode(array_merge($payload, $claims)); - - CoroutineContext::set( - $this->getContextKeyForToken($this->parseToken() ? $token : null), - $user - ); + $this->setToken($token); + $this->setUser($user); + $this->fireLoginEvent($user); return $token; } @@ -114,8 +119,8 @@ public function user(): ?AuthenticatableContract { self::$nullUserSentinel ??= new stdClass; - $token = $this->parseToken(); - $contextKey = $this->getContextKeyForToken($token); + $token = $this->getToken(); + $contextKey = $this->getUserContextKey($token); $cached = CoroutineContext::get($contextKey); if ($cached === self::$nullUserSentinel) { @@ -132,13 +137,27 @@ public function user(): ?AuthenticatableContract return null; } - $user = null; + try { + $payload = $this->decodeToken($token); + } catch (TokenInvalidException|TokenExpiredException|TokenBlacklistedException) { + CoroutineContext::set($contextKey, self::$nullUserSentinel); + + return null; + } + + $sub = $this->claimFactory->subjectMatchesProvider($payload, $this->provider) + ? ($payload['sub'] ?? null) + : null; + + $user = $sub !== null ? $this->provider->retrieveById($sub) : null; + + if ($user === null) { + CoroutineContext::set($contextKey, self::$nullUserSentinel); - $payload = $this->decodeToken($token); - $sub = $payload['sub'] ?? null; - $user = $sub ? $this->provider->retrieveById($sub) : null; + return null; + } - CoroutineContext::set($contextKey, $user ?? self::$nullUserSentinel); + $this->setUser($user); return $user; } @@ -148,7 +167,99 @@ public function user(): ?AuthenticatableContract */ public function validate(array $credentials = []): bool { - return $this->attempt($credentials, false); + return (bool) $this->attempt($credentials, false); + } + + /** + * Log a user into the application using their credentials without persisting. + */ + public function once(array $credentials = []): bool + { + if ($this->validate($credentials) && $user = $this->getLastAttempted()) { + $this->setUser($user); + + return true; + } + + return false; + } + + /** + * Log the given user ID into the application. + */ + public function onceUsingId(mixed $id): AuthenticatableContract|false + { + if ($user = $this->provider->retrieveById($id)) { + $this->setUser($user); + + return $user; + } + + return false; + } + + /** + * Create a new token by user ID. + */ + public function tokenById(mixed $id): ?string + { + if (! $user = $this->provider->retrieveById($id)) { + return null; + } + + return $this->makeTokenForUser($user); + } + + /** + * Alias for onceUsingId. + */ + public function byId(mixed $id): AuthenticatableContract|false + { + return $this->onceUsingId($id); + } + + /** + * Get the currently authenticated user or throw an exception. + * + * @throws UserNotDefinedException + */ + public function userOrFail(): AuthenticatableContract + { + if (! $user = $this->user()) { + throw new UserNotDefinedException; + } + + return $user; + } + + /** + * Get the ID for the currently authenticated user. + */ + public function getUserId(): int|string|null + { + if ($user = $this->cachedUser()) { + return $user->getAuthIdentifier(); + } + + try { + $payload = $this->getPayload(); + } catch (TokenInvalidException|TokenExpiredException|TokenBlacklistedException) { + return null; + } + + if (! $this->claimFactory->subjectMatchesProvider($payload, $this->provider)) { + return null; + } + + return $payload['sub'] ?? null; + } + + /** + * Get the ID for the currently authenticated user. + */ + public function id(): int|string|null + { + return $this->getUserId(); } /** @@ -156,12 +267,10 @@ public function validate(array $credentials = []): bool */ public function claims(array $claims): static { - $contextKey = "__auth.guards.{$this->name}.claims"; - if ($contextClaims = CoroutineContext::get($contextKey)) { - $claims = array_merge($contextClaims, $claims); - } + $contextKey = $this->getContextStateKey('claims'); + $existing = CoroutineContext::get($contextKey, []); - CoroutineContext::set($contextKey, $claims); + CoroutineContext::set($contextKey, array_merge($existing, $claims)); return $this; } @@ -171,7 +280,7 @@ public function claims(array $claims): static */ public function getPayload(): array { - $token = $this->parseToken(); + $token = $this->getToken(); if (! $token) { return []; @@ -181,66 +290,117 @@ public function getPayload(): array } /** - * Decode a JWT token, caching the result per-request. - * - * Avoids decoding the same token multiple times when both user() - * and getPayload() are called in the same request. + * Alias for getPayload. */ - protected function decodeToken(string $token): array + public function payload(): array + { + return $this->getPayload(); + } + + /** + * Set the token. + */ + public function setToken(string $token): static { - $contextKey = "__auth.guards.{$this->name}.payload." . hash('xxh128', $token); + $this->setContextState('token', $token); - return CoroutineContext::getOrSet($contextKey, fn () => $this->jwtManager->decode($token)); + return $this; } /** - * Refresh the current JWT token. + * Get the current token. */ - public function refresh(): ?string + public function getToken(): ?string { - if (! $token = $this->parseToken()) { - return null; - } + $token = $this->getContextState('token'); + + return is_string($token) && $token !== '' ? $token : $this->parseToken(); + } + + /** + * Get the token TTL. + */ + public function getTTL(): ?int + { + $ttl = $this->getContextState('ttl'); - CoroutineContext::forget($this->getContextKeyForToken($token)); + if ($ttl === null) { + return $this->ttl; + } - return $this->jwtManager->refresh($token); + return $ttl === self::NO_EXPIRY ? null : (int) $ttl; } /** - * Log a user into the application using their credentials without persisting. + * Set the token TTL for the next token-producing operation. */ - public function once(array $credentials = []): bool + public function setTTL(?int $ttl): static { - return $this->attempt($credentials, true); + $this->setContextState('ttl', $ttl ?? self::NO_EXPIRY); + + return $this; } /** - * Log the given user ID into the application. + * Refresh the current JWT token. */ - public function onceUsingId(mixed $id): AuthenticatableContract|bool + public function refresh(bool $forceForever = false, bool $resetClaims = false): ?string { - if ($user = $this->provider->retrieveById($id)) { - $this->login($user); + if (! $token = $this->getToken()) { + return null; + } - return true; + $cachedUser = $this->cachedUser(); + $customClaims = $this->pullCustomClaims(); + $ttl = $this->getTTL(); + + try { + $newToken = $this->jwtManager->refresh($token, $forceForever, $resetClaims, $customClaims, $ttl); + } finally { + $this->forgetContextState('ttl'); + $this->forgetUser(); + CoroutineContext::forget($this->getPayloadContextKey($token)); } - return false; + $this->setToken($newToken); + + if ($cachedUser !== null) { + $this->cacheUser($cachedUser); + } + + return $newToken; } /** * Log the user out by invalidating the current token. */ - public function logout(): void + public function logout(bool $forceForever = false): void { - $token = $this->parseToken(); + $user = $this->cachedUser(); + $token = $this->getToken(); - CoroutineContext::forget($this->getContextKeyForToken($token)); + $this->forgetUser(); + $this->forgetContextState('token'); if ($token) { - $this->jwtManager->invalidate($token); + CoroutineContext::forget($this->getPayloadContextKey($token)); + + if ($this->jwtManager->hasBlacklistEnabled()) { + $this->jwtManager->invalidate($token, $forceForever); + } } + + $this->fireLogoutEvent($user); + } + + /** + * Invalidate the current token. + */ + public function invalidate(bool $forceForever = false): static + { + $this->jwtManager->invalidate($this->requireToken(), $forceForever); + + return $this; } /** @@ -250,7 +410,7 @@ public function hasUser(): bool { self::$nullUserSentinel ??= new stdClass; - $cached = CoroutineContext::get($this->getContextKeyForToken($this->parseToken())); + $cached = CoroutineContext::get($this->getUserContextKey()); return $cached !== null && $cached !== self::$nullUserSentinel; } @@ -260,7 +420,8 @@ public function hasUser(): bool */ public function setUser(AuthenticatableContract $user): static { - CoroutineContext::set($this->getContextKeyForToken($this->parseToken()), $user); + $this->cacheUser($user); + $this->fireAuthenticatedEvent($user); return $this; } @@ -270,21 +431,236 @@ public function setUser(AuthenticatableContract $user): static */ public function forgetUser(): static { - CoroutineContext::forget($this->getContextKeyForToken($this->parseToken())); + CoroutineContext::forget($this->getUserContextKey()); + CoroutineContext::forget($this->getContextStateKey('user.default')); return $this; } /** - * Get the Context key for caching the authenticated user, keyed by token. + * Register an authentication attempt event listener. + * + * Boot-only. Listener registrations persist on the worker-lifetime dispatcher + * and affect every subsequent request. */ - protected function getContextKeyForToken(?string $token): string + public function attempting(callable $callback): void + { + $this->events?->listen(Attempting::class, $callback); + } + + /** + * Get the event dispatcher instance. + */ + public function getDispatcher(): ?Dispatcher + { + return $this->events; + } + + /** + * Set the event dispatcher instance. + * + * Boot or tests only. The dispatcher is stored on the worker-lifetime guard + * and affects every subsequent request. + */ + public function setDispatcher(Dispatcher $events): void + { + $this->events = $events; + } + + /** + * Get the last user we attempted to authenticate. + */ + public function getLastAttempted(): ?AuthenticatableContract + { + return $this->getContextState('lastAttempted'); + } + + /** + * Create a token for the given user. + */ + protected function makeTokenForUser(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'); + } + } + + /** + * Decode a JWT token, caching the result per coroutine. + */ + protected function decodeToken(string $token): array { + return CoroutineContext::getOrSet( + $this->getPayloadContextKey($token), + fn () => $this->jwtManager->decode($token) + ); + } + + /** + * Return the currently cached user. + */ + protected function cachedUser(): ?AuthenticatableContract + { + self::$nullUserSentinel ??= new stdClass; + + $cached = CoroutineContext::get($this->getUserContextKey()); + + return ($cached === null || $cached === self::$nullUserSentinel) ? null : $cached; + } + + /** + * Cache the current user without firing guard events. + */ + protected function cacheUser(AuthenticatableContract $user): void + { + CoroutineContext::set($this->getUserContextKey(), $user); + } + + /** + * Pull custom claims for the next token. + */ + protected function pullCustomClaims(): array + { + $contextKey = $this->getContextStateKey('claims'); + $claims = CoroutineContext::get($contextKey, []); + CoroutineContext::forget($contextKey); + + return $claims; + } + + /** + * Require a token to be available. + * + * @throws JWTException + */ + protected function requireToken(): string + { + if (! $token = $this->getToken()) { + throw new JWTException('Token could not be parsed from the request.'); + } + + return $token; + } + + /** + * Get Context state. + */ + protected function getContextState(string $key, mixed $default = null): mixed + { + return CoroutineContext::get($this->getContextStateKey($key), $default); + } + + /** + * Set Context state. + */ + protected function setContextState(string $key, mixed $value): void + { + CoroutineContext::set($this->getContextStateKey($key), $value); + } + + /** + * Forget Context state. + */ + protected function forgetContextState(string $key): void + { + CoroutineContext::forget($this->getContextStateKey($key)); + } + + /** + * Get a Context state key. + */ + protected function getContextStateKey(string $key): string + { + return static::GUARD_CONTEXT_KEY_PREFIX . $this->name . '.' . $key; + } + + /** + * Get the Context key for caching the authenticated user. + */ + protected function getUserContextKey(?string $token = null): string + { + $token ??= $this->getToken(); + if ($token === null || $token === '') { - return "__auth.guards.{$this->name}.user.default"; + return $this->getContextStateKey('user.default'); + } + + return $this->getContextStateKey('user.' . hash('xxh128', $token)); + } + + /** + * Get the Context key for caching a decoded payload. + */ + protected function getPayloadContextKey(string $token): string + { + return $this->getContextStateKey('payload.' . hash('xxh128', $token)); + } + + /** + * Dispatch the given event if listeners are registered. + */ + protected function dispatchIfListening(string $eventClass, Closure $event): void + { + if ($this->events?->hasListeners($eventClass)) { + $this->events->dispatch($event()); } + } + + /** + * Fire the attempt event. + */ + protected function fireAttemptEvent(array $credentials): void + { + $this->dispatchIfListening(Attempting::class, fn () => new Attempting($this->name, $credentials, false)); + } + + /** + * Fire the validated event. + */ + protected function fireValidatedEvent(AuthenticatableContract $user): void + { + $this->dispatchIfListening(Validated::class, fn () => new Validated($this->name, $user)); + } + + /** + * Fire the failed authentication attempt event. + */ + protected function fireFailedEvent(?AuthenticatableContract $user, array $credentials): void + { + $this->dispatchIfListening(Failed::class, fn () => new Failed($this->name, $user, $credentials)); + } + + /** + * Fire the login event. + */ + protected function fireLoginEvent(AuthenticatableContract $user): void + { + $this->dispatchIfListening(Login::class, fn () => new Login($this->name, $user, false)); + } - return "__auth.guards.{$this->name}.user." . hash('xxh128', $token); + /** + * Fire the authenticated event. + */ + protected function fireAuthenticatedEvent(AuthenticatableContract $user): void + { + $this->dispatchIfListening(Authenticated::class, fn () => new Authenticated($this->name, $user)); + } + + /** + * Fire the logout event. + */ + protected function fireLogoutEvent(?AuthenticatableContract $user): void + { + $this->dispatchIfListening(Logout::class, fn () => new Logout($this->name, $user)); } /** diff --git a/src/jwt/src/Providers/Lcobucci.php b/src/jwt/src/Providers/Lcobucci.php index 2b296f3b9..6f139d141 100644 --- a/src/jwt/src/Providers/Lcobucci.php +++ b/src/jwt/src/Providers/Lcobucci.php @@ -9,6 +9,7 @@ use Exception; use Hypervel\JWT\Contracts\ProviderContract; use Hypervel\JWT\Exceptions\JWTException; +use Hypervel\JWT\Exceptions\SecretMissingException; use Hypervel\JWT\Exceptions\TokenInvalidException; use Hypervel\Support\Collection; use Lcobucci\JWT\Builder; @@ -133,7 +134,9 @@ protected function getBuilderFromClaims(array $payload): Builder $builder = $builder->issuedBy($value); break; case RegisteredClaims::AUDIENCE: - $builder = $builder->permittedFor($value); + $builder = is_array($value) + ? $builder->permittedFor(...$value) + : $builder->permittedFor($value); break; case RegisteredClaims::SUBJECT: $builder = $builder->relatedTo((string) $value); @@ -159,11 +162,9 @@ protected function buildConfig(): Configuration ) : Configuration::forSymmetricSigner($this->signer, $this->getSigningKey()); - $config->setValidationConstraints( + return $config->withValidationConstraints( new SignedWith($this->signer, $this->getVerificationKey()) ); - - return $config; } /** @@ -220,7 +221,7 @@ protected function getSigningKey(): mixed } if (! $secret = $this->getSecret()) { - throw new JWTException('Secret is not set.'); + throw new SecretMissingException('Secret is not set.'); } return $this->getKey($secret); @@ -240,7 +241,7 @@ protected function getVerificationKey(): mixed } if (! $secret = $this->getSecret()) { - throw new JWTException('Secret is not set.'); + throw new SecretMissingException('Secret is not set.'); } return $this->getKey($secret); @@ -251,6 +252,10 @@ protected function getVerificationKey(): mixed */ protected function getKey(string $contents, string $passphrase = ''): Key { + if (str_starts_with($contents, 'file://')) { + return InMemory::file($contents, $passphrase); + } + return InMemory::plainText($contents, $passphrase); } } diff --git a/src/jwt/src/Validations/ExpiredClaim.php b/src/jwt/src/Validations/ExpiredClaim.php index d068272dd..1d0d11cdb 100644 --- a/src/jwt/src/Validations/ExpiredClaim.php +++ b/src/jwt/src/Validations/ExpiredClaim.php @@ -4,10 +4,11 @@ namespace Hypervel\JWT\Validations; +use Hypervel\JWT\Contracts\TemporalValidation; use Hypervel\JWT\Exceptions\TokenExpiredException; use Hypervel\Support\Facades\Date; -class ExpiredClaim extends AbstractValidation +class ExpiredClaim extends AbstractValidation implements TemporalValidation { public function validate(array $payload): void { diff --git a/src/jwt/src/Validations/IssuedAtClaim.php b/src/jwt/src/Validations/IssuedAtClaim.php index c50363a4e..ca0821aaa 100644 --- a/src/jwt/src/Validations/IssuedAtClaim.php +++ b/src/jwt/src/Validations/IssuedAtClaim.php @@ -4,10 +4,11 @@ namespace Hypervel\JWT\Validations; +use Hypervel\JWT\Contracts\TemporalValidation; use Hypervel\JWT\Exceptions\TokenInvalidException; use Hypervel\Support\Facades\Date; -class IssuedAtClaim extends AbstractValidation +class IssuedAtClaim extends AbstractValidation implements TemporalValidation { public function validate(array $payload): void { diff --git a/src/jwt/src/Validations/IssuerClaim.php b/src/jwt/src/Validations/IssuerClaim.php new file mode 100644 index 000000000..d8ed94f1e --- /dev/null +++ b/src/jwt/src/Validations/IssuerClaim.php @@ -0,0 +1,26 @@ +config['issuer'] ?? null; + + if ($issuer === null || $issuer === '') { + return; + } + + if (($payload['iss'] ?? null) !== $issuer) { + throw new TokenInvalidException('Issuer is invalid'); + } + } +} diff --git a/src/jwt/src/Validations/NotBeforeCliam.php b/src/jwt/src/Validations/NotBeforeClaim.php similarity index 91% rename from src/jwt/src/Validations/NotBeforeCliam.php rename to src/jwt/src/Validations/NotBeforeClaim.php index 866cc3e91..06af44de0 100644 --- a/src/jwt/src/Validations/NotBeforeCliam.php +++ b/src/jwt/src/Validations/NotBeforeClaim.php @@ -7,7 +7,7 @@ use Hypervel\JWT\Exceptions\TokenInvalidException; use Hypervel\Support\Facades\Date; -class NotBeforeCliam extends AbstractValidation +class NotBeforeClaim extends AbstractValidation { public function validate(array $payload): void { diff --git a/src/support/src/Facades/JWT.php b/src/support/src/Facades/JWT.php index 0697dd1f8..bad4ce529 100644 --- a/src/support/src/Facades/JWT.php +++ b/src/support/src/Facades/JWT.php @@ -9,8 +9,9 @@ * @method static string getDefaultDriver() * @method static string encode(array $payload) * @method static array decode(string $token, bool $validate = true, bool $checkBlacklist = true) - * @method static string refresh(string $token, bool $forceForever = false) + * @method static string refresh(string $token, bool $forceForever = false, bool $resetClaims = false, array $customClaims = [], int|false|null $ttl = false) * @method static bool invalidate(string $token, bool $forceForever = false) + * @method static bool hasBlacklistEnabled() * @method static mixed driver(string|null $driver = null) * @method static \Hypervel\JWT\JWTManager extend(string $driver, \Closure $callback) * @method static array getDrivers() diff --git a/src/testbench/hypervel/.env.example b/src/testbench/hypervel/.env.example index 9ed3043f9..e448b5e8d 100644 --- a/src/testbench/hypervel/.env.example +++ b/src/testbench/hypervel/.env.example @@ -30,6 +30,11 @@ SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null +# JWT_ISSUER= +# JWT_REFRESH_IAT=false +# JWT_LOCK_SUBJECT=true +# JWT_TOKEN=token + BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/tests/AfterEachTestSubscriber.php b/tests/AfterEachTestSubscriber.php index 49780fb73..daa7a69dc 100644 --- a/tests/AfterEachTestSubscriber.php +++ b/tests/AfterEachTestSubscriber.php @@ -136,6 +136,7 @@ public function notify(Finished $event): void \Hypervel\Inertia\ResponseFactory::flushState(); \Hypervel\Inertia\Ssr\BundleDetector::flushState(); \Hypervel\Inertia\Ssr\HttpGateway::flushState(); + \Hypervel\JWT\ClaimFactory::flushState(); \Hypervel\JWT\JwtGuard::flushState(); \Hypervel\Log\Context\Repository::flushState(); \Hypervel\Mail\Attachment::flushState(); diff --git a/tests/JWT/BlacklistTest.php b/tests/JWT/BlacklistTest.php index 658daa54b..a71726a5d 100644 --- a/tests/JWT/BlacklistTest.php +++ b/tests/JWT/BlacklistTest.php @@ -33,7 +33,7 @@ protected function setUp(): void $this->blacklist = new Blacklist($this->storage); } - public function testAddAValidTokenToTheBlacklist() + public function testAddAValidTokenToTheBlacklist(): void { $payload = [ 'sub' => 1, @@ -58,7 +58,7 @@ public function testAddAValidTokenToTheBlacklist() $this->blacklist->setRefreshTTL($refreshTTL)->add($payload); } - public function testAddATokenWithNoExpToTheBlacklistForever() + public function testAddATokenWithNoExpToTheBlacklistForever(): void { $payload = [ 'sub' => 1, @@ -73,7 +73,7 @@ public function testAddATokenWithNoExpToTheBlacklistForever() $this->blacklist->add($payload); } - public function testReturnTrueWhenAddingAnExpiredTokenToTheBlacklist() + public function testReturnTrueWhenAddingAnExpiredTokenToTheBlacklist(): void { $payload = [ 'sub' => 1, @@ -98,7 +98,7 @@ public function testReturnTrueWhenAddingAnExpiredTokenToTheBlacklist() $this->assertTrue($this->blacklist->setRefreshTTL($refreshTTL)->add($payload)); } - public function testReturnTrueEarlyWhenAddingAnItemAndItAlreadyExists() + public function testReturnTrueEarlyWhenAddingAnItemAndItAlreadyExists(): void { $payload = [ 'sub' => 1, @@ -123,7 +123,33 @@ public function testReturnTrueEarlyWhenAddingAnItemAndItAlreadyExists() $this->assertTrue($this->blacklist->setRefreshTTL($refreshTTL)->add($payload)); } - public function testCheckWhetherATokenHasBeenBlacklisted() + public function testBlacklistTtlRoundsFractionalMinutesUp(): void + { + Carbon::setTestNow('2000-01-01T00:00:00.500000Z'); + + $nowTimestamp = Carbon::now()->timestamp; + $payload = [ + 'sub' => 1, + 'iss' => 'http://example.com', + 'exp' => $nowTimestamp + 60, + 'nbf' => $nowTimestamp, + 'iat' => $nowTimestamp, + 'jti' => 'foo', + ]; + + $this->storage->shouldReceive('get') + ->with('foo') + ->once() + ->andReturn([]); + + $this->storage->shouldReceive('add') + ->with('foo', ['valid_until' => $nowTimestamp], 2) + ->once(); + + $this->blacklist->setRefreshTTL(0)->add($payload); + } + + public function testCheckWhetherATokenHasBeenBlacklisted(): void { $payload = [ 'sub' => 1, @@ -140,7 +166,7 @@ public function testCheckWhetherATokenHasBeenBlacklisted() } #[DataProvider('blacklistProvider')] - public function testCheckWhetherATokenHasNotBeenBlacklisted($result) + public function testCheckWhetherATokenHasNotBeenBlacklisted($result): void { $payload = [ 'sub' => 1, @@ -167,7 +193,7 @@ public static function blacklistProvider(): array ]; } - public function testCheckWhetherATokenHasBeenBlacklistedForever() + public function testCheckWhetherATokenHasBeenBlacklistedForever(): void { $payload = [ 'sub' => 1, @@ -183,7 +209,7 @@ public function testCheckWhetherATokenHasBeenBlacklistedForever() $this->assertTrue($this->blacklist->has($payload)); } - public function testCheckWhetherATokenHasBeenBlacklistedWhenTheTokenIsNotBlacklisted() + public function testCheckWhetherATokenHasBeenBlacklistedWhenTheTokenIsNotBlacklisted(): void { $payload = [ 'sub' => 1, @@ -199,7 +225,7 @@ public function testCheckWhetherATokenHasBeenBlacklistedWhenTheTokenIsNotBlackli $this->assertFalse($this->blacklist->has($payload)); } - public function testRemoveATokenFromTheBlacklist() + public function testRemoveATokenFromTheBlacklist(): void { $payload = [ 'sub' => 1, @@ -215,7 +241,7 @@ public function testRemoveATokenFromTheBlacklist() $this->assertTrue($this->blacklist->remove($payload)); } - public function testSetACustomUniqueKeyForTheBlacklist() + public function testSetACustomUniqueKeyForTheBlacklist(): void { $payload = [ 'sub' => '1', @@ -232,28 +258,28 @@ public function testSetACustomUniqueKeyForTheBlacklist() $this->assertSame('1', $this->blacklist->getKey($payload)); } - public function testEmptyTheBlacklist() + public function testEmptyTheBlacklist(): void { $this->storage->shouldReceive('flush'); $this->assertTrue($this->blacklist->clear()); } - public function testSetAndGetTheBlacklistGracePeriod() + public function testSetAndGetTheBlacklistGracePeriod(): void { $this->assertInstanceOf(Blacklist::class, $this->blacklist->setGracePeriod(15)); $this->assertSame(15, $this->blacklist->getGracePeriod()); } - public function testSetAndGetTheBlacklistRefreshTTL() + public function testSetAndGetTheBlacklistRefreshTTL(): void { $this->assertInstanceOf(Blacklist::class, $this->blacklist->setRefreshTTL(15)); $this->assertSame(15, $this->blacklist->getRefreshTTL()); } - public function testKeyNotExistsInPayload() + public function testKeyNotExistsInPayload(): void { $this->expectException(TokenInvalidException::class); $this->expectExceptionMessage('Claim `jti` is missing in payload for blacklist'); diff --git a/tests/JWT/ClaimFactoryTest.php b/tests/JWT/ClaimFactoryTest.php new file mode 100644 index 000000000..fffd932c7 --- /dev/null +++ b/tests/JWT/ClaimFactoryTest.php @@ -0,0 +1,336 @@ +factory(['jwt' => ['issuer' => 'https://api.example.test', 'lock_subject' => true]]) + ->make(new ClaimFactoryUser(42), new ClaimFactoryModelProvider(ClaimFactoryUser::class), 120); + + $this->assertSame(42, $claims['sub']); + $this->assertSame('https://api.example.test', $claims['iss']); + $this->assertSame(1767225600, $claims['iat']); + $this->assertSame(1767225600, $claims['nbf']); + $this->assertSame(1767232800, $claims['exp']); + $this->assertSame(hash('xxh128', ClaimFactoryUser::class), $claims['prv']); + } + + public function testOmitsExpirationWhenTtlIsNull(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-01 00:00:00')); + + $claims = $this->factory()->make(new ClaimFactoryUser(42), new ClaimFactoryProvider, null); + + $this->assertArrayNotHasKey('exp', $claims); + } + + public function testUsesJwtSubjectIdentifierAndCustomClaims(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-01 00:00:00')); + + $claims = $this->factory()->make( + new ClaimFactoryJwtSubjectUser(42, 'jwt-42', ['role' => 'user', 'tenant' => 'one']), + new ClaimFactoryProvider, + 120, + ['role' => 'admin'], + ); + + $this->assertSame('jwt-42', $claims['sub']); + $this->assertSame('admin', $claims['role']); + $this->assertSame('one', $claims['tenant']); + } + + public function testRejectsReservedJwtSubjectClaims(): void + { + $this->expectException(JWTException::class); + $this->expectExceptionMessage('Custom JWT claims may not override reserved claims: exp, sub.'); + + $this->factory()->make( + new ClaimFactoryJwtSubjectUser(42, 'jwt-42', ['sub' => 999, 'exp' => 123]), + new ClaimFactoryProvider, + 120, + ); + } + + public function testRejectsReservedInlineClaims(): void + { + $this->expectException(JWTException::class); + $this->expectExceptionMessage('Custom JWT claims may not override reserved claims: iss, prv.'); + + $this->factory()->make( + new ClaimFactoryUser(42), + new ClaimFactoryProvider, + 120, + ['iss' => 'https://tenant.example.test', 'prv' => 'fake-provider'], + ); + } + + public function testSubjectMatchingHonorsProviderLock(): void + { + $factory = $this->factory(['jwt' => ['issuer' => null, 'lock_subject' => true]]); + $provider = new ClaimFactoryModelProvider(ClaimFactoryUser::class); + + $this->assertTrue($factory->subjectMatchesProvider([ + 'prv' => hash('xxh128', ClaimFactoryUser::class), + ], $provider)); + $this->assertFalse($factory->subjectMatchesProvider([ + 'prv' => hash('xxh128', ClaimFactoryJwtSubjectUser::class), + ], $provider)); + $this->assertFalse($factory->subjectMatchesProvider([], $provider)); + } + + public function testSubjectMatchingSkipsProvidersWithoutModel(): void + { + $factory = $this->factory(['jwt' => ['issuer' => null, 'lock_subject' => true]]); + + $this->assertTrue($factory->subjectMatchesProvider([], new ClaimFactoryProvider)); + } + + public function testRefreshKeepsPersistentClaimsAndDropsManagedClaims(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-01 00:00:00')); + + $claims = $this->factory(['jwt' => ['issuer' => 'https://api.example.test', 'lock_subject' => true]]) + ->refresh( + payload: [ + 'sub' => 42, + 'iat' => 100, + 'nbf' => 100, + 'exp' => 200, + 'iss' => 'old-issuer', + 'jti' => 'old-jti', + 'prv' => 'provider-hash', + 'role' => 'user', + 'tenant' => 'one', + ], + ttl: 120, + refreshIssuedAt: false, + resetClaims: true, + persistentClaims: ['tenant', 'exp', 'jti'], + customClaims: ['role' => 'admin'], + ); + + $this->assertSame(42, $claims['sub']); + $this->assertSame(100, $claims['iat']); + $this->assertSame(1767225600, $claims['nbf']); + $this->assertSame(1767232800, $claims['exp']); + $this->assertSame('https://api.example.test', $claims['iss']); + $this->assertSame('provider-hash', $claims['prv']); + $this->assertSame('one', $claims['tenant']); + $this->assertSame('admin', $claims['role']); + $this->assertArrayNotHasKey('jti', $claims); + } + + public function testRefreshKeepsNonManagedClaimsWhenResetClaimsIsFalse(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-01 00:00:00')); + + $claims = $this->factory(['jwt' => ['issuer' => null, 'lock_subject' => true]]) + ->refresh( + payload: [ + 'sub' => 42, + 'iat' => 100, + 'nbf' => 100, + 'exp' => 200, + 'jti' => 'old-jti', + 'role' => 'user', + 'tenant' => 'one', + ], + ttl: null, + refreshIssuedAt: false, + resetClaims: false, + persistentClaims: [], + customClaims: ['role' => 'admin'], + ); + + $this->assertSame(42, $claims['sub']); + $this->assertSame(100, $claims['iat']); + $this->assertSame(1767225600, $claims['nbf']); + $this->assertArrayNotHasKey('exp', $claims); + $this->assertArrayNotHasKey('jti', $claims); + $this->assertSame('admin', $claims['role']); + $this->assertSame('one', $claims['tenant']); + } + + public function testRefreshIssuedAtRestampsIat(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-01 00:00:00')); + + $claims = $this->factory()->refresh( + payload: ['sub' => 42, 'iat' => 100], + ttl: null, + refreshIssuedAt: true, + resetClaims: false, + persistentClaims: [], + ); + + $this->assertSame(1767225600, $claims['iat']); + } + + public function testRejectsReservedRefreshClaims(): void + { + $this->expectException(JWTException::class); + $this->expectExceptionMessage('Custom JWT claims may not override reserved claims: exp, jti.'); + + $this->factory()->refresh( + payload: ['sub' => 42, 'iat' => 100], + ttl: 120, + refreshIssuedAt: false, + resetClaims: false, + persistentClaims: [], + customClaims: ['exp' => 999, 'jti' => 'custom-jti'], + ); + } + + public function testFlushStateClearsModelHashCache(): void + { + $factory = $this->factory(['jwt' => ['issuer' => null, 'lock_subject' => true]]); + + $factory->make(new ClaimFactoryUser(42), new ClaimFactoryModelProvider(ClaimFactoryUser::class), 120); + + $property = new ReflectionProperty(ClaimFactory::class, 'subjectModelHashes'); + $this->assertSame([ + ClaimFactoryUser::class => hash('xxh128', ClaimFactoryUser::class), + ], $property->getValue()); + + ClaimFactory::flushState(); + + $this->assertSame([], $property->getValue()); + } + + /** + * Create a claim factory. + */ + private function factory(array $config = ['jwt' => ['issuer' => null, 'lock_subject' => false]]): ClaimFactory + { + return new ClaimFactory(new Repository($config)); + } +} + +class ClaimFactoryUser implements Authenticatable +{ + public function __construct( + private readonly int|string $id, + ) { + } + + public function getAuthIdentifierName(): string + { + return 'id'; + } + + public function getAuthIdentifier(): mixed + { + return $this->id; + } + + public function getAuthPasswordName(): string + { + return 'password'; + } + + public function getAuthPassword(): ?string + { + return null; + } + + public function getRememberToken(): ?string + { + return null; + } + + public function setRememberToken(string $value): void + { + } + + public function getRememberTokenName(): string + { + return 'remember_token'; + } +} + +class ClaimFactoryJwtSubjectUser extends ClaimFactoryUser implements JWTSubject +{ + public function __construct( + int|string $id, + private readonly mixed $jwtIdentifier, + private readonly array $customClaims, + ) { + parent::__construct($id); + } + + public function getJWTIdentifier(): mixed + { + return $this->jwtIdentifier; + } + + public function getJWTCustomClaims(): array + { + return $this->customClaims; + } +} + +class ClaimFactoryProvider implements UserProvider +{ + public function retrieveById(mixed $identifier): ?Authenticatable + { + return null; + } + + public function retrieveByToken(mixed $identifier, #[SensitiveParameter] string $token): ?Authenticatable + { + return null; + } + + public function updateRememberToken(Authenticatable $user, #[SensitiveParameter] string $token): void + { + } + + public function retrieveByCredentials(#[SensitiveParameter] array $credentials): ?Authenticatable + { + return null; + } + + public function validateCredentials(Authenticatable $user, #[SensitiveParameter] array $credentials): bool + { + return false; + } + + public function rehashPasswordIfRequired( + Authenticatable $user, + #[SensitiveParameter] + array $credentials, + bool $force = false, + ): void { + } +} + +class ClaimFactoryModelProvider extends ClaimFactoryProvider +{ + public function __construct( + private readonly string $model, + ) { + } + + public function getModel(): string + { + return $this->model; + } +} diff --git a/tests/JWT/Console/JwtGenerateCertsCommandTest.php b/tests/JWT/Console/JwtGenerateCertsCommandTest.php new file mode 100644 index 000000000..8bd069aea --- /dev/null +++ b/tests/JWT/Console/JwtGenerateCertsCommandTest.php @@ -0,0 +1,242 @@ + + */ + private array $temporaryPaths = []; + + #[Override] + protected function setUp(): void + { + parent::setUp(); + + $this->filesystem = new Filesystem; + $this->environmentPath = ParallelTesting::tempDir('JwtGenerateCertsCommandTest-env'); + + $this->filesystem->deleteDirectory($this->environmentPath); + $this->filesystem->ensureDirectoryExists($this->environmentPath); + $this->app->useEnvironmentPath($this->environmentPath); + file_put_contents($this->app->environmentFilePath(), "APP_ENV=testing\n"); + } + + #[Override] + protected function tearDown(): void + { + foreach ($this->temporaryPaths as $path) { + $this->filesystem->deleteDirectory($path); + } + + $this->filesystem->deleteDirectory($this->environmentPath); + + parent::tearDown(); + } + + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + JWTServiceProvider::class, + ]; + } + + public function testGeneratesCertificatesAndWritesEnvironmentVariables(): void + { + $directory = $this->temporaryDirectory('generate'); + + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'rsa', + '--bits' => 512, + '--sha' => 256, + '--dir' => $directory, + '--passphrase' => 'secret', + ])->assertSuccessful(); + + $privateKeyPath = $directory . '/jwt-rsa-512-private.pem'; + $publicKeyPath = $directory . '/jwt-rsa-512-public.pem'; + + $this->assertFileExists($privateKeyPath); + $this->assertFileExists($publicKeyPath); + + $contents = file_get_contents($this->app->environmentFilePath()); + + $this->assertStringContainsString('JWT_ALGO=RS256', $contents); + $this->assertStringContainsString('JWT_PRIVATE_KEY="file://' . $privateKeyPath . '"', $contents); + $this->assertStringContainsString('JWT_PUBLIC_KEY="file://' . $publicKeyPath . '"', $contents); + $this->assertStringContainsString('JWT_PASSPHRASE=secret', $contents); + } + + public function testGeneratesUnencryptedPrivateKeyWhenNoPassphraseIsConfigured(): void + { + $directory = $this->temporaryDirectory('no-passphrase'); + + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'rsa', + '--bits' => 512, + '--sha' => 256, + '--dir' => $directory, + ])->assertSuccessful(); + + $privateKey = file_get_contents($directory . '/jwt-rsa-512-private.pem'); + + $this->assertStringContainsString('-----BEGIN PRIVATE KEY-----', $privateKey); + $this->assertStringNotContainsString('-----BEGIN ENCRYPTED PRIVATE KEY-----', $privateKey); + } + + public function testGeneratesEcCertificates(): void + { + $directory = $this->temporaryDirectory('ec'); + + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'ec', + '--bits' => 256, + '--sha' => 256, + '--dir' => $directory, + '--curve' => 'prime256v1', + ])->assertSuccessful(); + + $privateKeyPath = $directory . '/jwt-ec-prime256v1-private.pem'; + $publicKeyPath = $directory . '/jwt-ec-prime256v1-public.pem'; + + $this->assertFileExists($privateKeyPath); + $this->assertFileExists($publicKeyPath); + $this->assertSame('0600', substr(sprintf('%o', fileperms($privateKeyPath)), -4)); + + $contents = file_get_contents($this->app->environmentFilePath()); + + $this->assertStringContainsString('JWT_ALGO=ES256', $contents); + $this->assertStringContainsString('JWT_PRIVATE_KEY="file://' . $privateKeyPath . '"', $contents); + $this->assertStringContainsString('JWT_PUBLIC_KEY="file://' . $publicKeyPath . '"', $contents); + } + + public function testGeneratesAllEcCertificateVariantsWithMatchingCurves(): void + { + foreach ([256 => 'prime256v1', 384 => 'secp384r1', 512 => 'secp521r1'] as $sha => $curve) { + $directory = $this->temporaryDirectory("ec-{$sha}"); + + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'ec', + '--sha' => $sha, + '--dir' => $directory, + '--curve' => $curve, + ])->assertSuccessful(); + + $this->assertFileExists($directory . "/jwt-ec-{$curve}-private.pem"); + $this->assertFileExists($directory . "/jwt-ec-{$curve}-public.pem"); + $this->assertStringContainsString("JWT_ALGO=ES{$sha}", file_get_contents($this->app->environmentFilePath())); + } + } + + public function testRejectsMismatchedEcCurve(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ES512 requires the [secp521r1] curve.'); + + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'ec', + '--sha' => 512, + '--curve' => 'prime256v1', + '--dir' => $this->temporaryDirectory('ec-mismatch'), + ]); + } + + public function testRejectsUnsupportedShaVariant(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('JWT certificate SHA variant must be 256, 384, or 512.'); + + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'rsa', + '--sha' => 999, + '--dir' => $this->temporaryDirectory('invalid-sha'), + ]); + } + + public function testRefusesToOverwriteExistingCertificatesWithoutForce(): void + { + $directory = $this->temporaryDirectory('existing'); + + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + file_put_contents($directory . '/jwt-rsa-512-private.pem', 'existing'); + + $this->artisan('jwt:generate-certs', [ + '--algo' => 'rsa', + '--bits' => 512, + '--sha' => 256, + '--dir' => $directory, + ]) + ->expectsOutputToContain('JWT certificates already exist. Use --force to overwrite them.') + ->assertExitCode(Command::FAILURE); + + $this->assertSame('existing', file_get_contents($directory . '/jwt-rsa-512-private.pem')); + } + + public function testFailsWhenEnvironmentFileIsMissing(): void + { + $environmentFile = $this->app->environmentFilePath(); + $directory = $this->temporaryDirectory('missing-env'); + + unlink($environmentFile); + + $this->artisan('jwt:generate-certs', [ + '--force' => true, + '--algo' => 'rsa', + '--bits' => 512, + '--sha' => 256, + '--dir' => $directory, + ]) + ->expectsOutputToContain("The file [{$environmentFile}] does not exist.") + ->assertExitCode(Command::FAILURE); + + $this->assertDirectoryDoesNotExist($directory); + } + + public function testInvalidAlgorithmFailsFast(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unknown JWT certificate algorithm.'); + + $this->artisan('jwt:generate-certs', [ + '--algo' => 'invalid', + '--dir' => $this->temporaryDirectory('invalid'), + ]); + } + + /** + * Create an isolated temporary directory path for certificate output. + */ + private function temporaryDirectory(string $suffix): string + { + $path = ParallelTesting::tempDir("JwtGenerateCertsCommandTest-{$suffix}"); + + $this->filesystem->deleteDirectory($path); + $this->temporaryPaths[] = $path; + + return $path; + } +} diff --git a/tests/JWT/Console/JwtSecretCommandTest.php b/tests/JWT/Console/JwtSecretCommandTest.php new file mode 100644 index 000000000..a143d4ea0 --- /dev/null +++ b/tests/JWT/Console/JwtSecretCommandTest.php @@ -0,0 +1,130 @@ +filesystem = new Filesystem; + $this->environmentPath = ParallelTesting::tempDir('JwtSecretCommandTest'); + + $this->filesystem->deleteDirectory($this->environmentPath); + $this->filesystem->ensureDirectoryExists($this->environmentPath); + $this->app->useEnvironmentPath($this->environmentPath); + file_put_contents($this->app->environmentFilePath(), "APP_ENV=testing\n"); + } + + #[Override] + protected function tearDown(): void + { + $this->filesystem->deleteDirectory($this->environmentPath); + + parent::tearDown(); + } + + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + JWTServiceProvider::class, + ]; + } + + public function testShowPrintsSecretWithoutWritingEnvironmentFile(): void + { + $environmentFile = $this->app->environmentFilePath(); + $originalContents = file_get_contents($environmentFile); + + $this->artisan('jwt:secret', ['--show' => true]) + ->assertSuccessful(); + + $this->assertSame($originalContents, file_get_contents($environmentFile)); + } + + public function testForceWritesSecretAndAlgorithm(): void + { + $environmentFile = $this->app->environmentFilePath(); + + $this->artisan('jwt:secret', ['--force' => true]) + ->assertSuccessful(); + + $contents = file_get_contents($environmentFile); + + $this->assertMatchesRegularExpression('/^JWT_SECRET=.{64}$/m', $contents); + $this->assertStringContainsString('JWT_ALGO=HS256', $contents); + } + + public function testAlwaysNoSkipsExistingSecret(): void + { + $environmentFile = $this->app->environmentFilePath(); + + file_put_contents($environmentFile, "JWT_SECRET=existing-secret\n"); + + $this->artisan('jwt:secret', ['--always-no' => true]) + ->expectsOutputToContain('JWT secret already exists. Skipping...') + ->assertSuccessful(); + + $this->assertSame("JWT_SECRET=existing-secret\n", file_get_contents($environmentFile)); + } + + public function testConfirmationNoSkipsExistingSecret(): void + { + $environmentFile = $this->app->environmentFilePath(); + + file_put_contents($environmentFile, "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?', 'no') + ->expectsOutputToContain('No changes were made to your JWT secret.') + ->assertSuccessful(); + + $this->assertSame("JWT_SECRET=existing-secret\n", file_get_contents($environmentFile)); + } + + public function testConfirmationYesOverwritesExistingSecret(): void + { + $environmentFile = $this->app->environmentFilePath(); + + file_put_contents($environmentFile, "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') + ->assertSuccessful(); + + $contents = file_get_contents($environmentFile); + + $this->assertStringNotContainsString('JWT_SECRET=existing-secret', $contents); + $this->assertMatchesRegularExpression('/^JWT_SECRET=.{64}$/m', $contents); + $this->assertStringContainsString('JWT_ALGO=HS256', $contents); + } + + public function testFailsWhenEnvironmentFileIsMissing(): void + { + $environmentFile = $this->app->environmentFilePath(); + + if (file_exists($environmentFile)) { + unlink($environmentFile); + } + + $this->artisan('jwt:secret', ['--force' => true]) + ->expectsOutputToContain("The file [{$environmentFile}] does not exist.") + ->assertExitCode(Command::FAILURE); + } +} diff --git a/tests/JWT/Http/ParserTest.php b/tests/JWT/Http/ParserTest.php new file mode 100644 index 000000000..491e78a29 --- /dev/null +++ b/tests/JWT/Http/ParserTest.php @@ -0,0 +1,134 @@ + 'Bearer header-token', + ]); + + $this->assertSame('header-token', $parser->parseToken($request)); + } + + public function testParsesBearerHeaderBeforeComma(): void + { + $parser = new Parser([new AuthHeaders]); + + $request = Request::create('/', 'GET', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer header-token, Basic ignored', + ]); + + $this->assertSame('header-token', $parser->parseToken($request)); + } + + public function testParsesBearerHeaderAfterComma(): void + { + $parser = new Parser([new AuthHeaders]); + + $request = Request::create('/', 'GET', server: [ + 'HTTP_AUTHORIZATION' => 'Basic ignored, Bearer header-token', + ]); + + $this->assertSame('header-token', $parser->parseToken($request)); + } + + public function testParsesRedirectAuthorizationHeader(): void + { + $parser = new Parser([new AuthHeaders]); + + $request = Request::create('/', 'GET', server: [ + 'REDIRECT_HTTP_AUTHORIZATION' => 'Bearer redirect-token', + ]); + + $this->assertSame('redirect-token', $parser->parseToken($request)); + } + + public function testReturnsNullWhenBearerHeaderIsMissing(): void + { + $parser = new Parser([new AuthHeaders]); + + $request = Request::create('/', 'GET', server: [ + 'HTTP_AUTHORIZATION' => 'Basic token', + ]); + + $this->assertNull($parser->parseToken($request)); + } + + public function testReturnsNullWhenBearerIsNotTheAuthScheme(): void + { + $parser = new Parser([new AuthHeaders]); + + $request = Request::create('/', 'GET', server: [ + 'HTTP_AUTHORIZATION' => 'Basic not-a-Bearer token', + ]); + + $this->assertNull($parser->parseToken($request)); + } + + public function testParsesQueryInput(): void + { + $parser = new Parser([new InputSource]); + + $request = Request::create('/?token=query-token'); + + $this->assertSame('query-token', $parser->parseToken($request)); + } + + public function testParsesCookieWhenCookieExtractorIsConfigured(): void + { + $parser = new Parser([new Cookie]); + + $request = Request::create('/', 'GET', cookies: [ + 'token' => 'cookie-token', + ]); + + $this->assertSame('cookie-token', $parser->parseToken($request)); + } + + public function testIgnoresNonStringInputTokens(): void + { + $parser = new Parser([new InputSource]); + + $request = Request::create('/', 'GET', [ + 'token' => ['not-a-token'], + ]); + + $this->assertNull($parser->parseToken($request)); + } + + public function testParsesLiteralZeroToken(): void + { + $parser = new Parser([new InputSource]); + + $request = Request::create('/', 'GET', [ + 'token' => '0', + ]); + + $this->assertSame('0', $parser->parseToken($request)); + } + + public function testParserDoesNotRetainRequestBetweenCalls(): void + { + $parser = new Parser([new InputSource]); + + $firstRequest = Request::create('/?token=first-token'); + $secondRequest = Request::create('/'); + + $this->assertSame('first-token', $parser->parseToken($firstRequest)); + $this->assertNull($parser->parseToken($secondRequest)); + } +} diff --git a/tests/JWT/JWTConfigTest.php b/tests/JWT/JWTConfigTest.php index 87fb637c2..e0e59b6de 100644 --- a/tests/JWT/JWTConfigTest.php +++ b/tests/JWT/JWTConfigTest.php @@ -4,6 +4,12 @@ namespace Hypervel\Tests\JWT; +use Hypervel\JWT\Http\Parser\AuthHeaders; +use Hypervel\JWT\Http\Parser\InputSource; +use Hypervel\JWT\Validations\ExpiredClaim; +use Hypervel\JWT\Validations\IssuedAtClaim; +use Hypervel\JWT\Validations\IssuerClaim; +use Hypervel\JWT\Validations\NotBeforeClaim; use Hypervel\Support\Env; use Hypervel\Tests\TestCase; @@ -29,6 +35,24 @@ public function testBlacklistDurationsAreLoadedAsIntegersFromEnvironment(): void } } + public function testLeewayIsLoadedAsIntegerFromEnvironment(): void + { + $originalValues = $this->setEnvironmentVariables([ + 'JWT_LEEWAY' => '30', + ]); + + try { + Env::flushRepository(); + + $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; + + $this->assertSame(30, $config['leeway']); + } finally { + $this->restoreEnvironmentVariables($originalValues); + Env::flushRepository(); + } + } + public function testTtlIsLoadedAsIntegerFromEnvironment(): void { $originalValues = $this->setEnvironmentVariables([ @@ -101,6 +125,58 @@ public function testRefreshTtlCanBeLoadedAsNullFromEnvironment(): void } } + public function testNewJwtOptionsAreLoadedFromEnvironment(): void + { + $originalValues = $this->setEnvironmentVariables([ + 'JWT_ISSUER' => 'https://api.example.test', + 'JWT_REFRESH_IAT' => 'true', + 'JWT_LOCK_SUBJECT' => 'false', + 'JWT_TOKEN' => 'api_token', + ]); + + try { + Env::flushRepository(); + + $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; + + $this->assertSame('https://api.example.test', $config['issuer']); + $this->assertTrue($config['refresh_iat']); + $this->assertFalse($config['lock_subject']); + $this->assertSame('api_token', $config['token']); + } 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'; + $contents = file_get_contents(dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'); + + $this->assertStringContainsString(NotBeforeClaim::class, $contents); + $this->assertStringNotContainsString('NotBeforeCliam', $contents); + $this->assertNotContains('Hypervel\JWT\Validations\NotBeforeCliam', $config['validations']); + } + + public function testDefaultConfigurationValidatesStandardTemporalClaimsAndIssuer(): void + { + $config = require dirname(__DIR__, 2) . '/src/jwt/config/jwt.php'; + + $this->assertContains(ExpiredClaim::class, $config['validations']); + $this->assertContains(IssuerClaim::class, $config['validations']); + $this->assertContains(IssuedAtClaim::class, $config['validations']); + $this->assertContains(NotBeforeClaim::class, $config['validations']); + } + /** * Set the given environment variables. * diff --git a/tests/JWT/JWTManagerTest.php b/tests/JWT/JWTManagerTest.php index d347002c2..55e505311 100644 --- a/tests/JWT/JWTManagerTest.php +++ b/tests/JWT/JWTManagerTest.php @@ -7,12 +7,16 @@ use Carbon\Carbon; use Hypervel\Config\Repository; use Hypervel\Contracts\Container\Container; +use Hypervel\JWT\ClaimFactory; use Hypervel\JWT\Contracts\BlacklistContract; use Hypervel\JWT\Exceptions\JWTException; use Hypervel\JWT\Exceptions\TokenBlacklistedException; use Hypervel\JWT\Exceptions\TokenExpiredException; use Hypervel\JWT\JWTManager; use Hypervel\JWT\Providers\Lcobucci; +use Hypervel\JWT\Validations\ExpiredClaim; +use Hypervel\JWT\Validations\NotBeforeClaim; +use Hypervel\JWT\Validations\RequiredClaims; use Hypervel\Support\Str; use Hypervel\Tests\JWT\Fixtures\ValidationStub; use Hypervel\Tests\TestCase; @@ -42,18 +46,26 @@ class JWTManagerTest extends TestCase */ private BlacklistContract $blacklist; + /** + * @var ClaimFactory|MockInterface + */ + private ClaimFactory $claimFactory; + private int $testNowTimestamp; protected function setUp(): void { + parent::setUp(); + $this->setTestNow(); $this->mockContainer(); $this->mockConfig(); $this->mockProvider(); $this->mockBlacklist(); + $this->mockClaimFactory(); } - public function testEncodeAPayload() + public function testEncodeAPayload(): void { $jti = '11111111-1111-4111-8111-111111111111'; $token = 'foo.bar.baz'; @@ -74,7 +86,46 @@ public function testEncodeAPayload() $this->assertEquals($token, $this->createManager()->encode($payload)); } - public function testDecodeAToken() + public function testEncodeAddsJtiWhenBlacklistIsEnabledAndMissing(): void + { + $token = 'foo.bar.baz'; + $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; + $jti = '11111111-1111-4111-8111-111111111111'; + + $this->mockUuid($jti); + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); + $this->provider->shouldReceive('encode')->once()->with($payload + ['jti' => $jti])->andReturn($token); + + $this->assertSame($token, $this->createManager()->encode($payload)); + } + + public function testEncodeDoesNotAddJtiWhenBlacklistIsDisabled(): void + { + $token = 'foo.bar.baz'; + $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $this->provider->shouldReceive('encode')->once()->with($payload)->andReturn($token); + + $this->assertSame($token, $this->createManager()->encode($payload)); + } + + public function testConstructorDoesNotResolveBlacklistWhenBlacklistIsDisabled(): void + { + $container = m::mock(Container::class); + $config = m::mock(Repository::class); + + $container->shouldReceive('make')->once()->with('config')->andReturn($config); + $container->shouldReceive('make')->with(BlacklistContract::class)->never(); + $config->shouldReceive('boolean')->once()->with('jwt.blacklist_enabled', false)->andReturnFalse(); + + $manager = new JWTManager($container, m::mock(ClaimFactory::class)); + + $this->assertFalse($manager->hasBlacklistEnabled()); + } + + public function testDecodeAToken(): void { $token = 'foo.bar.baz'; $payload = [ @@ -95,7 +146,7 @@ public function testDecodeAToken() $this->assertSame($payload, $this->createManager()->decode($token)); } - public function testThrowExceptionWhenTokenIsBlacklisted() + public function testThrowExceptionWhenTokenIsBlacklisted(): void { $this->expectException(TokenBlacklistedException::class); $this->expectExceptionMessage('The token has been blacklisted'); @@ -119,7 +170,7 @@ public function testThrowExceptionWhenTokenIsBlacklisted() $this->createManager()->decode($token); } - public function testRefreshAToken() + public function testRefreshAToken(): void { $token = 'foo.bar.baz'; $refreshedToken = 'baz.bar.foo'; @@ -148,6 +199,15 @@ public function testRefreshAToken() $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn(['iss']); $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->claimFactory->shouldReceive('refresh')->once()->with( + $payload, + 120, + false, + false, + ['iss'], + [], + )->andReturn($refreshPayload); $this->provider->shouldReceive('decode')->twice()->with('foo.bar.baz')->andReturn($payload); $this->provider->shouldReceive('encode')->with($refreshPayload)->andReturn($refreshedToken); $this->blacklist->shouldReceive('has')->with($payload)->andReturn(false); @@ -156,6 +216,39 @@ public function testRefreshAToken() $this->assertSame($refreshedToken, $this->createManager()->refresh($token)); } + public function testRefreshDoesNotInvalidateOldTokenWhenEncodingReplacementFails(): void + { + $this->expectException(JWTException::class); + $this->expectExceptionMessage('signing failed'); + + $token = 'foo.bar.baz'; + $payload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + ]; + $refreshPayload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub']]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); + $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn([]); + $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->claimFactory->shouldReceive('refresh')->once()->andReturn($refreshPayload); + $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); + $this->provider->shouldReceive('encode')->once()->with($refreshPayload + ['jti' => '11111111-1111-4111-8111-111111111111'])->andThrow(new JWTException('signing failed')); + $this->blacklist->shouldReceive('has')->once()->with($payload)->andReturnFalse(); + $this->blacklist->shouldReceive('add')->never(); + + $this->mockUuid('11111111-1111-4111-8111-111111111111'); + + $this->createManager()->refresh($token); + } + public function testRefreshOmitsExpirationWhenTtlIsNull(): void { $token = 'foo.bar.baz'; @@ -180,12 +273,171 @@ public function testRefreshOmitsExpirationWhenTtlIsNull(): void $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn(['iss']); $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(null); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->claimFactory->shouldReceive('refresh')->once()->with( + $payload, + null, + false, + false, + ['iss'], + [], + )->andReturn($refreshPayload); $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); $this->provider->shouldReceive('encode')->with($refreshPayload)->andReturn($refreshedToken); $this->assertSame($refreshedToken, $this->createManager()->refresh($token)); } + public function testDecodeStillRejectsExpiredTokensWhenExpiredClaimValidationIsEnabled(): void + { + $this->expectException(TokenExpiredException::class); + $this->expectExceptionMessage('Token has expired'); + + $payload = [ + 'sub' => 1, + 'exp' => $this->testNowTimestamp - 3600, + 'iat' => $this->testNowTimestamp, + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class, ExpiredClaim::class]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub']]); + $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); + + $this->createManager()->decode('foo.bar.baz'); + } + + public function testRefreshSkipsTemporalValidationsInsideRefreshWindow(): void + { + $token = 'foo.bar.baz'; + $refreshedToken = 'baz.bar.foo'; + $payload = [ + 'sub' => 1, + 'exp' => $this->testNowTimestamp - 3600, + 'iat' => $this->testNowTimestamp, + ]; + $refreshPayload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + 'exp' => $this->testNowTimestamp + 7200, + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class, ExpiredClaim::class]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub']]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); + $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn([]); + $this->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 testRefreshRejectsFutureNotBeforeClaim(): void + { + $this->expectException(JWTException::class); + $this->expectExceptionMessage('Not Before (nbf) timestamp cannot be in the future'); + + $token = 'foo.bar.baz'; + $payload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + 'nbf' => $this->testNowTimestamp + 3600, + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class, NotBeforeClaim::class]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub'], 'leeway' => 0]); + $this->provider->shouldReceive('decode')->once()->with($token)->andReturn($payload); + $this->provider->shouldReceive('encode')->never(); + + $this->createManager()->refresh($token); + } + + public function testRefreshAllowsPastNotBeforeClaim(): void + { + $token = 'foo.bar.baz'; + $refreshedToken = 'baz.bar.foo'; + $payload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + 'nbf' => $this->testNowTimestamp - 60, + ]; + $refreshPayload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + 'nbf' => $this->testNowTimestamp, + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class, NotBeforeClaim::class]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub'], 'leeway' => 0]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); + $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn([]); + $this->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 testRefreshPassesResetClaimsCustomClaimsAndExplicitTtlToClaimFactory(): void + { + $token = 'foo.bar.baz'; + $refreshedToken = 'baz.bar.foo'; + $payload = [ + 'sub' => 1, + 'iat' => $this->testNowTimestamp, + ]; + $refreshPayload = [ + 'sub' => 1, + 'tenant' => 'acme', + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnFalse(); + $this->config->shouldReceive('array')->with('jwt.validations', [])->andReturn([RequiredClaims::class]); + $this->config->shouldReceive('array')->with('jwt')->andReturn(['required_claims' => ['iat', 'sub']]); + $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(20160); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnTrue(); + $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn(['tenant']); + $this->claimFactory->shouldReceive('refresh')->once()->with( + $payload, + null, + true, + true, + ['tenant'], + ['tenant' => 'acme'], + )->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: $token, + resetClaims: true, + customClaims: ['tenant' => 'acme'], + ttl: null, + )); + } + public function testRefreshThrowsWhenRefreshWindowHasExpired(): void { $this->expectException(TokenExpiredException::class); @@ -238,13 +490,22 @@ public function testRefreshWindowCanBeDisabled(): void $this->config->shouldReceive('get')->with('jwt.refresh_ttl', 20160)->andReturn(null); $this->config->shouldReceive('array')->with('jwt.persistent_claims', [])->andReturn(['iss']); $this->config->shouldReceive('get')->with('jwt.ttl', 120)->andReturn(120); + $this->config->shouldReceive('boolean')->with('jwt.refresh_iat', false)->andReturnFalse(); + $this->claimFactory->shouldReceive('refresh')->once()->with( + $payload, + 120, + false, + false, + ['iss'], + [], + )->andReturn($refreshPayload); $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); $this->provider->shouldReceive('encode')->with($refreshPayload)->andReturn($refreshedToken); $this->assertSame($refreshedToken, $this->createManager()->refresh($token)); } - public function testInvalidateAToken() + public function testInvalidateAToken(): void { $token = 'foo.bar.baz'; $payload = [ @@ -264,7 +525,7 @@ public function testInvalidateAToken() $this->createManager()->invalidate($token); } - public function testForceInvalidateATokenForever() + public function testForceInvalidateATokenForever(): void { $token = 'foo.bar.baz'; $payload = [ @@ -284,7 +545,27 @@ public function testForceInvalidateATokenForever() $this->createManager()->invalidate($token, true); } - public function testThrowAnExceptionWhenEnableBlacklistIsSetToFalse() + public function testInvalidateIsIdempotentForAlreadyBlacklistedTokens(): void + { + $token = 'foo.bar.baz'; + $payload = [ + 'sub' => 1, + 'iss' => 'http://example.com', + 'exp' => $this->testNowTimestamp + 3600, + 'nbf' => $this->testNowTimestamp, + 'iat' => $this->testNowTimestamp, + 'jti' => 'foo', + ]; + + $this->config->shouldReceive('boolean')->with('jwt.blacklist_enabled', false)->andReturnTrue(); + $this->provider->shouldReceive('decode')->once()->with('foo.bar.baz')->andReturn($payload); + $this->blacklist->shouldNotReceive('has'); + $this->blacklist->shouldReceive('add')->once()->with($payload)->andReturn(true); + + $this->assertTrue($this->createManager()->invalidate($token)); + } + + public function testThrowAnExceptionWhenEnableBlacklistIsSetToFalse(): void { $this->expectException(JWTException::class); $this->expectExceptionMessage('You must have the blacklist enabled to invalidate a token.'); @@ -296,49 +577,54 @@ public function testThrowAnExceptionWhenEnableBlacklistIsSetToFalse() $this->createManager()->invalidate($token); } - private function setTestNow() + private function setTestNow(): void { Carbon::setTestNow('2000-01-01T00:00:00.000000Z'); $this->testNowTimestamp = Carbon::now()->timestamp; } - private function mockContainer() + private function mockContainer(): void { $this->container = m::mock(Container::class); } - private function mockConfig() + private function mockConfig(): void { $this->config = m::mock(Repository::class); $this->container->shouldReceive('make')->with('config')->andReturn($this->config); } - private function mockProvider() + private function mockProvider(): void { $this->provider = m::mock(Lcobucci::class); } - private function mockBlacklist() + private function mockBlacklist(): void { $this->blacklist = m::mock(BlacklistContract::class); $this->container->shouldReceive('make')->with(BlacklistContract::class)->andReturn($this->blacklist); } + private function mockClaimFactory(): void + { + $this->claimFactory = m::mock(ClaimFactory::class); + } + private function createManager(): JWTManager { $this->config->shouldReceive('string')->with('jwt.driver', 'lcobucci')->andReturn('dummy'); - $manager = new JWTManager($this->container); + $manager = new JWTManager($this->container, $this->claimFactory); $manager->extend('dummy', fn () => $this->provider); return $manager; } - private function mockUuid(string $value) + private function mockUuid(string $value): void { Str::createUuidsUsing(fn () => Uuid::fromString($value)); } diff --git a/tests/JWT/JWTServiceProviderTest.php b/tests/JWT/JWTServiceProviderTest.php new file mode 100644 index 000000000..6b59ce22d --- /dev/null +++ b/tests/JWT/JWTServiceProviderTest.php @@ -0,0 +1,213 @@ +app->make('config')->set('jwt.token', 'api_token'); + $this->app->make('config')->set('jwt.parser', [Cookie::class]); + + /** @var Parser $parser */ + $parser = $this->app->make(Parser::class); + + $this->assertSame('cookie-token', $parser->parseToken(Request::create('/', 'GET', cookies: [ + 'api_token' => 'cookie-token', + ]))); + } + + public function testJwtMiddlewareAliasesAreNotRegistered(): void + { + $middleware = $this->app->make('router')->getMiddleware(); + + $this->assertArrayNotHasKey('jwt.refresh', $middleware); + $this->assertArrayNotHasKey('jwt.renew', $middleware); + $this->assertArrayNotHasKey('jwt.auth', $middleware); + $this->assertArrayNotHasKey('jwt.check', $middleware); + } + + public function testGuardReceivesExplicitNullTtlAndDispatcher(): void + { + $config = $this->app->make('config'); + $config->set('auth.defaults.guard', 'jwt'); + $config->set('auth.guards.jwt', [ + 'driver' => 'jwt', + 'provider' => 'users', + 'ttl' => null, + ]); + $config->set('auth.providers.users', [ + 'driver' => 'jwt-test-provider', + ]); + + $this->app->instance('jwt', m::mock(ManagerContract::class)); + + /** @var AuthManager $authManager */ + $authManager = $this->app->make(AuthManager::class); + $authManager->provider('jwt-test-provider', fn () => m::mock(UserProvider::class)); + + /** @var JwtGuard $guard */ + $guard = $authManager->guard('jwt'); + + $this->assertNull($guard->getTTL()); + $this->assertSame($this->app->make('events'), $guard->getDispatcher()); + } + + public function testGuardReceivesNumericPerGuardTtl(): void + { + $config = $this->app->make('config'); + $config->set('auth.defaults.guard', 'jwt'); + $config->set('auth.guards.jwt', [ + 'driver' => 'jwt', + 'provider' => 'users', + 'ttl' => 15, + ]); + $config->set('auth.providers.users', [ + 'driver' => 'jwt-test-provider', + ]); + + $this->app->instance('jwt', m::mock(ManagerContract::class)); + + /** @var AuthManager $authManager */ + $authManager = $this->app->make(AuthManager::class); + $authManager->provider('jwt-test-provider', fn () => m::mock(UserProvider::class)); + + /** @var JwtGuard $guard */ + $guard = $authManager->guard('jwt'); + + $this->assertSame(15, $guard->getTTL()); + } + + public function testTaggedCacheStorageUsesCacheStore(): 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.blacklist_refresh_ttl', 20160); + + $repository = m::mock(CacheRepository::class); + $repository->shouldReceive('supportsTags')->once()->andReturnTrue(); + $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.blacklist_refresh_ttl', 20160); + + $repository = m::mock(CacheRepository::class); + $repository->shouldReceive('supportsTags')->never(); + $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->expectExceptionMessage('The JWT blacklist requires a taggable cache store.'); + + $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); + } + + public function testCustomBlacklistStorageBypassesTaggedCacheRequirement(): void + { + $config = $this->app->make('config'); + $config->set('jwt.providers.storage', JwtServiceProviderCustomStorage::class); + $config->set('jwt.blacklist_enabled', true); + $config->set('jwt.blacklist_grace_period', 0); + $config->set('jwt.blacklist_refresh_ttl', 20160); + + $cache = m::mock(); + $cache->shouldReceive('store')->never(); + + $this->app->instance('cache', $cache); + $this->app->forgetInstance(BlacklistContract::class); + + $blacklist = $this->app->make(BlacklistContract::class); + + $this->assertInstanceOf(Blacklist::class, $blacklist); + } +} + +class JwtServiceProviderCustomStorage implements StorageContract +{ + public function add(string $key, mixed $value, int $minutes): void + { + } + + public function forever(string $key, mixed $value): void + { + } + + public function get(string $key): mixed + { + return null; + } + + public function destroy(string $key): bool + { + return true; + } + + public function flush(): void + { + } +} diff --git a/tests/JWT/JwtGuardCoroutineSafetyTest.php b/tests/JWT/JwtGuardCoroutineSafetyTest.php new file mode 100644 index 000000000..28df89fdb --- /dev/null +++ b/tests/JWT/JwtGuardCoroutineSafetyTest.php @@ -0,0 +1,242 @@ +createGuard($manager, new JwtGuardCoroutineProvider); + + [$firstToken, $secondToken] = parallel([ + function () use ($guard): string { + $guard->claims(['tenant' => 'one'])->setTTL(5); + usleep(1000); + + return $guard->login(new JwtGuardCoroutineUser(1)); + }, + function () use ($guard): string { + $guard->claims(['tenant' => 'two'])->setTTL(10); + usleep(1000); + + return $guard->login(new JwtGuardCoroutineUser(2)); + }, + ]); + + $this->assertSame('one', $manager->payloads[$firstToken]['tenant']); + $this->assertSame(1, $manager->payloads[$firstToken]['sub']); + $this->assertSame(300, $manager->payloads[$firstToken]['exp'] - $manager->payloads[$firstToken]['iat']); + $this->assertSame('two', $manager->payloads[$secondToken]['tenant']); + $this->assertSame(2, $manager->payloads[$secondToken]['sub']); + $this->assertSame(600, $manager->payloads[$secondToken]['exp'] - $manager->payloads[$secondToken]['iat']); + } + + public function testTokenAndUserStateAreIsolatedBetweenCoroutines(): void + { + $guard = $this->createGuard(new JwtGuardCoroutineManager([ + 'token-one' => ['sub' => 1], + 'token-two' => ['sub' => 2], + ]), new JwtGuardCoroutineProvider); + + [$firstId, $secondId] = parallel([ + function () use ($guard): int|string|null { + $guard->setToken('token-one'); + usleep(1000); + + return $guard->id(); + }, + function () use ($guard): int|string|null { + $guard->setToken('token-two'); + usleep(1000); + + return $guard->id(); + }, + ]); + + $this->assertSame(1, $firstId); + $this->assertSame(2, $secondId); + } + + public function testResolvedUsersAreIsolatedBetweenCoroutines(): void + { + $guard = $this->createGuard(new JwtGuardCoroutineManager([ + 'token-one' => ['sub' => 1], + 'token-two' => ['sub' => 2], + ]), new JwtGuardCoroutineProvider); + + [$firstUserId, $secondUserId] = parallel([ + function () use ($guard): int|string { + $guard->setToken('token-one'); + usleep(1000); + + return $guard->user()->getAuthIdentifier(); + }, + function () use ($guard): int|string { + $guard->setToken('token-two'); + usleep(1000); + + return $guard->user()->getAuthIdentifier(); + }, + ]); + + $this->assertSame(1, $firstUserId); + $this->assertSame(2, $secondUserId); + } + + /** + * Create a JwtGuard instance. + */ + private function createGuard(ManagerContract $manager, UserProvider $provider): JwtGuard + { + return new JwtGuard( + 'jwt', + $provider, + $manager, + new ClaimFactory(new Repository([ + 'jwt' => [ + 'issuer' => null, + 'lock_subject' => false, + ], + ])), + new Parser([new AuthHeaders, new InputSource]), + $this->app, + ); + } +} + +class JwtGuardCoroutineManager implements ManagerContract +{ + public array $payloads = []; + + public function __construct( + private readonly array $decodedPayloads = [], + ) { + } + + public function encode(array $payload): string + { + $token = 'token-' . $payload['sub']; + $this->payloads[$token] = $payload; + + return $token; + } + + public function decode(string $token, bool $validate = true, bool $checkBlacklist = true): array + { + return $this->decodedPayloads[$token]; + } + + public function refresh( + string $token, + bool $forceForever = false, + bool $resetClaims = false, + array $customClaims = [], + int|false|null $ttl = false, + ): string { + return $token; + } + + public function invalidate(string $token, bool $forceForever = false): bool + { + return true; + } + + public function hasBlacklistEnabled(): bool + { + return false; + } +} + +class JwtGuardCoroutineUser implements Authenticatable +{ + public function __construct( + private readonly int|string $id, + ) { + } + + public function getAuthIdentifierName(): string + { + return 'id'; + } + + public function getAuthIdentifier(): mixed + { + return $this->id; + } + + public function getAuthPasswordName(): string + { + return 'password'; + } + + public function getAuthPassword(): ?string + { + return null; + } + + public function getRememberToken(): ?string + { + return null; + } + + public function setRememberToken(string $value): void + { + } + + public function getRememberTokenName(): string + { + return 'remember_token'; + } +} + +class JwtGuardCoroutineProvider implements UserProvider +{ + public function retrieveById(mixed $identifier): ?Authenticatable + { + return new JwtGuardCoroutineUser($identifier); + } + + public function retrieveByToken(mixed $identifier, #[SensitiveParameter] string $token): ?Authenticatable + { + return null; + } + + public function updateRememberToken(Authenticatable $user, #[SensitiveParameter] string $token): void + { + } + + public function retrieveByCredentials(#[SensitiveParameter] array $credentials): ?Authenticatable + { + return null; + } + + public function validateCredentials(Authenticatable $user, #[SensitiveParameter] array $credentials): bool + { + return false; + } + + public function rehashPasswordIfRequired( + Authenticatable $user, + #[SensitiveParameter] + array $credentials, + bool $force = false, + ): void { + } +} diff --git a/tests/JWT/JwtGuardEventTest.php b/tests/JWT/JwtGuardEventTest.php new file mode 100644 index 000000000..093e00c65 --- /dev/null +++ b/tests/JWT/JwtGuardEventTest.php @@ -0,0 +1,221 @@ +user(1); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturn('token'); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnFalse(); + $events->shouldNotReceive('dispatch'); + + $guard = $this->createGuard(jwtManager: $jwtManager); + $guard->setDispatcher($events); + $guard->login($user); + } + + public function testAttemptingEventIsDispatchedWhenListening(): void + { + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturn(null); + + $events = $this->dispatcherListeningFor(Attempting::class, function (Attempting $event): bool { + return $event->guard === 'jwt' + && $event->credentials === ['email' => 'foo@example.test'] + && $event->remember === false; + }); + + $guard = $this->createGuard(provider: $provider); + $guard->setDispatcher($events); + + $this->assertFalse($guard->attempt(['email' => 'foo@example.test'])); + } + + public function testValidatedEventIsDispatchedWhenListening(): void + { + $user = $this->user(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturn($user); + $provider->shouldReceive('validateCredentials')->once()->andReturnTrue(); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturn('token'); + + $events = $this->dispatcherListeningFor(Validated::class, function (Validated $event) use ($user): bool { + return $event->guard === 'jwt' && $event->user === $user; + }); + + $guard = $this->createGuard(provider: $provider, jwtManager: $jwtManager); + $guard->setDispatcher($events); + + $this->assertSame('token', $guard->attempt(['email' => 'foo@example.test'])); + } + + public function testFailedEventIsDispatchedWhenListening(): void + { + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturn(null); + + $events = $this->dispatcherListeningFor(Failed::class, function (Failed $event): bool { + return $event->guard === 'jwt' + && $event->user === null + && $event->credentials === ['email' => 'foo@example.test']; + }); + + $guard = $this->createGuard(provider: $provider); + $guard->setDispatcher($events); + + $this->assertFalse($guard->attempt(['email' => 'foo@example.test'])); + } + + public function testLoginAndAuthenticatedEventsAreDispatchedWhenListening(): void + { + $user = $this->user(1); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturn('token'); + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->with(Authenticated::class)->once()->andReturnTrue(); + $events->shouldReceive('hasListeners')->with(Login::class)->once()->andReturnTrue(); + $events->shouldReceive('dispatch')->once()->with(m::on( + fn (object $event): bool => $event instanceof Authenticated + && $event->guard === 'jwt' + && $event->user === $user + )); + $events->shouldReceive('dispatch')->once()->with(m::on( + fn (object $event): bool => $event instanceof Login + && $event->guard === 'jwt' + && $event->user === $user + && $event->remember === false + )); + + $guard = $this->createGuard(jwtManager: $jwtManager); + $guard->setDispatcher($events); + + $this->assertSame('token', $guard->login($user)); + } + + public function testLogoutEventIsDispatchedWhenListening(): void + { + $user = $this->user(1); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnFalse(); + + $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->setDispatcher($events); + $guard->logout(); + } + + public function testAttemptingRegistersListener(): void + { + $listener = static fn (): null => null; + + $events = m::mock(Dispatcher::class); + $events->shouldReceive('listen')->once()->with(Attempting::class, $listener); + + $guard = $this->createGuard(); + $guard->setDispatcher($events); + $guard->attempting($listener); + } + + /** + * Create a dispatcher that listens for one event. + */ + private function dispatcherListeningFor(string $eventClass, callable $assertion): Dispatcher + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnUsing( + fn (string $class): bool => $class === $eventClass + ); + $events->shouldReceive('dispatch')->once()->with(m::on( + fn (object $event): bool => $event instanceof $eventClass && $assertion($event) + )); + + return $events; + } + + /** + * Create a JwtGuard instance. + */ + private function createGuard( + ?UserProvider $provider = null, + ?ManagerContract $jwtManager = null, + ?Request $request = null, + ): 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([ + 'jwt' => [ + 'issuer' => null, + 'lock_subject' => false, + ], + ])), + new Parser([new AuthHeaders, new InputSource]), + $this->app, + ); + } + + /** + * Create an authenticatable user mock. + */ + private function user(int|string $id): Authenticatable + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn($id); + + return $user; + } + + /** + * Create a request with a bearer token. + */ + private function requestWithToken(string $token): Request + { + return Request::create('/', 'GET', server: [ + 'HTTP_AUTHORIZATION' => "Bearer {$token}", + ]); + } +} diff --git a/tests/JWT/JwtGuardSubjectLockTest.php b/tests/JWT/JwtGuardSubjectLockTest.php new file mode 100644 index 000000000..863c9f08c --- /dev/null +++ b/tests/JWT/JwtGuardSubjectLockTest.php @@ -0,0 +1,228 @@ +shouldReceive('decode')->once()->andReturn([ + 'sub' => 42, + 'prv' => hash('xxh128', JwtGuardSubjectUser::class), + ]); + + $guard = $this->createGuard($provider, $jwtManager); + + $this->assertSame($user, $guard->user()); + $this->assertSame(1, $provider->retrieveByIdCalls); + } + + public function testMismatchedProviderSubjectDoesNotResolveUser(): void + { + $provider = new JwtGuardSubjectModelProvider(JwtGuardSubjectUser::class, new JwtGuardSubjectUser(42)); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->once()->andReturn([ + 'sub' => 42, + 'prv' => hash('xxh128', JwtGuardOtherSubjectUser::class), + ]); + + $guard = $this->createGuard($provider, $jwtManager); + + $this->assertNull($guard->user()); + $this->assertSame(0, $provider->retrieveByIdCalls); + } + + public function testMissingProviderSubjectDoesNotResolveUser(): void + { + $provider = new JwtGuardSubjectModelProvider(JwtGuardSubjectUser::class, new JwtGuardSubjectUser(42)); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->once()->andReturn(['sub' => 42]); + + $guard = $this->createGuard($provider, $jwtManager); + + $this->assertNull($guard->user()); + $this->assertSame(0, $provider->retrieveByIdCalls); + } + + public function testGetUserIdRejectsMismatchedProviderSubject(): void + { + $provider = new JwtGuardSubjectModelProvider(JwtGuardSubjectUser::class, new JwtGuardSubjectUser(42)); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->once()->andReturn([ + 'sub' => 42, + 'prv' => hash('xxh128', JwtGuardOtherSubjectUser::class), + ]); + + $guard = $this->createGuard($provider, $jwtManager); + + $this->assertNull($guard->getUserId()); + $this->assertSame(0, $provider->retrieveByIdCalls); + } + + public function testProviderWithoutModelSkipsSubjectLocking(): void + { + $user = new JwtGuardSubjectUser(42); + $provider = new JwtGuardSubjectProvider($user); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->once()->andReturn(['sub' => 42]); + + $guard = $this->createGuard($provider, $jwtManager); + + $this->assertSame($user, $guard->user()); + $this->assertSame(1, $provider->retrieveByIdCalls); + } + + /** + * Create a JwtGuard instance. + */ + private function createGuard(UserProvider $provider, ManagerContract $jwtManager): JwtGuard + { + RequestContext::set(Request::create('/', 'GET', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer token', + ])); + + return new JwtGuard( + 'jwt', + $provider, + $jwtManager, + new ClaimFactory(new Repository([ + 'jwt' => [ + 'issuer' => null, + 'lock_subject' => true, + ], + ])), + new Parser([new AuthHeaders, new InputSource]), + $this->app, + ); + } +} + +class JwtGuardSubjectUser implements Authenticatable +{ + public function __construct( + private readonly int|string $id, + ) { + } + + public function getAuthIdentifierName(): string + { + return 'id'; + } + + public function getAuthIdentifier(): mixed + { + return $this->id; + } + + public function getAuthPasswordName(): string + { + return 'password'; + } + + public function getAuthPassword(): ?string + { + return null; + } + + public function getRememberToken(): ?string + { + return null; + } + + public function setRememberToken(string $value): void + { + } + + public function getRememberTokenName(): string + { + return 'remember_token'; + } +} + +class JwtGuardOtherSubjectUser extends JwtGuardSubjectUser +{ +} + +class JwtGuardSubjectProvider implements UserProvider +{ + public int $retrieveByIdCalls = 0; + + public function __construct( + protected readonly ?Authenticatable $user, + ) { + } + + public function retrieveById(mixed $identifier): ?Authenticatable + { + ++$this->retrieveByIdCalls; + + return $this->user; + } + + public function retrieveByToken(mixed $identifier, #[SensitiveParameter] string $token): ?Authenticatable + { + return null; + } + + public function updateRememberToken(Authenticatable $user, #[SensitiveParameter] string $token): void + { + } + + public function retrieveByCredentials(#[SensitiveParameter] array $credentials): ?Authenticatable + { + return null; + } + + public function validateCredentials(Authenticatable $user, #[SensitiveParameter] array $credentials): bool + { + return false; + } + + public function rehashPasswordIfRequired( + Authenticatable $user, + #[SensitiveParameter] + array $credentials, + bool $force = false, + ): void { + } +} + +class JwtGuardSubjectModelProvider extends JwtGuardSubjectProvider +{ + public function __construct( + private readonly string $model, + ?Authenticatable $user, + ) { + parent::__construct($user); + } + + public function getModel(): string + { + return $this->model; + } +} diff --git a/tests/JWT/JwtGuardTest.php b/tests/JWT/JwtGuardTest.php index e9b710894..80a896b6d 100644 --- a/tests/JWT/JwtGuardTest.php +++ b/tests/JWT/JwtGuardTest.php @@ -12,7 +12,15 @@ use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Foundation\Application; use Hypervel\Http\Request; +use Hypervel\JWT\ClaimFactory; use Hypervel\JWT\Contracts\ManagerContract; +use Hypervel\JWT\Exceptions\JWTException; +use Hypervel\JWT\Exceptions\SecretMissingException; +use Hypervel\JWT\Exceptions\TokenInvalidException; +use Hypervel\JWT\Exceptions\UserNotDefinedException; +use Hypervel\JWT\Http\Parser\AuthHeaders; +use Hypervel\JWT\Http\Parser\InputSource; +use Hypervel\JWT\Http\Parser\Parser; use Hypervel\JWT\JwtGuard; use Hypervel\JWT\JWTServiceProvider; use Hypervel\Testbench\TestCase; @@ -20,7 +28,7 @@ class JwtGuardTest extends TestCase { - public function testParseTokenFromBearerHeader() + public function testParseTokenFromBearerHeader(): void { $guard = $this->createGuard( request: $this->createRequestWithBearer('test-token') @@ -29,20 +37,14 @@ public function testParseTokenFromBearerHeader() $this->assertSame('test-token', $guard->parseToken()); } - public function testParseTokenFromRequestInput() + public function testParseTokenFromRequestInput(): void { - $request = m::mock(Request::class); - $request->shouldReceive('setUserResolver')->andReturnSelf(); - $request->shouldReceive('header')->with('Authorization', '')->andReturn(''); - $request->shouldReceive('has')->with('token')->andReturnTrue(); - $request->shouldReceive('input')->with('token')->andReturn('input-token'); - - $guard = $this->createGuard(request: $request); + $guard = $this->createGuard(request: Request::create('/', 'GET', ['token' => 'input-token'])); $this->assertSame('input-token', $guard->parseToken()); } - public function testParseTokenReturnsNullWhenNoRequestContext() + public function testParseTokenReturnsNullWhenNoRequestContext(): void { // Remove the request from context so RequestContext::has() returns false RequestContext::forget(); @@ -52,7 +54,7 @@ public function testParseTokenReturnsNullWhenNoRequestContext() $this->assertNull($guard->parseToken()); } - public function testUserReturnsUserFromJwtPayload() + public function testUserReturnsUserFromJwtPayload(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); @@ -70,7 +72,7 @@ public function testUserReturnsUserFromJwtPayload() $this->assertSame($user, $guard->user()); } - public function testUserReturnsNullWhenNoToken() + public function testUserReturnsNullWhenNoToken(): void { $guard = $this->createGuard(request: null); RequestContext::forget(); @@ -78,7 +80,7 @@ public function testUserReturnsNullWhenNoToken() $this->assertNull($guard->user()); } - public function testUserCachesResultInContext() + public function testUserCachesResultInContext(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); @@ -97,7 +99,7 @@ public function testUserCachesResultInContext() $this->assertSame($user, $guard->user()); // Should not call decode again } - public function testUserCachesNullViaSentinel() + public function testUserCachesNullViaSentinel(): void { $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveById')->with(42)->once()->andReturn(null); @@ -115,7 +117,7 @@ public function testUserCachesNullViaSentinel() $this->assertNull($guard->user()); // Should not call decode again } - public function testAttemptReturnsTrueOnValidCredentials() + public function testAttemptReturnsTokenOnValidCredentials(): void { $user = m::mock(Authenticatable::class); $user->shouldReceive('getAuthIdentifier')->andReturn(1); @@ -135,10 +137,10 @@ public function testAttemptReturnsTrueOnValidCredentials() request: $this->createRequestWithBearer(null), ); - $this->assertTrue($guard->attempt(['email' => 'foo@bar.com', 'password' => 'secret'])); + $this->assertSame('new-token', $guard->attempt(['email' => 'foo@bar.com', 'password' => 'secret'])); } - public function testAttemptReturnsFalseOnInvalidCredentials() + public function testAttemptReturnsFalseOnInvalidCredentials(): void { $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveByCredentials')->andReturn(null); @@ -151,7 +153,7 @@ public function testAttemptReturnsFalseOnInvalidCredentials() $this->assertFalse($guard->attempt(['email' => 'foo@bar.com', 'password' => 'wrong'])); } - public function testValidateDoesNotLoginUser() + public function testValidateDoesNotLoginUser(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); @@ -159,8 +161,6 @@ public function testValidateDoesNotLoginUser() $provider->shouldReceive('validateCredentials')->andReturnTrue(); $jwtManager = m::mock(ManagerContract::class); - // encode should still be called because validate calls attempt(credentials, true) - // actually validate calls attempt(credentials, false) $jwtManager->shouldNotReceive('encode'); $guard = $this->createGuard( @@ -172,7 +172,26 @@ public function testValidateDoesNotLoginUser() $this->assertTrue($guard->validate(['email' => 'foo@bar.com', 'password' => 'secret'])); } - public function testLoginReturnsToken() + public function testAttemptWithoutLoginReturnsTrueAndDoesNotMintToken(): void + { + $user = m::mock(Authenticatable::class); + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturn($user); + $provider->shouldReceive('validateCredentials')->once()->andReturnTrue(); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldNotReceive('encode'); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer(null), + ); + + $this->assertTrue($guard->attempt(['email' => 'foo@bar.com', 'password' => 'secret'], false)); + } + + public function testLoginReturnsToken(): void { $user = m::mock(Authenticatable::class); $user->shouldReceive('getAuthIdentifier')->andReturn(1); @@ -188,9 +207,30 @@ public function testLoginReturnsToken() $token = $guard->login($user); $this->assertSame('jwt-token', $token); + $this->assertSame('jwt-token', $guard->getToken()); + $this->assertSame($user, $guard->user()); } - public function testLoginPayloadContainsSubIatExp() + public function testLoginOverridesExistingRequestToken(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturn('new-token'); + $jwtManager->shouldNotReceive('decode'); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('old-token'), + ); + + $this->assertSame('new-token', $guard->login($user)); + $this->assertSame('new-token', $guard->getToken()); + $this->assertSame($user, $guard->user()); + } + + public function testLoginPayloadContainsSubIatExp(): void { $user = m::mock(Authenticatable::class); $user->shouldReceive('getAuthIdentifier')->andReturn(42); @@ -212,6 +252,7 @@ public function testLoginPayloadContainsSubIatExp() $this->assertSame(42, $capturedPayload['sub']); $this->assertArrayHasKey('iat', $capturedPayload); + $this->assertArrayHasKey('nbf', $capturedPayload); $this->assertArrayHasKey('exp', $capturedPayload); $this->assertGreaterThan($capturedPayload['iat'], $capturedPayload['exp']); } @@ -239,10 +280,11 @@ public function testLoginOmitsExpirationWhenTtlIsNull(): void $this->assertSame(42, $capturedPayload['sub']); $this->assertArrayHasKey('iat', $capturedPayload); + $this->assertArrayHasKey('nbf', $capturedPayload); $this->assertArrayNotHasKey('exp', $capturedPayload); } - public function testClaimsMergeIntoNextToken() + public function testClaimsMergeIntoNextToken(): void { $user = m::mock(Authenticatable::class); $user->shouldReceive('getAuthIdentifier')->andReturn(1); @@ -267,7 +309,82 @@ public function testClaimsMergeIntoNextToken() $this->assertSame('acme', $capturedPayload['org']); } - public function testGetPayloadReturnsDecodedToken() + public function testClaimsAreClearedAfterNextToken(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $capturedPayloads = []; + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->twice()->andReturnUsing(function ($payload) use (&$capturedPayloads) { + $capturedPayloads[] = $payload; + + return 'token-' . count($capturedPayloads); + }); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer(null), + ); + + $guard->claims(['role' => 'admin'])->login($user); + $guard->login($user); + + $this->assertSame('admin', $capturedPayloads[0]['role']); + $this->assertArrayNotHasKey('role', $capturedPayloads[1]); + } + + public function testSetTtlAppliesOnlyToNextToken(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $capturedPayloads = []; + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->twice()->andReturnUsing(function ($payload) use (&$capturedPayloads) { + $capturedPayloads[] = $payload; + + return 'token-' . count($capturedPayloads); + }); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer(null), + ); + + $guard->setTTL(5)->login($user); + $guard->login($user); + + $this->assertSame(300, $capturedPayloads[0]['exp'] - $capturedPayloads[0]['iat']); + $this->assertSame(7200, $capturedPayloads[1]['exp'] - $capturedPayloads[1]['iat']); + } + + public function testSetTtlNullAppliesOnlyToNextToken(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $capturedPayloads = []; + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->twice()->andReturnUsing(function ($payload) use (&$capturedPayloads) { + $capturedPayloads[] = $payload; + + return 'token-' . count($capturedPayloads); + }); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer(null), + ); + + $guard->setTTL(null)->login($user); + $guard->login($user); + + $this->assertArrayNotHasKey('exp', $capturedPayloads[0]); + $this->assertArrayHasKey('exp', $capturedPayloads[1]); + } + + public function testGetPayloadReturnsDecodedToken(): void { $jwtManager = m::mock(ManagerContract::class); $jwtManager->shouldReceive('decode')->with('valid-token')->once()->andReturn(['sub' => 1, 'iat' => 1000]); @@ -282,7 +399,35 @@ public function testGetPayloadReturnsDecodedToken() $this->assertSame(['sub' => 1, 'iat' => 1000], $payload); } - public function testGetPayloadReturnsEmptyArrayWhenNoToken() + public function testPayloadAliasesGetPayload(): void + { + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('valid-token')->once()->andReturn(['sub' => 1, 'iat' => 1000]); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); + + $this->assertSame(['sub' => 1, 'iat' => 1000], $guard->payload()); + } + + public function testGetPayloadThrowsWhenPresentTokenIsInvalid(): void + { + $this->expectException(TokenInvalidException::class); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('invalid-token')->once()->andThrow(new TokenInvalidException); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('invalid-token'), + ); + + $guard->getPayload(); + } + + public function testGetPayloadReturnsEmptyArrayWhenNoToken(): void { $guard = $this->createGuard(request: null); RequestContext::forget(); @@ -290,10 +435,10 @@ public function testGetPayloadReturnsEmptyArrayWhenNoToken() $this->assertSame([], $guard->getPayload()); } - public function testRefreshDelegatesAndClearsContext() + public function testRefreshDelegatesAndClearsContext(): void { $jwtManager = m::mock(ManagerContract::class); - $jwtManager->shouldReceive('refresh')->with('old-token')->once()->andReturn('new-token'); + $jwtManager->shouldReceive('refresh')->with('old-token', false, false, [], 120)->once()->andReturn('new-token'); $guard = $this->createGuard( jwtManager: $jwtManager, @@ -303,7 +448,70 @@ public function testRefreshDelegatesAndClearsContext() $this->assertSame('new-token', $guard->refresh()); } - public function testRefreshReturnsNullWhenNoToken() + public function testRefreshUsesPerCallTtlOverrideAndClearsIt(): void + { + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('refresh')->with('old-token', false, false, [], 5)->once()->andReturn('new-token'); + $jwtManager->shouldReceive('refresh')->with('new-token', false, false, [], 120)->once()->andReturn('newer-token'); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('old-token'), + ); + + $this->assertSame('new-token', $guard->setTTL(5)->refresh()); + $this->assertSame('newer-token', $guard->refresh()); + } + + public function testRefreshUsesNullTtlOverride(): void + { + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('refresh')->with('old-token', false, false, [], null)->once()->andReturn('new-token'); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('old-token'), + ); + + $this->assertSame('new-token', $guard->setTTL(null)->refresh()); + } + + public function testRefreshUsesPerGuardTtl(): void + { + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('refresh')->with('old-token', false, false, [], 15)->once()->andReturn('new-token'); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('old-token'), + ttl: 15, + ); + + $this->assertSame('new-token', $guard->refresh()); + } + + public function testRefreshKeepsCachedUserUnderNewToken(): 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('old-token')->once()->andReturn(['sub' => 1]); + $jwtManager->shouldReceive('refresh')->with('old-token', false, false, [], 120)->once()->andReturn('new-token'); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('old-token'), + ); + + $this->assertSame($user, $guard->user()); + $this->assertSame('new-token', $guard->refresh()); + $this->assertSame($user, $guard->user()); + } + + public function testRefreshReturnsNullWhenNoToken(): void { $guard = $this->createGuard(request: null); RequestContext::forget(); @@ -311,7 +519,7 @@ public function testRefreshReturnsNullWhenNoToken() $this->assertNull($guard->refresh()); } - public function testLogoutInvalidatesTokenAndClearsContext() + public function testLogoutInvalidatesTokenAndClearsContext(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); @@ -319,7 +527,8 @@ public function testLogoutInvalidatesTokenAndClearsContext() $jwtManager = m::mock(ManagerContract::class); $jwtManager->shouldReceive('decode')->with('valid-token')->andReturn(['sub' => 1]); - $jwtManager->shouldReceive('invalidate')->with('valid-token')->once()->andReturnTrue(); + $jwtManager->shouldReceive('hasBlacklistEnabled')->andReturnTrue(); + $jwtManager->shouldReceive('invalidate')->with('valid-token', false)->once()->andReturnTrue(); $guard = $this->createGuard( provider: $provider, @@ -336,7 +545,39 @@ public function testLogoutInvalidatesTokenAndClearsContext() $this->assertFalse($guard->hasUser()); } - public function testHasUserReturnsTrueAfterUserResolved() + public function testLogoutClearsDecodedPayloadCache(): void + { + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('valid-token')->twice()->andReturn(['sub' => 1]); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnFalse(); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); + + $this->assertSame(['sub' => 1], $guard->getPayload()); + + $guard->logout(); + + $this->assertSame(['sub' => 1], $guard->getPayload()); + } + + public function testLogoutPassesForceForeverFlag(): void + { + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnTrue(); + $jwtManager->shouldReceive('invalidate')->with('valid-token', true)->once()->andReturnTrue(); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); + + $guard->logout(true); + } + + public function testHasUserReturnsTrueAfterUserResolved(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); @@ -356,7 +597,7 @@ public function testHasUserReturnsTrueAfterUserResolved() $this->assertTrue($guard->hasUser()); } - public function testHasUserReturnsFalseBeforeResolution() + public function testHasUserReturnsFalseBeforeResolution(): void { $guard = $this->createGuard( request: $this->createRequestWithBearer('valid-token'), @@ -365,7 +606,24 @@ public function testHasUserReturnsFalseBeforeResolution() $this->assertFalse($guard->hasUser()); } - public function testSetUserOverridesCachedUser() + public function testGetUserIdUsesCachedUserWithoutDecodingToken(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->once()->andReturn(42); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldNotReceive('decode'); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); + $guard->setUser($user); + + $this->assertSame(42, $guard->getUserId()); + } + + public function testSetUserOverridesCachedUser(): void { $user1 = m::mock(Authenticatable::class); $user2 = m::mock(Authenticatable::class); @@ -387,7 +645,7 @@ public function testSetUserOverridesCachedUser() $this->assertSame($user2, $guard->user()); } - public function testForgetUserClearsCache() + public function testForgetUserClearsCache(): void { $user = m::mock(Authenticatable::class); $provider = m::mock(UserProvider::class); @@ -408,15 +666,81 @@ public function testForgetUserClearsCache() $this->assertFalse($guard->hasUser()); } - public function testOnceUsingIdReturnsTrueWhenUserExists() + public function testLogoutClearsDefaultUserCacheWhenTokenOverrideIsActive(): void + { + $user = m::mock(Authenticatable::class); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('hasBlacklistEnabled')->once()->andReturnFalse(); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: null, + ); + RequestContext::forget(); + + $guard->setUser($user); + $this->assertTrue($guard->hasUser()); + + $guard->setToken('active-token'); + $guard->logout(); + + $this->assertNull($guard->getToken()); + $this->assertFalse($guard->hasUser()); + } + + public function testSwitchingTokensResolvesDifferentUsersInSameCoroutine(): void + { + $firstUser = m::mock(Authenticatable::class); + $secondUser = m::mock(Authenticatable::class); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($firstUser); + $provider->shouldReceive('retrieveById')->with(2)->once()->andReturn($secondUser); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('first-token')->once()->andReturn(['sub' => 1]); + $jwtManager->shouldReceive('decode')->with('second-token')->once()->andReturn(['sub' => 2]); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: null, + ); + + $this->assertSame($firstUser, $guard->setToken('first-token')->user()); + $this->assertSame($secondUser, $guard->setToken('second-token')->user()); + $this->assertSame($firstUser, $guard->setToken('first-token')->user()); + } + + public function testOnceUsingIdReturnsUserWhenUserExists(): void { $user = m::mock(Authenticatable::class); - $user->shouldReceive('getAuthIdentifier')->andReturn(1); $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveById')->with(1)->andReturn($user); $jwtManager = m::mock(ManagerContract::class); - $jwtManager->shouldReceive('encode')->andReturn('token'); + $jwtManager->shouldNotReceive('encode'); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer(null), + ); + + $this->assertSame($user, $guard->onceUsingId(1)); + } + + public function testOnceDoesNotMintTokenAndSetsUser(): void + { + $user = m::mock(Authenticatable::class); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveByCredentials')->once()->andReturn($user); + $provider->shouldReceive('validateCredentials')->once()->andReturnTrue(); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldNotReceive('encode'); $guard = $this->createGuard( provider: $provider, @@ -424,10 +748,11 @@ public function testOnceUsingIdReturnsTrueWhenUserExists() request: $this->createRequestWithBearer(null), ); - $this->assertTrue($guard->onceUsingId(1)); + $this->assertTrue($guard->once(['email' => 'foo@bar.com', 'password' => 'secret'])); + $this->assertSame($user, $guard->user()); } - public function testOnceUsingIdReturnsFalseWhenUserNotFound() + public function testOnceUsingIdReturnsFalseWhenUserNotFound(): void { $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveById')->with(999)->andReturn(null); @@ -440,7 +765,97 @@ public function testOnceUsingIdReturnsFalseWhenUserNotFound() $this->assertFalse($guard->onceUsingId(999)); } - public function testDecodedPayloadIsCachedBetweenUserAndGetPayload() + public function testTokenByIdReturnsTokenWithoutSettingCurrentUserOrToken(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthIdentifier')->andReturn(1); + + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->once()->andReturn($user); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('encode')->once()->andReturn('token-by-id'); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: null, + ); + RequestContext::forget(); + + $this->assertSame('token-by-id', $guard->tokenById(1)); + $this->assertNull($guard->getToken()); + $this->assertFalse($guard->hasUser()); + } + + public function testInvalidateReturnsGuardInstance(): void + { + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('invalidate')->with('valid-token', true)->once()->andReturnTrue(); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); + + $this->assertSame($guard, $guard->invalidate(true)); + } + + public function testInvalidateThrowsWhenNoTokenIsAvailable(): void + { + $this->expectException(JWTException::class); + $this->expectExceptionMessage('Token could not be parsed from the request.'); + + $guard = $this->createGuard(request: null); + RequestContext::forget(); + + $guard->invalidate(); + } + + public function testUserOrFailThrowsWhenUserIsNotDefined(): void + { + $this->expectException(UserNotDefinedException::class); + + $guard = $this->createGuard(request: null); + RequestContext::forget(); + + $guard->userOrFail(); + } + + public function testUserOrFailReturnsResolvedUser(): void + { + $user = m::mock(Authenticatable::class); + $provider = m::mock(UserProvider::class); + $provider->shouldReceive('retrieveById')->with(1)->andReturn($user); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('valid-token')->andReturn(['sub' => 1]); + + $guard = $this->createGuard( + provider: $provider, + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); + + $this->assertSame($user, $guard->userOrFail()); + } + + public function testUserPropagatesSecretMisconfiguration(): void + { + $this->expectException(SecretMissingException::class); + + $jwtManager = m::mock(ManagerContract::class); + $jwtManager->shouldReceive('decode')->with('valid-token')->once()->andThrow(new SecretMissingException); + + $guard = $this->createGuard( + jwtManager: $jwtManager, + request: $this->createRequestWithBearer('valid-token'), + ); + + $guard->user(); + } + + public function testDecodedPayloadIsCachedBetweenUserAndGetPayload(): void { $provider = m::mock(UserProvider::class); $provider->shouldReceive('retrieveById')->with(1)->andReturn( @@ -470,7 +885,7 @@ public function testDecodedPayloadIsCachedBetweenUserAndGetPayload() $this->assertSame(1, $payload['sub']); } - public function testServiceProviderRegistersJwtGuardWhenAuthManagerResolvesAfterBoot() + public function testServiceProviderRegistersJwtGuardWhenAuthManagerResolvesAfterBoot(): void { $provider = m::mock(UserProvider::class); $container = $this->createAuthTestContainer(); @@ -487,7 +902,7 @@ public function testServiceProviderRegistersJwtGuardWhenAuthManagerResolvesAfter $this->assertInstanceOf(JwtGuard::class, $authManager->guard('jwt')); } - public function testServiceProviderRegistersJwtGuardWhenAuthManagerIsAlreadyResolved() + public function testServiceProviderRegistersJwtGuardWhenAuthManagerIsAlreadyResolved(): void { $provider = m::mock(UserProvider::class); $container = $this->createAuthTestContainer(); @@ -522,6 +937,13 @@ protected function createGuard( 'jwt', $provider ?? m::mock(UserProvider::class), $jwtManager ?? m::mock(ManagerContract::class), + new ClaimFactory(new Repository([ + 'jwt' => [ + 'issuer' => null, + 'lock_subject' => true, + ], + ])), + new Parser([new AuthHeaders, new InputSource]), $this->app, $ttl, ); @@ -532,21 +954,11 @@ protected function createGuard( */ protected function createRequestWithBearer(?string $token): Request { - $request = m::mock(Request::class); - $request->shouldReceive('setUserResolver')->andReturnSelf(); - - if ($token !== null) { - $request->shouldReceive('header') - ->with('Authorization', '') - ->andReturn("Bearer {$token}"); - } else { - $request->shouldReceive('header') - ->with('Authorization', '') - ->andReturn(''); - $request->shouldReceive('has')->with('token')->andReturnFalse(); - } - - return $request; + return Request::create( + '/', + 'GET', + server: $token !== null ? ['HTTP_AUTHORIZATION' => "Bearer {$token}"] : [] + ); } protected function createAuthTestContainer(): Application diff --git a/tests/JWT/Providers/LcobucciTest.php b/tests/JWT/Providers/LcobucciTest.php index 61a75819e..fa66eb08f 100644 --- a/tests/JWT/Providers/LcobucciTest.php +++ b/tests/JWT/Providers/LcobucciTest.php @@ -6,6 +6,7 @@ use Carbon\Carbon; use Hypervel\JWT\Exceptions\JWTException; +use Hypervel\JWT\Exceptions\SecretMissingException; use Hypervel\JWT\Exceptions\TokenInvalidException; use Hypervel\JWT\Providers\Lcobucci; use Hypervel\JWT\Providers\Provider; @@ -24,7 +25,7 @@ protected function setUp(): void $this->testNowTimestamp = Carbon::now()->timestamp; } - public function testEncodeClaimsUsingASymmetricKey() + public function testEncodeClaimsUsingASymmetricKey(): void { $payload = [ 'sub' => 1, @@ -50,7 +51,7 @@ public function testEncodeClaimsUsingASymmetricKey() $this->assertEquals($iat, $claims['iat']); } - public function testEncodeAndDecodeATokenUsingASymmetricKey() + public function testEncodeAndDecodeATokenUsingASymmetricKey(): void { $payload = [ 'sub' => 1, @@ -72,7 +73,7 @@ public function testEncodeAndDecodeATokenUsingASymmetricKey() $this->assertEquals($iat, $claims['iat']); } - public function testEncodeAndDecodeATokenUsingAnAsymmetricRs256Key() + public function testEncodeAndDecodeATokenUsingAnAsymmetricRs256Key(): void { $payload = [ 'sub' => 1, @@ -102,7 +103,22 @@ public function testEncodeAndDecodeATokenUsingAnAsymmetricRs256Key() $this->assertEquals($iat, $claims['iat']); } - public function testShouldThrowAnInvalidExceptionWhenThePayloadCouldNotBeEncoded() + public function testEncodeAndDecodeATokenWithMultipleAudiences(): void + { + $payload = [ + 'sub' => 1, + 'aud' => ['https://first.example.test', 'https://second.example.test'], + 'iat' => $this->testNowTimestamp, + ]; + + $provider = $this->getProvider($this->getRandomString(), Provider::ALGO_HS256); + + $claims = $provider->decode($provider->encode($payload)); + + $this->assertSame(['https://first.example.test', 'https://second.example.test'], $claims['aud']); + } + + public function testShouldThrowAnInvalidExceptionWhenThePayloadCouldNotBeEncoded(): void { $this->expectException(JWTException::class); $this->expectExceptionMessage('Could not create token:'); @@ -119,7 +135,7 @@ public function testShouldThrowAnInvalidExceptionWhenThePayloadCouldNotBeEncoded $this->getProvider($this->getRandomString(), Provider::ALGO_HS256)->encode($payload); } - public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecodedDueToABadSignature() + public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecodedDueToABadSignature(): void { $this->expectException(TokenInvalidException::class); $this->expectExceptionMessage('Token Signature could not be verified.'); @@ -129,7 +145,7 @@ public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecod ->decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNjQ5MjYxMDY1LCJpYXQiOjE2NDkyNTc0NjUsImlzcyI6Ii9mb28iLCJjdXN0b21fY2xhaW0iOiJmb29iYXIifQ.jamiInQiin-1RUviliPjZxl0MLEnQnVTbr2sGooeXBY'); } - public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecodedDueToTamperedToken() + public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecodedDueToTamperedToken(): void { $this->expectException(TokenInvalidException::class); $this->expectExceptionMessage('Token Signature could not be verified.'); @@ -139,7 +155,7 @@ public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecod ->decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNjQ5MjYxMDY1LCJpYXQiOjE2NDkyNTc0NjUsImlzcyI6Ii9mb29iYXIiLCJjdXN0b21fY2xhaW0iOiJmb29iYXIifQ.jamiInQiin-1RUviliPjZxl0MLEnQnVTbr2sGooeXBY'); } - public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecoded() + public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecoded(): void { $this->expectException(TokenInvalidException::class); $this->expectExceptionMessage('Could not decode token:'); @@ -147,7 +163,7 @@ public function testShouldThrowATokenInvalidExceptionWhenTheTokenCouldNotBeDecod $this->getProvider('secret', Provider::ALGO_HS256)->decode('foo.bar.baz'); } - public function testShouldThrowAnExceptionWhenTheAlgorithmPassedIsInvalid() + public function testShouldThrowAnExceptionWhenTheAlgorithmPassedIsInvalid(): void { $this->expectException(JWTException::class); $this->expectExceptionMessage('The given algorithm could not be found'); @@ -155,7 +171,7 @@ public function testShouldThrowAnExceptionWhenTheAlgorithmPassedIsInvalid() $this->getProvider('secret', 'INVALID_ALGO')->decode('foo.bar.baz'); } - public function testShouldThrowAnExceptionWhenNoAsymmetricPublicKeyIsProvided() + public function testShouldThrowAnExceptionWhenNoAsymmetricPublicKeyIsProvided(): void { $this->expectException(JWTException::class); $this->expectExceptionMessage('Public key is not set.'); @@ -167,7 +183,7 @@ public function testShouldThrowAnExceptionWhenNoAsymmetricPublicKeyIsProvided() )->decode('foo.bar.baz'); } - public function testShouldThrowAnExceptionWhenNoAsymmetricPrivateKeyIsProvided() + public function testShouldThrowAnExceptionWhenNoAsymmetricPrivateKeyIsProvided(): void { $this->expectException(JWTException::class); $this->expectExceptionMessage('Private key is not set.'); @@ -179,7 +195,39 @@ public function testShouldThrowAnExceptionWhenNoAsymmetricPrivateKeyIsProvided() )->encode(['sub' => 1]); } - public function testShouldReturnThePublicKey() + public function testShouldThrowASecretMissingExceptionWhenNoSymmetricSecretIsProvided(): void + { + $this->expectException(SecretMissingException::class); + $this->expectExceptionMessage('Secret is not set.'); + + $this->getProvider('', Provider::ALGO_HS256); + } + + public function testEncodeAndDecodeATokenUsingFileKeyPaths(): void + { + $payload = [ + 'sub' => 1, + 'exp' => $exp = $this->testNowTimestamp + 3600, + 'iat' => $iat = $this->testNowTimestamp, + ]; + + $provider = $this->getProvider( + 'does_not_matter', + Provider::ALGO_RS256, + [ + 'private' => 'file://' . __DIR__ . '/../Fixtures/keys/id_rsa', + 'public' => 'file://' . __DIR__ . '/../Fixtures/keys/id_rsa.pub', + ], + ); + + $claims = $provider->decode($provider->encode($payload)); + + $this->assertSame('1', $claims['sub']); + $this->assertSame($exp, $claims['exp']); + $this->assertSame($iat, $claims['iat']); + } + + public function testShouldReturnThePublicKey(): void { $provider = $this->getProvider( 'does_not_matter', @@ -190,7 +238,7 @@ public function testShouldReturnThePublicKey() $this->assertSame($keys['public'], $provider->getPublicKey()); } - public function testShouldReturnTheKeys() + public function testShouldReturnTheKeys(): void { $provider = $this->getProvider( 'does_not_matter', @@ -201,7 +249,7 @@ public function testShouldReturnTheKeys() $this->assertSame($keys, $provider->getKeys()); } - public function testSetAlgoTakesEffectOnEncoding() + public function testSetAlgoTakesEffectOnEncoding(): void { $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; @@ -214,7 +262,7 @@ public function testSetAlgoTakesEffectOnEncoding() $this->assertEquals(Provider::ALGO_HS512, $header['alg']); } - public function testSetSecretTakesEffectOnSigning() + public function testSetSecretTakesEffectOnSigning(): void { $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; @@ -236,7 +284,7 @@ public function testSetSecretTakesEffectOnSigning() $this->getProvider($originalSecret, Provider::ALGO_HS256)->decode($token); } - public function testSetKeysTakesEffectOnSigning() + public function testSetKeysTakesEffectOnSigning(): void { $payload = ['sub' => 1, 'iat' => $this->testNowTimestamp]; @@ -263,7 +311,7 @@ private function getProvider(string $secret, string $algo, array $keys = []): Lc return new Lcobucci($secret, $algo, $keys); } - private function getRandomString(int $length = 64) + private function getRandomString(int $length = 64): string { $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; $charactersLength = strlen($characters); @@ -276,22 +324,22 @@ private function getRandomString(int $length = 64) return $randomString; } - private function getDummyPrivateKey() + private function getDummyPrivateKey(): string { return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa'); } - private function getDummyPublicKey() + private function getDummyPublicKey(): string { return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa.pub'); } - private function getAltPrivateKey() + private function getAltPrivateKey(): string { return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa_alt'); } - private function getAltPublicKey() + private function getAltPublicKey(): string { return file_get_contents(__DIR__ . '/../Fixtures/keys/id_rsa_alt.pub'); } diff --git a/tests/JWT/Validations/IssuerClaimTest.php b/tests/JWT/Validations/IssuerClaimTest.php new file mode 100644 index 000000000..05c9e42dc --- /dev/null +++ b/tests/JWT/Validations/IssuerClaimTest.php @@ -0,0 +1,40 @@ + 'https://api.example.test']); + + $validation->validate(['iss' => 'https://api.example.test']); + + $this->expectNotToPerformAssertions(); + } + + public function testSkipsValidationWhenIssuerIsNotConfigured(): void + { + $validation = new IssuerClaim(['issuer' => null]); + + $validation->validate(['iss' => 'https://other.example.test']); + + $this->expectNotToPerformAssertions(); + } + + public function testInvalid(): void + { + $this->expectException(TokenInvalidException::class); + $this->expectExceptionMessage('Issuer is invalid'); + + $validation = new IssuerClaim(['issuer' => 'https://api.example.test']); + + $validation->validate(['iss' => 'https://other.example.test']); + } +} diff --git a/tests/JWT/Validations/NotBeforeCliamTest.php b/tests/JWT/Validations/NotBeforeClaimTest.php similarity index 74% rename from tests/JWT/Validations/NotBeforeCliamTest.php rename to tests/JWT/Validations/NotBeforeClaimTest.php index 40d374522..c5fa6d269 100644 --- a/tests/JWT/Validations/NotBeforeCliamTest.php +++ b/tests/JWT/Validations/NotBeforeClaimTest.php @@ -6,32 +6,32 @@ use Carbon\Carbon; use Hypervel\JWT\Exceptions\TokenInvalidException; -use Hypervel\JWT\Validations\NotBeforeCliam; +use Hypervel\JWT\Validations\NotBeforeClaim; use Hypervel\Tests\TestCase; -class NotBeforeCliamTest extends TestCase +class NotBeforeClaimTest extends TestCase { - public function testValid() + public function testValid(): void { Carbon::setTestNow('2000-01-01T00:00:00.000000Z'); $this->expectNotToPerformAssertions(); - $validation = new NotBeforeCliam(['leeway' => 3600]); + $validation = new NotBeforeClaim(['leeway' => 3600]); $validation->validate([]); $validation->validate(['nbf' => Carbon::now()->timestamp - 3600]); $validation->validate(['nbf' => Carbon::now()->timestamp + 3600]); } - public function testInvalid() + public function testInvalid(): void { Carbon::setTestNow('2000-01-01T00:00:00.000000Z'); $this->expectException(TokenInvalidException::class); $this->expectExceptionMessage('Not Before (nbf) timestamp cannot be in the future'); - $validation = new NotBeforeCliam; + $validation = new NotBeforeClaim; $validation->validate(['nbf' => Carbon::now()->timestamp + 3600]); }