Skip to content

Commit

Permalink
feat: Add EncodingOptions class for encapsulate JwtEncoder::encode() …
Browse files Browse the repository at this point in the history
…options
  • Loading branch information
vincent4vx committed Aug 2, 2023
1 parent 4a3c110 commit 146318a
Show file tree
Hide file tree
Showing 4 changed files with 392 additions and 26 deletions.
182 changes: 182 additions & 0 deletions src/EncodingOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?php

namespace B2pweb\Jwt;

use InvalidArgumentException;
use Jose\Component\Core\JWK;
use Jose\Component\Core\JWKSet;

/**
* Options for encoding a JWT
*
* @see JwtEncoder::encode()
*/
final class EncodingOptions
{
/**
* @var JWKSet
*/
private $keySet;

/**
* @var string
*/
private $algorithm;

/**
* @var string|null
*/
private $kid;

/**
* @var array<string, mixed>
*/
private $headers = [];

/**
* @param JWKSet $keySet
* @param string $algorithm
* @param string|null $kid
* @param array<string, mixed> $headers
*/
public function __construct(JWKSet $keySet, string $algorithm = 'RS256', ?string $kid = null, array $headers = [])
{
$this->keySet = $keySet;
$this->algorithm = $algorithm;
$this->kid = $kid;
$this->headers = $headers;
}

/**
* Get the requested signature algorithm
*
* @return string
*/
public function algorithm(): string
{
return $this->algorithm;
}

/**
* Define the signature algorithm
*
* @param string $algorithm
*
* @return $this
*/
public function setAlgorithm(string $algorithm): EncodingOptions
{
$this->algorithm = $algorithm;
return $this;
}

/**
* Get the defined key id
*
* @return string|null
*/
public function kid(): ?string
{
return $this->kid;
}

/**
* Define the key id
*
* @param string|null $kid
* @return $this
*/
public function setKid(?string $kid): EncodingOptions
{
$this->kid = $kid;
return $this;
}

/**
* Define additional headers
*
* @param array<string, mixed> $headers
* @return $this
*/
public function setHeaders(array $headers): EncodingOptions
{
$this->headers = $headers;
return $this;
}

/**
* Define an additional header
*
* @param string $key The header key
* @param mixed $value The header value
*
* @return $this
*/
public function setHeader(string $key, $value): EncodingOptions
{
$this->headers[$key] = $value;
return $this;
}

/**
* Try to select a signature key
*
* @param JWA $algorithms The supported algorithms
* @return JWK
*
* @throws InvalidArgumentException If there is no valid key, or requested algorithm is not supported
*/
public function selectSignatureKey(JWA $algorithms): JWK
{
$key = $this->keySet->selectKey(
'sig',
$algorithms->manager()->get($this->algorithm),
$this->kid ? ['kid' => $this->kid] : []
);

if (!$key) {
throw new InvalidArgumentException('Cannot found any valid key');
}

return $key;
}

/**
* Get the signature headers
*
* @return array<string, mixed>
*/
public function headers(): array
{
$headers = $this->headers;
$headers['alg'] = $this->algorithm;

if ($this->kid) {
$headers['kid'] = $this->kid;
}

return $headers;
}

/**
* Create options from a key
* Algorithm and kid will be resolved from the key
*
* @param JWK $key
* @return self
*/
public static function fromKey(JWK $key): self
{
$options = new self(new JWKSet([$key]));

if ($key->has('alg')) {
$options->setAlgorithm((string) $key->get('alg'));

Check warning on line 173 in src/EncodingOptions.php

View workflow job for this annotation

GitHub Actions / Analysis

Escaped Mutant: --- Original +++ New @@ @@ { $options = new self(new JWKSet([$key])); if ($key->has('alg')) { - $options->setAlgorithm((string) $key->get('alg')); + $options->setAlgorithm($key->get('alg')); } if ($key->has('kid')) { $options->setKid((string) $key->get('kid'));
}

if ($key->has('kid')) {
$options->setKid((string) $key->get('kid'));

Check warning on line 177 in src/EncodingOptions.php

View workflow job for this annotation

GitHub Actions / Analysis

Escaped Mutant: --- Original +++ New @@ @@ $options->setAlgorithm((string) $key->get('alg')); } if ($key->has('kid')) { - $options->setKid((string) $key->get('kid')); + $options->setKid($key->get('kid')); } return $options; } }
}

return $options;
}
}
32 changes: 13 additions & 19 deletions src/JwtEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,22 @@ public function supportedAlgorithms(array $algorithms): self
/**
* Decode the JWT string
*
* @param mixed|ClaimsInterface $payload Payload to encode. If ClaimsInterface is used, {@see ClaimsInterface::toJson()} will be used to encode the payload.
* @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
* @param mixed|ClaimsInterface $payload Payload to encode. If ClaimsInterface is used,
* {@see ClaimsInterface::toJson()} will be used to encode the payload.
* @param JWKSet|EncodingOptions $options Options to use for encoding.
* Can be a JWKSet for use legacy method signature.
* @param string $algorithm Algorithm to use.
* Deprecated: use EncodingOptions instead.
* @param string|null $kid Key ID to use. If null, the first key matching the algorithm will be used.
* Deprecated: use EncodingOptions instead.
*
* @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
public function encode($payload, $options, string $algorithm = 'RS256', ?string $kid = null): string
{
static $isLegacyJwsBuilder = null;

Expand All @@ -97,27 +101,17 @@ public function encode($payload, JWKSet $keySet, string $algorithm = 'RS256', ?s
: new JWSBuilder($manager)
;

$key = $keySet->selectKey(
'sig',
$manager->get($algorithm),
$kid ? ['kid' => $kid] : []
);

if (!$key) {
throw new InvalidArgumentException('Cannot found any valid key');
if ($options instanceof JWKSet) {
$options = new EncodingOptions($options, $algorithm, $kid);
}

$sigHeader = ['alg' => $algorithm];

if ($kid) {
$sigHeader['kid'] = $kid;
}
$key = $options->selectSignatureKey($this->jwa);

$payload = $payload instanceof ClaimsInterface ? $payload->toJson() : json_encode($payload);

$jws = $jwsBuilder->create()
->withPayload($payload)
->addSignature($key, $sigHeader)
->addSignature($key, $options->headers())
->build()
;

Expand Down
116 changes: 116 additions & 0 deletions tests/EncodingOptionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

namespace B2pweb\Jwt\Tests;

use B2pweb\Jwt\EncodingOptions;
use B2pweb\Jwt\JWA;
use B2pweb\Jwt\JwtEncoder;
use Jose\Component\Core\JWKSet;
use Jose\Component\KeyManagement\JWKFactory;
use PHPUnit\Framework\TestCase;

class EncodingOptionsTest extends TestCase
{
/**
* @var string
*/
private $publicKey;

/**
* @var string
*/
private $privateKey;

protected function setUp(): void
{
$this->publicKey = __DIR__.'/assets/public.key';
$this->privateKey = __DIR__.'/assets/private.key';
}

public function test_getter_setter()
{
$options = new EncodingOptions(new JWKSet([
JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig', 'alg' => 'RS256']),
]));

$this->assertSame('RS256', $options->algorithm());
$this->assertNull($options->kid());
$this->assertSame(['alg' => 'RS256'], $options->headers());

$options
->setAlgorithm('RS512')
->setKid('foo')
->setHeaders(['foo' => 'bar'])
;

$this->assertSame('RS512', $options->algorithm());
$this->assertSame('foo', $options->kid());
$this->assertSame(['foo' => 'bar', 'alg' => 'RS512', 'kid' => 'foo'], $options->headers());

$options->setHeader('a', 'b');
$this->assertSame(['foo' => 'bar', 'a' => 'b', 'alg' => 'RS512', 'kid' => 'foo'], $options->headers());
}

public function test_selectSignatureKey()
{
$options = new EncodingOptions(new JWKSet([
$k1 = JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig', 'alg' => 'RS256', 'kid' => 'k1']),
$k2 = JWKFactory::createFromKeyFile(__DIR__.'/assets/other.key', null, ['use' => 'sig', 'alg' => 'RS256', 'kid' => 'k2']),
$k3 = JWKFactory::createFromSecret('azertyuiopazertyuiopazertyuiopazertyuiopazertyuiopazertyuiopazertyuiop', ['alg' => 'HS256', 'kid' => 'k3']),
$k4 = JWKFactory::createFromKeyFile(__DIR__.'/assets/other.key', null, ['use' => 'enc', 'alg' => 'RS256', 'kid' => 'k4']),
]));

$this->assertSame($k1, $options->selectSignatureKey(new JWA()));
$this->assertSame($k3, $options->setAlgorithm('HS256')->selectSignatureKey(new JWA()));
$this->assertSame($k2, $options->setAlgorithm('RS256')->setKid('k2')->selectSignatureKey(new JWA()));

try {
$options->setAlgorithm('RS256')->setKid('k4')->selectSignatureKey(new JWA());
$this->fail('Expected exception not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertSame('Cannot found any valid key', $e->getMessage());
}

try {
$options->setAlgorithm('invalid')->setKid(null)->selectSignatureKey(new JWA());
$this->fail('Expected exception not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertSame('The algorithm "invalid" is not supported.', $e->getMessage());
}

try {
$options->setAlgorithm('RS256')->setKid('invalid')->selectSignatureKey(new JWA());
$this->fail('Expected exception not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertSame('Cannot found any valid key', $e->getMessage());
}

try {
$options->setAlgorithm('RS512')->setKid(null)->selectSignatureKey(new JWA());
$this->fail('Expected exception not thrown');
} catch (\InvalidArgumentException $e) {
$this->assertSame('Cannot found any valid key', $e->getMessage());
}
}

public function test_fromKey()
{
$options = EncodingOptions::fromKey($key = JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig']));
$this->assertSame('RS256', $options->algorithm());
$this->assertSame($key, $options->selectSignatureKey(new JWA()));
$this->assertNull($options->kid());
$this->assertSame(['alg' => 'RS256'], $options->headers());

$options = EncodingOptions::fromKey($key = JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig', 'alg' => 'RS512']));
$this->assertSame('RS512', $options->algorithm());
$this->assertSame($key, $options->selectSignatureKey(new JWA()));
$this->assertNull($options->kid());
$this->assertSame(['alg' => 'RS512'], $options->headers());

$options = EncodingOptions::fromKey($key = JWKFactory::createFromKeyFile($this->privateKey, null, ['use' => 'sig', 'alg' => 'RS512', 'kid' => 'foo']));
$this->assertSame('RS512', $options->algorithm());
$this->assertSame($key, $options->selectSignatureKey(new JWA()));
$this->assertSame('foo', $options->kid());
$this->assertSame(['alg' => 'RS512', 'kid' => 'foo'], $options->headers());
}
}
Loading

0 comments on commit 146318a

Please sign in to comment.