Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/Key/Jwk.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @author Torben Dannhauer <torben@dannhauer.de>
* @author Jean Charles Delépine <jean.charles.delepine@u-picardie.fr>
*/

namespace Horde\Jwt\Key;
Expand Down Expand Up @@ -52,6 +53,36 @@ public static function fromPublicKeyPem(string $pem): array
};
}

/**
* Deserialize a JWK to a PublicKey (RFC 7517).
*
* Supports RSA (kty=RSA, parameters n/e) and EC keys on curves
* P-256, P-384 and P-521 (kty=EC, parameters crv/x/y).
*
* Typical use case: verifying JWTs signed by an external OIDC provider.
* The provider's JWKS endpoint returns an array of JWK objects; pass each
* one to this method to obtain a PublicKey suitable for Rs256Verifier or
* Es256Verifier.
*
* @param array<string, mixed> $jwk A single JWK object (one entry from the
* 'keys' array of a JWKS document)
* @throws InvalidArgumentException on missing/invalid parameters or
* unsupported key type / EC curve
*/
public static function toPublicKey(array $jwk): PublicKey
{
$kty = (string) ($jwk['kty'] ?? '');

return match ($kty) {
'RSA' => self::rsaJwkToPublicKey($jwk),
'EC' => self::ecJwkToPublicKey($jwk),
'' => throw new InvalidArgumentException('JWK missing required "kty" parameter'),
default => throw new InvalidArgumentException(
"Unsupported JWK key type '$kty'; only RSA and EC are supported"
),
};
}

/**
* @return array<string, string>
*/
Expand Down Expand Up @@ -95,4 +126,120 @@ private static function base64UrlEncodeInt(string $binaryInt): string
{
return rtrim(strtr(base64_encode($binaryInt), '+/', '-_'), '=');
}

/**
* @param array<string, mixed> $jwk
*/
private static function rsaJwkToPublicKey(array $jwk): PublicKey
{
$n = $jwk['n'] ?? '';
$e = $jwk['e'] ?? '';

if ($n === '' || $e === '') {
throw new InvalidArgumentException('RSA JWK missing required parameters "n" and/or "e"');
}

$modulus = self::base64UrlDecode($n);
$exponent = self::base64UrlDecode($e);

$encLen = static function (int $len): string {
if ($len < 128) {
return chr($len);
}
if ($len < 256) {
return chr(0x81) . chr($len);
}
return chr(0x82) . chr($len >> 8) . chr($len & 0xff);
};

$encInt = static function (string $bytes) use ($encLen): string {
if (ord($bytes[0]) > 0x7f) {
$bytes = "\x00" . $bytes;
}
return "\x02" . $encLen(strlen($bytes)) . $bytes;
};

$inner = $encInt($modulus) . $encInt($exponent);
$seq = "\x30" . $encLen(strlen($inner)) . $inner;
$algId = "\x30\x0d\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00";
$bitStr = "\x03" . $encLen(strlen($seq) + 1) . "\x00" . $seq;
$spki = "\x30" . $encLen(strlen($algId) + strlen($bitStr)) . $algId . $bitStr;

$pem = "-----BEGIN PUBLIC KEY-----\n"
. chunk_split(base64_encode($spki), 64, "\n")
. "-----END PUBLIC KEY-----\n";

return PublicKey::fromString($pem);
}

/**
* @param array<string, mixed> $jwk
*/
private static function ecJwkToPublicKey(array $jwk): PublicKey
{
$crv = (string) ($jwk['crv'] ?? '');
$x = $jwk['x'] ?? '';
$y = $jwk['y'] ?? '';

if ($crv === '' || $x === '' || $y === '') {
throw new InvalidArgumentException('EC JWK missing required parameters "crv", "x" and/or "y"');
}

$curveMap = [
'P-256' => ['prime256v1', 32],
'P-384' => ['secp384r1', 48],
'P-521' => ['secp521r1', 66],
];

if (!isset($curveMap[$crv])) {
throw new InvalidArgumentException("Unsupported EC curve '$crv'; supported: P-256, P-384, P-521");
}

[$curveName, $coordSize] = $curveMap[$crv];

$xBytes = str_pad(self::base64UrlDecode($x), $coordSize, "\x00", STR_PAD_LEFT);
$yBytes = str_pad(self::base64UrlDecode($y), $coordSize, "\x00", STR_PAD_LEFT);

$point = "\x04" . $xBytes . $yBytes;

$oidMap = [
'prime256v1' => "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07",
'secp384r1' => "\x06\x05\x2b\x81\x04\x00\x22",
'secp521r1' => "\x06\x05\x2b\x81\x04\x00\x23",
];

$ecOid = "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01";
$curveOid = $oidMap[$curveName];
$algSeq = "\x30" . chr(strlen($ecOid) + strlen($curveOid)) . $ecOid . $curveOid;

$encLen = static function (int $len): string {
if ($len < 128) {
return chr($len);
}
if ($len < 256) {
return chr(0x81) . chr($len);
}
return chr(0x82) . chr($len >> 8) . chr($len & 0xff);
};

$bitStr = "\x03" . $encLen(strlen($point) + 1) . "\x00" . $point;
$spki = "\x30" . $encLen(strlen($algSeq) + strlen($bitStr)) . $algSeq . $bitStr;

$pem = "-----BEGIN PUBLIC KEY-----\n"
. chunk_split(base64_encode($spki), 64, "\n")
. "-----END PUBLIC KEY-----\n";

return PublicKey::fromString($pem);
}

private static function base64UrlDecode(string $data): string
{
$padded = strtr($data, '-_', '+/');
$padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4);
$decoded = base64_decode($padded, strict: true);
if ($decoded === false) {
throw new InvalidArgumentException("Invalid base64url data: $data");
}
return $decoded;
}
}
150 changes: 150 additions & 0 deletions test/Key/JwkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,21 @@
*
* @author Ralf Lang <ralf.lang@ralf-lang.de>
* @author Torben Dannhauer <torben@dannhauer.de>
* @author Jean Charles Delépine <jean.charles.delepine@u-picardie.fr>
*/

namespace Horde\Jwt\Test\Key;

use Horde\Jwt\Exception\InvalidTokenException;
use Horde\Jwt\Key\Jwk;
use Horde\Jwt\Key\PrivateKey;
use Horde\Jwt\Key\PublicKey;
use Horde\Jwt\Signer\Es256Signer;
use Horde\Jwt\Signer\Rs256Signer;
use Horde\Jwt\TokenDecoder;
use Horde\Jwt\TokenEncoder;
use Horde\Jwt\Verifier\Es256Verifier;
use Horde\Jwt\Verifier\Rs256Verifier;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -54,4 +64,144 @@ public function testRejectsInvalidPem(): void
$this->expectException(InvalidArgumentException::class);
Jwk::fromPublicKeyPem('not a key');
}

public function testRsaJwkRoundTrip(): void
{
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($key, $privPem);
$privKey = PrivateKey::fromString($privPem);
$pubKey = PublicKey::fromPrivateKey($privKey);

$jwk = Jwk::fromPublicKey($pubKey);
$restoredKey = Jwk::toPublicKey($jwk);

$this->assertInstanceOf(PublicKey::class, $restoredKey);

$token = (new TokenEncoder())->encode(['sub' => 'alice'], new Rs256Signer($privKey));
$verified = (new TokenDecoder())->decode($token->toString(), new Rs256Verifier($restoredKey));

$this->assertSame('alice', $verified->getSubject());
}

public function testEcP256JwkRoundTrip(): void
{
$key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
openssl_pkey_export($key, $privPem);
$privKey = PrivateKey::fromString($privPem);
$pubKey = PublicKey::fromPrivateKey($privKey);

$jwk = Jwk::fromPublicKey($pubKey);
$restoredKey = Jwk::toPublicKey($jwk);

$this->assertInstanceOf(PublicKey::class, $restoredKey);

$token = (new TokenEncoder())->encode(['sub' => 'bob'], new Es256Signer($privKey));
$verified = (new TokenDecoder())->decode($token->toString(), new Es256Verifier($restoredKey));

$this->assertSame('bob', $verified->getSubject());
}

public function testEcP384JwkRoundTrip(): void
{
$key = openssl_pkey_new(['curve_name' => 'secp384r1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
openssl_pkey_export($key, $privPem);
$privKey = PrivateKey::fromString($privPem);
$pubKey = PublicKey::fromPrivateKey($privKey);

$jwk = Jwk::fromPublicKey($pubKey);
$restoredKey = Jwk::toPublicKey($jwk);

$this->assertInstanceOf(PublicKey::class, $restoredKey);
}

public function testEcP521JwkRoundTrip(): void
{
$key = openssl_pkey_new(['curve_name' => 'secp521r1', 'private_key_type' => OPENSSL_KEYTYPE_EC]);
openssl_pkey_export($key, $privPem);
$privKey = PrivateKey::fromString($privPem);
$pubKey = PublicKey::fromPrivateKey($privKey);

$jwk = Jwk::fromPublicKey($pubKey);
$restoredKey = Jwk::toPublicKey($jwk);

$this->assertInstanceOf(PublicKey::class, $restoredKey);
}

public function testRsaJwkWithExtraFieldsIgnored(): void
{
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($key, $privPem);
$jwk = array_merge(
Jwk::fromPublicKey(PublicKey::fromPrivateKey(PrivateKey::fromString($privPem))),
['kid' => 'key-1', 'alg' => 'RS256']
);

$restoredKey = Jwk::toPublicKey($jwk);
$this->assertInstanceOf(PublicKey::class, $restoredKey);
}

public function testWrongKeyCannotVerify(): void
{
$keyA = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$keyB = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($keyA, $privPemA);
openssl_pkey_export($keyB, $privPemB);

$privA = PrivateKey::fromString($privPemA);
$privB = PrivateKey::fromString($privPemB);
$pubB = Jwk::toPublicKey(Jwk::fromPublicKey(PublicKey::fromPrivateKey($privB)));

$token = (new TokenEncoder())->encode(['sub' => 'alice'], new Rs256Signer($privA));

$this->expectException(InvalidTokenException::class);
(new TokenDecoder())->decode($token->toString(), new Rs256Verifier($pubB));
}

public function testMissingKtyThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/missing required "kty"/i');

Jwk::toPublicKey(['n' => 'abc', 'e' => 'AQAB']);
}

public function testUnsupportedKtyThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/Unsupported JWK key type/i');

Jwk::toPublicKey(['kty' => 'OKP', 'crv' => 'Ed25519', 'x' => 'abc']);
}

public function testRsaJwkMissingNThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/missing required parameters/i');

Jwk::toPublicKey(['kty' => 'RSA', 'e' => 'AQAB']);
}

public function testRsaJwkMissingEThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/missing required parameters/i');

Jwk::toPublicKey(['kty' => 'RSA', 'n' => 'abc123']);
}

public function testEcJwkMissingCrvThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/missing required parameters/i');

Jwk::toPublicKey(['kty' => 'EC', 'x' => 'abc', 'y' => 'def']);
}

public function testEcJwkUnsupportedCurveThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('/Unsupported EC curve/i');

Jwk::toPublicKey(['kty' => 'EC', 'crv' => 'P-224', 'x' => 'abc', 'y' => 'def']);
}
}