Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
88.10% |
37 / 42 |
|
33.33% |
2 / 6 |
CRAP | |
0.00% |
0 / 1 |
| Rs256Signer | |
88.10% |
37 / 42 |
|
33.33% |
2 / 6 |
21.74 | |
0.00% |
0 / 1 |
| __construct | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
6.05 | |||
| sign | |
89.47% |
17 / 19 |
|
0.00% |
0 / 1 |
4.02 | |||
| issuer | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| keyId | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| publicKeyPem | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| publicJwk | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
6.07 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Auth\Jwt; |
| 6 | |
| 7 | use InvalidArgumentException; |
| 8 | use OpenSSLAsymmetricKey; |
| 9 | use Psr\Clock\ClockInterface; |
| 10 | use 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 | */ |
| 17 | final 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 | } |