MixerApi JwtAuth
A JWT authentication library for CakePHP supporting both HMAC (HS256 or HS512) and RSA (RS256 or RS512) with JSON Web Keys. Before starting, you should determine which signing algorithm best fits your needs. It is the goal of this library to make both easy.
For an alternative approach see admad/cakephp-jwt-auth.
Installation
You can install this plugin into your CakePHP application using composer.
composer require mixerapi/jwt-auth
And then load the plugin
bin/cake plugin load MixerApi/JwtAuth
Configuration
Next create a config file (e.g. config/mixerapi_jwtauth.php
) and load it into your
application.
# in config/bootstrap.php
Configure::load('mixerapi_jwtauth');
alg
The alg
string is required and must be either HS256, HS512, RS256, or RS512.
secret
The secret
string is required when using HMAC. The secret should not be committed to your VCS and be at least 32
characters long. You can generate a strong secret using a tool like openssl or gpg:
openssl rand -base64 32 | cut -c1-32
gpg --gen-random 1 32 | base64 | cut -c1-32
keys
The keys
array is required when using RSA. The keys should not be committed to your VCS and be at least 2048 bits
long. You can generate a public/private keypair using openssl:
openssl genrsa -out config/keys/1/private.pem 2048
openssl rsa -in config/keys/1/private.pem -out config/keys/1/public.pem -pubout
Service Provider
Using the JwtAuthServiceProvider
is recommended to inject dependencies automatically.
# in src/Application.php
public function services(ContainerInterface $container): void
{
/** @var \League\Container\Container $container */
$container->addServiceProvider(new \MixerApi\JwtAuth\JwtAuthServiceProvider());
}
Authentication
You will need to configure CakePHP Authentication to use this library. There are several ways to do this documented in the quick start. See the mixerapi demo for a complete example.
Be sure to load the CakePHP Authentication.Component (generally in your AppController).
Here is an example that supports both HMAC and RSA with form and password based authentication. However way you
implement authentication, it is advised to use \MixerApi\JwtAuth\Configuration\Configuration
to pull values from
your MixerApi.JwtAuth
configuration file config/mixerapi_jwtauth.php
. This will validate your configuration before
applying it to your applications authentication.
# in src/Application.php
public function getAuthenticationService(ServerRequestInterface $request): \Authentication\AuthenticationServiceInterface
{
$fields = [
\Authentication\Identifier\IdentifierInterface::CREDENTIAL_USERNAME => 'email',
\Authentication\Identifier\IdentifierInterface::CREDENTIAL_PASSWORD => 'password',
];
$config = new \MixerApi\JwtAuth\Configuration\Configuration();
$service = new \Authentication\AuthenticationService();
$service->loadAuthenticator('Authentication.Form', [
'fields' => $fields,
'loginUrl' => '/admin/auth/login'
]);
$service->loadIdentifier('Authentication.JwtSubject');
if (str_starts_with(haystack: $config->getAlg(), needle: 'HS')) {
$service->loadAuthenticator('Authentication.Jwt', [
'secretKey' => $config->getSecret(),
'algorithm' => $config->getAlg(),
]);
} else if (str_starts_with(haystack: $config->getAlg(), needle: 'RS')) {
$jsonKeySet = \Cake\Cache\Cache::remember('jwkset', function() {
return json_encode((new \MixerApi\JwtAuth\Jwk\JwkSet)->getKeySet());
});
/*
* Caching is optional, you may also set the jwks key to the return value of (new JwkSet)->getKeySet()
*/
$service->loadAuthenticator('Authentication.Jwt', [
'jwks' => json_decode($jsonKeySet, true),
'algorithm' => $config->getAlg(),
]);
}
$service->loadIdentifier('Authentication.Password', ['fields' => $fields]);
return $service;
}
Defining your JWT
On your User entity implement JwtEntityInterface
. This will be used to generate the JWT, example:
namespace App\Model\Entity;
use Cake\ORM\Entity;
use MixerApi\JwtAuth\Jwt\Jwt;
use MixerApi\JwtAuth\Jwt\JwtEntityInterface;
use MixerApi\JwtAuth\Jwt\JwtInterface;
class User extends Entity implements JwtEntityInterface
{
/**
* @inheritDoc
*/
public function getJwt(): JwtInterface
{
return new Jwt(
exp: time() + 60 * 60 * 24,
sub: $this->get('id'),
iss: 'mixerapi',
aud: 'mixerapi-client',
nbf: null,
iat: time(),
jti: \Cake\Utility\Text::uuid(),
claims: [
'user' => [
'email' => $this->get('email')
]
]
);
}
}
JSON Web Keys
Signing your tokens with RSA uses a public/private key pair. You can skip this section if you are using HMAC.
Building Keys
We'll store the keys in config/keys/1/
but you can store these anywhere. Keys should not be stored in version
control, example:
# in config/mixerapi_jwtauth.php
return [
'MixerApi.JwtAuth' => [
'alg' => 'RS256',
'keys' => [
[
'kid' => '1',
'public' => file_get_contents(CONFIG . 'keys' . DS . '1' . DS . 'public.pem'),
'private' => file_get_contents(CONFIG . 'keys' . DS . '1' . DS . 'private.pem'),
]
]
]
];
JWK Set Controller
Read more about JSON Web Keys here. Let's create an endpoint to expose your JWK Set.
use Cake\Controller\Controller;
use Cake\Event\EventInterface;
use MixerApi\JwtAuth\Jwk\JwkSetInterface;
class JwksController extends Controller
{
public function beforeFilter(EventInterface $event)
{
parent::beforeFilter($event);
$this->Authentication->allowUnauthenticated(['index']);
}
public function index(JwkSetInterface $jwkSet)
{
$this->set('data', $jwkSet->getKeySet());
$this->viewBuilder()->setOption('serialize', 'data');
}
}
Add a route to your controller in your config/routes.php
file.
Example response:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "1",
"n": "wk865HbUKadJU-Mh-Iv2Z_30ZOMclkK1cbuiTVkINy_R9oHoAht2DS788q_Sll38dtTB4bzptd0u6k4cJd6Lj6nVQTe1uRyuAU47tqitiJmEXX_2SHIRv6aj4vygIfqr1FtQMHPlBW7r4q840H5mh_Z-E_a7d27QbtJ3eYNEiFow6LLvl17_7bdaenlwccY0j-PY1GzL7UwG8uHBZ78ZOcvu_GgaYC5suRrJrV_6_Qu6lySXObDaajr6Foz0m-z4Aj7KA8KmAiM_Rw_Yqm_KqPT3YBGj83TxeEiMPkrMYry123hFQYm09EO2Az9lGjr-PQc6SR08SDqZ3zbwe9iam55dzVZ-vQF3ASnZpBHyIDhCI7PFShceFI1Sv0RW7-Tl0uM2jQa1RyEpFle1xc0RxSFZium0aGMnFuE2W9JDERPw47wFZx2kSk1nB6PDK6XPLJLi_db0VrP5m5z2HDWeYVmsuAVFm6-l1PjiGH4G1TpuYfPKP2P8K-kveo1Ddm14IJSWfcACeAF_gx644Ua_IJ8wS98dQqE-R-jzfEv7aLBacP5_thCUbHfCRrAgtM5lBAM_1tfQ4XsOLnFWkl4arm3TzN2wCjjuqxipgwpUtY_SN6SXhJW4MW2qHVKtHtXl9haF5gEDBL7twDsFozYZCc5k0d85EgfJ5Jn7ZSAgwXk",
"e": "AQAB"
}
]
}
You may add/remove keys to your MixerApi.JwtAuth.keys
config as part of your key rotation strategy.
Note, if you are not using dependency injection:
public function index()
{
$this->set('data', (new JwkSet)->getKeySet());
$this->viewBuilder()->setOption('serialize', 'data');
}
Login Controller
In the example below we'll authenticate, create the JWT we defined earlier and return it to the requester.
use Cake\Controller\Controller;
use MixerApi\JwtAuth\JwtAuthenticatorInterface;
public function LoginController extends Controller
{
public function beforeFilter(EventInterface $event)
{
parent::beforeFilter($event);
$this->Authentication->allowUnauthenticated(['login']);
}
public function login(JwtAuthenticatorInterface $jwtAuth)
{
try {
return $this->response->withStringBody($jwtAuth->authenticate($this->Authentication));
} catch (UnauthenticatedException $e) {
return $this->response->withStringBody($e->getMessage())->withStatus(401);
}
}
}
Add a route to the controller in your config/routes.php
file.
This will build the JWT we defined earlier in the User Entity.
{
"iss": "mixerapi",
"sub": "5e28e9ed-f3e1-4eb2-aa88-8d618f4021ee",
"aud": "api-client",
"exp": 1651972707,
"jti": "a1f6f5ec-748d-4a1c-9d0e-f8e19ec7f9b2",
"user": {
"email": "test@example.com"
}
}
Note, if you're not using dependency injection:
public function login()
{
try {
return $this->response->withStringBody(
(new \MixerApi\JwtAuth\JwtAuthenticator)->authenticate($this->Authentication)
);
} catch (UnauthenticatedException $e) {
return $this->response->withStringBody($e->getMessage())->withStatus(401);
}
}
Or, if you prefer to handle the authentication yourself you may pass an instance of JwtInterface
instead, example:
public function login(JwtAuthenticatorInterface $jwtAuth)
{
try {
$result = $this->Authentication->getResult();
if (!$result->isValid()) {
throw new UnauthenticatedException();
}
return $this->response->withStringBody($jwtAuth->authenticate($result->getData()->getJwt()));
} catch (UnauthenticatedException $e) {
return $this->response->withStringBody($e->getMessage())->withStatus(401);
}
}
Security
Some security measures are baked into this library:
Weak HMAC secrets
JWT signed with HMAC can be brute forced with a tool like JWT Tool. Once cracked the JWT can be altered. This library mitigates this by requiring a minimum secret length of 32 characters though you may want to consider using 64 characters if security is more important than speed and token size. Generating a strong random secret and securing it is up to you.
Weak RSA Keys
Weak keys can be cracked as well. This library requires a minimum key length of 2048 bits. You may want to consider a key length of 4096 bits depending on your security requirements. Securing your keys is up to you.
Alg None Bypass
The alg=none signature-bypass vulnerability is mitigated by requiring a single valid algorithm. Additional protection exists within the firebase/php-jwt library which should be kept up to date.
RS/HS256 public key mismatch vulnerability
Mitigated by requiring a single valid algorithm. Additional protection exists within the firebase/php-jwt library which should be kept up to date.