hasValidJwtStructure($validToken)) { throw new \RuntimeException('Valid JWT structure rejected'); } echo "✅ Valid JWT structure accepted\n"; // Test invalid structures $invalidTokens = [ 'invalid', // Not base64 'header.payload', // Missing signature 'header.payload.signature.extra', // Too many parts '', // Empty ]; foreach ($invalidTokens as $token) { if ($this->hasValidJwtStructure($token)) { throw new \RuntimeException("Invalid JWT structure accepted: {$token}"); } } echo "✅ Invalid JWT structures rejected\n"; } /** * Test token expiration validation */ public function testValidatesTokenExpiration(): void { // Expired token (exp claim in the past) $expiredToken = [ 'sub' => '1234567890', 'name' => 'John Doe', 'exp' => time() - 3600 // Expired 1 hour ago ]; if (!$this->isTokenExpired($expiredToken)) { throw new \RuntimeException('Expired token not detected'); } echo "✅ Expired token detected\n"; // Valid token (exp claim in the future) $validToken = [ 'sub' => '1234567890', 'name' => 'John Doe', 'exp' => time() + 3600 // Expires in 1 hour ]; if ($this->isTokenExpired($validToken)) { throw new \RuntimeException('Valid token marked as expired'); } echo "✅ Valid token accepted\n"; } /** * Test token signature verification */ public function testVerifiesTokenSignature(): void { $secret = 'test-secret-key'; $header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT'])); $payload = base64_encode(json_encode(['sub' => '123', 'exp' => time() + 3600])); // Valid signature $validSignature = base64_encode(hash_hmac('sha256', "$header.$payload", $secret, true)); $validToken = "$header.$payload.$validSignature"; if (!$this->verifyTokenSignature($validToken, $secret)) { throw new \RuntimeException('Valid token signature rejected'); } echo "✅ Valid token signature verified\n"; // Invalid signature $invalidSignature = base64_encode('invalid-signature'); $invalidToken = "$header.$payload.$invalidSignature"; if ($this->verifyTokenSignature($invalidToken, $secret)) { throw new \RuntimeException('Invalid token signature accepted'); } echo "✅ Invalid token signature rejected\n"; } /** * Test Bearer token format */ public function testValidatesBearerTokenFormat(): void { $validBearerTokens = [ 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature', 'Bearer valid-api-key-12345', ]; foreach ($validBearerTokens as $token) { if (!$this->hasValidBearerFormat($token)) { throw new \RuntimeException("Valid Bearer token rejected: {$token}"); } } echo "✅ Valid Bearer token formats accepted\n"; $invalidBearerTokens = [ 'bearer token', // Lowercase 'bearer' 'Token xyz', // Wrong prefix 'eyJhbGci...', // Missing 'Bearer' prefix '', // Empty ]; foreach ($invalidBearerTokens as $token) { if ($this->hasValidBearerFormat($token)) { throw new \RuntimeException("Invalid Bearer token accepted: {$token}"); } } echo "✅ Invalid Bearer token formats rejected\n"; } /** * Test token claims validation */ public function testValidatesTokenClaims(): void { // Valid claims $validClaims = [ 'sub' => '1234567890', 'name' => 'John Doe', 'iat' => time(), 'exp' => time() + 3600, 'iss' => 'https://example.com', 'aud' => 'https://api.example.com' ]; $validationErrors = $this->validateTokenClaims($validClaims); if (!empty($validationErrors)) { throw new \RuntimeException( "Valid claims rejected: " . implode(', ', $validationErrors) ); } echo "✅ Valid token claims accepted\n"; // Missing required claims $invalidClaims = [ 'name' => 'John Doe', // Missing 'sub' and 'exp' ]; $validationErrors = $this->validateTokenClaims($invalidClaims); if (empty($validationErrors)) { throw new \RuntimeException('Missing required claims not detected'); } echo "✅ Missing required claims detected\n"; } /** * Test token issued at (iat) validation */ public function testValidatesIssuedAtClaim(): void { // Future iat (token issued in the future) $futureIat = [ 'sub' => '123', 'iat' => time() + 3600, // Issued 1 hour in the future 'exp' => time() + 7200 ]; if (!$this->isIssuedInFuture($futureIat)) { throw new \RuntimeException('Future iat not detected'); } echo "✅ Future iat claim detected\n"; // Valid iat (issued in the past) $validIat = [ 'sub' => '123', 'iat' => time() - 60, // Issued 1 minute ago 'exp' => time() + 3600 ]; if ($this->isIssuedInFuture($validIat)) { throw new \RuntimeException('Valid iat marked as future'); } echo "✅ Valid iat claim accepted\n"; } /** * Test token not before (nbf) validation */ public function testValidatesNotBeforeClaim(): void { // Token not yet valid (nbf in the future) $notYetValid = [ 'sub' => '123', 'nbf' => time() + 3600, // Valid in 1 hour 'exp' => time() + 7200 ]; if (!$this->isNotYetValid($notYetValid)) { throw new \RuntimeException('Not-yet-valid token not detected'); } echo "✅ Not-yet-valid token detected\n"; // Token already valid (nbf in the past) $alreadyValid = [ 'sub' => '123', 'nbf' => time() - 60, // Valid since 1 minute ago 'exp' => time() + 3600 ]; if ($this->isNotYetValid($alreadyValid)) { throw new \RuntimeException('Valid token marked as not-yet-valid'); } echo "✅ Valid token accepted (nbf check)\n"; } /** * Run all token validation tests */ public function runAllTests(): array { $results = []; try { $this->testValidatesJwtStructure(); $results['jwt_structure'] = 'PASS'; } catch (\Exception $e) { $results['jwt_structure'] = 'FAIL: ' . $e->getMessage(); } try { $this->testValidatesTokenExpiration(); $results['token_expiration'] = 'PASS'; } catch (\Exception $e) { $results['token_expiration'] = 'FAIL: ' . $e->getMessage(); } try { $this->testVerifiesTokenSignature(); $results['signature_verification'] = 'PASS'; } catch (\Exception $e) { $results['signature_verification'] = 'FAIL: ' . $e->getMessage(); } try { $this->testValidatesBearerTokenFormat(); $results['bearer_format'] = 'PASS'; } catch (\Exception $e) { $results['bearer_format'] = 'FAIL: ' . $e->getMessage(); } try { $this->testValidatesTokenClaims(); $results['claims_validation'] = 'PASS'; } catch (\Exception $e) { $results['claims_validation'] = 'FAIL: ' . $e->getMessage(); } try { $this->testValidatesIssuedAtClaim(); $results['iat_validation'] = 'PASS'; } catch (\Exception $e) { $results['iat_validation'] = 'FAIL: ' . $e->getMessage(); } try { $this->testValidatesNotBeforeClaim(); $results['nbf_validation'] = 'PASS'; } catch (\Exception $e) { $results['nbf_validation'] = 'FAIL: ' . $e->getMessage(); } return $results; } private function hasValidJwtStructure(string $token): bool { $parts = explode('.', $token); return count($parts) === 3 && !empty($parts[0]) && !empty($parts[1]) && !empty($parts[2]); } private function isTokenExpired(array $claims): bool { if (!isset($claims['exp'])) { return true; // No expiration claim = treat as expired } return time() >= $claims['exp']; } private function verifyTokenSignature(string $token, string $secret): bool { $parts = explode('.', $token); if (count($parts) !== 3) { return false; } [$header, $payload, $signature] = $parts; $validSignature = base64_encode(hash_hmac('sha256', "$header.$payload", $secret, true)); return hash_equals($validSignature, $signature); } private function hasValidBearerFormat(string $authHeader): bool { return str_starts_with($authHeader, 'Bearer ') && strlen($authHeader) > 7; } private function validateTokenClaims(array $claims): array { $errors = []; // Required claims $requiredClaims = ['sub', 'exp']; foreach ($requiredClaims as $claim) { if (!isset($claims[$claim])) { $errors[] = "Missing required claim: {$claim}"; } } return $errors; } private function isIssuedInFuture(array $claims): bool { if (!isset($claims['iat'])) { return false; } return $claims['iat'] > time(); } private function isNotYetValid(array $claims): bool { if (!isset($claims['nbf'])) { return false; } return time() < $claims['nbf']; } }