Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.83% covered (success)
96.83%
61 / 63
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jwt
96.83% covered (success)
96.83%
61 / 63
80.00% covered (success)
80.00%
8 / 10
38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setAudience
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setIssuer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLeeway
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 authenticate
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
13
 claimsAreValid
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
10
 base64UrlDecode
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 esSignatureToDer
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 derInteger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 derLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Auth;
16
17use Pop\Crypt\Signature\Verifier;
18
19/**
20 * Jwt auth class
21 *
22 * @category   Pop
23 * @package    Pop\Auth
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    5.0.0
28 */
29class Jwt extends AbstractAuth
30{
31
32    use AdapterUserTrait;
33
34    /**
35     * Supported algorithms
36     * @var array
37     */
38    const ALGORITHMS = ['HS256', 'RS256', 'ES256'];
39
40    /**
41     * Expected byte length of a raw ES256 (P-256) JOSE signature: 32-byte R + 32-byte S (RFC 7518 ยง3.4)
42     * @var int
43     */
44    const ES256_SIGNATURE_LENGTH = 64;
45
46    /**
47     * Algorithm
48     * @var string
49     */
50    protected string $algorithm;
51
52    /**
53     * Key (shared secret for HS256, PEM public key for RS256/ES256)
54     * @var string
55     */
56    protected string $key;
57
58    /**
59     * Audience to validate the 'aud' claim against
60     * @var ?string
61     */
62    protected ?string $audience = null;
63
64    /**
65     * Issuer to validate the 'iss' claim against
66     * @var ?string
67     */
68    protected ?string $issuer = null;
69
70    /**
71     * Leeway (in seconds) allowed for exp/nbf claim comparisons
72     * @var int
73     */
74    protected int $leeway = 0;
75
76    /**
77     * Constructor
78     *
79     * Instantiate the Jwt auth adapter object
80     *
81     * @param  string $algorithm
82     * @param  string $key
83     * @throws Exception
84     */
85    public function __construct(string $algorithm, string $key)
86    {
87        if (!in_array($algorithm, self::ALGORITHMS, true)) {
88            throw new Exception("The algorithm '" . $algorithm . "' is not supported.");
89        }
90
91        $this->algorithm = $algorithm;
92        $this->key        = $key;
93    }
94
95    /**
96     * Set the audience to validate the 'aud' claim against
97     *
98     * @param  string $audience
99     * @return Jwt
100     */
101    public function setAudience(string $audience): Jwt
102    {
103        $this->audience = $audience;
104        return $this;
105    }
106
107    /**
108     * Set the issuer to validate the 'iss' claim against
109     *
110     * @param  string $issuer
111     * @return Jwt
112     */
113    public function setIssuer(string $issuer): Jwt
114    {
115        $this->issuer = $issuer;
116        return $this;
117    }
118
119    /**
120     * Set the leeway (in seconds) allowed for exp/nbf claim comparisons
121     *
122     * @param  int $seconds
123     * @return Jwt
124     */
125    public function setLeeway(int $seconds): Jwt
126    {
127        $this->leeway = $seconds;
128        return $this;
129    }
130
131    /**
132     * Method to authenticate
133     *
134     * @param  string  $token
135     * @param  ?string $secondary
136     * @throws Exception
137     * @return int
138     */
139    public function authenticate(string $token, ?string $secondary = null): int
140    {
141        $this->result      = self::NOT_VALID;
142        $this->needsRehash = false;
143        $this->user        = null;
144
145        $segments = explode('.', $token);
146        if (count($segments) !== 3) {
147            return $this->result;
148        }
149
150        [$headerB64, $payloadB64, $signatureB64] = $segments;
151
152        $header  = json_decode(self::base64UrlDecode($headerB64), true);
153        $payload = json_decode(self::base64UrlDecode($payloadB64), true);
154
155        if (!is_array($header) || !is_array($payload) || (($header['alg'] ?? null) !== $this->algorithm)) {
156            return $this->result;
157        }
158
159        $signature    = self::base64UrlDecode($signatureB64);
160        $signingInput = $headerB64 . '.' . $payloadB64;
161
162        try {
163            $verified = match ($this->algorithm) {
164                'HS256' => Verifier::hmac($signingInput, $signature, $this->key),
165                'RS256' => Verifier::rsa($signingInput, $signature, $this->key),
166                'ES256' => (strlen($signature) === self::ES256_SIGNATURE_LENGTH)
167                    && Verifier::ec($signingInput, self::esSignatureToDer($signature), $this->key),
168                default => throw new Exception('Unsupported algorithm.'),
169            };
170        } catch (\Throwable $e) {
171            throw new Exception('Unable to verify the token signature: ' . $e->getMessage(), 0, $e);
172        }
173
174        if (!$verified || !$this->claimsAreValid($payload)) {
175            return $this->result;
176        }
177
178        $this->user   = $payload;
179        $this->result = self::VALID;
180
181        return $this->result;
182    }
183
184    /**
185     * Determine if the token's claims (exp/nbf/aud/iss) are valid
186     *
187     * @param  array $payload
188     * @return bool
189     */
190    protected function claimsAreValid(array $payload): bool
191    {
192        $now = time();
193
194        if (isset($payload['exp']) && ($now > ((int)$payload['exp'] + $this->leeway))) {
195            return false;
196        }
197
198        if (isset($payload['nbf']) && ($now < ((int)$payload['nbf'] - $this->leeway))) {
199            return false;
200        }
201
202        if ($this->audience !== null) {
203            $aud = $payload['aud'] ?? null;
204            if (!in_array($this->audience, is_array($aud) ? $aud : [$aud], true)) {
205                return false;
206            }
207        }
208
209        if (($this->issuer !== null) && (($payload['iss'] ?? null) !== $this->issuer)) {
210            return false;
211        }
212
213        return true;
214    }
215
216    /**
217     * Base64url-decode a JWT segment
218     *
219     * @param  string $data
220     * @return string
221     */
222    protected static function base64UrlDecode(string $data): string
223    {
224        $padded  = str_pad($data, strlen($data) + ((4 - (strlen($data) % 4)) % 4), '=');
225        $decoded = base64_decode(strtr($padded, '-_', '+/'), true);
226        return ($decoded === false) ? '' : $decoded;
227    }
228
229    /**
230     * Convert a JOSE ES256 raw R||S signature into the DER-encoded ASN.1 sequence openssl_verify() expects
231     *
232     * @param  string $signature
233     * @return string
234     */
235    protected static function esSignatureToDer(string $signature): string
236    {
237        $length = (int)(strlen($signature) / 2);
238        $r      = ltrim(substr($signature, 0, $length), "\x00");
239        $s      = ltrim(substr($signature, $length), "\x00");
240
241        if (($r === '') || ((ord($r[0]) & 0x80) !== 0)) {
242            $r = "\x00" . $r;
243        }
244        if (($s === '') || ((ord($s[0]) & 0x80) !== 0)) {
245            $s = "\x00" . $s;
246        }
247
248        $sequence = self::derInteger($r) . self::derInteger($s);
249
250        return "\x30" . self::derLength(strlen($sequence)) . $sequence;
251    }
252
253    /**
254     * DER-encode a single ASN.1 INTEGER
255     *
256     * @param  string $bytes
257     * @return string
258     */
259    protected static function derInteger(string $bytes): string
260    {
261        return "\x02" . self::derLength(strlen($bytes)) . $bytes;
262    }
263
264    /**
265     * DER-encode a length value
266     *
267     * @param  int $length
268     * @return string
269     */
270    protected static function derLength(int $length): string
271    {
272        return ($length < 128) ? chr($length) : chr(0x81) . chr($length);
273    }
274
275}