From 17247dc5d9241fc8d9a9d1d9bc649d19a8121c6f Mon Sep 17 00:00:00 2001 From: Vincent QUATREVIEUX Date: Tue, 1 Aug 2023 16:48:16 +0200 Subject: [PATCH] Setup library --- .github/workflows/php.yml | 77 ++++++++++++ .gitignore | 7 ++ LICENSE | 21 ++++ Makefile | 45 +++++++ composer.json | 41 ++++++ infection.json.dist | 14 +++ phpunit.xml.dist | 26 ++++ psalm.xml | 15 +++ src/JWA.php | 230 ++++++++++++++++++++++++++++++++++ src/JWT.php | 75 +++++++++++ src/JwtDecoder.php | 115 +++++++++++++++++ src/JwtEncoder.php | 124 ++++++++++++++++++ tests/JWATest.php | 194 ++++++++++++++++++++++++++++ tests/JwtDecoderTest.php | 219 ++++++++++++++++++++++++++++++++ tests/JwtEncoderTest.php | 140 +++++++++++++++++++++ tests/assets/other.key | 27 ++++ tests/assets/private.key | 27 ++++ tests/assets/public-other.key | 9 ++ tests/assets/public.key | 9 ++ 19 files changed, 1415 insertions(+) create mode 100644 .github/workflows/php.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 composer.json create mode 100644 infection.json.dist create mode 100755 phpunit.xml.dist create mode 100644 psalm.xml create mode 100644 src/JWA.php create mode 100644 src/JWT.php create mode 100644 src/JwtDecoder.php create mode 100644 src/JwtEncoder.php create mode 100644 tests/JWATest.php create mode 100644 tests/JwtDecoderTest.php create mode 100644 tests/JwtEncoderTest.php create mode 100644 tests/assets/other.key create mode 100644 tests/assets/private.key create mode 100644 tests/assets/public-other.key create mode 100644 tests/assets/public.key diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..4eca166 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,77 @@ +name: Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + name: PHP ${{ matrix.php-versions }} + + steps: + - uses: actions/checkout@v2 + + - name: Set Timezone + uses: szenius/set-timezone@v1.0 + with: + timezoneLinux: "Europe/Paris" + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + ini-values: date.timezone=Europe/Paris + - name: Check PHP Version + run: php -v + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: make tests-unit + + analysis: + name: Analysis + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set Timezone + uses: szenius/set-timezone@v1.0 + with: + timezoneLinux: "Europe/Paris" + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + extensions: json + ini-values: date.timezone=Europe/Paris + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run type coverage + run: make psalm-ci + + - name: Run Infection + run: | + git fetch --depth=1 origin $GITHUB_BASE_REF + make infection-ci + + - name: Run PHPCS + run: make phpcs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..957fcde --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +vendor/ +infection.log +infection.phar +infection.phar.asc +.phpunit.result.cache +composer.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6d4b293 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 b2pweb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7cac5cc --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +ROOT_DIR=$(shell pwd)/ +TESTDIR=$(ROOT_DIR)/tests +PHPUNIT=vendor/bin/phpunit +INFECTION_VERSION=0.15.3 +INFECTION_ARGS= + +all: install tests + +install: + composer update + +tests: run-phpunit psalm run-infection phpcs + +coverage: PUARGS="--coverage-clover=coverage.xml" +coverage: tests-unit + +tests-unit: run-phpunit + +run-phpunit: + @$(PHPUNIT) $(PUARGS) + +psalm: + vendor/bin/psalm + +psalm-ci: + vendor/bin/psalm --shepherd + +infection.phar: + wget --no-check-certificate "https://github.com/infection/infection/releases/download/$(INFECTION_VERSION)/infection.phar" + wget --no-check-certificate "https://github.com/infection/infection/releases/download/$(INFECTION_VERSION)/infection.phar.asc" + chmod +x infection.phar + +infection: infection.phar test-server run-infection kill-test-server + +infection-ci: INFECTION_ARGS=--logger-github --git-diff-filter=AM +infection-ci: INFECTION_VERSION=0.23.0 +infection-ci: infection + +phpcs: + vendor/bin/phpcs src/ --standard=psr12 --runtime-set ignore_warnings_on_exit true + +run-infection: infection.phar + ./infection.phar $(INFECTION_ARGS) + +.PHONY: tests test-server clean install infection infection-ci psalm psalm-ci phpcs diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..da40430 --- /dev/null +++ b/composer.json @@ -0,0 +1,41 @@ +{ + "name": "b2pweb/jwt", + "description": "Simple library for parse JWT token", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "B2pweb\\Jwt\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "B2pweb\\Jwt\\Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Vincent Quatrevieux", + "email": "vquatrevieux@b2pweb.com" + } + ], + "minimum-stability": "stable", + "require": { + "php": "~7.1 | ~8.0.0 | ~8.1.0 | ~8.2.0", + "ext-json": "*", + "spomky-labs/base64url": "~2.0", + "web-token/jwt-signature": "~1.3|~2.0|~3.0", + "web-token/jwt-checker": "~1.3|~2.0|~3.0", + "web-token/jwt-key-mgmt": "~1.3|~2.0|~3.0", + "web-token/jwt-signature-algorithm-ecdsa": "~1.3|~2.0|~3.0", + "web-token/jwt-signature-algorithm-eddsa": "~1.3|~2.0|~3.0", + "web-token/jwt-signature-algorithm-hmac": "~1.3|~2.0|~3.0", + "web-token/jwt-signature-algorithm-none": "~1.3|~2.0|~3.0", + "web-token/jwt-signature-algorithm-rsa": "~1.3|~2.0|~3.0" + }, + "require-dev": { + "phpunit/phpunit": "~7.0 | ~8.5", + "vimeo/psalm": "~4.9", + "squizlabs/php_codesniffer": "~3.6" + } +} diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..60a2a84 --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,14 @@ +{ + "timeout": 10, + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": "infection.log" + }, + "mutators": { + "@default": true + } +} \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100755 index 0000000..9f5f8f9 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + + + + + + tests + + + + + src + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..3240886 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/src/JWA.php b/src/JWA.php new file mode 100644 index 0000000..dccb8b6 --- /dev/null +++ b/src/JWA.php @@ -0,0 +1,230 @@ +, hash?: string, type: JWA::TYPE_*}> + */ + private $algMap = [ + // HMAC : https://tools.ietf.org/html/rfc7518#section-3.2 + 'HS256' => ['class' => HS256::class, 'hash' => 'sha256', 'type' => self::TYPE_HMAC], + 'HS384' => ['class' => HS384::class, 'hash' => 'sha384', 'type' => self::TYPE_HMAC], + 'HS512' => ['class' => HS512::class, 'hash' => 'sha512', 'type' => self::TYPE_HMAC], + + // RSA : https://tools.ietf.org/html/rfc7518#section-3.3 + 'RS256' => ['class' => RS256::class, 'hash' => 'sha256', 'type' => self::TYPE_RSA], + 'RS384' => ['class' => RS384::class, 'hash' => 'sha384', 'type' => self::TYPE_RSA], + 'RS512' => ['class' => RS512::class, 'hash' => 'sha512', 'type' => self::TYPE_RSA], + + // ECDSA : https://tools.ietf.org/html/rfc7518#section-3.4 + 'ES256' => ['class' => ES256::class, 'hash' => 'sha256', 'type' => self::TYPE_ELLIPTIC_CURVE], + 'ES384' => ['class' => ES384::class, 'hash' => 'sha384', 'type' => self::TYPE_ELLIPTIC_CURVE], + 'ES512' => ['class' => ES512::class, 'hash' => 'sha512', 'type' => self::TYPE_ELLIPTIC_CURVE], + + // RSASSA-PSS : https://tools.ietf.org/html/rfc7518#section-3.5 + 'PS256' => ['class' => PS256::class, 'hash' => 'sha256', 'type' => self::TYPE_RSASSA_PSS], + 'PS384' => ['class' => PS384::class, 'hash' => 'sha384', 'type' => self::TYPE_RSASSA_PSS], + 'PS512' => ['class' => PS512::class, 'hash' => 'sha512', 'type' => self::TYPE_RSASSA_PSS], + + // Unsecure : https://tools.ietf.org/html/rfc7518#section-3.6 + 'none' => ['class' => None::class, 'type' => self::TYPE_NONE], + ]; + + /** + * Map of enabled algorithms + * + * @var array + */ + private $enabled = [ + 'HS256' => true, + 'HS384' => true, + 'HS512' => true, + 'RS256' => true, + 'RS384' => true, + 'RS512' => true, + 'ES256' => true, + 'ES384' => true, + 'ES512' => true, + 'PS256' => true, + 'PS384' => true, + 'PS512' => true, + ]; + + /** + * @var AlgorithmManager|null + */ + private $manager; + + /** + * Get list of algorithms identifiers (alg header parameter) for a given type + * + * @param string $type One of the JWA::TYPE_ constant + * + * @return string[] + */ + public function algorithmsByType(string $type): array + { + $algorithms = []; + + foreach ($this->enabled as $id => $enabled) { + if ($enabled && $this->algMap[$id]['type'] === $type) { + $algorithms[] = $id; + } + } + + return $algorithms; + } + + /** + * Get the hash algorithm used by the given alg + * + * @param string $alg The "alg" header parameter + * + * @return string The hash algorithm + * + * @throws InvalidArgumentException When an unsupported alg is given + */ + public function hashAlgorithm(string $alg): string + { + if (empty($this->enabled[$alg]) || empty($this->algMap[$alg]['hash'])) { + throw new InvalidArgumentException('Unsupported alg "' . $alg . '"'); + } + + return $this->algMap[$alg]['hash']; + } + + /** + * Enable (or disable) an algorithm + * + * @param string $alg The alg id + * @param bool $value Enable ? + * + * @return $this + */ + public function enable(string $alg, bool $value = true): self + { + if (!isset($this->algMap[$alg]) || !class_exists($this->algMap[$alg]['class'])) { + throw new InvalidArgumentException('Unsupported alg "' . $alg . '"'); + } + + $this->enabled[$alg] = $value; + $this->manager = null; + + return $this; + } + + /** + * Filter the algorithms, and returns a new instance of JWA representing the subset of algorithms + * This method will not modify the current instance + * + * @param string[] $algorithms List of algorithms to keep + * + * @return self The new instance + */ + public function filter(array $algorithms): self + { + $jwa = clone $this; + + $algorithms = array_flip($algorithms); + + // Enable intersection of already enabled algorithms with $algorithms + foreach ($jwa->enabled as $alg => $enabled) { + $jwa->enabled[$alg] = $enabled && isset($algorithms[$alg]); + } + + // A manager is already instantiated : filters enabled algorithms and recreates a new manager + if ($jwa->manager) { + $algorithms = []; + + foreach ($jwa->enabled as $alg => $enabled) { + if ($enabled && $jwa->manager->has($alg)) { + $algorithms[] = $jwa->manager->get($alg); + } + } + + $jwa->manager = new AlgorithmManager($algorithms); + } + + return $jwa; + } + + /** + * Get the algorithm manager + * + * @return AlgorithmManager + */ + public function manager(): AlgorithmManager + { + if ($this->manager !== null) { + return $this->manager; + } + + $algorithms = []; + + foreach ($this->enabled as $id => $enabled) { + if ($enabled) { + $className = $this->algMap[$id]['class']; + + if (class_exists($className)) { + $algorithms[] = new $className(); + } + } + } + + return $this->manager = new AlgorithmManager($algorithms); + } + + /** + * Register a new algorithm + * + * @param string $alg The "alg" header parameter + * @param class-string $class The algorithm implementation class + * @param JWA::TYPE_* $type The algorithm type + * @param string|null $hash The hash function + */ + public function register(string $alg, string $class, string $type, ?string $hash = null): void + { + $this->algMap[$alg] = ['class' => $class, 'type' => $type]; + + if ($hash) { + $this->algMap[$alg]['hash'] = $hash; + } + } +} diff --git a/src/JWT.php b/src/JWT.php new file mode 100644 index 0000000..22dafe3 --- /dev/null +++ b/src/JWT.php @@ -0,0 +1,75 @@ +encoded = $encoded; + $this->headers = $headers; + $this->payload = $payload; + } + + /** + * Get the raw encoded value of the JWT + * + * @return string + */ + public function encoded(): string + { + return $this->encoded; + } + + /** + * Merged value of protected and unprotected headers + * + * @return array + */ + public function headers(): array + { + return $this->headers; + } + + /** + * The JWT payload + * + * @return array + */ + public function payload(): array + { + return $this->payload; + } +} diff --git a/src/JwtDecoder.php b/src/JwtDecoder.php new file mode 100644 index 0000000..90aa49f --- /dev/null +++ b/src/JwtDecoder.php @@ -0,0 +1,115 @@ +jwa = $jwa ?: new JWA(); + $this->serializerManager = $serializerManager ?: new JWSSerializerManager([new CompactSerializer()]); + } + + /** + * Get the supported algorithms + * + * @return JWA + */ + public function jwa(): JWA + { + return $this->jwa; + } + + /** + * Define supported algorithms + * + * @param string[] $algorithms + * + * @return self A new JwtDecoder instance, with filtered algorithms + * + * @see JWA::filter() + */ + public function supportedAlgorithms(array $algorithms): self + { + $decoder = clone $this; + + $decoder->jwa = $decoder->jwa->filter($algorithms); + + return $decoder; + } + + /** + * Decode the JWT string + * + * @param string $jwt String to decode + * @param JWKSet $keySet Keys to use + * + * @return JWT + * + * @throws InvalidArgumentException When cannot decode the JWT string + */ + public function decode(string $jwt, JWKSet $keySet): JWT + { + $loader = new JWSLoader( + $this->serializerManager, + new JWSVerifier($this->jwa->manager()), + new HeaderCheckerManager([new AlgorithmChecker($this->jwa->manager()->list())], [new JWSTokenSupport()]) + ); + + try { + $decoded = $loader->loadAndVerifyWithKeySet($jwt, $keySet, $signatureOffset); + } catch (Exception $e) { + throw new InvalidArgumentException('Invalid JWT or signature', 0, $e); + } + + /** @psalm-suppress PossiblyNullArrayOffset */ + $signature = $decoded->getSignatures()[$signatureOffset]; + + $payload = json_decode((string) $decoded->getPayload(), true); + + if (!is_array($payload)) { + throw new InvalidArgumentException('Invalid JWT payload'); + } + + /** @psalm-suppress PossiblyNullArgument */ + return new JWT( + $jwt, + $signature->getProtectedHeader() + $signature->getHeader(), + $payload + ); + } +} diff --git a/src/JwtEncoder.php b/src/JwtEncoder.php new file mode 100644 index 0000000..4c37e31 --- /dev/null +++ b/src/JwtEncoder.php @@ -0,0 +1,124 @@ +jwa = $jwa ?? new JWA(); + $this->serializer = $serializer ?? new CompactSerializer(); + } + + /** + * Get the supported algorithms + * + * @return JWA + */ + public function jwa(): JWA + { + return $this->jwa; + } + + /** + * Define supported algorithms + * + * @param string[] $algorithms + * + * @return self A new JwtEncoder instance, with filtered algorithms + * + * @see JWA::filter() + */ + public function supportedAlgorithms(array $algorithms): self + { + $decoder = clone $this; + + $decoder->jwa = $decoder->jwa->filter($algorithms); + + return $decoder; + } + + /** + * Decode the JWT string + * + * @param mixed $payload Payload to encode + * @param JWKSet $keySet Keys to use + * @param string $algorithm Algorithm to use + * @param string|null $kid Key ID to use. If null, the first key matching the algorithm will be used + * + * @return string The encoded JWT + * + * @throws InvalidArgumentException When cannot found any valid key, or the requested algorithm is not supported + * @psalm-suppress NullArgument + * @psalm-suppress TooManyArguments + */ + public function encode($payload, JWKSet $keySet, string $algorithm = 'RS256', ?string $kid = null): string + { + static $isLegacyJwsBuilder = null; + + if ($isLegacyJwsBuilder === null) { + $ctor = (new \ReflectionClass(JWSBuilder::class))->getConstructor(); + /** @psalm-suppress PossiblyNullReference */ + $isLegacyJwsBuilder = $ctor->getNumberOfParameters() === 2; + } + + $manager = $this->jwa->manager(); + $jwsBuilder = $isLegacyJwsBuilder + ? new JWSBuilder(null, $manager) + : new JWSBuilder($manager) + ; + + $key = $keySet->selectKey( + 'sig', + $manager->get($algorithm), + $kid ? ['kid' => $kid] : [] + ); + + if (!$key) { + throw new InvalidArgumentException('Cannot found any valid key'); + } + + $sigHeader = ['alg' => $algorithm]; + + if ($kid) { + $sigHeader['kid'] = $kid; + } + + $jws = $jwsBuilder->create() + ->withPayload(json_encode($payload)) + ->addSignature($key, $sigHeader) + ->build() + ; + + return $this->serializer->serialize($jws); + } +} diff --git a/tests/JWATest.php b/tests/JWATest.php new file mode 100644 index 0000000..cfdfbca --- /dev/null +++ b/tests/JWATest.php @@ -0,0 +1,194 @@ +jwa = new JWA(); + } + + /** + * + */ + public function test_manager() + { + $manager = $this->jwa->manager(); + $this->assertInstanceOf(AlgorithmManager::class, $manager); + $this->assertEquals(['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512'], $manager->list()); + $this->assertSame($manager, $this->jwa->manager()); + + $this->jwa + ->enable('HS384', false) + ->enable('RS384', false) + ->enable('PS384', false) + ->enable('ES384', false) + ; + + $this->assertNotSame($manager, $this->jwa->manager()); + $this->assertEquals(['HS256', 'HS512', 'RS256', 'RS512', 'ES256', 'ES512', 'PS256', 'PS512'], $this->jwa->manager()->list()); + } + + /** + * + */ + public function test_hashAlgorithm_not_enabled() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported alg "HS256"'); + + $this->jwa->enable('HS256', false); + $this->jwa->hashAlgorithm('HS256'); + } + + /** + * + */ + public function test_hashAlgorithm_not_supported() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported alg "none"'); + + $this->jwa->enable('none'); + $this->jwa->hashAlgorithm('none'); + } + + /** + * + */ + public function test_hashAlgorithm() + { + $this->assertEquals('sha256', $this->jwa->hashAlgorithm('HS256')); + $this->assertEquals('sha256', $this->jwa->hashAlgorithm('RS256')); + $this->assertEquals('sha256', $this->jwa->hashAlgorithm('ES256')); + $this->assertEquals('sha256', $this->jwa->hashAlgorithm('PS256')); + $this->assertEquals('sha384', $this->jwa->hashAlgorithm('HS384')); + $this->assertEquals('sha384', $this->jwa->hashAlgorithm('RS384')); + $this->assertEquals('sha384', $this->jwa->hashAlgorithm('ES384')); + $this->assertEquals('sha384', $this->jwa->hashAlgorithm('PS384')); + $this->assertEquals('sha512', $this->jwa->hashAlgorithm('HS512')); + $this->assertEquals('sha512', $this->jwa->hashAlgorithm('RS512')); + $this->assertEquals('sha512', $this->jwa->hashAlgorithm('ES512')); + $this->assertEquals('sha512', $this->jwa->hashAlgorithm('PS512')); + } + + /** + * + */ + public function test_algorithmByType() + { + $this->assertEquals(['HS256', 'HS384', 'HS512'], $this->jwa->algorithmsByType(JWA::TYPE_HMAC)); + $this->assertEquals(['RS256', 'RS384', 'RS512'], $this->jwa->algorithmsByType(JWA::TYPE_RSA)); + $this->assertEquals(['PS256', 'PS384', 'PS512'], $this->jwa->algorithmsByType(JWA::TYPE_RSASSA_PSS)); + $this->assertEquals(['ES256', 'ES384', 'ES512'], $this->jwa->algorithmsByType(JWA::TYPE_ELLIPTIC_CURVE)); + $this->assertEquals([], $this->jwa->algorithmsByType('not found')); + } + + /** + * + */ + public function test_algorithmByType_disabled() + { + $this->jwa->enable('HS384', false); + $this->assertEquals(['HS256', 'HS512'], $this->jwa->algorithmsByType(JWA::TYPE_HMAC)); + } + + /** + * + */ + public function test_filter() + { + $old = clone $this->jwa; + $jwa = $this->jwa->filter(['HS256', 'RS512', 'none']); + + $this->assertEquals($old, $this->jwa); + $this->assertNotSame($jwa, $this->jwa); + $this->assertEquals(['HS256', 'RS512'], $jwa->manager()->list()); + } + + /** + * + */ + public function test_filter_with_already_instantiated_manager() + { + $manager = $this->jwa->manager(); + $jwa = $this->jwa->filter(['HS256', 'RS512', 'none']); + + $this->assertEquals(['HS256', 'RS512'], $jwa->manager()->list()); + $this->assertSame($manager->get('HS256'), $jwa->manager()->get('HS256')); + $this->assertSame($manager->get('RS512'), $jwa->manager()->get('RS512')); + } + + /** + * + */ + public function test_register() + { + $this->jwa->register('custom', RS256::class, 'type', 'hash'); + $this->jwa->enable('custom'); + + $this->assertEquals('hash', $this->jwa->hashAlgorithm('custom')); + $this->assertEquals(['custom'], $this->jwa->algorithmsByType('type')); + } + + /** + * + */ + public function test_enable_not_available() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported alg "not found"'); + + $this->jwa->enable('not found'); + } + + /** + * + */ + public function test_enable_class_not_exists() + { + $this->jwa->register('NOTFOUND', 'NotFound', JWA::TYPE_HMAC, 'sha1'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported alg "NOTFOUND"'); + + $this->jwa->enable('NOTFOUND'); + } + + /** + * + */ + public function test_manager_with_class_not_found_should_be_filtered() + { + $jwa = $this->jwa->filter(['RS256', 'HS512']); + $jwa->register('NOTFOUND', 'NotFound', JWA::TYPE_HMAC, 'sha1'); + + $this->assertEquals(['HS512', 'RS256'], $jwa->manager()->list()); + } + + /** + * + */ + public function test_filter_should_ignore_not_found_algo_class() + { + $this->jwa->register('NOTFOUND', 'NotFound', JWA::TYPE_HMAC, 'sha1'); + $this->jwa->manager(); + $jwa = $this->jwa->filter(['RS256', 'HS512', 'NOTFOUND']); + + $this->assertEquals(['HS512', 'RS256'], $jwa->manager()->list()); + } +} diff --git a/tests/JwtDecoderTest.php b/tests/JwtDecoderTest.php new file mode 100644 index 0000000..38c7f89 --- /dev/null +++ b/tests/JwtDecoderTest.php @@ -0,0 +1,219 @@ +decoder = new JwtDecoder(); + $this->serializer = new CompactSerializer(); + } + + /** + * + */ + public function test_decode_success() + { + $jws = $this->jwsBuilder([new RS256()]) + ->create() + ->addSignature(JWKFactory::createFromKeyFile(__DIR__.'/assets/private.key'), ['alg' => 'RS256']) + ->withPayload('{"foo":"bar"}') + ->build() + ; + + $jws = $this->serializer->serialize($jws); + + $decoded = $this->decoder->decode($jws, new JWKSet([ + JWKFactory::createFromKeyFile(__DIR__.'/assets/public.key', null, ['alg' => 'RS256']) + ])); + + $this->assertEquals($jws, $decoded->encoded()); + $this->assertEquals(['foo' => 'bar'], $decoded->payload()); + $this->assertEquals(['alg' => 'RS256'], $decoded->headers()); + } + + /** + * + */ + public function test_decode_success_with_symmetric_signature() + { + $jws = $this->jwsBuilder([new HS256()]) + ->create() + ->addSignature(JWKFactory::createFromSecret('my-keymy-keymy-keymy-keymy-keymy-keymy-keymy-key'), ['alg' => 'HS256']) + ->withPayload('{"foo":"bar"}') + ->build() + ; + + $jws = $this->serializer->serialize($jws); + + $decoded = $this->decoder->decode($jws, new JWKSet([ + JWKFactory::createFromSecret('my-keymy-keymy-keymy-keymy-keymy-keymy-keymy-key') + ])); + + $this->assertEquals($jws, $decoded->encoded()); + $this->assertEquals(['foo' => 'bar'], $decoded->payload()); + $this->assertEquals(['alg' => 'HS256'], $decoded->headers()); + } + + /** + * + */ + public function test_decode_key_type_do_not_match() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JWT or signature'); + + $jws = $this->jwsBuilder([new HS256()]) + ->create() + ->addSignature(JWKFactory::createFromSecret(file_get_contents(__DIR__.'/assets/private.key')), ['alg' => 'HS256']) + ->withPayload('{"foo":"bar"}') + ->build() + ; + + $jws = $this->serializer->serialize($jws); + + $this->decoder->decode($jws, new JWKSet([ + JWKFactory::createFromKeyFile(__DIR__.'/assets/public.key', null, ['alg' => 'RS256']) + ])); + } + + /** + * + */ + public function test_decode_invalid_key() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JWT or signature'); + + $jws = $this->jwsBuilder([new RS256()]) + ->create() + ->addSignature(JWKFactory::createFromKeyFile(__DIR__.'/assets/private.key'), ['alg' => 'RS256']) + ->withPayload('{"foo":"bar"}') + ->build() + ; + + $jws = $this->serializer->serialize($jws); + + $this->decoder->decode($jws, new JWKSet([ + JWKFactory::createFromKeyFile(__DIR__.'/assets/public-other.key', null, ['alg' => 'RS256']) + ])); + } + + /** + * + */ + public function test_decode_invalid_key_with_symmetric() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JWT or signature'); + + $jws = $this->jwsBuilder([new HS256()]) + ->create() + ->addSignature(JWKFactory::createFromSecret('secretsecretsecretsecretsecretsecretsecret'), ['alg' => 'HS256']) + ->withPayload('{"foo":"bar"}') + ->build() + ; + + $jws = $this->serializer->serialize($jws); + + $this->decoder->decode($jws, new JWKSet([ + JWKFactory::createFromSecret('invalid', ['alg' => 'HS256']) + ])); + } + + /** + * + */ + public function test_decode_not_a_jwt() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JWT or signature'); + + $this->decoder->decode('invalid', new JWKSet([ + JWKFactory::createFromSecret('invalid', ['alg' => 'HS256']) + ])); + } + + /** + * + */ + public function test_supportedAlgorithms() + { + $jws = $this->jwsBuilder([new HS256()]) + ->create() + ->addSignature(JWKFactory::createFromSecret('secretsecretsecretsecretsecretsecret'), ['alg' => 'HS256']) + ->withPayload('{"foo":"bar"}') + ->build() + ; + $jws = $this->serializer->serialize($jws); + + $jwks = new JWKSet([JWKFactory::createFromSecret('secretsecretsecretsecretsecretsecret', ['alg' => 'HS256'])]); + + $this->assertNotSame($this->decoder, $this->decoder->supportedAlgorithms(['HS256'])); + $this->assertEquals(['HS256'], $this->decoder->supportedAlgorithms(['HS256'])->jwa()->manager()->list()); + + $this->assertInstanceOf(JWT::class, $this->decoder->supportedAlgorithms(['HS256'])->decode($jws, $jwks)); + + try { + $this->decoder->supportedAlgorithms(['RS256'])->decode($jws, $jwks); + $this->fail('Expects InvalidArgumentException'); + } catch (\InvalidArgumentException $e) {} + } + + /** + * + */ + public function test_decode_payload_not_object() + { + $this->expectExceptionMessage('Invalid JWT payload'); + $jws = $this->jwsBuilder([new RS256()]) + ->create() + ->addSignature(JWKFactory::createFromKeyFile(__DIR__.'/assets/private.key'), ['alg' => 'RS256']) + ->withPayload('"foo"') + ->build() + ; + + $jws = $this->serializer->serialize($jws); + + $decoded = $this->decoder->decode($jws, new JWKSet([ + JWKFactory::createFromKeyFile(__DIR__.'/assets/public.key', null, ['alg' => 'RS256']) + ])); + } + + private function jwsBuilder(array $algo): JWSBuilder + { + $ctor = (new \ReflectionClass(JWSBuilder::class))->getConstructor(); + + return $ctor->getNumberOfParameters() === 1 + ? new JWSBuilder(new AlgorithmManager($algo)) + : new JWSBuilder(null, new AlgorithmManager($algo)) + ; + } +} diff --git a/tests/JwtEncoderTest.php b/tests/JwtEncoderTest.php new file mode 100644 index 0000000..604cfa1 --- /dev/null +++ b/tests/JwtEncoderTest.php @@ -0,0 +1,140 @@ +encoder = new JwtEncoder(); + $this->publicKey = __DIR__.'/assets/public.key'; + $this->privateKey = __DIR__.'/assets/private.key'; + } + + public function test_encode_simple() + { + $token = $this->encoder->encode( + ['foo' => 'bar'], + new JWKSet([ + JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig', 'alg' => 'RS256']), + ]) + ); + + $decoder = new JwtDecoder(); + $jwt = $decoder->decode($token, new JWKSet([ + JWKFactory::createFromKeyFile($this->publicKey, null, ['use' => 'sig', 'alg' => 'RS256']), + ])); + + $this->assertSame(['foo' => 'bar'], $jwt->payload()); + $this->assertSame(['alg' => 'RS256'], $jwt->headers()); + } + + public function test_encode_symmetric_algo() + { + $token = $this->encoder->encode( + ['foo' => 'bar'], + $jwks = new JWKSet([ + JWKFactory::createFromSecret('secretsecretsecretsecretsecretsecretsecretsecretsecret', ['alg' => 'HS256']), + ]), + 'HS256' + ); + + $decoder = new JwtDecoder(); + $jwt = $decoder->decode($token, $jwks); + + $this->assertSame(['foo' => 'bar'], $jwt->payload()); + $this->assertSame(['alg' => 'HS256'], $jwt->headers()); + } + + public function test_encode_with_kid() + { + $token = $this->encoder->encode( + ['foo' => 'bar'], + new JWKSet([ + JWKFactory::createFromKeyFile($this->publicKey, null, ['use' => 'sig', 'alg' => 'RS256', 'kid' => 'foo']), + JWKFactory::createFromKeyFile(__DIR__ . '/assets/other.key', null, ['use' => 'sig', 'alg' => 'RS256', 'kid' => 'bar']) + ]), + 'RS256', + 'bar' + ); + + $decoder = new JwtDecoder(); + $jwt = $decoder->decode($token, new JWKSet([ + JWKFactory::createFromKeyFile(__DIR__ . '/assets/public-other.key', null, ['use' => 'sig', 'alg' => 'RS256', 'kid' => 'bar']) + ])); + + $this->assertSame(['foo' => 'bar'], $jwt->payload()); + $this->assertSame(['alg' => 'RS256', 'kid' => 'bar'], $jwt->headers()); + } + + public function test_encode_invalid_algo() + { + $this->expectExceptionMessage('The algorithm "invalid" is not supported.'); + + $this->encoder->encode( + ['foo' => 'bar'], + new JWKSet([ + JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig', 'alg' => 'RS256']), + ]), + 'invalid' + ); + } + + public function test_encode_key_do_not_match() + { + $this->expectExceptionMessage('Cannot found any valid key'); + + $this->encoder->encode( + ['foo' => 'bar'], + new JWKSet([ + JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig', 'alg' => 'RS128']), + ]) + ); + } + + public function test_limit_supportedAlgorithms() + { + $jwks = new JWKSet([ + JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig', 'alg' => 'RS256']), + JWKFactory::createFromSecret('secretsecretsecretsecretsecretsecretsecretsecretsecret', ['alg' => 'HS256']), + ]); + + $this->assertEquals(['RS256', 'RS384', 'RS512'], $this->encoder->jwa()->algorithmsByType(JWA::TYPE_RSA)); + + $this->assertNotEmpty($this->encoder->encode(['foo' => 'bar'], $jwks, 'RS256')); + $this->assertNotEmpty($this->encoder->encode(['foo' => 'bar'], $jwks, 'HS256')); + + $encoder = $this->encoder->supportedAlgorithms(['RS256']); + $this->assertNotEquals($this->encoder, $encoder); + + $this->assertEquals(['RS256'], $encoder->jwa()->algorithmsByType(JWA::TYPE_RSA)); + $this->assertNotEmpty($encoder->encode(['foo' => 'bar'], $jwks, 'RS256')); + + try { + $encoder->encode(['foo' => 'bar'], $jwks, 'HS256'); + $this->fail('Should throw an exception'); + } catch (\Exception $e) { + $this->assertSame('The algorithm "HS256" is not supported.', $e->getMessage()); + } + } +} diff --git a/tests/assets/other.key b/tests/assets/other.key new file mode 100644 index 0000000..a233392 --- /dev/null +++ b/tests/assets/other.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAolrXgZ4Jc/t/l9Ka5e6m+LV9cZATD/8V/LAL+glOsddz6syL +aTjA2E1YRWUJXUrfdPODYc6nxcX8M6GdO2/r2hxzG1XfYvsxGuJgDHSDjftA5M4Z +Q0TFL11t35F9QK9sCIifqQwWXAyEIYdSFeYIjNDXugAxhjqJs1BnpWmk5xyeG33c +gNuzhQm7ICfHsVo3TBBznNb7sCR0c+EzdRmnbKl0ZahbK9pb1lLRTtpInoTXrbSQ +wI+24sWtLEWDMNNSB9ZTb3VkEgMxybogmqTjYJMP7QaAxK3koNyfM3iLP9QUwOf0 +FLb2lnHJdhau/IhDfcfhzwLwvnfJ7IJWY9tU6QIDAQABAoIBAHOE3Ygdka5TybKW +KJ7oEygtwqcWE2ozX0qfXLY4/yRtH852Yc+pkRWW2vLtdB9MUfjd96KVLyz6tXtr +R/vto8xap9BQZpUAFC2D20GheaWBm+fxeWoyuXb9LFuCIrPu3Zio1amrrxEp2q4c +odxxA65mPsjasGJIrofG8yhwJeRGh0vTmtYhAEXfv7Ssgnw0LZ4b1pysvpI/BAht +oVSwKMtbvR9hBE6TGinyacnqcO5K7GKsNyZa8rdKbJursRKZLnEU1dx8KAPVHxMR +E643yyL/vOtiN7CuECgNot9MmJjL0lzZlBsmAbroZJAyf9DHRp9wgOT+x1aa/s8z +UpmbqjECgYEA0HvT61yjDuHqpIApvY6g+te0zRjDPQj2J3tjIZtDJiKwm+kLCU8A +JS40nWaN1hxNJ5ue1SF3ylP7W67z86ndGpSvzEGiLSDr0y31FKNGKSOtJS00dNFi +vokbpkErRhmgmrMuPdAgf3csUf2KzwAq+d3/TIXViiTaBsK+3jdxkIUCgYEAx1uV +gT9i3/IETi+fqWN2YvFVU941YUwT/GovUuNdnrvmtx+636nxNOkgVvxzaPf6FlbC +p7IIeZ09LcQjBMM77siuiQ9uzSmIRSPGonytzV2pnytGFL2CoA+9WuGgXEgTt341 +YiiT3ofTdetjtIgZPOqkq/vgLvD1elU5+GE2shUCgYEAoBRETvbtWNMMyMyDwEHQ +x2pzL/vwhV/pKb2rCzXdJF4Ef2I8ECSxttq4mZcSFzHZ6Cysk3fEM/2yBd7a/+AQ +noVGSq6mqMIbcSZbhUIs8A+ttdr06TGRAT3jlg95+7RXxhEI03uISHcn97iXKYQ2 +e28CxS4KHa1YH1LPHW8y6sUCgYAL3dK3X8uF4wUIlmMdlRMN7qmSlW59/SZPZw6s +E2aWNT8VdWkNOTNnX90R8HL6M5CKVd2+V6WTf52NpNgkx25A12c0N1v0EF3RJ8EC +GQPLbqDTHaNNRr6IwocV6so90/iAep242wt6OoGGyl0j+Nnvu1PT/OkqQiOKhZou +w7KaxQKBgHUVAMWL6XJuNB3TTQb3X1yYCx9tMf5zxPvcmF5XELBd2OzCUmhrtHyS +nMQ2aEKeXjVefT1Kw/VFjrKTjFYtQ7q0fd09GOluJYyHWVN7uruUeNwYtMBYYg5u +9TWJDSsI8MiZrEUaLnxPxj7vBJSL4zIzCjtvjD9df32rWdd3efg4 +-----END RSA PRIVATE KEY----- diff --git a/tests/assets/private.key b/tests/assets/private.key new file mode 100644 index 0000000..aa7716e --- /dev/null +++ b/tests/assets/private.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAmut91TAscVjoPlpGiBIEUGn0cLR7kG31rCU48Cm3Nk4O+UGq +gErLaarSmPlCAF8Tgpl3CGVsfNk2Lhig3NFhHPA0Pps/6+/cf1bfi9bsur48CZFY +fvQ9ErFE1ZNxsoyJZgG0BO5IHkSrwGoa6KYkdVYGQ7qJqfQlMYZGo0yP3IQaLXjj +PlSfSTNzSETXJKOZjL36mK2DVS2BfV5R+Bdp2f//4///8+8ZqNYksFQ7AVKzJBOn +cK//uy66e5O5ZrxKjCC3BOmzjlniYxXqLlzvO9j5AD9Xb4pU5KmMRHlvLh95sfRR +ZbbINkYCVQ4guS8Oe94lTwLRw47QhGq1ivz88wIDAQABAoIBAQCRhW3A6SyGGGan +03L3dD0bMDwN9msvYyrnVluitPzFhRNprfYz5o4mpvCGA3WtFaIlBnUarPL4X60W +JcpNQly/qx7YREsKHFj6QKdiGzRpwjJxJleDgXcw7NVk7chaWMrjx+vKye1yiTzw +VHsMWKxcj774teuEerLJT4Fg+ZUVJIeo4HBIK/BaJFDAT3/xlUugqOjYYVNWVuzm +yNBIb0W88YglNzejeopebebbeplFww/3Tja4UKiD65z7sLGxjpKf2khUr63jVBHr +S4xdPw/t90Bnhbmts6d5m9X8QjWTPSQJjFZpIk7cW4T5Etn1o6LnYsWt6X6foPOW +uuxi579JAoGBAMop2VRe4xjMX8LxXLGVQ3l+3yjvU1nWvH3xBeNtw2TpjdBpXgMw +UiXQOkmrYq/M7iXgWd2Aiza5jWzBPV3ajJYWcNIHbSlB+D97rwV/xtFXDb1B4MUx +LTJzZbtozIbwwwHbYHcJCV5o/Mg0cOI0WAytHQlTXVP7WrM5lAtDg9fPAoGBAMQs +5D9F24XSaOU6ge4oBQudjlZ/q8tOSQMxGfAb8GosHFmXzY98GVmO9gfqs570t394 +J7J/qePyXSFEsgNUIQQ0frph7zhmuswWg/mVVZfWN7jrPBR8PqXkC7+xB4iXxZbP +06ga2IDiAZM7l0wl+nbRmq9JL0e9+9p97F0U+e2dAoGANdjPullLw98r4pDHT8Wi +I0pXxl94pAU+T41TNDCYStiqnUhzcgX823WLEPRFZO4AwLXxOb5zVjA2KzGNVuJP +b+qqQkcYHFUl+kLHa3+NRVUao75YUC25DCcQgcp4L7kRN/1mxE3z4OG18t2E87td +eILjqQg7Y5MfpX1AoX9qLqECgYBGwcJZqAKz096Nv3qZwcmAFQX/4PC/1a6z/gPS +/ODMCrj2/6/e7u3dxZir5lV/IdkFmvsGgNFwLDy3ASYL2U5HS//hje1QtIzvi7dy +UBCdQWC7y+zRnrah8wzhySJkfAmCiddXrMcmRV44EqhRiOk77gIS8xygjb/HYN/d ++vDiaQKBgF9OJ2ktKyO7jhjpsRT8VJjwHNdesBBZDhXaXItolbcUCp9fR6zUPpA4 +InOGs76asfxhrExCSWZhDYbseB7u28g24lia5ljGhSa2tALm1ELI3pV25f101et9 +/At/axZkgx/utK3c7+qKJpuUIUD0yOqjwN1uvRnkkQFLRSiDrfDw +-----END RSA PRIVATE KEY----- diff --git a/tests/assets/public-other.key b/tests/assets/public-other.key new file mode 100644 index 0000000..baf1b25 --- /dev/null +++ b/tests/assets/public-other.key @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAolrXgZ4Jc/t/l9Ka5e6m ++LV9cZATD/8V/LAL+glOsddz6syLaTjA2E1YRWUJXUrfdPODYc6nxcX8M6GdO2/r +2hxzG1XfYvsxGuJgDHSDjftA5M4ZQ0TFL11t35F9QK9sCIifqQwWXAyEIYdSFeYI +jNDXugAxhjqJs1BnpWmk5xyeG33cgNuzhQm7ICfHsVo3TBBznNb7sCR0c+EzdRmn +bKl0ZahbK9pb1lLRTtpInoTXrbSQwI+24sWtLEWDMNNSB9ZTb3VkEgMxybogmqTj +YJMP7QaAxK3koNyfM3iLP9QUwOf0FLb2lnHJdhau/IhDfcfhzwLwvnfJ7IJWY9tU +6QIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/assets/public.key b/tests/assets/public.key new file mode 100644 index 0000000..8a2918f --- /dev/null +++ b/tests/assets/public.key @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmut91TAscVjoPlpGiBIE +UGn0cLR7kG31rCU48Cm3Nk4O+UGqgErLaarSmPlCAF8Tgpl3CGVsfNk2Lhig3NFh +HPA0Pps/6+/cf1bfi9bsur48CZFYfvQ9ErFE1ZNxsoyJZgG0BO5IHkSrwGoa6KYk +dVYGQ7qJqfQlMYZGo0yP3IQaLXjjPlSfSTNzSETXJKOZjL36mK2DVS2BfV5R+Bdp +2f//4///8+8ZqNYksFQ7AVKzJBOncK//uy66e5O5ZrxKjCC3BOmzjlniYxXqLlzv +O9j5AD9Xb4pU5KmMRHlvLh95sfRRZbbINkYCVQ4guS8Oe94lTwLRw47QhGq1ivz8 +8wIDAQAB +-----END PUBLIC KEY-----