Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
JwtTokenManager
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
12 / 12
32
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decode
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
 encode
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 generateRefreshToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTokenTtl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRefreshTokenTtl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastSessionId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastJti
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateIssuer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 validateAudience
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 validateTokenType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 validateRequiredClaims
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace DevToolbelt\JwtTokenManager;
6
7use Exception;
8use DomainException;
9use Firebase\JWT\JWT;
10use Firebase\JWT\Key;
11use Ramsey\Uuid\Uuid;
12use DateTimeImmutable;
13use UnexpectedValueException;
14use Firebase\JWT\ExpiredException;
15use Firebase\JWT\BeforeValidException;
16use Firebase\JWT\SignatureInvalidException;
17use DevToolbelt\JwtTokenManager\Exceptions\ExpiredTokenException;
18use DevToolbelt\JwtTokenManager\Exceptions\InvalidClaimException;
19use DevToolbelt\JwtTokenManager\Exceptions\InvalidSignatureException;
20use DevToolbelt\JwtTokenManager\Exceptions\InvalidTokenException;
21use 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 */
32final 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}