'JWT', 'alg' => $this->algorithm ]; $payload['iat'] = time(); // Issued at $payload['exp'] = time() + $expirationTime; // Expiration // Add JWT ID for token identification and revocation support $payload['jti'] = $jti ?? $this->generateJti(); $headerEncoded = $this->base64UrlEncode(json_encode($header)); $payloadEncoded = $this->base64UrlEncode(json_encode($payload)); $signature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey); return $headerEncoded . '.' . $payloadEncoded . '.' . $signature; } // ========================================================================= // Token Validation // ========================================================================= /** * Validate a JWT token and return its payload * * @param string $token The JWT token to validate * @param string $secretKey The secret key for verification * @param bool $checkBlacklist Whether to check the blacklist (default: true) * @return array|null The token payload if valid, null otherwise */ public function validateToken(string $token, string $secretKey, bool $checkBlacklist = true): ?array { $parts = explode('.', $token); if (count($parts) !== 3) { return null; } [$headerEncoded, $payloadEncoded, $signature] = $parts; // Decode and validate header first $header = json_decode($this->base64UrlDecode($headerEncoded), true); if (!$header) { return null; } // SECURITY: Validate algorithm to prevent "none" algorithm and algorithm switching attacks if (!isset($header['alg']) || !in_array($header['alg'], self::ALLOWED_ALGORITHMS, true)) { return null; // Reject tokens with unexpected algorithms } // Verify signature using our expected algorithm (not the one in the header) $expectedSignature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey); if (!hash_equals($signature, $expectedSignature)) { return null; } // Decode payload $payload = json_decode($this->base64UrlDecode($payloadEncoded), true); if (!$payload) { return null; } // Check expiration if (isset($payload['exp']) && $payload['exp'] < time()) { return null; // Token expired } // Check blacklist if enabled if ($checkBlacklist) { // Check if this specific token has been blacklisted (by jti) if (isset($payload['jti']) && $this->isBlacklisted($payload['jti'])) { return null; } // Check if user's tokens have been globally invalidated if (isset($payload['identity'], $payload['iat'])) { if ($this->isUserTokenBlacklisted($payload['identity'], $payload['iat'])) { return null; } } } return $payload; } /** * Refresh a token by creating a new one with fresh timestamps * * @param string $token The token to refresh * @param string $secretKey The secret key * @return string|null The new token, or null if original was invalid */ public function refreshToken(string $token, string $secretKey): ?string { $payload = $this->validateToken($token, $secretKey); if (!$payload) { return null; } // Remove old timestamps and jti (new token gets new jti) unset($payload['iat'], $payload['exp'], $payload['jti']); // Create new token with fresh timestamps and new jti return $this->createToken($payload, $secretKey); } // ========================================================================= // Token Blacklisting // ========================================================================= /** * Add a token to the blacklist (revoke it) * * @param string $jti The JWT ID to blacklist * @param int $expiresAt Unix timestamp when the token expires (for cleanup) */ public function blacklist(string $jti, int $expiresAt): void { $ttl = max($expiresAt - time(), 60); // Minimum 60 seconds $this->cache->set( $this->getTokenCacheKey($jti), $expiresAt, CacheScope::Tenant, self::CACHE_USAGE_BLACKLIST, $ttl ); } /** * Check if a token is blacklisted * * @param string $jti The JWT ID to check * @return bool True if blacklisted, false otherwise */ public function isBlacklisted(string $jti): bool { return $this->cache->has( $this->getTokenCacheKey($jti), CacheScope::Tenant, self::CACHE_USAGE_BLACKLIST ); } /** * Remove a token from the blacklist * * @param string $jti The JWT ID to remove */ public function unblacklist(string $jti): void { $this->cache->delete( $this->getTokenCacheKey($jti), CacheScope::Tenant, self::CACHE_USAGE_BLACKLIST ); } /** * Blacklist all tokens for a user issued before a timestamp * Used for "logout all devices" functionality * * @param string $identity User identity * @param int $beforeTimestamp Tokens issued before this time are invalid */ public function blacklistUserTokensBefore(string $identity, int $beforeTimestamp): void { // Store for 30 days (longer than any token lifetime) $this->cache->set( $this->getUserCacheKey($identity), $beforeTimestamp, CacheScope::Tenant, self::CACHE_USAGE_USER_BLACKLIST, 2592000 // 30 days ); } /** * Check if a user's token was issued before the blacklist timestamp * * @param string $identity User identity * @param int $issuedAt Token's iat claim * @return bool True if token should be rejected */ public function isUserTokenBlacklisted(string $identity, int $issuedAt): bool { $blacklistBefore = $this->cache->get( $this->getUserCacheKey($identity), CacheScope::Tenant, self::CACHE_USAGE_USER_BLACKLIST ); if ($blacklistBefore === null) { return false; } return $issuedAt < (int) $blacklistBefore; } /** * Clear user's "logout all devices" blacklist * * @param string $identity User identity */ public function clearUserBlacklist(string $identity): void { $this->cache->delete( $this->getUserCacheKey($identity), CacheScope::Tenant, self::CACHE_USAGE_USER_BLACKLIST ); } // ========================================================================= // Private Helpers // ========================================================================= private function createSignature(string $data, string $secretKey): string { $signature = hash_hmac('sha256', $data, $secretKey, true); return $this->base64UrlEncode($signature); } private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } private function base64UrlDecode(string $data): string { return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT)); } /** * Generate cache key for token blacklist */ private function getTokenCacheKey(string $jti): string { return 'jti_' . hash('sha256', $jti); } /** * Generate cache key for user blacklist */ private function getUserCacheKey(string $identity): string { return 'user_' . hash('sha256', $identity); } }