Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.88% covered (success)
96.88%
31 / 32
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Claims
96.88% covered (success)
96.88%
31 / 32
91.67% covered (success)
91.67%
11 / 12
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 issuer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 subject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 audience
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 expiresAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 notBefore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 issuedAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 time
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 tokenId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 logisticsAgentUri
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 timestamp
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
11.06
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Auth\Jwt;
6
7use DateTimeImmutable;
8use Exception;
9
10/**
11 * The verified payload of a token. ONE Record requires iss, exp and
12 * logistics_agent_uri; everything else is whatever the identity provider put in.
13 */
14final readonly class Claims
15{
16    public const string LOGISTICS_AGENT_URI = 'logistics_agent_uri';
17
18    /**
19     * @param array<string, mixed> $all
20     */
21    public function __construct(public array $all) {}
22
23    public function get(string $name): mixed
24    {
25        return $this->all[$name] ?? null;
26    }
27
28    public function issuer(): ?string
29    {
30        return \is_string($this->all['iss'] ?? null) ? $this->all['iss'] : null;
31    }
32
33    public function subject(): ?string
34    {
35        return \is_string($this->all['sub'] ?? null) ? $this->all['sub'] : null;
36    }
37
38    /**
39     * @return list<string>
40     */
41    public function audience(): array
42    {
43        $aud = $this->all['aud'] ?? null;
44        if (\is_string($aud)) {
45            return [$aud];
46        }
47        if (\is_array($aud)) {
48            return array_values(array_filter($aud, is_string(...)));
49        }
50
51        return [];
52    }
53
54    /**
55     * @throws JwtException when the claim is present but not a time
56     */
57    public function expiresAt(): ?float
58    {
59        return $this->time('exp');
60    }
61
62    /**
63     * @throws JwtException when the claim is present but not a time
64     */
65    public function notBefore(): ?float
66    {
67        return $this->time('nbf');
68    }
69
70    /**
71     * @throws JwtException when the claim is present but not a time
72     */
73    public function issuedAt(): ?float
74    {
75        return $this->time('iat');
76    }
77
78    /**
79     * Absent is null; present, null included, must be a time (R8-005).
80     *
81     * @throws JwtException
82     */
83    private function time(string $claim): ?float
84    {
85        if (!\array_key_exists($claim, $this->all)) {
86            return null;
87        }
88
89        return self::timestamp($this->all[$claim], $claim);
90    }
91
92    public function tokenId(): ?string
93    {
94        return \is_string($this->all['jti'] ?? null) ? $this->all['jti'] : null;
95    }
96
97    /**
98     * The URI of the cargo:LogisticsAgent the caller acts as; the claim ONE Record adds to OAuth.
99     */
100    public function logisticsAgentUri(): ?string
101    {
102        $value = $this->all[self::LOGISTICS_AGENT_URI] ?? null;
103
104        return \is_string($value) && $value !== '' ? $value : null;
105    }
106
107    /**
108     * RFC 7519 wants NumericDate, kept with its fraction; the spec's own
109     * example writes exp as an ISO string, so an absolute RFC 3339 instant
110     * with a zone is read too. Nothing else: a relative expression would get
111     * a new instant at every parse from the process clock, and a present but
112     * malformed claim is a refusal, never "absent" (R7-005).
113     *
114     * @throws JwtException
115     */
116    private static function timestamp(mixed $value, string $claim): float
117    {
118        if (\is_int($value) || \is_float($value)) {
119            if (!is_finite((float) $value)) {
120                throw new JwtException(JwtException::INVALID_CLAIM, \sprintf('The %s claim is not a time.', $claim));
121            }
122
123            return (float) $value;
124        }
125        if (\is_string($value)) {
126            if (preg_match('/^\d{1,12}(\.\d+)?$/', $value) === 1) {
127                return (float) $value;
128            }
129            // The string form is this SDK's ONE Record compatibility extension, deliberately a smaller
130            // subset than RFC 3339: hours 00-23, minutes and seconds 00-59 (no leap second), offsets up
131            // to 14:00 (the largest in use; the RFC's grammar would allow up to 23:59). Checked before
132            // PHP sees the string, which would normalise 24:00 or +24:00 instead of refusing them (D9-002).
133            if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-](0\d|1[0-4]):([0-5]\d))$/', $value, $m) === 1
134                && checkdate((int) $m[2], (int) $m[3], (int) $m[1])
135                && !(($m[7] ?? '') === '14' && ($m[8] ?? '') !== '00')) {
136                try {
137                    return (float) (new DateTimeImmutable($value))->format('U.u');
138                } catch (Exception) {
139                    // falls through to the refusal
140                }
141            }
142        }
143
144        throw new JwtException(JwtException::INVALID_CLAIM, \sprintf('The %s claim is not a time.', $claim));
145    }
146}