Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
name: Test coverage

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
on: [push, pull_request]

permissions:
contents: read
Expand All @@ -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

Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ $token = new Token([
"iss" => "customer-data-service",
"sub" => $user->id,
"custom_claim_foo" => "bar",
"iat" => \time(),
"exp" => \strtotime("+1 hour")
])
```
Expand All @@ -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.
Expand Down Expand Up @@ -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:

Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
2 changes: 1 addition & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
bootstrap="tests/bootstrap.php"
executionOrder="depends,defects"
forceCoversAnnotation="true"
beStrictAboutCoversAnnotation="false"
Expand Down
3 changes: 3 additions & 0 deletions src/ExpiredTokenException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Nimbly\Proof;

/**
* This expception is thrown when a JWT has expired based on its `exp` (expiration) claim.
*/
class ExpiredTokenException extends TokenDecodingException
{
}
8 changes: 8 additions & 0 deletions src/InvalidTokenException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

namespace Nimbly\Proof;

/**
* This exception is thrown when something is fundamentally wrong with the JWT being decoded:
*
* - Token is not a JWT
* - Token does not contain a signature
* - Token (header or payload) does not contain valid JSON
* - Expiration (exp) or Not Before (nbf) claims are not in Unix timestamp format
*/
class InvalidTokenException extends TokenDecodingException
{
}
80 changes: 43 additions & 37 deletions src/Proof.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class Proof
/**
* @param SignerInterface $signer The default signing key used for encoding and decoding tokens.
* @param integer $leeway Time in seconds to add to expiration and "not-before" date calculations to account for drift. The leeway can be negative if you wish.
* @param array<string,SignerInterface> $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<string,SignerInterface> $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,
Expand All @@ -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(),
Expand Down Expand Up @@ -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
Expand All @@ -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}",
Expand All @@ -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;
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/SignatureMismatchException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
3 changes: 2 additions & 1 deletion src/Signer/HmacSigner.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Nimbly\Proof\SignerInterface;
use Nimbly\Proof\SigningException;
use ParagonIE\HiddenString\HiddenString;
use SensitiveParameter;

class HmacSigner implements SignerInterface
{
Expand All @@ -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 ){
Expand Down
2 changes: 1 addition & 1 deletion src/Signer/KeypairSigner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}

Expand Down
4 changes: 4 additions & 0 deletions src/SignerNotFoundException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
3 changes: 3 additions & 0 deletions src/SigningException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

use Exception;

/**
* This exception is thrown when a signature could not be generated.
*/
class SigningException extends Exception
{
}
8 changes: 6 additions & 2 deletions src/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
class Token implements JsonSerializable
{
/**
* Token constructor.
* @param array<array-key,mixed> $claims Array of key/value pairs of JWT claims for token.
*
* @param array<array-key,mixed> $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 = [])
{
Expand Down
9 changes: 9 additions & 0 deletions src/TokenDecodingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
3 changes: 3 additions & 0 deletions src/TokenEncodingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

use Exception;

/**
* This exception is thrown when encoding a token into a string JWT has failed.
*/
class TokenEncodingException extends Exception
{
}
4 changes: 4 additions & 0 deletions src/TokenNotReadyException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
Loading