Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.10% covered (warning)
88.10%
37 / 42
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Rs256Signer
88.10% covered (warning)
88.10%
37 / 42
33.33% covered (danger)
33.33%
2 / 6
21.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 sign
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
4.02
 issuer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 keyId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 publicKeyPem
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 publicJwk
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Auth\Jwt;
6
7use InvalidArgumentException;
8use OpenSSLAsymmetricKey;
9use Psr\Clock\ClockInterface;
10use RuntimeException;
11
12/**
13 * Issues RS256 tokens: for a host acting as its partners' identity provider
14 * (the token endpoint), and for a client authenticating to a server that
15 * trusts the host's key directly.
16 */
17final class Rs256Signer
18{
19    private OpenSSLAsymmetricKey $privateKey;
20
21    /** @var callable(int): string */
22    private $randomBytes;
23
24    /**
25     * @param string $privateKeyPem a PEM RSA private key; "\n" escapes are accepted for keys passed through environment variables
26     * @param ?callable(int): string $randomBytes source for token ids, defaults to random_bytes
27     */
28    public function __construct(
29        string $privateKeyPem,
30        private readonly string $issuer,
31        private readonly ClockInterface $clock,
32        private readonly ?string $keyId = null,
33        ?callable $randomBytes = null,
34    ) {
35        $pem = str_replace('\n', "\n", $privateKeyPem);
36        $key = $pem === '' ? false : openssl_pkey_get_private($pem);
37        if ($key === false) {
38            throw new InvalidArgumentException('The private key is not a PEM RSA private key.');
39        }
40        $details = openssl_pkey_get_details($key);
41        if ($details === false || $details['type'] !== OPENSSL_KEYTYPE_RSA || $details['bits'] < 2048) {
42            throw new InvalidArgumentException('RS256 needs an RSA key of at least 2048 bits.');
43        }
44        $this->privateKey = $key;
45        $this->randomBytes = $randomBytes ?? static fn(int $length): string => random_bytes(max(1, $length));
46    }
47
48    /**
49     * @param array<string, mixed> $claims added to the standard claims; iss, iat, nbf, exp and jti cannot be overridden
50     */
51    public function sign(array $claims, int $ttlSeconds): string
52    {
53        $now = $this->clock->now()->getTimestamp();
54        $payload = [
55            ...$claims,
56            'iss' => $this->issuer,
57            'iat' => $now,
58            'nbf' => $now,
59            'exp' => $now + $ttlSeconds,
60            'jti' => bin2hex(($this->randomBytes)(16)),
61        ];
62        $header = ['alg' => 'RS256', 'typ' => 'JWT'];
63        if ($this->keyId !== null) {
64            $header['kid'] = $this->keyId;
65        }
66        $signed = Jwk::base64UrlEncode(json_encode($header, JSON_THROW_ON_ERROR)) . '.'
67            . Jwk::base64UrlEncode(json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
68        if (!openssl_sign($signed, $signature, $this->privateKey, OPENSSL_ALGO_SHA256)) {
69            throw new RuntimeException('Signing failed.');
70        }
71        if (!\is_string($signature)) {
72            throw new RuntimeException('Signing produced no signature.');
73        }
74
75        return $signed . '.' . Jwk::base64UrlEncode($signature);
76    }
77
78    public function issuer(): string
79    {
80        return $this->issuer;
81    }
82
83    public function keyId(): ?string
84    {
85        return $this->keyId;
86    }
87
88    /**
89     * The public half, in PEM: what partners (or NE:ONE) are configured to trust.
90     */
91    public function publicKeyPem(): string
92    {
93        $details = openssl_pkey_get_details($this->privateKey);
94        if ($details === false || !\is_string($details['key'] ?? null)) {
95            throw new RuntimeException('Cannot derive the public key.');
96        }
97
98        return $details['key'];
99    }
100
101    /**
102     * The public key as a JWK, for a JWKS document other servers can fetch.
103     *
104     * @return array<string, string>
105     */
106    public function publicJwk(): array
107    {
108        $details = openssl_pkey_get_details($this->privateKey);
109        $rsa = $details === false ? null : ($details['rsa'] ?? null);
110        if (!\is_array($rsa) || !\is_string($rsa['n'] ?? null) || !\is_string($rsa['e'] ?? null)) {
111            throw new RuntimeException('Cannot derive the public key.');
112        }
113        $jwk = ['kty' => 'RSA', 'use' => 'sig', 'alg' => 'RS256', 'n' => Jwk::base64UrlEncode($rsa['n']), 'e' => Jwk::base64UrlEncode($rsa['e'])];
114        if ($this->keyId !== null) {
115            $jwk['kid'] = $this->keyId;
116        }
117
118        return $jwk;
119    }
120}