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

daycry / jwt / 27087995322

06 Jun 2026 08:38PM UTC coverage: 89.775% (+1.1%) from 88.718%
27087995322

push

github

daycry
chore(psalm): baseline framework-helper false positives in ApiCustomisersTest

config() (CI4 helper) and the setUp() MissingOverrideAttribute are already
baselined for the other test files; add the new test file's equivalent entries
so `composer qa` (psalm) is green.

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

439 of 489 relevant lines covered (89.78%)

16.16 hits per line

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

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

3
declare(strict_types=1);
4

5
namespace Daycry\JWT;
6

7
use DateInterval;
8
use DateTimeImmutable;
9
use Daycry\JWT\Config\JWT as JWTConfig;
10
use Daycry\JWT\Enums\AlgorithmType;
11
use Daycry\JWT\Enums\ConstraintName;
12
use Daycry\JWT\Exceptions\InvalidTokenException;
13
use Daycry\JWT\Exceptions\JWTConfigurationException;
14
use InvalidArgumentException;
15
use Lcobucci\Clock\SystemClock;
16
use Lcobucci\JWT\Builder;
17
use Lcobucci\JWT\Configuration;
18
use Lcobucci\JWT\Encoding\JoseEncoder;
19
use Lcobucci\JWT\Signer;
20
use Lcobucci\JWT\Signer\Hmac;
21
use Lcobucci\JWT\Signer\Key;
22
use Lcobucci\JWT\Signer\Key\InMemory;
23
use Lcobucci\JWT\Signer\OpenSSL;
24
use Lcobucci\JWT\Token\Parser;
25
use Lcobucci\JWT\Token\Plain;
26
use Lcobucci\JWT\Token\RegisteredClaimGiven;
27
use Lcobucci\JWT\Validation\Constraint;
28
use Lcobucci\JWT\Validation\Constraint\IdentifiedBy;
29
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
30
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
31
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
32
use Lcobucci\JWT\Validation\Constraint\SignedWith;
33
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
34
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
35
use Throwable;
36

37
/**
38
 * Immutable JWT facade over `lcobucci/jwt ^5`.
39
 *
40
 * Construct via the constructor (with an explicit config) or `JWT::for()` and
41
 * configure per-call needs through the `with*()` methods, which return a new
42
 * instance — the original is unchanged.
43
 */
44
final class JWT
45
{
46
    /**
47
     * Claim names the facade manages itself. They cannot be reused as the
48
     * compact-payload claim name (`withParamData()`); `uid` plus the registered
49
     * JWT claims would otherwise be silently shadowed or rejected by lcobucci.
50
     *
51
     * @var list<string>
52
     */
53
    private const RESERVED_CLAIMS = ['uid', 'aud', 'exp', 'jti', 'iat', 'iss', 'nbf', 'sub'];
54

55
    private bool $split = false;
56

57
    /**
58
     * @var non-empty-string
59
     */
60
    private string $paramData = 'data';
61

62
    private ?int $leewaySeconds;
63
    private ?string $expiresAtOverride = null;
64

65
    /**
66
     * @var non-empty-string|null
67
     */
68
    private ?string $issuerOverride = null;
69

70
    /**
71
     * @var non-empty-string|null
72
     */
73
    private ?string $identifierOverride = null;
74

75
    /**
76
     * @var non-empty-string|null
77
     */
78
    private ?string $keyIdOverride = null;
79

80
    /**
81
     * Per-instance audience override. Null means "use the configured audience".
82
     *
83
     * @var non-empty-list<non-empty-string>|null
84
     */
85
    private ?array $audienceOverride = null;
86

87
    /**
88
     * Extra top-level claims to merge into every token (compact or split mode).
89
     *
90
     * @var array<non-empty-string, mixed>
91
     */
92
    private array $extraClaims = [];
93

94
    /**
95
     * Extra JOSE headers to write on every token.
96
     *
97
     * @var array<non-empty-string, mixed>
98
     */
99
    private array $extraHeaders = [];
100

101
    /**
102
     * Lazily-built, memoized signer + key configuration. Stateless (no clock),
103
     * so it is safe to reuse across calls and to share across `with*()` clones.
104
     */
105
    private ?Configuration $configuration = null;
106

107
    public function __construct(private JWTConfig $config)
108
    {
109
        $this->leewaySeconds = $config->leeway;
111✔
110
    }
111

112
    /**
113
     * Static factory. Falls back to the bound `Config\JWT` when no instance is provided.
114
     */
115
    public static function for(?JWTConfig $config = null): self
116
    {
117
        return new self($config ?? config('JWT'));
2✔
118
    }
119

120
    public function withSplitData(bool $split = true): self
121
    {
122
        $clone        = clone $this;
4✔
123
        $clone->split = $split;
4✔
124

125
        return $clone;
4✔
126
    }
127

128
    public function withParamData(string $name): self
129
    {
130
        if ($name === '') {
5✔
131
            throw new InvalidArgumentException('paramData claim name cannot be empty.');
1✔
132
        }
133
        if (in_array($name, self::RESERVED_CLAIMS, true)) {
4✔
134
            throw new InvalidArgumentException(sprintf(
1✔
135
                'paramData claim name "%s" is reserved by the library; choose another name.',
1✔
136
                $name,
1✔
137
            ));
1✔
138
        }
139
        $clone            = clone $this;
3✔
140
        $clone->paramData = $name;
3✔
141

142
        return $clone;
3✔
143
    }
144

145
    public function withLeeway(?int $seconds): self
146
    {
147
        if ($seconds !== null && $seconds < 0) {
5✔
148
            throw new InvalidArgumentException('Leeway cannot be negative.');
1✔
149
        }
150
        $clone                = clone $this;
4✔
151
        $clone->leewaySeconds = $seconds;
4✔
152

153
        return $clone;
4✔
154
    }
155

156
    /**
157
     * Override the configured `expiresAt` modifier for this instance only.
158
     *
159
     * Enables short-lived access tokens without mutating the shared config:
160
     * `JWT::for()->withExpiresAt('+5 minutes')->encode($data)`.
161
     */
162
    public function withExpiresAt(string $modifier): self
163
    {
164
        if ($modifier === '') {
4✔
165
            throw new InvalidArgumentException('expiresAt modifier cannot be empty.');
1✔
166
        }
167
        $clone                    = clone $this;
3✔
168
        $clone->expiresAtOverride = $modifier;
3✔
169

170
        return $clone;
3✔
171
    }
172

173
    /**
174
     * Override the `iss` claim for this instance only (encode + validate).
175
     */
176
    public function withIssuer(string $issuer): self
177
    {
178
        if ($issuer === '') {
4✔
179
            throw new InvalidArgumentException('issuer cannot be empty.');
1✔
180
        }
181
        $clone                 = clone $this;
3✔
182
        $clone->issuerOverride = $issuer;
3✔
183

184
        return $clone;
3✔
185
    }
186

187
    /**
188
     * Override the `aud` claim(s) for this instance only. Multiple audiences are
189
     * written on encode; validation requires the token to be permitted for the
190
     * first audience.
191
     */
192
    public function withAudience(string ...$audiences): self
193
    {
194
        if ($audiences === []) {
3✔
195
            throw new InvalidArgumentException('At least one audience is required.');
1✔
196
        }
197
        $validated = [];
2✔
198

199
        foreach ($audiences as $audience) {
2✔
200
            if ($audience === '') {
2✔
201
                throw new InvalidArgumentException('audience cannot be empty.');
×
202
            }
203
            $validated[] = $audience;
2✔
204
        }
205
        $clone                   = clone $this;
2✔
206
        $clone->audienceOverride = $validated;
2✔
207

208
        return $clone;
2✔
209
    }
210

211
    /**
212
     * Override the `jti` (identifier) claim for this instance only.
213
     */
214
    public function withIdentifier(string $identifier): self
215
    {
216
        if ($identifier === '') {
2✔
217
            throw new InvalidArgumentException('identifier cannot be empty.');
×
218
        }
219
        $clone                     = clone $this;
2✔
220
        $clone->identifierOverride = $identifier;
2✔
221

222
        return $clone;
2✔
223
    }
224

225
    /**
226
     * Override the `kid` header for this instance only (key rotation).
227
     */
228
    public function withKeyId(string $keyId): self
229
    {
230
        if ($keyId === '') {
3✔
231
            throw new InvalidArgumentException('keyId cannot be empty.');
×
232
        }
233
        $clone                = clone $this;
3✔
234
        $clone->keyIdOverride = $keyId;
3✔
235

236
        return $clone;
3✔
237
    }
238

239
    /**
240
     * Add a custom JOSE header to issued tokens. The internal `cty` header
241
     * (written for compact JSON payloads) cannot be overridden.
242
     */
243
    public function withHeader(string $name, mixed $value): self
244
    {
245
        if ($name === '' || $name === 'cty') {
3✔
246
            throw new InvalidArgumentException(sprintf('Header name "%s" is reserved or empty.', $name));
1✔
247
        }
248
        $clone                      = clone $this;
2✔
249
        $clone->extraHeaders        = $this->extraHeaders;
2✔
250
        $clone->extraHeaders[$name] = $value;
2✔
251

252
        return $clone;
2✔
253
    }
254

255
    /**
256
     * Add several custom JOSE headers at once.
257
     *
258
     * @param array<string, mixed> $headers
259
     */
260
    public function withHeaders(array $headers): self
261
    {
262
        $clone = $this;
×
263

264
        foreach ($headers as $name => $value) {
×
265
            $clone = $clone->withHeader($name, $value);
×
266
        }
267

268
        return $clone;
×
269
    }
270

271
    /**
272
     * Add custom top-level claims to issued tokens. Reserved claim names (`uid`
273
     * and the registered JWT claims) are rejected to surface collisions early.
274
     *
275
     * @param array<string, mixed> $claims
276
     */
277
    public function withClaims(array $claims): self
278
    {
279
        $clone              = clone $this;
3✔
280
        $clone->extraClaims = $this->extraClaims;
3✔
281

282
        foreach ($claims as $name => $value) {
3✔
283
            if ($name === '' || in_array($name, self::RESERVED_CLAIMS, true)) {
3✔
284
                throw new InvalidArgumentException(sprintf('Claim name "%s" is reserved or empty.', $name));
1✔
285
            }
286
            $clone->extraClaims[$name] = $value;
2✔
287
        }
288

289
        return $clone;
2✔
290
    }
291

292
    public function getParamData(): string
293
    {
294
        return $this->paramData;
2✔
295
    }
296

297
    public function isSplitData(): bool
298
    {
299
        return $this->split;
1✔
300
    }
301

302
    public function encode(mixed $data, int|string|null $uid = null): string
303
    {
304
        $now           = new DateTimeImmutable();
88✔
305
        $configuration = $this->buildConfiguration();
88✔
306

307
        $issuer     = $this->resolveIssuer();
81✔
308
        $audiences  = $this->resolveAudiences();
79✔
309
        $identifier = $this->resolveIdentifier();
78✔
310

311
        $builder = $this->applyPayload($configuration->builder(), $data);
77✔
312

313
        $resolvedUid = $uid ?? $this->config->uid;
76✔
314
        if ($resolvedUid !== null && $resolvedUid !== '') {
76✔
315
            if ($this->split && $this->dataHasUidKey($data)) {
64✔
316
                // The framework-owned uid claim overwrites a same-named split key.
317
                $this->logUidCollisionWarning();
×
318
            }
319
            $builder = $builder->withClaim('uid', $resolvedUid);
64✔
320
        }
321

322
        foreach ($this->extraClaims as $name => $value) {
76✔
323
            $builder = $builder->withClaim($name, $value);
1✔
324
        }
325

326
        foreach ($this->extraHeaders as $name => $value) {
76✔
327
            $builder = $builder->withHeader($name, $value);
1✔
328
        }
329

330
        $keyId = $this->keyIdOverride ?? $this->config->keyId;
76✔
331
        if ($keyId !== null && $keyId !== '') {
76✔
332
            $builder = $builder->withHeader('kid', $keyId);
4✔
333
        }
334

335
        $notBefore = $this->applyModifier($now, $this->config->canOnlyBeUsedAfter, 'canOnlyBeUsedAfter');
76✔
336
        if ($notBefore > $now) {
75✔
337
            // Documented behaviour: clamp a future "not before" back to issuance time
338
            // so freshly-minted tokens are immediately usable.
339
            $notBefore = $now;
1✔
340
        }
341

342
        $expiresAt = $this->applyModifier(
75✔
343
            $now,
75✔
344
            $this->expiresAtOverride ?? $this->config->expiresAt,
75✔
345
            'expiresAt',
75✔
346
        );
75✔
347

348
        $token = $builder
74✔
349
            ->issuedBy($issuer)
74✔
350
            ->permittedFor(...$audiences)
74✔
351
            ->identifiedBy($identifier)
74✔
352
            ->issuedAt($now)
74✔
353
            ->canOnlyBeUsedAfter($notBefore)
74✔
354
            ->expiresAt($expiresAt)
74✔
355
            ->getToken($configuration->signer(), $configuration->signingKey());
74✔
356

357
        return $token->toString();
71✔
358
    }
359

360
    /**
361
     * Decode and validate. Always throws on parse errors and validation failures.
362
     *
363
     * @throws InvalidTokenException       When the token cannot be parsed.
364
     * @throws JWTConfigurationException   When the library is misconfigured (bad signer/keys/constraints).
365
     * @throws RequiredConstraintsViolated When a constraint fails.
366
     */
367
    public function decode(string $token): Plain
368
    {
369
        $configuration = $this->buildConfiguration();
70✔
370

371
        try {
372
            $parsed = $configuration->parser()->parse($token);
70✔
373
        } catch (Throwable $e) {
4✔
374
            throw new InvalidTokenException('Token is malformed: ' . $e->getMessage(), 0, $e);
4✔
375
        }
376

377
        if (! $parsed instanceof Plain) {
67✔
378
            throw new InvalidTokenException('Only Plain tokens are supported.');
×
379
        }
380

381
        if ($this->config->validate) {
67✔
382
            $kid = $parsed->headers()->get('kid');
65✔
383
            $configuration->validator()->assert(
65✔
384
                $parsed,
65✔
385
                ...$this->buildValidationConstraints(is_string($kid) ? $kid : null),
65✔
386
            );
65✔
387
        } else {
388
            $this->logValidationDisabledWarning();
2✔
389
        }
390

391
        return $parsed;
44✔
392
    }
393

394
    /**
395
     * Decode + validate without throwing on *token* failures (malformed token or
396
     * a failed constraint) — those return null. A `JWTConfigurationException`
397
     * (e.g. a misconfigured `$validateClaims`) is deliberately NOT swallowed: a
398
     * library misconfiguration must surface loudly instead of masquerading as an
399
     * invalid token, which would otherwise make every valid token look invalid.
400
     *
401
     * @throws JWTConfigurationException When the library itself is misconfigured.
402
     */
403
    public function tryDecode(string $token): ?Plain
404
    {
405
        try {
406
            return $this->decode($token);
12✔
407
        } catch (InvalidTokenException|RequiredConstraintsViolated) {
10✔
408
            return null;
8✔
409
        }
410
    }
411

412
    /**
413
     * Validate the token and return the original payload value.
414
     *
415
     * Symmetric to `encode()`:
416
     *   - Scalar / split-mode tokens → raw claim value.
417
     *   - Compact-mode tokens (header `cty=json`) → `json_decode`d back into an array.
418
     *
419
     * @throws InvalidTokenException
420
     * @throws RequiredConstraintsViolated
421
     */
422
    public function getPayload(string $token): mixed
423
    {
424
        $parsed = $this->decode($token);
5✔
425
        $value  = $parsed->claims()->get($this->paramData);
4✔
426

427
        if ($parsed->headers()->get('cty') === 'json' && is_string($value)) {
4✔
428
            return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
2✔
429
        }
430

431
        return $value;
2✔
432
    }
433

434
    /**
435
     * Validate the token and return all of its claims. The validated counterpart
436
     * of `extractClaimsUnsafe()`.
437
     *
438
     * @return array<string, mixed>
439
     *
440
     * @throws InvalidTokenException
441
     * @throws JWTConfigurationException
442
     * @throws RequiredConstraintsViolated
443
     */
444
    public function getClaims(string $token): array
445
    {
446
        return $this->decode($token)->claims()->all();
2✔
447
    }
448

449
    /**
450
     * Validate the token and return a single claim value (null when absent).
451
     *
452
     * @throws InvalidTokenException
453
     * @throws JWTConfigurationException
454
     * @throws RequiredConstraintsViolated
455
     */
456
    public function getClaim(string $token, string $name): mixed
457
    {
458
        if ($name === '') {
1✔
459
            return null;
×
460
        }
461

462
        return $this->decode($token)->claims()->get($name);
1✔
463
    }
464

465
    /**
466
     * Full validity check: verifies the signature AND every configured claim.
467
     *
468
     * @throws JWTConfigurationException When the library is misconfigured.
469
     */
470
    public function isValid(string $token): bool
471
    {
472
        return $this->tryDecode($token) instanceof Plain;
5✔
473
    }
474

475
    /**
476
     * Cheap pre-flight check of the `exp` claim.
477
     *
478
     * WARNING: this parses the token WITHOUT verifying its signature, so the
479
     * `exp` value is attacker-controlled. Never use the result to drive an
480
     * authentication or authorization decision — use `decode()`/`tryDecode()`/
481
     * `isValid()` for that. Returns true for a token that cannot be parsed or
482
     * has no `exp` claim is treated as not-expired (false).
483
     */
484
    public function isExpired(string $token): bool
485
    {
486
        $parsed = $this->parseWithoutValidation($token);
5✔
487
        if (! $parsed instanceof Plain) {
5✔
488
            return true;
1✔
489
        }
490

491
        $exp = $parsed->claims()->get('exp');
4✔
492
        if (! $exp instanceof DateTimeImmutable) {
4✔
493
            return false;
1✔
494
        }
495

496
        return $exp->getTimestamp() < time();
3✔
497
    }
498

499
    /**
500
     * Seconds until the `exp` claim, or null when unknown.
501
     *
502
     * WARNING: like `isExpired()`, this parses WITHOUT verifying the signature;
503
     * the returned TTL is attacker-controlled and must not gate access decisions.
504
     */
505
    public function getTimeToExpiry(string $token): ?int
506
    {
507
        $parsed = $this->parseWithoutValidation($token);
6✔
508
        if (! $parsed instanceof Plain) {
6✔
509
            return null;
1✔
510
        }
511

512
        $exp = $parsed->claims()->get('exp');
5✔
513
        if (! $exp instanceof DateTimeImmutable) {
5✔
514
            return null;
1✔
515
        }
516

517
        return max(0, $exp->getTimestamp() - time());
4✔
518
    }
519

520
    /**
521
     * Inspect claims without validation.
522
     *
523
     * Logs a warning unless `Config\JWT::$allowUnsafeExtraction === true` so accidental
524
     * production usage shows up in logs.
525
     *
526
     * @return array<string, mixed>|null Null when the token cannot be parsed.
527
     */
528
    public function extractClaimsUnsafe(string $token): ?array
529
    {
530
        if (! $this->config->allowUnsafeExtraction) {
4✔
531
            $this->logUnsafeExtractionWarning();
1✔
532
        }
533

534
        $parsed = $this->parseWithoutValidation($token);
4✔
535

536
        return $parsed?->claims()->all();
4✔
537
    }
538

539
    private function parseWithoutValidation(string $token): ?Plain
540
    {
541
        if ($token === '') {
13✔
542
            return null;
×
543
        }
544

545
        // Use a key-less parser: inspection (isExpired/getTimeToExpiry/
546
        // extractClaimsUnsafe) needs no signing config, so a configuration error
547
        // must not be reinterpreted as "expired"/null. Only genuinely malformed
548
        // input is swallowed here.
549
        try {
550
            $parsed = (new Parser(new JoseEncoder()))->parse($token);
13✔
551
        } catch (Throwable) {
3✔
552
            return null;
3✔
553
        }
554

555
        return $parsed instanceof Plain ? $parsed : null;
10✔
556
    }
557

558
    /**
559
     * Write the user payload onto the builder according to the split/compact mode.
560
     */
561
    private function applyPayload(Builder $builder, mixed $data): Builder
562
    {
563
        if (is_array($data) || is_object($data)) {
77✔
564
            if ($this->split) {
14✔
565
                return $this->applySplitPayload($builder, $data);
3✔
566
            }
567

568
            // Compact mode: nest the payload as JSON under the paramData claim and
569
            // tag cty=json so getPayload() can auto-decode it.
570
            return $builder
11✔
571
                ->withClaim($this->paramData, json_encode($data, JSON_THROW_ON_ERROR))
11✔
572
                ->withHeader('cty', 'json');
11✔
573
        }
574

575
        return $builder->withClaim($this->paramData, $data);
63✔
576
    }
577

578
    /**
579
     * @param array<array-key, mixed>|object $data
580
     */
581
    private function applySplitPayload(Builder $builder, array|object $data): Builder
582
    {
583
        /** @var iterable<string, mixed> $iterable */
584
        $iterable = is_object($data) ? get_object_vars($data) : $data;
3✔
585

586
        foreach ($iterable as $key => $value) {
3✔
587
            $claimName = (string) $key;
3✔
588

589
            try {
590
                $builder = $builder->withClaim($claimName, $value);
3✔
591
            } catch (RegisteredClaimGiven $e) {
1✔
592
                // Turn lcobucci's raw error into a library exception that names the
593
                // offending key and points to compact mode.
594
                throw JWTConfigurationException::reservedClaimInSplitMode($claimName, $e);
1✔
595
            }
596
        }
597

598
        return $builder;
2✔
599
    }
600

601
    private function dataHasUidKey(mixed $data): bool
602
    {
603
        if (is_array($data)) {
2✔
604
            return array_key_exists('uid', $data);
2✔
605
        }
606

607
        if (is_object($data)) {
×
608
            return array_key_exists('uid', get_object_vars($data));
×
609
        }
610

611
        return false;
×
612
    }
613

614
    private function buildConfiguration(): Configuration
615
    {
616
        // Memoize the stateless signer + key configuration for this immutable
617
        // instance: decode() builds it once and the SignedWith constraint reuses
618
        // the same instance, so we no longer rebuild the Configuration twice per
619
        // call nor re-read the asymmetric PEM from disk. The misconfiguration
620
        // guards (missingSigner / algorithmMismatch / missingClaim) still run on
621
        // the first build. This caches only the key material — the time-dependent
622
        // LooseValidAt / StrictValidAt constraints are deliberately rebuilt per
623
        // call (see buildValidationConstraints()).
624
        return $this->configuration ??= match (AlgorithmType::tryFrom($this->config->algorithmType)) {
95✔
625
            AlgorithmType::Symmetric  => $this->buildSymmetricConfiguration(),
95✔
626
            AlgorithmType::Asymmetric => $this->buildAsymmetricConfiguration(),
15✔
627
            default                   => throw JWTConfigurationException::invalidAlgorithmType($this->config->algorithmType),
1✔
628
        };
95✔
629
    }
630

631
    private function buildSymmetricConfiguration(): Configuration
632
    {
633
        $secret = $this->config->signer;
80✔
634
        if ($secret === null || $secret === '') {
80✔
635
            throw JWTConfigurationException::missingSigner();
2✔
636
        }
637

638
        $signer = $this->buildSigner();
78✔
639
        if (! $signer instanceof Hmac) {
78✔
640
            throw JWTConfigurationException::algorithmMismatch('symmetric', $this->config->algorithm);
1✔
641
        }
642

643
        return Configuration::forSymmetricSigner(
77✔
644
            $signer,
77✔
645
            InMemory::base64Encoded($secret),
77✔
646
        );
77✔
647
    }
648

649
    private function buildAsymmetricConfiguration(): Configuration
650
    {
651
        $signingKey   = $this->requireClaim($this->config->signingKey, 'signingKey');
14✔
652
        $verifyingKey = $this->requireClaim($this->config->verifyingKey, 'verifyingKey');
13✔
653

654
        $signer = $this->buildSigner();
12✔
655
        if (! $signer instanceof OpenSSL) {
12✔
656
            throw JWTConfigurationException::algorithmMismatch('asymmetric', $this->config->algorithm);
1✔
657
        }
658

659
        return Configuration::forAsymmetricSigner(
11✔
660
            $signer,
11✔
661
            $this->loadKey($signingKey, $this->config->passphrase ?? ''),
11✔
662
            $this->loadKey($verifyingKey, ''),
11✔
663
        );
11✔
664
    }
665

666
    private function buildSigner(): Signer
667
    {
668
        $signerClass = $this->config->algorithm;
90✔
669

670
        return new $signerClass();
90✔
671
    }
672

673
    private function loadKey(string $reference, string $passphrase): Key
674
    {
675
        if (str_starts_with($reference, 'file://')) {
11✔
676
            return InMemory::file(substr($reference, 7), $passphrase);
1✔
677
        }
678

679
        if (! str_contains($reference, "\n") && file_exists($reference) && is_readable($reference)) {
10✔
680
            return InMemory::file($reference, $passphrase);
9✔
681
        }
682

683
        return InMemory::plainText($reference, $passphrase);
2✔
684
    }
685

686
    /**
687
     * @return list<Constraint>
688
     */
689
    private function buildValidationConstraints(?string $kid = null): array
690
    {
691
        if (! in_array(ConstraintName::SignedWith->value, $this->config->validateClaims, true)) {
65✔
692
            throw JWTConfigurationException::missingSignatureConstraint();
2✔
693
        }
694

695
        $constraints = [];
63✔
696

697
        foreach ($this->config->validateClaims as $name) {
63✔
698
            $constraint = ConstraintName::fromName($name)
63✔
699
                ?? throw JWTConfigurationException::unknownConstraint($name);
2✔
700

701
            $constraints[] = match ($constraint) {
63✔
702
                ConstraintName::SignedWith    => $this->buildSignedWithConstraint($kid),
63✔
703
                ConstraintName::IssuedBy      => new IssuedBy($this->resolveIssuer()),
61✔
704
                ConstraintName::IdentifiedBy  => new IdentifiedBy($this->resolveIdentifier()),
61✔
705
                ConstraintName::PermittedFor  => new PermittedFor($this->resolveAudiences()[0]),
61✔
706
                ConstraintName::StrictValidAt => new StrictValidAt(
61✔
707
                    SystemClock::fromUTC(),
61✔
708
                    $this->buildLeewayInterval(),
61✔
709
                ),
61✔
710
                ConstraintName::LooseValidAt => new LooseValidAt(
59✔
711
                    SystemClock::fromUTC(),
59✔
712
                    $this->buildLeewayInterval(),
59✔
713
                ),
59✔
714
            };
63✔
715
        }
716

717
        return $constraints;
61✔
718
    }
719

720
    private function buildSignedWithConstraint(?string $kid = null): SignedWith
721
    {
722
        $configuration = $this->buildConfiguration();
63✔
723

724
        // Key rotation: a token's `kid` header selects the matching verification
725
        // key from Config\JWT::$verifyingKeys. The configured signer (algorithm)
726
        // is always used, so an attacker-chosen kid can never downgrade it.
727
        $verificationKey = $this->resolveVerificationKey($kid) ?? $configuration->verificationKey();
63✔
728

729
        return new SignedWith($configuration->signer(), $verificationKey);
63✔
730
    }
731

732
    private function resolveVerificationKey(?string $kid): ?Key
733
    {
734
        if ($kid === null || $this->config->verifyingKeys === []) {
63✔
735
            return null;
60✔
736
        }
737

738
        $reference = $this->config->verifyingKeys[$kid] ?? null;
3✔
739
        if (! is_string($reference) || $reference === '') {
3✔
740
            return null;
1✔
741
        }
742

743
        // Asymmetric: PEM contents or path. Symmetric: base64-encoded secret.
744
        if (AlgorithmType::tryFrom($this->config->algorithmType) === AlgorithmType::Asymmetric) {
2✔
745
            return $this->loadKey($reference, '');
1✔
746
        }
747

748
        return InMemory::base64Encoded($reference);
1✔
749
    }
750

751
    /**
752
     * @return non-empty-string
753
     */
754
    private function resolveIssuer(): string
755
    {
756
        return $this->issuerOverride ?? $this->requireClaim($this->config->issuer, 'issuer');
83✔
757
    }
758

759
    /**
760
     * @return non-empty-string
761
     */
762
    private function resolveIdentifier(): string
763
    {
764
        return $this->identifierOverride ?? $this->requireClaim($this->config->identifier, 'identifier');
80✔
765
    }
766

767
    /**
768
     * @return non-empty-list<non-empty-string>
769
     */
770
    private function resolveAudiences(): array
771
    {
772
        return $this->audienceOverride ?? [$this->requireClaim($this->config->audience, 'audience')];
81✔
773
    }
774

775
    /**
776
     * Resolve a required string claim, rejecting both `null` and `''`.
777
     *
778
     * @return non-empty-string
779
     */
780
    private function requireClaim(?string $value, string $name): string
781
    {
782
        if ($value === null || $value === '') {
86✔
783
            throw JWTConfigurationException::missingClaim($name);
6✔
784
        }
785

786
        return $value;
83✔
787
    }
788

789
    /**
790
     * Apply a `DateTimeImmutable::modify()` modifier, normalising the cross-version
791
     * failure modes (PHP < 8.3 returns `false`, PHP >= 8.3 throws) into a single,
792
     * descriptive exception. Both `canOnlyBeUsedAfter` and `expiresAt` go through here
793
     * so an invalid modifier fails loudly and consistently.
794
     */
795
    private function applyModifier(DateTimeImmutable $base, string $modifier, string $name): DateTimeImmutable
796
    {
797
        try {
798
            $result = $base->modify($modifier);
76✔
799
        } catch (Throwable $e) {
2✔
800
            throw $this->invalidModifier($name, $modifier, $e);
2✔
801
        }
802

803
        // PHP < 8.3 returns false instead of throwing on an invalid modifier.
804
        if ($result === false) {
75✔
805
            throw $this->invalidModifier($name, $modifier);
×
806
        }
807

808
        return $result;
75✔
809
    }
810

811
    private function invalidModifier(string $name, string $modifier, ?Throwable $previous = null): InvalidArgumentException
812
    {
813
        return new InvalidArgumentException(
2✔
814
            "Config::\${$name} is not a valid DateTimeImmutable modifier: \"{$modifier}\".",
2✔
815
            0,
2✔
816
            $previous,
2✔
817
        );
2✔
818
    }
819

820
    private function buildLeewayInterval(): ?DateInterval
821
    {
822
        if ($this->leewaySeconds === null || $this->leewaySeconds <= 0) {
61✔
823
            return null;
59✔
824
        }
825

826
        return new DateInterval('PT' . $this->leewaySeconds . 'S');
3✔
827
    }
828

829
    private function logUidCollisionWarning(): void
830
    {
831
        $this->logWarning(
×
832
            'Daycry\\JWT\\JWT::encode() in split mode: the framework "uid" claim '
×
833
            . 'overwrote a same-named key in the payload data.',
×
834
        );
×
835
    }
836

837
    private function logUnsafeExtractionWarning(): void
838
    {
839
        $this->logWarning(
1✔
840
            'Daycry\\JWT\\JWT::extractClaimsUnsafe() was called without setting '
1✔
841
            . 'Config\\JWT::$allowUnsafeExtraction = true. The token has not been validated.',
1✔
842
        );
1✔
843
    }
844

845
    private function logValidationDisabledWarning(): void
846
    {
847
        $this->logWarning(
2✔
848
            'Daycry\\JWT\\JWT::decode() ran with Config\\JWT::$validate = false. '
2✔
849
            . 'The token signature and registered claims were NOT verified.',
2✔
850
        );
2✔
851
    }
852

853
    private function logWarning(string $message): void
854
    {
855
        if (! function_exists('log_message')) {
3✔
856
            return;
×
857
        }
858

859
        log_message('warning', $message);
3✔
860
    }
861
}
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