• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / jwt / 25547514855

08 May 2026 09:15AM UTC coverage: 78.328% (-2.0%) from 80.319%
25547514855

push

github

daycry
test: give each generated key pair a unique filename

testRsaTokenSignedWithDifferentKeyFails was hitting a CI-only false
negative because generateRsaKeyPair() always wrote to the same
fixture paths (rsa-private.pem / rsa-public.pem). The second call
overwrote the first key pair, so $publicA and $publicB ended up
pointing at the same file on disk — the verifier was effectively
verifying with the right key and the test never reached the expected
RequiredConstraintsViolated.

Suffix each pair with `uniqid('', true)` so distinct calls produce
distinct files. Same fix applied to generateEcdsaKeyPair() for
symmetry.

Surfaced when CI Linux finally ran the asymmetric tests (previously
skipped on Windows due to missing openssl.cnf).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

253 of 323 relevant lines covered (78.33%)

7.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.82
/src/JWT.php
1
<?php
2

3
namespace Daycry\JWT;
4

5
use DateInterval;
6
use DateTimeImmutable;
7
use Daycry\JWT\Config\JWT as JWTConfig;
8
use Daycry\JWT\Exceptions\InvalidTokenException;
9
use Daycry\JWT\Exceptions\JWTConfigurationException;
10
use InvalidArgumentException;
11
use Lcobucci\Clock\SystemClock;
12
use Lcobucci\JWT\Configuration;
13
use Lcobucci\JWT\Signer\Key;
14
use Lcobucci\JWT\Signer\Key\InMemory;
15
use Lcobucci\JWT\Token\Plain;
16
use Lcobucci\JWT\Validation\Constraint;
17
use Lcobucci\JWT\Validation\Constraint\IdentifiedBy;
18
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
19
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
20
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
21
use Lcobucci\JWT\Validation\Constraint\SignedWith;
22
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
23
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
24
use Throwable;
25

26
/**
27
 * Immutable JWT facade over `lcobucci/jwt ^5`.
28
 *
29
 * Construct via the constructor (with an explicit config) or `JWT::for()` and
30
 * configure per-call needs through the `with*()` methods, which return a new
31
 * instance — the original is unchanged.
32
 */
33
final class JWT
34
{
35
    private bool $split = false;
36

37
    /**
38
     * @var non-empty-string
39
     */
40
    private string $paramData = 'data';
41

42
    private ?int $leewaySeconds;
43

44
    public function __construct(private JWTConfig $config)
45
    {
46
        $this->leewaySeconds = $config->leeway;
47✔
47
    }
48

49
    /**
50
     * Static factory. Falls back to the bound `Config\JWT` when no instance is provided.
51
     */
52
    public static function for(?JWTConfig $config = null): self
53
    {
54
        return new self($config ?? config('JWT'));
1✔
55
    }
56

57
    public function withSplitData(bool $split = true): self
58
    {
59
        $clone        = clone $this;
2✔
60
        $clone->split = $split;
2✔
61

62
        return $clone;
2✔
63
    }
64

65
    public function withParamData(string $name): self
66
    {
67
        if ($name === '') {
3✔
68
            throw new InvalidArgumentException('paramData claim name cannot be empty.');
1✔
69
        }
70
        $clone            = clone $this;
2✔
71
        $clone->paramData = $name;
2✔
72

73
        return $clone;
2✔
74
    }
75

76
    public function withLeeway(int $seconds): self
77
    {
78
        if ($seconds < 0) {
1✔
79
            throw new InvalidArgumentException('Leeway cannot be negative.');
1✔
80
        }
81
        $clone                = clone $this;
×
82
        $clone->leewaySeconds = $seconds;
×
83

84
        return $clone;
×
85
    }
86

87
    public function getParamData(): string
88
    {
89
        return $this->paramData;
2✔
90
    }
91

92
    public function isSplitData(): bool
93
    {
94
        return $this->split;
1✔
95
    }
96

97
    public function encode(mixed $data, mixed $uid = null): string
98
    {
99
        $now           = new DateTimeImmutable();
36✔
100
        $configuration = $this->buildConfiguration();
36✔
101

102
        $issuer     = $this->config->issuer ?? throw JWTConfigurationException::missingClaim('issuer');
33✔
103
        $audience   = $this->config->audience ?? throw JWTConfigurationException::missingClaim('audience');
32✔
104
        $identifier = $this->config->identifier ?? throw JWTConfigurationException::missingClaim('identifier');
32✔
105

106
        $builder          = $configuration->builder();
32✔
107
        $serializedAsJson = false;
32✔
108

109
        if (is_array($data) || is_object($data)) {
32✔
110
            if ($this->split) {
10✔
111
                /** @var iterable<string, mixed> $iterable */
112
                $iterable = is_object($data) ? get_object_vars($data) : $data;
1✔
113

114
                foreach ($iterable as $key => $value) {
1✔
115
                    $builder = $builder->withClaim((string) $key, $value);
1✔
116
                }
117
            } else {
118
                $builder = $builder->withClaim(
9✔
119
                    $this->paramData,
9✔
120
                    json_encode($data, JSON_THROW_ON_ERROR),
9✔
121
                );
9✔
122
                $serializedAsJson = true;
9✔
123
            }
124
        } else {
125
            $builder = $builder->withClaim($this->paramData, $data);
22✔
126
        }
127

128
        $resolvedUid = $uid ?? $this->config->uid;
32✔
129
        if ($resolvedUid !== null && $resolvedUid !== '') {
32✔
130
            $builder = $builder->withClaim('uid', $resolvedUid);
29✔
131
        }
132

133
        if ($serializedAsJson) {
32✔
134
            $builder = $builder->withHeader('cty', 'json');
9✔
135
        }
136

137
        $notBefore = $now->modify($this->config->canOnlyBeUsedAfter);
32✔
138
        if ($notBefore === false || $notBefore > $now) {
32✔
139
            $notBefore = $now;
140
        }
141

142
        $expiresAt = $now->modify($this->config->expiresAt);
32✔
143
        if ($expiresAt === false) {
32✔
144
            throw new InvalidArgumentException(
145
                "Config::\$expiresAt is not a valid DateTimeImmutable modifier: {$this->config->expiresAt}",
146
            );
147
        }
148

149
        $token = $builder
32✔
150
            ->issuedBy($issuer)
32✔
151
            ->permittedFor($audience)
32✔
152
            ->identifiedBy($identifier)
32✔
153
            ->issuedAt($now)
32✔
154
            ->canOnlyBeUsedAfter($notBefore)
32✔
155
            ->expiresAt($expiresAt)
32✔
156
            ->getToken($configuration->signer(), $configuration->signingKey());
32✔
157

158
        return $token->toString();
32✔
159
    }
160

161
    /**
162
     * Decode and validate. Always throws on parse errors and validation failures.
163
     *
164
     * @throws InvalidTokenException       When the token cannot be parsed.
165
     * @throws RequiredConstraintsViolated When a constraint fails.
166
     */
167
    public function decode(string $token): Plain
168
    {
169
        $configuration = $this->buildConfiguration();
32✔
170

171
        try {
172
            $parsed = $configuration->parser()->parse($token);
32✔
173
        } catch (Throwable $e) {
3✔
174
            throw new InvalidTokenException('Token is malformed: ' . $e->getMessage(), 0, $e);
3✔
175
        }
176

177
        if (! $parsed instanceof Plain) {
29✔
178
            throw new InvalidTokenException('Only Plain tokens are supported.');
×
179
        }
180

181
        if ($this->config->validate) {
29✔
182
            $configuration->validator()->assert($parsed, ...$this->buildValidationConstraints());
28✔
183
        }
184

185
        return $parsed;
18✔
186
    }
187

188
    /**
189
     * Decode + validate without throwing. Returns null on any failure.
190
     */
191
    public function tryDecode(string $token): ?Plain
192
    {
193
        try {
194
            return $this->decode($token);
5✔
195
        } catch (Throwable) {
4✔
196
            return null;
4✔
197
        }
198
    }
199

200
    /**
201
     * Validate the token and return the original payload value.
202
     *
203
     * Symmetric to `encode()`:
204
     *   - Scalar / split-mode tokens → raw claim value.
205
     *   - Compact-mode tokens (header `cty=json`) → `json_decode`d back into an array.
206
     *
207
     * @throws InvalidTokenException
208
     * @throws RequiredConstraintsViolated
209
     */
210
    public function getPayload(string $token): mixed
211
    {
212
        $parsed = $this->decode($token);
3✔
213
        $value  = $parsed->claims()->get($this->paramData);
2✔
214

215
        if ($parsed->headers()->get('cty') === 'json' && is_string($value)) {
2✔
216
            return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
1✔
217
        }
218

219
        return $value;
1✔
220
    }
221

222
    public function isValid(string $token): bool
223
    {
224
        return $this->tryDecode($token) instanceof Plain;
3✔
225
    }
226

227
    public function isExpired(string $token): bool
228
    {
229
        $parsed = $this->parseWithoutValidation($token);
2✔
230
        if (! $parsed instanceof Plain) {
2✔
231
            return true;
1✔
232
        }
233

234
        $exp = $parsed->claims()->get('exp');
1✔
235
        if (! $exp instanceof DateTimeImmutable) {
1✔
236
            return false;
×
237
        }
238

239
        return $exp->getTimestamp() < time();
1✔
240
    }
241

242
    public function getTimeToExpiry(string $token): ?int
243
    {
244
        $parsed = $this->parseWithoutValidation($token);
2✔
245
        if (! $parsed instanceof Plain) {
2✔
246
            return null;
1✔
247
        }
248

249
        $exp = $parsed->claims()->get('exp');
1✔
250
        if (! $exp instanceof DateTimeImmutable) {
1✔
251
            return null;
×
252
        }
253

254
        return max(0, $exp->getTimestamp() - time());
1✔
255
    }
256

257
    /**
258
     * Inspect claims without validation.
259
     *
260
     * Logs a warning unless `Config\JWT::$allowUnsafeExtraction === true` so accidental
261
     * production usage shows up in logs.
262
     *
263
     * @return array<string, mixed>|null Null when the token cannot be parsed.
264
     */
265
    public function extractClaimsUnsafe(string $token): ?array
266
    {
267
        if (! $this->config->allowUnsafeExtraction) {
2✔
268
            $this->logUnsafeExtractionWarning();
×
269
        }
270

271
        $parsed = $this->parseWithoutValidation($token);
2✔
272

273
        return $parsed?->claims()->all();
2✔
274
    }
275

276
    private function parseWithoutValidation(string $token): ?Plain
277
    {
278
        try {
279
            $parsed = $this->buildConfiguration()->parser()->parse($token);
6✔
280
        } catch (Throwable) {
3✔
281
            return null;
3✔
282
        }
283

284
        return $parsed instanceof Plain ? $parsed : null;
3✔
285
    }
286

287
    private function buildConfiguration(): Configuration
288
    {
289
        return match ($this->config->algorithmType) {
42✔
290
            'symmetric'  => $this->buildSymmetricConfiguration(),
38✔
291
            'asymmetric' => $this->buildAsymmetricConfiguration(),
3✔
292
            default      => throw JWTConfigurationException::invalidAlgorithmType($this->config->algorithmType),
40✔
293
        };
42✔
294
    }
295

296
    private function buildSymmetricConfiguration(): Configuration
297
    {
298
        if ($this->config->signer === null || $this->config->signer === '') {
38✔
299
            throw JWTConfigurationException::missingSigner();
2✔
300
        }
301

302
        $signerClass = $this->config->algorithm;
36✔
303

304
        return Configuration::forSymmetricSigner(
36✔
305
            new $signerClass(),
36✔
306
            InMemory::base64Encoded($this->config->signer),
36✔
307
        );
36✔
308
    }
309

310
    private function buildAsymmetricConfiguration(): Configuration
311
    {
312
        if ($this->config->signingKey === null || $this->config->signingKey === '') {
3✔
313
            throw JWTConfigurationException::missingClaim('signingKey');
×
314
        }
315
        if ($this->config->verifyingKey === null || $this->config->verifyingKey === '') {
3✔
316
            throw JWTConfigurationException::missingClaim('verifyingKey');
×
317
        }
318

319
        $signerClass = $this->config->algorithm;
3✔
320

321
        return Configuration::forAsymmetricSigner(
3✔
322
            new $signerClass(),
3✔
323
            $this->loadKey($this->config->signingKey, $this->config->passphrase ?? ''),
3✔
324
            $this->loadKey($this->config->verifyingKey, ''),
3✔
325
        );
3✔
326
    }
327

328
    private function loadKey(string $reference, string $passphrase): Key
329
    {
330
        if (str_starts_with($reference, 'file://')) {
3✔
331
            return InMemory::file(substr($reference, 7), $passphrase);
×
332
        }
333

334
        if (! str_contains($reference, "\n") && file_exists($reference) && is_readable($reference)) {
3✔
335
            return InMemory::file($reference, $passphrase);
3✔
336
        }
337

338
        return InMemory::plainText($reference, $passphrase);
×
339
    }
340

341
    /**
342
     * @return list<Constraint>
343
     */
344
    private function buildValidationConstraints(): array
345
    {
346
        $constraints = [];
28✔
347

348
        foreach ($this->config->validateClaims as $name) {
28✔
349
            $constraints[] = match ($name) {
28✔
350
                'SignedWith' => $this->buildSignedWithConstraint(),
28✔
351
                'IssuedBy'   => new IssuedBy(
25✔
352
                    $this->config->issuer ?? throw JWTConfigurationException::missingClaim('issuer'),
25✔
353
                ),
25✔
354
                'IdentifiedBy' => new IdentifiedBy(
25✔
355
                    $this->config->identifier ?? throw JWTConfigurationException::missingClaim('identifier'),
25✔
356
                ),
25✔
357
                'PermittedFor' => new PermittedFor(
25✔
358
                    $this->config->audience ?? throw JWTConfigurationException::missingClaim('audience'),
25✔
359
                ),
25✔
360
                'StrictValidAt' => new StrictValidAt(
1✔
361
                    SystemClock::fromUTC(),
1✔
362
                    $this->buildLeewayInterval(),
1✔
363
                ),
1✔
364
                'ValidAt', 'LooseValidAt' => new LooseValidAt(
26✔
365
                    SystemClock::fromUTC(),
26✔
366
                    $this->buildLeewayInterval(),
26✔
367
                ),
26✔
368
                default => throw JWTConfigurationException::unknownConstraint($name),
1✔
369
            };
28✔
370
        }
371

372
        return $constraints;
27✔
373
    }
374

375
    private function buildSignedWithConstraint(): SignedWith
376
    {
377
        $configuration = $this->buildConfiguration();
28✔
378

379
        return new SignedWith($configuration->signer(), $configuration->verificationKey());
28✔
380
    }
381

382
    private function buildLeewayInterval(): ?DateInterval
383
    {
384
        if ($this->leewaySeconds === null || $this->leewaySeconds <= 0) {
27✔
385
            return null;
27✔
386
        }
387

388
        return new DateInterval('PT' . $this->leewaySeconds . 'S');
×
389
    }
390

391
    private function logUnsafeExtractionWarning(): void
392
    {
393
        if (! function_exists('log_message')) {
×
394
            return;
×
395
        }
396

397
        log_message(
×
398
            'warning',
×
399
            'Daycry\\JWT\\JWT::extractClaimsUnsafe() was called without setting '
×
400
            . 'Config\\JWT::$allowUnsafeExtraction = true. The token has not been validated.',
×
401
        );
×
402
    }
403
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc