Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
71 / 71 |
|
100.00% |
12 / 12 |
CRAP | |
100.00% |
1 / 1 |
| JwtTokenManager | |
100.00% |
71 / 71 |
|
100.00% |
12 / 12 |
32 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| decode | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
6 | |||
| encode | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
1 | |||
| generateRefreshToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getTokenTtl | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getRefreshTokenTtl | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getLastSessionId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getLastJti | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| validateIssuer | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| validateAudience | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
7 | |||
| validateTokenType | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
3 | |||
| validateRequiredClaims | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace DevToolbelt\JwtTokenManager; |
| 6 | |
| 7 | use Exception; |
| 8 | use DomainException; |
| 9 | use Firebase\JWT\JWT; |
| 10 | use Firebase\JWT\Key; |
| 11 | use Ramsey\Uuid\Uuid; |
| 12 | use DateTimeImmutable; |
| 13 | use UnexpectedValueException; |
| 14 | use Firebase\JWT\ExpiredException; |
| 15 | use Firebase\JWT\BeforeValidException; |
| 16 | use Firebase\JWT\SignatureInvalidException; |
| 17 | use DevToolbelt\JwtTokenManager\Exceptions\ExpiredTokenException; |
| 18 | use DevToolbelt\JwtTokenManager\Exceptions\InvalidClaimException; |
| 19 | use DevToolbelt\JwtTokenManager\Exceptions\InvalidSignatureException; |
| 20 | use DevToolbelt\JwtTokenManager\Exceptions\InvalidTokenException; |
| 21 | use DevToolbelt\JwtTokenManager\Exceptions\MissingClaimsException; |
| 22 | |
| 23 | /** |
| 24 | * JWT Token Manager for encoding, decoding and validating JSON Web Tokens. |
| 25 | * |
| 26 | * This class provides a complete solution for JWT management including: |
| 27 | * - Token generation with configurable claims |
| 28 | * - Token decoding and validation |
| 29 | * - Refresh token generation |
| 30 | * - Session and JTI tracking |
| 31 | */ |
| 32 | final class JwtTokenManager |
| 33 | { |
| 34 | private ?string $lastSessionId = null; |
| 35 | private ?string $lastJti = null; |
| 36 | |
| 37 | /** |
| 38 | * Create a new JwtTokenManager instance. |
| 39 | * |
| 40 | * @param JwtConfig $config The JWT configuration containing keys, algorithm, and validation rules |
| 41 | */ |
| 42 | public function __construct( |
| 43 | private readonly JwtConfig $config |
| 44 | ) { |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Decode and validate a JWT token. |
| 49 | * |
| 50 | * Performs the following validations: |
| 51 | * - Token signature verification |
| 52 | * - Token expiration (exp claim) |
| 53 | * - Token not-before time (nbf claim) |
| 54 | * - Issuer validation (iss claim) |
| 55 | * - Audience validation (aud claim) - if configured |
| 56 | * - Token type validation (typ claim) |
| 57 | * - Required claims presence |
| 58 | * |
| 59 | * @param string $token The raw JWT token (without "Bearer " prefix) |
| 60 | * @return TokenPayload The decoded payload wrapped in a TokenPayload object |
| 61 | * @throws ExpiredTokenException When the token has expired |
| 62 | * @throws InvalidSignatureException When the public key cannot validate the token signature |
| 63 | * @throws InvalidTokenException When the token is malformed or is not yet valid |
| 64 | * @throws InvalidClaimException When a claim value doesn't match the expected value |
| 65 | * @throws MissingClaimsException When required claims are missing from the token |
| 66 | */ |
| 67 | public function decode(string $token): TokenPayload |
| 68 | { |
| 69 | try { |
| 70 | $payload = JWT::decode( |
| 71 | $token, |
| 72 | new Key($this->config->getPublicKey(), $this->config->getAlgorithmValue()) |
| 73 | ); |
| 74 | |
| 75 | $this->validateRequiredClaims($payload); |
| 76 | $this->validateIssuer($payload); |
| 77 | $this->validateAudience($payload); |
| 78 | $this->validateTokenType($payload); |
| 79 | |
| 80 | return new TokenPayload($payload); |
| 81 | } catch (ExpiredException) { |
| 82 | throw new ExpiredTokenException(); |
| 83 | } catch (BeforeValidException) { |
| 84 | throw new InvalidTokenException('Token is not yet valid'); |
| 85 | } catch (SignatureInvalidException) { |
| 86 | throw new InvalidSignatureException(); |
| 87 | } catch (DomainException $e) { |
| 88 | throw new InvalidTokenException($e->getMessage()); |
| 89 | } catch (UnexpectedValueException $e) { |
| 90 | throw new InvalidTokenException($e->getMessage()); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Generate a JWT access token. |
| 96 | * |
| 97 | * Creates a new JWT with the following claims: |
| 98 | * |
| 99 | * Protected claims (cannot be overridden): |
| 100 | * - iss: Issuer (from config) |
| 101 | * - sub: Subject (the provided subject parameter) |
| 102 | * - iat: Issued at (current timestamp) |
| 103 | * - exp: Expiration (current timestamp + TTL from config) |
| 104 | * - jti: JWT ID (unique UUID v7) |
| 105 | * - sid: Session ID (unique UUID v7) |
| 106 | * |
| 107 | * Optional claims with defaults (can be overridden via $customClaims): |
| 108 | * - aud: Audience (default: from config, if set) |
| 109 | * - typ: Token type (default: "access") |
| 110 | * - nbf: Not before (default: current timestamp - 5 seconds for clock skew) |
| 111 | * |
| 112 | * @param string $subject The subject identifier (typically user ID or external_id) |
| 113 | * @param array<string, mixed> $customClaims Additional claims to include in the token payload. |
| 114 | * Can override optional claims (aud, typ, nbf). |
| 115 | * @return string The encoded JWT token string |
| 116 | * @throws Exception |
| 117 | */ |
| 118 | public function encode(string $subject, array $customClaims = []): string |
| 119 | { |
| 120 | $now = new DateTimeImmutable('now', $this->config->getDateTimeZone()); |
| 121 | $timestamp = $now->getTimestamp(); |
| 122 | |
| 123 | $this->lastSessionId = Uuid::uuid7()->toString(); |
| 124 | $this->lastJti = Uuid::uuid7()->toString(); |
| 125 | |
| 126 | $optionalClaims = [ |
| 127 | 'aud' => $this->config->getAudience(), |
| 128 | 'typ' => 'access', |
| 129 | 'nbf' => $timestamp - 5, |
| 130 | ]; |
| 131 | |
| 132 | $protectedClaims = [ |
| 133 | 'iss' => $this->config->getIssuer(), |
| 134 | 'sub' => $subject, |
| 135 | 'iat' => $timestamp, |
| 136 | 'exp' => $timestamp + $this->config->getTtlSeconds(), |
| 137 | 'jti' => $this->lastJti, |
| 138 | 'sid' => $this->lastSessionId, |
| 139 | ]; |
| 140 | |
| 141 | $payload = array_filter($optionalClaims, fn ($value) => $value !== null); |
| 142 | $payload = array_merge($payload, $customClaims); |
| 143 | $payload = array_merge($payload, $protectedClaims); |
| 144 | |
| 145 | return JWT::encode($payload, $this->config->getPrivateKey(), $this->config->getAlgorithmValue()); |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Generate a refresh token. |
| 150 | * |
| 151 | * Creates a simple refresh token using SHA1 hash of the current timestamp. |
| 152 | * This token should be stored securely and associated with the user session. |
| 153 | * |
| 154 | * @return string A 40-character hexadecimal SHA1 hash string |
| 155 | */ |
| 156 | public function generateRefreshToken(): string |
| 157 | { |
| 158 | return sha1((string) time()); |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * Get the access token time-to-live in seconds. |
| 163 | * |
| 164 | * @return int The TTL in seconds as configured in JwtConfig |
| 165 | */ |
| 166 | public function getTokenTtl(): int |
| 167 | { |
| 168 | return $this->config->getTtlSeconds(); |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Get the refresh token time-to-live in seconds. |
| 173 | * |
| 174 | * @return int The refresh TTL in seconds as configured in JwtConfig |
| 175 | */ |
| 176 | public function getRefreshTokenTtl(): int |
| 177 | { |
| 178 | return $this->config->getRefreshTtlSeconds(); |
| 179 | } |
| 180 | |
| 181 | /** |
| 182 | * Get the session ID from the last generated token. |
| 183 | * |
| 184 | * The session ID (sid) is a UUID v7 generated during token encoding. |
| 185 | * It can be used to track user sessions and implement token revocation. |
| 186 | * |
| 187 | * @return string|null The session ID or null if no token has been generated yet |
| 188 | */ |
| 189 | public function getLastSessionId(): ?string |
| 190 | { |
| 191 | return $this->lastSessionId; |
| 192 | } |
| 193 | |
| 194 | /** |
| 195 | * Get the JTI (JWT ID) from the last generated token. |
| 196 | * |
| 197 | * The JTI is a unique identifier (UUID v7) for each token. |
| 198 | * It can be used for token blacklisting and preventing replay attacks. |
| 199 | * |
| 200 | * @return string|null The JWT ID or null if no token has been generated yet |
| 201 | */ |
| 202 | public function getLastJti(): ?string |
| 203 | { |
| 204 | return $this->lastJti; |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * Validate the issuer claim against the configured issuer. |
| 209 | * |
| 210 | * @param object $payload The decoded JWT payload |
| 211 | * @throws InvalidClaimException When the issuer doesn't match the expected value |
| 212 | */ |
| 213 | private function validateIssuer(object $payload): void |
| 214 | { |
| 215 | $expectedIssuer = $this->config->getIssuer(); |
| 216 | |
| 217 | if (!isset($payload->iss) || $payload->iss !== $expectedIssuer) { |
| 218 | throw new InvalidClaimException('iss', $payload->iss ?? null, $expectedIssuer); |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * Validate the audience claim against the configured audiences. |
| 224 | * |
| 225 | * If no audience is configured, validation is skipped. |
| 226 | * The token's audience can be a string or array; at least one must match. |
| 227 | * |
| 228 | * @param object $payload The decoded JWT payload |
| 229 | * @throws InvalidClaimException When the audience doesn't match any expected value |
| 230 | */ |
| 231 | private function validateAudience(object $payload): void |
| 232 | { |
| 233 | $expectedAudiences = $this->config->getAudience(); |
| 234 | |
| 235 | if ($expectedAudiences === null) { |
| 236 | return; |
| 237 | } |
| 238 | |
| 239 | if (!isset($payload->aud)) { |
| 240 | throw new InvalidClaimException('aud', null, $expectedAudiences); |
| 241 | } |
| 242 | |
| 243 | $tokenAudiences = is_array($payload->aud) ? $payload->aud : [$payload->aud]; |
| 244 | |
| 245 | $hasValidAudience = false; |
| 246 | foreach ($expectedAudiences as $expectedAudience) { |
| 247 | if (in_array($expectedAudience, $tokenAudiences, true)) { |
| 248 | $hasValidAudience = true; |
| 249 | break; |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | if (!$hasValidAudience) { |
| 254 | throw new InvalidClaimException('aud', $payload->aud, $expectedAudiences); |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Validate that the token type is "access". |
| 260 | * |
| 261 | * @param object $payload The decoded JWT payload |
| 262 | * @throws InvalidClaimException When the token type is not "access" |
| 263 | */ |
| 264 | private function validateTokenType(object $payload): void |
| 265 | { |
| 266 | if (!isset($payload->typ) || $payload->typ !== 'access') { |
| 267 | throw new InvalidClaimException('typ', $payload->typ ?? null, 'access'); |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Validate that all required claims are present and non-empty. |
| 273 | * |
| 274 | * Required claims are defined in the JwtConfig. Each claim must exist |
| 275 | * and have a non-empty value (not null, empty string, or empty array). |
| 276 | * |
| 277 | * @param object $payload The decoded JWT payload |
| 278 | * @throws MissingClaimsException When one or more required claims are missing or empty |
| 279 | */ |
| 280 | private function validateRequiredClaims(object $payload): void |
| 281 | { |
| 282 | $missingClaims = []; |
| 283 | $requiredClaims = $this->config->getRequiredClaims(); |
| 284 | |
| 285 | foreach ($requiredClaims as $claim) { |
| 286 | if (!isset($payload->{$claim}) || $payload->{$claim} === '' || $payload->{$claim} === []) { |
| 287 | $missingClaims[] = $claim; |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | if (!empty($missingClaims)) { |
| 292 | throw new MissingClaimsException($missingClaims); |
| 293 | } |
| 294 | } |
| 295 | } |