diff --git a/docs/en/authenticators.rst b/docs/en/authenticators.rst index 7f909c6a..90c13a7b 100644 --- a/docs/en/authenticators.rst +++ b/docs/en/authenticators.rst @@ -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 `_ v5.5 or above to your app to use the ``JwtAuthenticator``. @@ -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:: diff --git a/src/Authenticator/JwtAuthenticator.php b/src/Authenticator/JwtAuthenticator.php index bc4921a6..417b615f 100644 --- a/src/Authenticator/JwtAuthenticator.php +++ b/src/Authenticator/JwtAuthenticator.php @@ -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; @@ -39,6 +41,7 @@ class JwtAuthenticator extends TokenAuthenticator 'returnPayload' => true, 'secretKey' => null, 'subjectKey' => IdentifierInterface::CREDENTIAL_JWT_SUBJECT, + 'jwks' => null, ]; /** @@ -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); diff --git a/tests/TestCase/Authenticator/JwtAuthenticatorTest.php b/tests/TestCase/Authenticator/JwtAuthenticatorTest.php index 2bf4138b..fffcc725 100644 --- a/tests/TestCase/Authenticator/JwtAuthenticatorTest.php +++ b/tests/TestCase/Authenticator/JwtAuthenticatorTest.php @@ -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 @@ -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([]); } @@ -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', @@ -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, [ @@ -140,7 +151,7 @@ public function testAuthenticateViaQueryParamToken() { $this->request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/'], - ['token' => $this->token] + ['token' => $this->tokenHS256] ); $authenticator = new JwtAuthenticator($this->identifiers, [ @@ -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); @@ -202,7 +213,7 @@ public function testAuthenticateInvalidPayloadNotAnObject() { $request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/'], - ['token' => $this->token] + ['token' => $this->tokenHS256] ); $authenticator = $this->getMockBuilder(JwtAuthenticator::class) @@ -233,7 +244,7 @@ public function testAuthenticateInvalidPayloadEmpty() { $request = ServerRequestFactory::fromGlobals( ['REQUEST_URI' => '/'], - ['token' => $this->token] + ['token' => $this->tokenHS256] ); $authenticator = $this->getMockBuilder(JwtAuthenticator::class) @@ -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, [ @@ -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); + } } diff --git a/tests/TestCase/IdentityTest.php b/tests/TestCase/IdentityTest.php index 2ec46877..dedb9cb0 100644 --- a/tests/TestCase/IdentityTest.php +++ b/tests/TestCase/IdentityTest.php @@ -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); } diff --git a/tests/TestCase/Middleware/AuthenticationMiddlewareTest.php b/tests/TestCase/Middleware/AuthenticationMiddlewareTest.php index 1f84c7f0..74b12fcf 100644 --- a/tests/TestCase/Middleware/AuthenticationMiddlewareTest.php +++ b/tests/TestCase/Middleware/AuthenticationMiddlewareTest.php @@ -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' => [ diff --git a/tests/data/rsa-jwkset.json b/tests/data/rsa-jwkset.json new file mode 100644 index 00000000..34fd885b --- /dev/null +++ b/tests/data/rsa-jwkset.json @@ -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" + } + ] +} diff --git a/tests/data/rsa1-private.pem b/tests/data/rsa1-private.pem new file mode 100644 index 00000000..b194b5b4 --- /dev/null +++ b/tests/data/rsa1-private.pem @@ -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----- diff --git a/tests/data/rsa2-private.pem b/tests/data/rsa2-private.pem new file mode 100644 index 00000000..74380869 --- /dev/null +++ b/tests/data/rsa2-private.pem @@ -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-----