diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index a66f991..fe398de 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,10 +1,6 @@ name: Test coverage -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] +on: [push, pull_request] permissions: contents: read @@ -14,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: [8.0, 8.1, 8.2, 8.3] + php-version: [8.2, 8.3] runs-on: ubuntu-latest diff --git a/README.md b/README.md index 66329a3..1310b5c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ $token = new Token([ "iss" => "customer-data-service", "sub" => $user->id, "custom_claim_foo" => "bar", + "iat" => \time(), "exp" => \strtotime("+1 hour") ]) ``` @@ -86,6 +87,19 @@ Or you can set a claim on a `Token` by calling the `setClaim` method. $token->setClaim("nbf", \strtotime("+1 week")); ``` +**NOTE:** The `exp` and `nbf` public claims that represent a timestamp *must* be formatted as a `NumericDate` (an integer Unix timestamp.) `Proof` will automatically attempt to validate the token against the given expiration claim (exp) and, if present, the not before claim (nbf). As such, it expects those values to be integers. + +Please see the official [RFC](https://datatracker.ietf.org/doc/html/rfc7519#section-2), specifcally the definition of **NumericDate**. + +> A JSON numeric value representing the number of seconds from + 1970-01-01T00:00:00Z UTC until the specified UTC date/time, + ignoring leap seconds. This is equivalent to the IEEE Std 1003.1, + 2013 Edition [POSIX.1] definition "Seconds Since the Epoch", in + which each day is accounted for by exactly 86400 seconds, other + than that non-integer values can be represented. See RFC 3339 + [RFC3339] for details regarding date/times in general and UTC in + particular. + ### Encode a Token into a JWT With a `Token` instance, you can encode it into a signed JWT by passing it into the `encode` method. You will be returned a signed JWT string. @@ -228,7 +242,8 @@ If the JWT is invalid, an exception will be thrown. This exception will need to * `InvalidTokenException` is thrown if the JWT cannot be decoded due to being malformed or containing invalid JSON. * `SignatureMismatchException` is thrown if the signature does not match. * `ExpiredTokenException` is thrown if the token's `exp` claim is expired. -* `TokenNotReadyException` is thrown if the token's `nbf` claim is not ready (i.e. the timestamp is still in the future.). +* `TokenNotReadyException` is thrown if the token's `nbf` claim is not ready (i.e. the timestamp is still in the future.) +* `SignerNotFoundException` is thrown if a `kid` (key ID) was passed in the JWT header that does not exist in the key map. The middleware defaults to looking for the JWT in the `Authorization` HTTP header with a `Bearer` scheme. For example: diff --git a/composer.json b/composer.json index 67ad6c2..2233d01 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": "^8.0", + "php": "^8.2", "ext-openssl": "*", "paragonie/hidden-string": "^2.0", "psr/http-message": "^1.0|^2.0", @@ -26,6 +26,6 @@ "phpunit/phpunit": "^9.0", "vimeo/psalm": "^5.0", "symfony/var-dumper": "^5.1", - "nimbly/capsule": "^2.0" + "nimbly/capsule": "^3.0" } } diff --git a/phpunit.xml b/phpunit.xml index 27d112c..49c73ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -2,7 +2,7 @@ $keyMap If you're using multiple different signing keys, you can map them here as key/value pairs. If no kid is present in header or no kid provided when signing, then the default signing key will be used. + * @param array $keyMap If you're using multiple different signing keys, you can map them here as key/value pairs. If no `kid` is present in header or no `kid` provided when signing, then the default signing key will be used. If the default key should also be mapped to a key ID, be sure to add it here as well. */ public function __construct( protected SignerInterface $signer, @@ -23,24 +23,15 @@ public function __construct( /** * Encode a Token instance into a JWT. * - * @param Token $token - * @param string|null $kid Key ID to include in header + * @param Token $token Token instance with JWT claims. + * @param string|null $kid Key ID to include in header. The given key ID *must* map to a key in the key map. * @throws SignerNotFoundException * @throws TokenEncodingException * @return string */ public function encode(Token $token, ?string $kid = null): string { - if( $kid !== null ) { - $signer = $this->getSignerByKeyId($kid); - - if( empty($signer) ){ - throw new SignerNotFoundException("No signer found for key ID."); - } - } - else { - $signer = $this->signer; - } + $signer = $this->getSigner($kid); $header = [ "alg" => $signer->getAlgorithm(), @@ -71,7 +62,7 @@ public function encode(Token $token, ?string $kid = null): string /** * Decode a JWT string into a Token instance. * - * @param string $jwt + * @param string $jwt The encoded JWT string. * @throws InvalidTokenException * @throws SignerNotFoundException * @throws SignatureMismatchException @@ -83,29 +74,20 @@ public function decode(string $jwt): Token { $parts = \explode(".", $jwt); - if( \count($parts) < 3 ){ + if( \count($parts) !== 3 ){ throw new InvalidTokenException("Invalid number of token parts."); } [$header, $payload, $signature] = $parts; - /** @var object{algo:string,typ:string,kid:mixed} $decoded_header */ + /** @var object{alg:string,typ:string,kid:mixed} $decoded_header */ $decoded_header = \json_decode($this->base64UrlDecode($header)); if( \json_last_error() !== JSON_ERROR_NONE ){ throw new InvalidTokenException("Token header could not be JSON decoded."); } - if( isset($decoded_header->kid) ){ - $signer = $this->getSignerByKeyId((string) $decoded_header->kid); - - if( empty($signer) ){ - throw new SignerNotFoundException("No signer found for decoding."); - } - } - else { - $signer = $this->signer; - } + $signer = $this->getSigner($decoded_header->kid ?? null); $signature_verified = $signer->verify( "{$header}.{$payload}", @@ -127,28 +109,52 @@ public function decode(string $jwt): Token $timestamp = \time(); - if( isset($decoded_payload->exp) && - $decoded_payload->exp < ($timestamp + $this->leeway) ){ - throw new ExpiredTokenException("The token has expired."); + if( isset($decoded_payload->exp) ){ + + if( !\is_int($decoded_payload->exp) ){ + throw new InvalidTokenException("Expiration (exp) claim is not correctly formatted."); + } + + if( $decoded_payload->exp < ($timestamp + $this->leeway) ){ + throw new ExpiredTokenException("The token has expired."); + } } - if( isset($decoded_payload->nbf) && - $decoded_payload->nbf > ($timestamp - $this->leeway) ){ - throw new TokenNotReadyException("The token is not ready to be accepted yet."); + if( isset($decoded_payload->nbf) ){ + + if( !\is_int($decoded_payload->nbf) ){ + throw new InvalidTokenException("Not before (nbf) claim is not correctly formatted."); + } + + if( $decoded_payload->nbf > ($timestamp - $this->leeway) ){ + throw new TokenNotReadyException("The token is not ready to be accepted yet."); + } } return new Token((array) $decoded_payload); } /** - * Get a SignerInterface instance by its Key ID (kid) from the key map. + * Get the SignerInterface instance to use. * - * @param string $kid - * @return SignerInterface|null + * @param string|null $kid Get a specific signer by its key from the KeyMap as defined in the constructor. If null, default signer will be returned. + * @throws SignerNotFoundException + * @return SignerInterface */ - private function getSignerByKeyId(string $kid): ?SignerInterface + private function getSigner(?string $kid = null): SignerInterface { - return $this->keyMap[$kid] ?? null; + if( $kid !== null ) { + if( !isset($this->keyMap[$kid]) ){ + throw new SignerNotFoundException("No signer found for key ID."); + } + + $signer = $this->keyMap[$kid]; + } + else { + $signer = $this->signer; + } + + return $signer; } /** diff --git a/src/SignatureMismatchException.php b/src/SignatureMismatchException.php index 329886d..45736f6 100644 --- a/src/SignatureMismatchException.php +++ b/src/SignatureMismatchException.php @@ -2,6 +2,13 @@ namespace Nimbly\Proof; +/** + * This exception is thrown when the signature of the JWT does not match with the computed signature. + * If this exception is thrown, it is *very* likely that the JWT was either tampered with or the incorrect + * signer is being used. + * + * In either case, DO NOT trust the JWT being sent. + */ class SignatureMismatchException extends TokenDecodingException { } \ No newline at end of file diff --git a/src/Signer/HmacSigner.php b/src/Signer/HmacSigner.php index 4eb5e1d..22dd9f1 100644 --- a/src/Signer/HmacSigner.php +++ b/src/Signer/HmacSigner.php @@ -5,6 +5,7 @@ use Nimbly\Proof\SignerInterface; use Nimbly\Proof\SigningException; use ParagonIE\HiddenString\HiddenString; +use SensitiveParameter; class HmacSigner implements SignerInterface { @@ -28,7 +29,7 @@ class HmacSigner implements SignerInterface */ public function __construct( protected string $algorithm, - string $key + #[SensitiveParameter] string $key ) { if( \array_key_exists($algorithm, $this->supported_algorithms) === false ){ diff --git a/src/Signer/KeypairSigner.php b/src/Signer/KeypairSigner.php index 79054af..29bd907 100644 --- a/src/Signer/KeypairSigner.php +++ b/src/Signer/KeypairSigner.php @@ -92,7 +92,7 @@ public function verify(string $message, string $signature): bool $this->algorithm ); - if( $status < 0 ){ + if( $status === false || $status < 0 ){ throw new SigningException("An error occured when trying to verify signature of message."); } diff --git a/src/SignerNotFoundException.php b/src/SignerNotFoundException.php index 6be19ef..bb2e0e5 100644 --- a/src/SignerNotFoundException.php +++ b/src/SignerNotFoundException.php @@ -4,6 +4,10 @@ use Exception; +/** + * This exception is thrown when a `kid` was passed in the JWT header but no such key exists in + * the key map. + */ class SignerNotFoundException extends Exception { } \ No newline at end of file diff --git a/src/SigningException.php b/src/SigningException.php index f333987..ea6fa29 100644 --- a/src/SigningException.php +++ b/src/SigningException.php @@ -4,6 +4,9 @@ use Exception; +/** + * This exception is thrown when a signature could not be generated. + */ class SigningException extends Exception { } \ No newline at end of file diff --git a/src/Token.php b/src/Token.php index 9beac83..ca18333 100644 --- a/src/Token.php +++ b/src/Token.php @@ -7,9 +7,13 @@ class Token implements JsonSerializable { /** - * Token constructor. + * @param array $claims Array of key/value pairs of JWT claims for token. * - * @param array $claims JWT claims for token. + * For timestamp based public claims, be sure to use a Unix timestamp. + * + * * `exp` expiration date, in Unix timestamp format + * * `nbf` token not valid before before given date, in Unix timestamp format + * * `iat` date token issued at, in Unix timestamp format */ public function __construct(protected array $claims = []) { diff --git a/src/TokenDecodingException.php b/src/TokenDecodingException.php index b0182e8..26d69d8 100644 --- a/src/TokenDecodingException.php +++ b/src/TokenDecodingException.php @@ -4,6 +4,15 @@ use Exception; +/** + * This exception isn't thrown directly, but encompasses a class of exceptions. + * + * - InvalidTokenException + * - ExpiredTokenException + * - SignatureMismatchException + * - TokenNotReadyException + * + */ class TokenDecodingException extends Exception { } \ No newline at end of file diff --git a/src/TokenEncodingException.php b/src/TokenEncodingException.php index 81b6b07..68b5b0f 100644 --- a/src/TokenEncodingException.php +++ b/src/TokenEncodingException.php @@ -4,6 +4,9 @@ use Exception; +/** + * This exception is thrown when encoding a token into a string JWT has failed. + */ class TokenEncodingException extends Exception { } \ No newline at end of file diff --git a/src/TokenNotReadyException.php b/src/TokenNotReadyException.php index 2b922e4..f447f74 100644 --- a/src/TokenNotReadyException.php +++ b/src/TokenNotReadyException.php @@ -2,6 +2,10 @@ namespace Nimbly\Proof; +/** + * This exception is thrown when a JWT has an `nbf` (not before) claim and represents a date + * at which a token becomes active or usable however that date is still in the future. + */ class TokenNotReadyException extends TokenDecodingException { } \ No newline at end of file diff --git a/tests/KeypairSignerTest.php b/tests/KeypairSignerTest.php index 036a79d..7ff91da 100644 --- a/tests/KeypairSignerTest.php +++ b/tests/KeypairSignerTest.php @@ -1,9 +1,9 @@ sign("Message"); } + public function test_signing_failure_throws_signing_exception(): void + { + $keypairSigner = new KeypairSigner( + Proof::ALGO_SHA256, + null, + \openssl_get_publickey(\file_get_contents(__DIR__ . "/keys/public.pem")) + ); + + $this->expectException(SigningException::class); + + $keypairSigner->sign("Message"); + } + public function test_verify_with_no_public_key_throws_signing_exception(): void { $keypairSigner = new KeypairSigner( @@ -168,4 +181,16 @@ public function test_verify_with_no_public_key_throws_signing_exception(): void $this->expectException(SigningException::class); $keypairSigner->verify("Message", "signature"); } + + public function test_verify_failure_throws_signing_exception(): void + { + $keypairSigner = new KeypairSigner( + Proof::ALGO_SHA256, + \openssl_get_privatekey(\file_get_contents(__DIR__ . "/keys/private.pem")) + ); + + $this->expectException(SigningException::class); + + $keypairSigner->verify("Message", "Signature"); + } } \ No newline at end of file diff --git a/tests/ProofTest.php b/tests/ProofTest.php index 182e029..bb80773 100644 --- a/tests/ProofTest.php +++ b/tests/ProofTest.php @@ -178,6 +178,23 @@ public function test_malformed_json_payload_throws_invalid_token_exception(): vo $proof->decode($jwt); } + public function test_invalid_exp_claim_throws_invalid_token_exception(): void + { + $token = new Token([ + "exp" => (string) \date("c") + ]); + + $proof = new Proof( + new HmacSigner(Proof::ALGO_SHA256, "supersecret") + ); + + $jwt = $proof->encode($token); + + $this->expectException(InvalidTokenException::class); + + $proof->decode($jwt); + } + public function test_expired_token_throws_expired_token_exception(): void { $token = new Token([ @@ -195,6 +212,23 @@ public function test_expired_token_throws_expired_token_exception(): void $proof->decode($jwt); } + public function test_invalid_nbf_claim_throws_invalid_token_exception(): void + { + $token = new Token([ + "nbf" => \date("c") + ]); + + $proof = new Proof( + new HmacSigner(Proof::ALGO_SHA256, "supersecret") + ); + + $jwt = $proof->encode($token); + + $this->expectException(InvalidTokenException::class); + + $proof->decode($jwt); + } + public function test_forthcoming_token_throws_token_not_ready_exception(): void { $token = new Token([ diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..80fdf79 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ +