Skip to content

Commit b0def5f

Browse files
authored
feat: adds JWK support (#273)
1 parent 51034b4 commit b0def5f

File tree

2 files changed

+330
-0
lines changed

2 files changed

+330
-0
lines changed

src/JWK.php

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
namespace Firebase\JWT;
4+
5+
use DomainException;
6+
use UnexpectedValueException;
7+
8+
/**
9+
* JSON Web Key implementation, based on this spec:
10+
* https://tools.ietf.org/html/draft-ietf-jose-json-web-key-41
11+
*
12+
* PHP version 5
13+
*
14+
* @category Authentication
15+
* @package Authentication_JWT
16+
* @author Bui Sy Nguyen <[email protected]>
17+
* @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD
18+
* @link https://github.com/firebase/php-jwt
19+
*/
20+
class JWK
21+
{
22+
/**
23+
* Parse a set of JWK keys
24+
*
25+
* @param array $jwks The JSON Web Key Set as an associative array
26+
*
27+
* @return array An associative array that represents the set of keys
28+
*
29+
* @throws InvalidArgumentException Provided JWK Set is empty
30+
* @throws UnexpectedValueException Provided JWK Set was invalid
31+
* @throws DomainException OpenSSL failure
32+
*
33+
* @uses parseKey
34+
*/
35+
public static function parseKeySet(array $jwks)
36+
{
37+
$keys = array();
38+
39+
if (!isset($jwks['keys'])) {
40+
throw new UnexpectedValueException('"keys" member must exist in the JWK Set');
41+
}
42+
if (empty($jwks['keys'])) {
43+
throw new InvalidArgumentException('JWK Set did not contain any keys');
44+
}
45+
46+
foreach ($jwks['keys'] as $k => $v) {
47+
$kid = isset($v['kid']) ? $v['kid'] : $k;
48+
if ($key = self::parseKey($v)) {
49+
$keys[$kid] = $key;
50+
}
51+
}
52+
53+
if (0 === count($keys)) {
54+
throw new UnexpectedValueException('No supported algorithms found in JWK Set');
55+
}
56+
57+
return $keys;
58+
}
59+
60+
/**
61+
* Parse a JWK key
62+
*
63+
* @param array $jwk An individual JWK
64+
*
65+
* @return resource|array An associative array that represents the key
66+
*
67+
* @throws InvalidArgumentException Provided JWK is empty
68+
* @throws UnexpectedValueException Provided JWK was invalid
69+
* @throws DomainException OpenSSL failure
70+
*
71+
* @uses createPemFromModulusAndExponent
72+
*/
73+
private static function parseKey(array $jwk)
74+
{
75+
if (empty($jwk)) {
76+
throw new InvalidArgumentException('JWK must not be empty');
77+
}
78+
if (!isset($jwk['kty'])) {
79+
throw new UnexpectedValueException('JWK must contain a "kty" parameter');
80+
}
81+
82+
switch ($jwk['kty']) {
83+
case 'RSA':
84+
if (array_key_exists('d', $jwk)) {
85+
throw new UnexpectedValueException('RSA private keys are not supported');
86+
}
87+
if (!isset($jwk['n']) || !isset($jwk['e'])) {
88+
throw new UnexpectedValueException('RSA keys must contain values for both "n" and "e"');
89+
}
90+
91+
$pem = self::createPemFromModulusAndExponent($jwk['n'], $jwk['e']);
92+
$publicKey = openssl_pkey_get_public($pem);
93+
if (false === $publicKey) {
94+
throw new DomainException(
95+
'OpenSSL error: ' . openssl_error_string()
96+
);
97+
}
98+
return $publicKey;
99+
default:
100+
// Currently only RSA is supported
101+
break;
102+
}
103+
}
104+
105+
/**
106+
* Create a public key represented in PEM format from RSA modulus and exponent information
107+
*
108+
* @param string $n The RSA modulus encoded in Base64
109+
* @param string $e The RSA exponent encoded in Base64
110+
*
111+
* @return string The RSA public key represented in PEM format
112+
*
113+
* @uses encodeLength
114+
*/
115+
private static function createPemFromModulusAndExponent($n, $e)
116+
{
117+
$modulus = JWT::urlsafeB64Decode($n);
118+
$publicExponent = JWT::urlsafeB64Decode($e);
119+
120+
$components = array(
121+
'modulus' => pack('Ca*a*', 2, self::encodeLength(strlen($modulus)), $modulus),
122+
'publicExponent' => pack('Ca*a*', 2, self::encodeLength(strlen($publicExponent)), $publicExponent)
123+
);
124+
125+
$rsaPublicKey = pack(
126+
'Ca*a*a*',
127+
48,
128+
self::encodeLength(strlen($components['modulus']) + strlen($components['publicExponent'])),
129+
$components['modulus'],
130+
$components['publicExponent']
131+
);
132+
133+
// sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption.
134+
$rsaOID = pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA
135+
$rsaPublicKey = chr(0) . $rsaPublicKey;
136+
$rsaPublicKey = chr(3) . self::encodeLength(strlen($rsaPublicKey)) . $rsaPublicKey;
137+
138+
$rsaPublicKey = pack(
139+
'Ca*a*',
140+
48,
141+
self::encodeLength(strlen($rsaOID . $rsaPublicKey)),
142+
$rsaOID . $rsaPublicKey
143+
);
144+
145+
$rsaPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" .
146+
chunk_split(base64_encode($rsaPublicKey), 64) .
147+
'-----END PUBLIC KEY-----';
148+
149+
return $rsaPublicKey;
150+
}
151+
152+
/**
153+
* DER-encode the length
154+
*
155+
* DER supports lengths up to (2**8)**127, however, we'll only support lengths up to (2**8)**4. See
156+
* {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} for more information.
157+
*
158+
* @param int $length
159+
* @return string
160+
*/
161+
private static function encodeLength($length)
162+
{
163+
if ($length <= 0x7F) {
164+
return chr($length);
165+
}
166+
167+
$temp = ltrim(pack('N', $length), chr(0));
168+
169+
return pack('Ca*', 0x80 | strlen($temp), $temp);
170+
}
171+
}

tests/JWKTest.php

+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
namespace Firebase\JWT;
3+
4+
use PHPUnit\Framework\TestCase;
5+
6+
class JWKTest extends TestCase
7+
{
8+
/*
9+
* For compatibility with PHPUnit 4.8 and PHP < 5.6
10+
*/
11+
public function setExpectedException($exceptionName, $message = '', $code = NULL) {
12+
if (method_exists($this, 'expectException')) {
13+
$this->expectException($exceptionName);
14+
} else {
15+
parent::setExpectedException($exceptionName, $message, $code);
16+
}
17+
}
18+
19+
public function testDecodeByJWKKeySetTokenExpired()
20+
{
21+
$jsKey = array(
22+
'kty' => 'RSA',
23+
'e' => 'AQAB',
24+
'use' => 'sig',
25+
'kid' => 's1',
26+
'n' => 'kWp2zRA23Z3vTL4uoe8kTFptxBVFunIoP4t_8TDYJrOb7D1iZNDXVeEsYKp6ppmrTZDAgd-cNOTKLd4M39WJc5FN0maTAVKJc7NxklDeKc4dMe1BGvTZNG4MpWBo-taKULlYUu0ltYJuLzOjIrTHfarucrGoRWqM0sl3z2-fv9k',
27+
);
28+
29+
$key = JWK::parseKeySet(array('keys' => array($jsKey)));
30+
31+
$header = array(
32+
'kid' => 's1',
33+
'alg' => 'RS256',
34+
);
35+
$payload = array (
36+
'scp' => array ('openid', 'email', 'profile', 'aas'),
37+
'sub' => 'tUCYtnfIBPWcrSJf4yBfvN1kww4KGcy3LIPk1GVzsE0',
38+
'clm' => array ('!5v8H'),
39+
'iss' => 'http://130.211.243.114:8080/c2id',
40+
'exp' => 1441126539,
41+
'uip' => array('groups' => array('admin', 'audit')),
42+
'cid' => 'pk-oidc-01',
43+
);
44+
$signature = 'PvYrnf3k1Z0wgRwCgq0WXKaoIv1hHtzBFO5cGfCs6bl4suc6ilwCWmJqRxGYkU2fNTGyMOt3OUnnBEwl6v5qN6jv7zbkVAVKVvbQLxhHC2nXe3izvoCiVaMEH6hE7VTWwnPbX_qO72mCwTizHTJTZGLOsyXLYM6ctdOMf7sFPTI';
45+
$msg = sprintf('%s.%s.%s',
46+
JWT::urlsafeB64Encode(json_encode($header)),
47+
JWT::urlsafeB64Encode(json_encode($payload)),
48+
$signature
49+
);
50+
51+
$this->setExpectedException('Firebase\JWT\ExpiredException');
52+
53+
JWT::decode($msg, $key, array('RS256'));
54+
}
55+
56+
public function testDecodeByJWKKeySet()
57+
{
58+
$jsKey = array(
59+
'kty' => 'RSA',
60+
'e' => 'AQAB',
61+
'use' => 'sig',
62+
'kid' => 's1',
63+
'n' => 'kWp2zRA23Z3vTL4uoe8kTFptxBVFunIoP4t_8TDYJrOb7D1iZNDXVeEsYKp6ppmrTZDAgd-cNOTKLd4M39WJc5FN0maTAVKJc7NxklDeKc4dMe1BGvTZNG4MpWBo-taKULlYUu0ltYJuLzOjIrTHfarucrGoRWqM0sl3z2-fv9k',
64+
);
65+
66+
$key = JWK::parseKeySet(array('keys' => array($jsKey)));
67+
68+
$header = array(
69+
'kid' => 's1',
70+
'alg' => 'RS256',
71+
);
72+
$payload = array (
73+
'scp' => array ('openid', 'email', 'profile', 'aas'),
74+
'sub' => 'tUCYtnfIBPWcrSJf4yBfvN1kww4KGcy3LIPk1GVzsE0',
75+
'clm' => array ('!5v8H'),
76+
'iss' => 'http://130.211.243.114:8080/c2id',
77+
'exp' => 1441126539,
78+
'uip' => array('groups' => array('admin', 'audit')),
79+
'cid' => 'pk-oidc-01',
80+
);
81+
$signature = 'PvYrnf3k1Z0wgRwCgq0WXKaoIv1hHtzBFO5cGfCs6bl4suc6ilwCWmJqRxGYkU2fNTGyMOt3OUnnBEwl6v5qN6jv7zbkVAVKVvbQLxhHC2nXe3izvoCiVaMEH6hE7VTWwnPbX_qO72mCwTizHTJTZGLOsyXLYM6ctdOMf7sFPTI';
82+
$msg = sprintf('%s.%s.%s',
83+
JWT::urlsafeB64Encode(json_encode($header)),
84+
JWT::urlsafeB64Encode(json_encode($payload)),
85+
$signature
86+
);
87+
88+
$this->setExpectedException('Firebase\JWT\ExpiredException');
89+
90+
$payload = JWT::decode($msg, $key, array('RS256'));
91+
92+
$this->assertEquals("tUCYtnfIBPWcrSJf4yBfvN1kww4KGcy3LIPk1GVzsE0", $payload->sub);
93+
$this->assertEquals(1441126539, $payload->exp);
94+
}
95+
96+
public function testDecodeByMultiJWKKeySet()
97+
{
98+
$jsKey1 = array(
99+
'kty' => 'RSA',
100+
'e' => 'AQAB',
101+
'use' => 'sig',
102+
'kid' => 'CXup',
103+
'n' => 'hrwD-lc-IwzwidCANmy4qsiZk11yp9kHykOuP0yOnwi36VomYTQVEzZXgh2sDJpGgAutdQudgwLoV8tVSsTG9SQHgJjH9Pd_9V4Ab6PANyZNG6DSeiq1QfiFlEP6Obt0JbRB3W7X2vkxOVaNoWrYskZodxU2V0ogeVL_LkcCGAyNu2jdx3j0DjJatNVk7ystNxb9RfHhJGgpiIkO5S3QiSIVhbBKaJHcZHPF1vq9g0JMGuUCI-OTSVg6XBkTLEGw1C_R73WD_oVEBfdXbXnLukoLHBS11p3OxU7f4rfxA_f_72_UwmWGJnsqS3iahbms3FkvqoL9x_Vj3GhuJSf97Q',
104+
);
105+
$jsKey2 = array(
106+
'kty' => 'EC',
107+
'use' => 'sig',
108+
'crv' => 'P-256',
109+
'kid' => 'yGvt',
110+
'x' => 'pvgdqM3RCshljmuCF1D2Ez1w5ei5k7-bpimWLPNeEHI',
111+
'y' => 'JSmUhbUTqiFclVLEdw6dz038F7Whw4URobjXbAReDuM',
112+
);
113+
$jsKey3 = array(
114+
'kty' => 'EC',
115+
'use' => 'sig',
116+
'crv' => 'P-384',
117+
'kid' => '9nHY',
118+
'x' => 'JPKhjhE0Bj579Mgj3Cn3ERGA8fKVYoGOaV9BPKhtnEobphf8w4GSeigMesL-038W',
119+
'y' => 'UbJa1QRX7fo9LxSlh7FOH5ABT5lEtiQeQUcX9BW0bpJFlEVGqwec80tYLdOIl59M',
120+
);
121+
$jsKey4 = array(
122+
'kty' => 'EC',
123+
'use' => 'sig',
124+
'crv' => 'P-521',
125+
'kid' => 'tVzS',
126+
'x' => 'AZgkRHlIyNQJlPIwTWdHqouw41k9dS3GJO04BDEnJnd_Dd1owlCn9SMXA-JuXINn4slwbG4wcECbctXb2cvdGtmn',
127+
'y' => 'AdBC6N9lpupzfzcIY3JLIuc8y8MnzV-ItmzHQcC5lYWMTbuM9NU_FlvINeVo8g6i4YZms2xFB-B0VVdaoF9kUswC',
128+
);
129+
130+
$key = JWK::parseKeySet(array('keys' => array($jsKey1, $jsKey2, $jsKey3, $jsKey4)));
131+
132+
$header = array(
133+
'kid' => 'CXup',
134+
'alg' => 'RS256',
135+
);
136+
$payload = array(
137+
'sub' => 'f8b67cc46030777efd8bce6c1bfe29c6c0f818ec',
138+
'scp' => array('openid', 'name', 'profile', 'picture', 'email', 'rs-pk-main', 'rs-pk-so', 'rs-pk-issue', 'rs-pk-web'),
139+
'clm' => array('!5v8H'),
140+
'iss' => 'https://id.projectkit.net/authenticate',
141+
'exp' => 1492228336,
142+
'iat' => 1491364336,
143+
'cid' => 'cid-pk-web',
144+
);
145+
$signature = 'KW1K-72bMtiNwvyYBgffG6VaG6I59cELGYQR8M2q7HA8dmzliu6QREJrqyPtwW_rDJZbsD3eylvkRinK9tlsMXCOfEJbxLdAC9b4LKOsnsbuXXwsJHWkFG0a7osdW0ZpXJDoMFlO1aosxRGMkaqhf1wIkvQ5PM_EB08LJv7oz64Antn5bYaoajwgvJRl7ChatRDn9Sx5UIElKD1BK4Uw5WdrZwBlWdWZVNCSFhy4F6SdZvi3OBlXzluDwq61RC-pl2iivilJNljYWVrthHDS1xdtaVz4oteHW13-IS7NNEz6PVnzo5nyoPWMAB4JlRnxcfOFTTUqOA2mX5Csg0UpdQ';
146+
$msg = sprintf('%s.%s.%s',
147+
JWT::urlsafeB64Encode(json_encode($header)),
148+
JWT::urlsafeB64Encode(json_encode($payload)),
149+
$signature
150+
);
151+
152+
$this->setExpectedException('Firebase\JWT\ExpiredException');
153+
154+
$payload = JWT::decode($msg, $key, array('RS256'));
155+
156+
$this->assertEquals("f8b67cc46030777efd8bce6c1bfe29c6c0f818ec", $payload->sub);
157+
$this->assertEquals(1492228336, $payload->exp);
158+
}
159+
}

0 commit comments

Comments
 (0)