Skip to content

Commit

Permalink
Add more awesomeness
Browse files Browse the repository at this point in the history
  • Loading branch information
adhocore committed Apr 14, 2017
1 parent 1a9bff1 commit 04152ad
Show file tree
Hide file tree
Showing 4 changed files with 287 additions and 30 deletions.
21 changes: 21 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ use Ahc\Jwt\JWT;
// Instantiate with key, algo, maxAge and leeway.
$jwt = new JWT('secret', 'HS256', 3600, 10);

// Only the key is required. Defaults will be used for the rest:
// algo = HS256, maxAge = 3600, leeway = 0
$jwt = new JWT('secret');

// For RS* algo, the key should be either a resource like below:
$key = openssl_pkey_new(['digest_alg' => 'sha256', 'private_key_bits' => 1024, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
// OR, a string with full path to the RSA private key like below:
$key = '/path/to/rsa.key';
// Then, instantiate JWT with this key and RS* as algo:
$jwt = new JWT($key, 'RS384');

// Generate JWT token from payload array.
$token = $jwt->generate([
'uid' => 1,
Expand Down Expand Up @@ -45,3 +56,13 @@ $token = $jwt->encode($payload);
$jwt->decode($token);

```

## Features

- Six algorithms supported:
```
'HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512'
```
- Leeway support 0-120 seconds.
- Timestamp spoofing for tests.
- Passphrase support for `RS*` algos.
209 changes: 185 additions & 24 deletions src/JWT.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,29 @@
*/
class JWT
{
const ERROR_KEY_EMPTY = 1;
const ERROR_ALGO_UNSUPPORTED = 2;
const ERROR_ALGO_MISSING = 3;
const ERROR_INVALID_MAXAGE = 4;
const ERROR_JSON_FAILED = 5;
const ERROR_TOKEN_INVALID = 6;
const ERROR_TOKEN_EXPIRED = 7;
const ERROR_TOKEN_NOT_NOW = 8;
const ERROR_SIGNATURE_FAILED = 9;
const ERROR_KEY_EMPTY = 10;
const ERROR_KEY_INVALID = 12;
const ERROR_ALGO_UNSUPPORTED = 20;
const ERROR_ALGO_MISSING = 22;
const ERROR_INVALID_MAXAGE = 30;
const ERROR_JSON_FAILED = 40;
const ERROR_TOKEN_INVALID = 50;
const ERROR_TOKEN_EXPIRED = 52;
const ERROR_TOKEN_NOT_NOW = 54;
const ERROR_SIGNATURE_FAILED = 60;

/**
* Supported Signing algorithms.
*
* @var array
*/
protected $algos = [
'HS256' => 'sha256',
'HS384' => 'sha384',
'HS512' => 'sha512',
'RS256' => OPENSSL_ALGO_SHA256,
'RS384' => OPENSSL_ALGO_SHA384,
'RS512' => OPENSSL_ALGO_SHA512,
];

/**
Expand Down Expand Up @@ -63,7 +72,24 @@ class JWT
*/
protected $leeway = 0;

public function __construct(string $key, string $algo = 'HS256', int $maxAge = 3600, int $leeway = 0)
/**
* The passphrase for RSA signing (optional).
*
* @var string|null
*/
protected $passphrase;

/**
* Constructor.
*
* @param string|resource $key The signature key. For RS* it should be file path or resource of private key.
* @param string $algo The algorithm to sign/verify the token.
* @param integer $maxAge The TTL of token to be used to determine expiry if `iat` claim is present.
* This is also used to provide default `exp` claim in case it is missing.
* @param integer $leeway Leeway for clock skew. Shouldnot be more than 2 minutes (120s).
* @param string $pass The passphrase (only for RS* algos).
*/
public function __construct($key, string $algo = 'HS256', int $maxAge = 3600, int $leeway = 0, string $pass = null)
{
// @codeCoverageIgnoreStart
if (empty($key)) {
Expand All @@ -83,31 +109,62 @@ public function __construct(string $key, string $algo = 'HS256', int $maxAge = 3
}
// @codeCoverageIgnoreEnd

$this->key = $key;
$this->algo = $algo;
$this->maxAge = $maxAge;
$this->leeway = $leeway;
$this->key = $key;
$this->algo = $algo;
$this->maxAge = $maxAge;
$this->leeway = $leeway;
$this->passphrase = $pass;
}

// @codeCoverageIgnoreStart
/**
* Encode payload as JWT token.
*
* This method is alias of self::generate().
*
* @param array $payload
* @param array $header Extra header (if any) to append.
*
* @return string URL safe JWT token.
*/
public function encode(array $payload, array $header = []) : string
{
return $this->generate($payload, $header);
}

/**
* Decode JWT token and return original payload.
*
* This method is alias of self::parse().
*
* @param string $token
*
* @return array
*/
public function decode(string $token) : array
{
return $this->parse($token);
}
// @codeCoverageIgnoreEnd

/**
* Spoof current timestamp for testing.
*
* @param integer|null $timestamp
*/
public function setTestTimestamp(int $timestamp = null) : JWT
{
$this->timestamp = $timestamp;

return $this;
}

/**
* Generate JWT token.
*
* @param array $payload
* @param array $header Extra header (if any) to append.
*
* @return string URL safe JWT token.
*/
public function generate(array $payload, array $header = []) : string
{
$header = ['typ' => 'JWT', 'alg' => $this->algo] + $header;
Expand All @@ -116,15 +173,22 @@ public function generate(array $payload, array $header = []) : string
$payload['exp'] = ($this->timestamp ?? time()) + $this->maxAge;
}

$header = $this->urlSafeEncode($header);
$payload = $this->urlSafeEncode($payload);

$signature = hash_hmac($this->algos[$this->algo], $header . '.' . $payload, $this->key, true);
$signature = $this->urlSafeEncode($signature);
$header = $this->urlSafeEncode($header);
$payload = $this->urlSafeEncode($payload);
$signature = $this->urlSafeEncode($this->sign($header . '.' . $payload));

return $header . '.' . $payload . '.' . $signature;
}

/**
* Parse JWT token and return original payload.
*
* @param string $token
*
* @return array
*
* @throws \InvalidArgumentException When JWT token is invalid or expired or signature can't be verified.
*/
public function parse(string $token) : array
{
if (substr_count($token, '.') < 2) {
Expand All @@ -143,8 +207,7 @@ public function parse(string $token) : array
}

// Validate signature.
$signature = hash_hmac($this->algos[$header->alg], $token[0] . '.' . $token[1], $this->key, true);
if (!hash_equals($this->urlSafeEncode($signature), $token[2])) {
if (!$this->verify($token[0] . '.' . $token[1], $token[2])) {
throw new \InvalidArgumentException('Invalid token: Signature failed', static::ERROR_SIGNATURE_FAILED);
}

Expand All @@ -160,13 +223,92 @@ public function parse(string $token) : array
throw new \InvalidArgumentException('Invalid token: Expired', static::ERROR_TOKEN_EXPIRED);
}

// Validate nbf claim.
if (isset($payload->nbf) && $timestamp <= ($payload->nbf - $this->leeway)) {
throw new \InvalidArgumentException('Invalid token: Cannot accept now', static::ERROR_TOKEN_NOT_NOW);
}

return (array) $payload;
}

/**
* Sign the input with configured key and return the signature.
*
* @param string $input
*
* @return string
*/
protected function sign(string $input) : string
{
// HMAC SHA.
if (substr($this->algo, 0, 2) === 'HS') {
return hash_hmac($this->algos[$this->algo], $input, $this->key, true);
}

$this->throwIfKeyInvalid();

openssl_sign($input, $signature, $this->key, $this->algos[$this->algo]);

return $signature;
}

/**
* Verify the signature of given input.
*
* @param string $input
* @param string $signature
*
* @return bool
*
* @throws \InvalidArgumentException When key is invalid.
*/
protected function verify(string $input, string $signature) : bool
{
$algo = $this->algos[$this->algo];

// HMAC SHA.
if (substr($this->algo, 0, 2) === 'HS') {
return hash_equals($this->urlSafeEncode(hash_hmac($algo, $input, $this->key, true)), $signature);
}

$this->throwIfKeyInvalid();

$pubKey = openssl_pkey_get_details($this->key)['key'];

return openssl_verify($input, $this->urlSafeDecode($signature, false), $pubKey, $algo) === 1;
}

/**
* Throw up if key is not resource or file path to private key.
*
* @throws \InvalidArgumentException
*/
protected function throwIfKeyInvalid()
{
if (is_string($this->key)) {
if (!is_file($this->key)) {
throw new \InvalidArgumentException('Invalid key: Should be file path of private key', static::ERROR_KEY_INVALID);
}

$this->key = openssl_get_privatekey('file://' . $this->key, $this->passphrase ?? '');
}

if (!is_resource($this->key)) {
throw new \InvalidArgumentException('Invalid key: Should be resource of private key', static::ERROR_KEY_INVALID);
}
}

/**
* URL safe base64 encode.
*
* First serialized the payload as json if it is an array.
*
* @param array|string $data
*
* @return string
*
* @throws \InvalidArgumentException When JSON encode fails.
*/
protected function urlSafeEncode($data) : string
{
if (is_array($data)) {
Expand All @@ -177,14 +319,33 @@ protected function urlSafeEncode($data) : string
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

protected function urlSafeDecode(string $data)
/**
* URL safe base64 decode.
*
* @param array|string $data
* @param bool $asJson Whether to parse as JSON (defaults to true).
*
* @return array|\stdClass
*
* @throws \InvalidArgumentException When JSON encode fails.
*/
protected function urlSafeDecode(string $data, bool $asJson = true)
{
if (!$asJson) {
return base64_decode(strtr($data, '-_', '+/'));
}

$data = json_decode(base64_decode(strtr($data, '-_', '+/')));
$this->throwIfJsonError();

return $data;
}

/**
* Throw up if last json_encode/decode was a failure.
*
* @return void
*/
protected function throwIfJsonError()
{
if (JSON_ERROR_NONE === $error = json_last_error()) {
Expand Down
Loading

0 comments on commit 04152ad

Please sign in to comment.