Skip to content

Commit

Permalink
Merge pull request #505 from swiffer/2.x
Browse files Browse the repository at this point in the history
Support for JSON Web Key Sets
  • Loading branch information
markstory authored Jan 6, 2022
2 parents 463bd69 + d8fe674 commit 71cfa8a
Show file tree
Hide file tree
Showing 8 changed files with 186 additions and 14 deletions.
36 changes: 36 additions & 0 deletions docs/en/authenticators.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ example.
- **secretKey**: Default is ``null`` but you’re **required** to pass a
secret key if you’re not in the context of a CakePHP application that
provides it through ``Security::salt()``.
- **jwks**: Default is ``null``. Associative array with a ``'keys'`` key.
If provided will be used instead of the secret key.

You need to add the lib `firebase/php-jwt <https://github.com/firebase/php-jwt>`_
v5.5 or above to your app to use the ``JwtAuthenticator``.
Expand Down Expand Up @@ -171,6 +173,40 @@ In your ``UsersController``::
$this->viewBuilder()->setOption('serialize', 'json');
}

Using a JWKS fetched from an external JWKS endpoint is supported as well::

// Application.php
public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
{
$service = new AuthenticationService();
// ...
$service->loadIdentifier('Authentication.JwtSubject');

$jwksUrl = 'https://appleid.apple.com/auth/keys';

// Set of keys. The "keys" key is required. Additionally keys require a "alg" key.
// Add it manually to your JWK array if it doesn't already exist.
$jsonWebKeySet = Cache::remember('jwks-' . md5($jwksUrl), function () use ($jwksUrl) {
$http = new Client();
$response = $http->get($jwksUrl);
return $response->getJson();
});

$service->loadAuthenticator('Authentication.Jwt', [
'jwks' => $jsonWebKeySet,
'returnPayload' => false
]);
}

The JWKS resource will return the same set of keys most of the time.
Applications should cache these resources, but they also need to be
prepared to handle signing key rotations.

.. warning::

Applications need to pick a cache lifetime that balances performance and security.
This is particularly important in situations where a private key is compromised.

Beside from sharing the public key file to external application, you can
distribute it via a JWKS endpoint by configuring your app as follows::

Expand Down
21 changes: 21 additions & 0 deletions src/Authenticator/JwtAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@

use ArrayObject;
use Authentication\Identifier\IdentifierInterface;
use Cake\Utility\Hash;
use Cake\Utility\Security;
use Exception;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Http\Message\ServerRequestInterface;
Expand All @@ -39,6 +41,7 @@ class JwtAuthenticator extends TokenAuthenticator
'returnPayload' => true,
'secretKey' => null,
'subjectKey' => IdentifierInterface::CREDENTIAL_JWT_SUBJECT,
'jwks' => null,
];

/**
Expand Down Expand Up @@ -166,6 +169,24 @@ protected function decodeToken(string $token): ?object
);
}

$jsonWebKeySet = $this->getConfig('jwks');
if ($jsonWebKeySet) {
$keySet = JWK::parseKeySet($jsonWebKeySet);
/*
* TODO Converting Keys to Key Objects is no longer needed in firebase/php-jwt ^6.0
* @link https://github.com/firebase/php-jwt/pull/376/files#diff-374f5998b3c572d86be0e79432aac3de362c79e8fb146b9ce422dc2388cdc5daR50
*/
$keyAlgorithms = Hash::combine($jsonWebKeySet['keys'], '{n}.kid', '{n}.alg');
array_walk($keySet, function (&$keyMaterial, $k) use ($keyAlgorithms) {
$keyMaterial = new Key($keyMaterial, $keyAlgorithms[$k]);
});

return JWT::decode(
$token,
$keySet
);
}

$key = new Key($this->getConfig('secretKey'), $this->getConfig('algorithm'));

return JWT::decode($token, $key);
Expand Down
67 changes: 55 additions & 12 deletions tests/TestCase/Authenticator/JwtAuthenticatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,18 @@ class JwtAuthenticatorTest extends TestCase
];

/**
* Test token
* Test token encoded via HS256
*
* @var string
*/
public $token;
protected $tokenHS256;

/**
* Test token encoded via RS256
*
* @var string
*/
protected $tokenRS256;

/**
* Identifier Collection
Expand All @@ -66,7 +73,11 @@ public function setUp(): void
'firstname' => 'larry',
];

$this->token = JWT::encode($data, 'secretKey');
$this->tokenHS256 = JWT::encode($data, 'secretKey', 'HS256');

$privKey1 = file_get_contents(__DIR__ . '/../../data/rsa1-private.pem');
$this->tokenRS256 = JWT::encode($data, $privKey1, 'RS256', 'jwk1');

$this->identifiers = new IdentifierCollection([]);
}

Expand Down Expand Up @@ -97,7 +108,7 @@ public function testAuthenticateViaHeaderToken()
$this->request = ServerRequestFactory::fromGlobals(
['REQUEST_URI' => '/']
);
$this->request = $this->request->withAddedHeader('Authorization', 'Bearer ' . $this->token);
$this->request = $this->request->withAddedHeader('Authorization', 'Bearer ' . $this->tokenHS256);

$authenticator = new JwtAuthenticator($this->identifiers, [
'secretKey' => 'secretKey',
Expand All @@ -118,7 +129,7 @@ public function testUsingDeprecatedConfig()
$this->request = ServerRequestFactory::fromGlobals(
['REQUEST_URI' => '/']
);
$this->request = $this->request->withAddedHeader('Authorization', 'Bearer ' . $this->token);
$this->request = $this->request->withAddedHeader('Authorization', 'Bearer ' . $this->tokenHS256);

$this->deprecated(function () {
$authenticator = new JwtAuthenticator($this->identifiers, [
Expand All @@ -140,7 +151,7 @@ public function testAuthenticateViaQueryParamToken()
{
$this->request = ServerRequestFactory::fromGlobals(
['REQUEST_URI' => '/'],
['token' => $this->token]
['token' => $this->tokenHS256]
);

$authenticator = new JwtAuthenticator($this->identifiers, [
Expand All @@ -163,7 +174,7 @@ public function testAuthenticationViaIdentifierAndSubject()
{
$this->request = ServerRequestFactory::fromGlobals(
['REQUEST_URI' => '/'],
['token' => $this->token]
['token' => $this->tokenHS256]
);

$this->identifiers = $this->createMock(IdentifierCollection::class);
Expand Down Expand Up @@ -202,7 +213,7 @@ public function testAuthenticateInvalidPayloadNotAnObject()
{
$request = ServerRequestFactory::fromGlobals(
['REQUEST_URI' => '/'],
['token' => $this->token]
['token' => $this->tokenHS256]
);

$authenticator = $this->getMockBuilder(JwtAuthenticator::class)
Expand Down Expand Up @@ -233,7 +244,7 @@ public function testAuthenticateInvalidPayloadEmpty()
{
$request = ServerRequestFactory::fromGlobals(
['REQUEST_URI' => '/'],
['token' => $this->token]
['token' => $this->tokenHS256]
);

$authenticator = $this->getMockBuilder(JwtAuthenticator::class)
Expand Down Expand Up @@ -277,15 +288,15 @@ public function testInvalidToken()
}

/**
* testGetPayload
* testGetPayload with HS256 token
*
* @return void
*/
public function testGetPayload()
public function testGetPayloadHS256()
{
$this->request = ServerRequestFactory::fromGlobals(
['REQUEST_URI' => '/'],
['token' => $this->token]
['token' => $this->tokenHS256]
);

$authenticator = new JwtAuthenticator($this->identifiers, [
Expand All @@ -307,4 +318,36 @@ public function testGetPayload()
$result = $authenticator->getPayload();
$this->assertEquals($expected, (array)$result);
}

/**
* testGetPayload with RS256 token
*
* @return void
*/
public function testGetPayloadRS256()
{
$this->request = ServerRequestFactory::fromGlobals(
['REQUEST_URI' => '/'],
['token' => $this->tokenRS256]
);

$authenticator = new JwtAuthenticator($this->identifiers, [
'jwks' => json_decode(file_get_contents(__DIR__ . '/../../data/rsa-jwkset.json'), true),
]);

$result = $authenticator->getPayload();
$this->assertNull($result);

$authenticator->authenticate($this->request);

$expected = [
'subjectId' => 3,
'id' => 3,
'username' => 'larry',
'firstname' => 'larry',
];

$result = $authenticator->getPayload();
$this->assertEquals($expected, (array)$result);
}
}
2 changes: 1 addition & 1 deletion tests/TestCase/IdentityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public function testFieldMapping()
$this->assertTrue(isset($identity->first_name), 'old alias responds to isset.');
$this->assertFalse(isset($identity->missing));

$this->assertSame('florian', $identity['username'], 'renamed field responsds to offsetget');
$this->assertSame('florian', $identity['username'], 'renamed field responds to offsetget');
$this->assertSame('florian', $identity->username, 'renamed field responds to__get');
$this->assertNull($identity->missing);
}
Expand Down
2 changes: 1 addition & 1 deletion tests/TestCase/Middleware/AuthenticationMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@ public function testJwtTokenAuthorizationThroughTheMiddlewareStack()
'firstname' => 'larry',
];

$token = JWT::encode($data, 'secretKey');
$token = JWT::encode($data, 'secretKey', 'HS256');

$this->service = new AuthenticationService([
'identifiers' => [
Expand Down
18 changes: 18 additions & 0 deletions tests/data/rsa-jwkset.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "jwk1",
"alg": "RS256",
"n": "0Ttga33B1yX4w77NbpKyNYDNSVCo8j-RlZaZ9tI-KfkV1d-tfsvI9ZPAheP11FoN52ceBaY5ltelHW-IKwCfyT0orLdsxLgowaXki9woF1Azvcg2JVxQLv9aVjjAvy3CZFIG_EeN7J3nsyCXGnu1yMEbnvkWxA88__Q6HQ2K9wqfApkQ0LNlsK0YHz_sfjHNvRKxnbAJk7D5fUhZunPZXOPHXFgA5SvLvMaNIXduMKJh4OMfuoLdJowXJAR9j31Mqz_is4FMhm_9Mq7vZZ-uF09htRvIR8tRY28oJuW1gKWyg7cQQpnjHgFyG3XLXWAeXclWqyh_LfjyHQjrYhyeFw"
},
{
"kty": "RSA",
"e": "AQAB",
"kid": "jwk2",
"alg": "RS256",
"n": "pXi2o6AnNhwL30MaK_nuDHi2fxZHVen7Xwk0bjLGlHYpq3mSvXm2HBA-zR41vQCbHkYGsDpsyDhIXLBDTbSa7ue7D1ZqYdv5YLIS33zdX9GtUHfFHc6zYgXAU9ziWeyTzVn7icAbjxqcgT2xKNuGK7Zf2ZJ053rr-dxjAE-SjX4SG0WWUhwPjxlr1etF7mEurhHweuSdZYl36g39o9BtTBVfS87io2MwdIRsnL3w8ulgXRVRWjv-vvcuhMS_y6zGbzOC55Yr23sb4h2PSll32bgyglEIsGgHqjOdyjuUzl0t6jh86DHzbu9h-u1iihX8EI8t7CBbizbPPyHQygp-rQ"
}
]
}
27 changes: 27 additions & 0 deletions tests/data/rsa1-private.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA0Ttga33B1yX4w77NbpKyNYDNSVCo8j+RlZaZ9tI+KfkV1d+t
fsvI9ZPAheP11FoN52ceBaY5ltelHW+IKwCfyT0orLdsxLgowaXki9woF1Azvcg2
JVxQLv9aVjjAvy3CZFIG/EeN7J3nsyCXGnu1yMEbnvkWxA88//Q6HQ2K9wqfApkQ
0LNlsK0YHz/sfjHNvRKxnbAJk7D5fUhZunPZXOPHXFgA5SvLvMaNIXduMKJh4OMf
uoLdJowXJAR9j31Mqz/is4FMhm/9Mq7vZZ+uF09htRvIR8tRY28oJuW1gKWyg7cQ
QpnjHgFyG3XLXWAeXclWqyh/LfjyHQjrYhyeFwIDAQABAoIBAHMqdJsWAGEVNIVB
+792HYNXnydQr32PwemNmLeD59WglgU/9jZJoxaROjI4VLKK0wZg+uRvJ1nA3tCB
+Hh7Anh5Im9XExaAq2ZTkqXtC2AxtBktH6iW1EfaI/Y7jNRuMoaXo+Ku3A62p7cw
JBvepiOXL0Xko0RNguz7mBUvxCLPhYhzn7qCbM8uXLcjsXq/YhWQwQmtMqv0sd3W
Hy+8Jb2c18sqDeZIBne4dWD6qPClPEOsrq9gPTkl0DjbT27oVc2u1p4HMNm5BJIh
u3rMSxnZHUd7Axj1FgyLIOHl63UhaiaA1aPe/fLiVIGOA1jBZrpbnjgqDy9Uxyn6
eydbiwECgYEA9mtRydz22idyUOlBCDXk+vdGBvFAucNYaNNUAXUJ2wfPmdGgFCA7
g5eQG8JC6J/FU+2AfIuz6LGr7SxMBYcsWGjFAzGqs/sJib+zzN1dPUSRn4uJNFit
51yQzPgBqHS6S/XBi6YAODeZDl9jiPl3FxxucqLY5NstqZFXbE0SjIECgYEA2V3r
7xnRAK1krY1+zkPof4kcBmjqOXjnl/oRxlXP65lEXmyNJwm/ulOIko9mElWRs8CG
AxSWKaab9Gk6lc8MHjVRbuW52RGLGKq1mp6ENr4d3IBOfrNsTvD3gtNEN1JFLeF1
jIbSsrbi2txr7VZ06Irac0C/ytro0QDOUoXkvpcCgYA8O0EzmToRWsD7e/g0XJAK
s/Q+8CtE/LWYccc/z+7HxeH9lBqPsM07Pgmwb0xRdfQSrqPQTYl9ICiJAWHXnBG/
zmQRgstZ0MulCuGU+qq2thLuL3oq/F4NhjeykhA9r8J1nK1hSAMXuqdDtxcqPOfa
E03/4UQotFY181uuEiytgQKBgHQT+gjHqptH/XnJFCymiySAXdz2bg6fCF5aht95
t/1C7gXWxlJQnHiuX0KVHZcw5wwtBePjPIWlmaceAtE5rmj7ZC9qsqK/AZ78mtql
SEnLoTq9si1rN624dRUCKW25m4Py4MlYvm/9xovGJkSqZOhCLoJZ05JK8QWb/pKH
Oi6lAoGBAOUN6ICpMQvzMGPgIbgS0H/gvRTnpAEs59vdgrkhlCII4tzfgvBQlVae
hRcdM6GTMq5pekBPKu45eanIzwVc88P6coT4qiWYKk2jYoLBa0UV3xEAuqBMymrj
X4nLcSbZtO0tcDGMfMpWF2JGYOEJQNetPozL/ICGVFyIO8yzXm8U
-----END RSA PRIVATE KEY-----
27 changes: 27 additions & 0 deletions tests/data/rsa2-private.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEApXi2o6AnNhwL30MaK/nuDHi2fxZHVen7Xwk0bjLGlHYpq3mS
vXm2HBA+zR41vQCbHkYGsDpsyDhIXLBDTbSa7ue7D1ZqYdv5YLIS33zdX9GtUHfF
Hc6zYgXAU9ziWeyTzVn7icAbjxqcgT2xKNuGK7Zf2ZJ053rr+dxjAE+SjX4SG0WW
UhwPjxlr1etF7mEurhHweuSdZYl36g39o9BtTBVfS87io2MwdIRsnL3w8ulgXRVR
Wjv+vvcuhMS/y6zGbzOC55Yr23sb4h2PSll32bgyglEIsGgHqjOdyjuUzl0t6jh8
6DHzbu9h+u1iihX8EI8t7CBbizbPPyHQygp+rQIDAQABAoIBACF25kj1LLjutx/x
7CsUoqX3C8Fr+gVQCrxPmkDnF+4Sb570OU8EfGX0ix7kiy2sH7LhqpydVD6x00Cb
jSD785F5YAVcDqu31xlNKi/0irjEKO7rKfw7P2AFlb3gIA7bn5CaMBrNtUUdtqUU
mu2OZ/YTLhNMYUQnQe4IOiVn8lWW5D4Kje/RlLRRdGn8voXaD5BnOwZNXAxjdXqM
RxyXRG74tLKyfe3W8xTL8uhlKCNHjsdtUg9IZdnKT7I3DJPobpqgC3fUuC/IbfGf
MPK1aiu067/3DdgonC2ZWqFeKLJqtUa7z0pSQaZeDa1iiUuRivfqKYEBovFre6ni
1qHkp8ECgYEA089VnKc74NRGVbIs0VtQGprNhkl47eBq6jhTlG3hfaFF4VuDiZiu
wT8enlbhlbDb/gM0CDr9tkfDs7R4exNnhSVvn2PT8b1mhonOAeE466y/4YBA0d9x
gj0wF2vjH/bsVNBe6MBrIx12R2tBKTZ7tbCzgJRszSZqkrK7sljTlaUCgYEAx/54
G3Yd3ULqGIG/JA7w/QEYitgjwAUSJ+eLU+iqlIjo/njAJwJ/kixqaI3Jzcl+kYmp
yNIXNNaJUz8c0M/QsuqvQjLnHkF0FOZUrdyVseU2mSbI6DhAGsPJEtAOep/61vyz
uJSu0z34gQ6bNrKdqfkA7XIQRNJ1r0qQXrVLRmkCgYB2/UYaIDTaREZTBCp7XnHs
0ERfiUz/TZCijgweGXCQ1BXe2TtXBEhAVcZMq4BFSLr9wyzq5sD7Muu1O9BnS+pe
+T3w6/L4Hi/HqwjpM253r2+ILjW78Wvh/5/RuJE6tsvjhb+bv+UwL+/vhUhw76Ol
2WOt+zP4N/ms+e3J7m7G5QKBgQCmasN65nC3WyT8u4pX8O7rOOw5LN2ivRV8ixnO
+r5m1v46MjSCwXtyIO9yjPmt+csOQ+U6LEgPOa4PzWanAyaAmvS3OzBCZui3M2qn
OfR+kWM7UaDAS35cRyqcMvC5bUIHf0P1hhNryBdvHL5fZ4X2mDMDYnTTL+WptXwo
sucucQKBgAGHzi5+ZRwffhpZiYVR/lA6zvqyekAncJZwGe2UVDL0axTumX1NPdin
2mOnVuvKVvJkisyKTIQzFk6ClQEyiArO4+t7zhUbg5Crh8q6nObRo2R2NcP8o0Iq
BRIwPgaG/WlEvZ6zqlHQ0qH7WoL4HnRG5uyLOuzRIkjasYmZdfR8
-----END RSA PRIVATE KEY-----

0 comments on commit 71cfa8a

Please sign in to comment.