diff --git a/README.md b/README.md index e4152cb..8f67643 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,47 @@ # PSR-7 and PSR-15 JWT Authentication Middleware +> [!IMPORTANT] +> This is a replacement for `tuupola/slim-jwt-auth` with the updated version of `firebase/php-jwt` to resolve +> [CVE-2021-46743](https://nvd.nist.gov/vuln/detail/CVE-2021-46743) for the meantime I plan to maintiane conpatability in v1 some, +> there is v2 I plan to deverge This middleware implements JSON Web Token Authentication. It was originally developed for Slim but can be used with any framework using PSR-7 and PSR-15 style middlewares. It has been tested with [Slim Framework](http://www.slimframework.com/) and [Zend Expressive](https://zendframework.github.io/zend-expressive/). -[![Latest Version](https://img.shields.io/packagist/v/tuupola/slim-jwt-auth.svg?style=flat-square)](https://packagist.org/packages/tuupola/slim-jwt-auth) -[![Packagist](https://img.shields.io/packagist/dm/tuupola/slim-jwt-auth.svg)](https://packagist.org/packages/tuupola/slim-jwt-auth) +[![Latest Version](https://img.shields.io/packagist/v/jimtools/jwt-auth.svg?style=flat-square)](https://packagist.org/packages/jimtools/jwt-auth) +[![Packagist](https://img.shields.io/packagist/dm/jimtools/jwt-auth.svg?style=flat-square)](https://packagist.org/packages/jimtools/jwt-auth) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) -[![Build Status](https://img.shields.io/github/actions/workflow/status/tuupola/slim-jwt-auth/tests.yml?branch=3.x&style=flat-square)](https://github.com/tuupola/slim-jwt-auth/actions) -[![Coverage](https://img.shields.io/codecov/c/github/tuupola/slim-jwt-auth/3.x.svg?style=flat-square)](https://codecov.io/github/tuupola/slim-jwt-auth/branch/3.x) +[![Build Status](https://img.shields.io/github/actions/workflow/status/jimtools/jwt-auth/tests.yml?branch=main&style=flat-square)](https://github.com/jimtools/jwt-auth/actions) +[![Coverage](https://img.shields.io/codecov/c/gh/jimtools/jwt-auth/main.svg?style=flat-square)](https://codecov.io/github/jimtools/jwt-auth/branch/main) -Heads up! You are reading documentation for [3.x branch](https://github.com/tuupola/slim-jwt-auth/tree/3.x) which is PHP 7.1 and up only. If you are using older version of PHP see the [2.x branch](https://github.com/tuupola/slim-jwt-auth/tree/2.x). These two branches are not backwards compatible, see [UPGRADING](https://github.com/tuupola/slim-jwt-auth/blob/3.x/UPGRADING.md) for instructions how to upgrade. + +Heads up! You are reading documentation for [3.x branch](https://github.com/tuupola/slim-jwt-auth/tree/3.x) which is PHP 7.4 and up only. If you are using older version of PHP see the [2.x branch](https://github.com/tuupola/slim-jwt-auth/tree/2.x). These two branches are not backwards compatible, see [UPGRADING](https://github.com/tuupola/slim-jwt-auth/blob/main/UPGRADING.md) for instructions how to upgrade. Middleware does **not** implement OAuth 2.0 authorization server nor does it provide ways to generate, issue or store authentication tokens. It only parses and authenticates a token when passed via header or cookie. This is useful for example when you want to use [JSON Web Tokens as API keys](https://auth0.com/blog/2014/12/02/using-json-web-tokens-as-api-keys/). For example implementation see [Slim API Skeleton](https://github.com/tuupola/slim-api-skeleton). +## Breaking Changes +Because of the way firebase/php-jwt:v6 now works, the way `secrets` and `algorithm` are pass needs to change so the following change will need to be made. + +```php +$app = new Slim\App; + +$app->add(new Tuupola\Middleware\JwtAuthentication([ + "secret" => [ + "acme" => "supersecretkeyyoushouldnotcommittogithub", + "beta" => "supersecretkeyyoushouldnotcommittogithub", + "algorithm" => [ + "amce" => "HS256", + "beta" => "HS384" + ] +])); +``` + ## Install Install latest version using [composer](https://getcomposer.org/). ``` bash -$ composer require tuupola/slim-jwt-auth +$ composer require jimtools/jwt-auth ``` If using Apache add the following to the `.htaccess` file. Otherwise PHP wont have access to `Authorization: Bearer` header. @@ -127,17 +149,28 @@ $app->add(new Tuupola\Middleware\JwtAuthentication([ ### Algorithm -You can set supported algorithms via `algorithm` parameter. This can be either string or array of strings. Default value is `["HS256", "HS512", "HS384"]`. Supported algorithms are `HS256`, `HS384`, `HS512` and `RS256`. Note that enabling both `HS256` and `RS256` is a [security risk](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/). +You can set supported algorithms via `algorithm` parameter. This can be either string or array of strings. Default value is `["HS256"]`. Supported algorithms are `HS256`, `HS384`, `HS512` and `RS256`. Note that enabling both `HS256` and `RS256` is a [security risk](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/). + +When passing multiple algorithm it be a key value array, with the key being the `KID` of the jwt. ``` php $app = new Slim\App; $app->add(new Tuupola\Middleware\JwtAuthentication([ - "secret" => "supersecretkeyyoushouldnotcommittogithub", - "algorithm" => ["HS256", "HS384"] + "secret" => [ + "acme" => "supersecretkeyyoushouldnotcommittogithub", + "beta" => "supersecretkeyyoushouldnotcommittogithub", + "algorithm" => [ + "amce" => "HS256", + "beta" => "HS384" + ] ])); ``` +> :warning: **Warning**:
+Because of changes in `firebase/php-jwt` the `kid` is now checked when multiple algorithm are passed, failing to provide a key the algorithm will be used for the kid. +this also means the `kid` will now need to be present in the JWT header as well. + ### Attribute When the token is decoded successfully and authentication succeeds the contents of the decoded token is saved as `token` attribute to the `$request` object. You can change this with. `attribute` parameter. Set to `null` or `false` to disable this behavour diff --git a/composer.json b/composer.json index 490a448..057d936 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "tuupola/slim-jwt-auth", - "description": "PSR-7 and PSR-15 JWT Authentication Middleware", + "name": "jimtools/jwt-auth", + "description": "Drop in replacement for tuupola/slim-jwt-auth", "keywords": [ "psr-7", "psr-15", @@ -9,20 +9,20 @@ "json", "auth" ], - "homepage": "https://github.com/tuupola/slim-jwt-auth", + "homepage": "https://github.com/jimtools/jwt-auth", "license": "MIT", "authors": [ { - "name": "Mika Tuupola", - "email": "tuupola@appelsiini.net", - "homepage": "https://appelsiini.net/", + "name": "James Read", + "email": "james.read.18@gmail.com", + "homepage": "https://github.com/jimtools", "role": "Developer" } ], "require": { "php": "^7.4|^8.0", "psr/log": "^1.0|^2.0|^3.0", - "firebase/php-jwt": "^3.0|^4.0|^5.0", + "firebase/php-jwt": "^6.0", "psr/http-message": "^1.0|^2.0", "tuupola/http-factory": "^1.3", "tuupola/callable-handler": "^1.0", @@ -50,5 +50,8 @@ "branch-alias": { "dev-3.x": "3.0.x-dev" } + }, + "conflict": { + "tuupola/slim-jwt-auth": "*" } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c88bd3a..8c455de 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,24 +1,82 @@ parameters: ignoreErrors: - - message: '#^Method .* is unused.$#' + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:after\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + - - message: "#^Offset 'secret' does not exist on array\\{secret\\?\\: array\\\\|string, secure\\: bool, relaxed\\: array\\, algorithm\\: array\\, header\\: string, regexp\\: string, cookie\\: string, attribute\\: string, \\.\\.\\.\\}\\.$#" + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:algorithm\\(\\) is unused\\.$#" count: 1 path: src/JwtAuthentication.php - - message: "#^Parameter \\#1 \\$callback of function call_user_func expects callable\\(\\)\\: mixed, array\\{\\$this\\(Tuupola\\\\Middleware\\\\JwtAuthentication\\), string\\} given\\.$#" + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:attribute\\(\\) is unused\\.$#" count: 1 path: src/JwtAuthentication.php - - message: "#^Parameter \\#1 \\$value of method SplDoublyLinkedList\\\\:\\:push\\(\\) expects Tuupola\\\\Middleware\\\\JwtAuthentication\\\\RuleInterface, callable\\(\\)\\: mixed given\\.$#" + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:before\\(\\) is unused\\.$#" count: 1 path: src/JwtAuthentication.php - - message: "#^Property Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:\\$message is unused\\.$#" + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:cookie\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:error\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:header\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:ignore\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:logger\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:path\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:regexp\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:relaxed\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:rules\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:secret\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Method Tuupola\\\\Middleware\\\\JwtAuthentication\\:\\:secure\\(\\) is unused\\.$#" + count: 1 + path: src/JwtAuthentication.php + + - + message: "#^Parameter \\#1 \\$value of method SplDoublyLinkedList\\\\:\\:push\\(\\) expects Tuupola\\\\Middleware\\\\JwtAuthentication\\\\RuleInterface, callable\\(\\)\\: mixed given\\.$#" count: 1 path: src/JwtAuthentication.php diff --git a/src/JwtAuthentication.php b/src/JwtAuthentication.php index 6a453bd..e0dab4c 100644 --- a/src/JwtAuthentication.php +++ b/src/JwtAuthentication.php @@ -34,13 +34,15 @@ namespace Tuupola\Middleware; +use ArrayAccess; use Closure; use DomainException; -use InvalidArgumentException; use Exception; use Firebase\JWT\JWT; -use Psr\Http\Message\ServerRequestInterface; +use Firebase\JWT\Key; +use InvalidArgumentException; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; @@ -52,6 +54,11 @@ use Tuupola\Middleware\JwtAuthentication\RequestPathRule; use Tuupola\Middleware\JwtAuthentication\RuleInterface; +use function array_fill_keys; +use function array_keys; +use function count; +use function is_array; + final class JwtAuthentication implements MiddlewareInterface { use DoublePassTrait; @@ -72,10 +79,10 @@ final class JwtAuthentication implements MiddlewareInterface * Stores all the options passed to the middleware. * * @var array{ - * secret?: string|array, + * secret?: string|array|array, * secure: bool, * relaxed: array, - * algorithm: array, + * algorithm: array|array, * header: string, * regexp: string, * cookie: string, @@ -90,7 +97,7 @@ final class JwtAuthentication implements MiddlewareInterface private array $options = [ "secure" => true, "relaxed" => ["localhost", "127.0.0.1"], - "algorithm" => ["HS256", "HS512", "HS384"], + "algorithm" => ["HS256"], "header" => "Authorization", "regexp" => "/Bearer\s+(.*)$/i", "cookie" => "token", @@ -306,11 +313,15 @@ private function fetchToken(ServerRequestInterface $request): string */ private function decodeToken(string $token): array { + $keys = $this->createKeysFromAlgorithms(); + if (count($keys) === 1) { + $keys = current($keys); + } + try { $decoded = JWT::decode( $token, - $this->options["secret"], - (array) $this->options["algorithm"] + $keys, ); return (array) $decoded; } catch (Exception $exception) { @@ -326,6 +337,16 @@ private function decodeToken(string $token): array */ private function hydrate(array $data = []): void { + $data['algorithm'] = $data['algorithm'] ?? $this->options['algorithm']; + if ((is_array($data['secret']) || $data['secret'] instanceof ArrayAccess) + && is_array($data['algorithm']) + && count($data['algorithm']) === 1 + && count((array) $data['secret']) > count($data['algorithm']) + ) { + $secretIndex = array_keys((array) $data['secret']); + $data['algorithm'] = array_fill_keys($secretIndex, $data['algorithm'][0]); + } + foreach ($data as $key => $value) { /* https://github.com/facebook/hhvm/issues/6368 */ $key = str_replace(".", " ", $key); @@ -504,4 +525,31 @@ private function rules(array $rules): void $this->rules->push($callable); } } + + /** + * @return array + */ + private function createKeysFromAlgorithms(): array + { + if (!isset($this->options["secret"])) { + throw new InvalidArgumentException( + 'Secret must be either a string or an array of "kid" => "secret" pairs' + ); + } + + $keyObjects = []; + foreach ($this->options["algorithm"] as $kid => $algorithm) { + $keyId = !is_numeric($kid) ? $kid : $algorithm; + + $secret = $this->options["secret"]; + + if (is_array($secret) || $secret instanceof ArrayAccess) { + $secret = $this->options["secret"][$kid]; + } + + $keyObjects[$keyId] = new Key($secret, $algorithm); + } + + return $keyObjects; + } } diff --git a/tests/ArrayAccessImpl.php b/tests/ArrayAccessImpl.php deleted file mode 100644 index 9f3fa33..0000000 --- a/tests/ArrayAccessImpl.php +++ /dev/null @@ -1,58 +0,0 @@ -array[$offset]); - } - - public function offsetGet($offset) - { - return $this->array[$offset]; - } - - public function offsetSet($offset, $value) - { - $this->array[$offset] = $value; - } - - public function offsetUnset($offset) - { - unset($this->array[$offset]); - } -} diff --git a/tests/JwtAuthenticationTest.php b/tests/JwtAuthenticationTest.php index 12c704f..9f232f6 100644 --- a/tests/JwtAuthenticationTest.php +++ b/tests/JwtAuthenticationTest.php @@ -32,6 +32,7 @@ namespace Tuupola\Middleware; +use ArrayObject; use Equip\Dispatch\MiddlewareCollection; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -210,7 +211,34 @@ public function testShouldReturn200WithSecretArray(): void "secret" => [ "acme" =>"supersecretkeyyoushouldnotcommittogithub", "beta" =>"anothersecretkeyfornevertocommittogithub" - ] + ], + ]) + ]); + + $response = $collection->dispatch($request, $default); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals("Success", $response->getBody()); + } + + public function testShouldReturn200WithSecretArrayCheckKid(): void + { + $request = (new ServerRequestFactory) + ->createServerRequest("GET", "https://example.com/api") + ->withHeader("Authorization", "Bearer " . self::$betaToken); + + $default = function (ServerRequestInterface $request) { + $response = (new ResponseFactory)->createResponse(); + $response->getBody()->write("Success"); + return $response; + }; + + $collection = new MiddlewareCollection([ + new JwtAuthentication([ + "algorithm" => ["acme" => "HS256", "beta" => "HS256"], + "secret" => [ + "acme" =>"supersecretkeyyoushouldnotcommittogithub", + "beta" =>"anothersecretkeyfornevertocommittogithub" + ], ]) ]); @@ -257,13 +285,13 @@ public function testShouldReturn200WithSecretArrayAccess(): void return $response; }; - $secret = new ArrayAccessImpl(); + $secret = new ArrayObject(); $secret["acme"] = "supersecretkeyyoushouldnotcommittogithub"; $secret["beta"] ="anothersecretkeyfornevertocommittogithub"; $collection = new MiddlewareCollection([ new JwtAuthentication([ - "secret" => $secret + "secret" => $secret, ]) ]); @@ -284,13 +312,13 @@ public function testShouldReturn401WithSecretArrayAccess(): void return $response; }; - $secret = new ArrayAccessImpl(); + $secret = new ArrayObject(); $secret["xxxx"] = "supersecretkeyyoushouldnotcommittogithub"; $secret["yyyy"] = "anothersecretkeyfornevertocommittogithub"; $collection = new MiddlewareCollection([ new JwtAuthentication([ - "secret" => $secret + "secret" => $secret, ]) ]);