diff --git a/src/Config.php b/src/Config.php index ffa9026..c5b8c58 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,8 +4,8 @@ use Pdsinterop\Solid\Auth\Config\Client; use Pdsinterop\Solid\Auth\Config\Expiration; -use Pdsinterop\Solid\Auth\Config\Keys; -use Pdsinterop\Solid\Auth\Config\Server; +use Pdsinterop\Solid\Auth\Config\KeysInterface as Keys; +use Pdsinterop\Solid\Auth\Config\ServerInterface as Server; class Config { diff --git a/src/Config/Keys.php b/src/Config/Keys.php index 725cab7..28e9cae 100644 --- a/src/Config/Keys.php +++ b/src/Config/Keys.php @@ -6,48 +6,35 @@ use Lcobucci\JWT\Signer\Key\InMemory as Key; use League\OAuth2\Server\CryptKey; -class Keys +class Keys implements KeysInterface { ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\ - /** @var string|CryptoKey */ - private $encryptionKey; - /** @var CryptKey*/ - private $privateKey; - /** @var Key */ - private $publicKey; + private string|CryptoKey $encryptionKey; + private CryptKey $privateKey; + private Key $publicKey; //////////////////////////// GETTERS AND SETTERS \\\\\\\\\\\\\\\\\\\\\\\\\\\ - /** @return CryptoKey|string */ - final public function getEncryptionKey() + final public function getEncryptionKey(): CryptoKey|string { return $this->encryptionKey; } - /** @return CryptKey */ - final public function getPrivateKey() : CryptKey + final public function getPrivateKey(): CryptKey { return $this->privateKey; } - /*** @return Key */ - public function getPublicKey() : Key + public function getPublicKey(): Key { return $this->publicKey; } //////////////////////////////// PUBLIC API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ - /** - * Keys constructor. - * - * @param CryptKey $privateKey - * @param string|CryptoKey $encryptionKey - */ - final public function __construct(CryptKey $privateKey, Key $publicKey, $encryptionKey) + final public function __construct(CryptKey $privateKey, Key $publicKey, CryptoKey|string $encryptionKey) { - // @FIXME: Add type-check for $encryptionKey (or an extending class with different parameter type?) $this->encryptionKey = $encryptionKey; $this->privateKey = $privateKey; $this->publicKey = $publicKey; diff --git a/src/Config/KeysInterface.php b/src/Config/KeysInterface.php new file mode 100644 index 0000000..395b112 --- /dev/null +++ b/src/Config/KeysInterface.php @@ -0,0 +1,16 @@ + $this->getCode(), @@ -18,5 +20,8 @@ final public function jsonSerialize() } } - class LogicException extends Exception {} + +class AuthorizationHeaderException extends Exception {} + +class InvalidTokenException extends Exception {} diff --git a/src/ReplayDetectorInterface.php b/src/ReplayDetectorInterface.php new file mode 100644 index 0000000..843148a --- /dev/null +++ b/src/ReplayDetectorInterface.php @@ -0,0 +1,8 @@ +config = $config; + $this->validFor = $validFor; + $this->setEncryptionKey($this->config->getKeys()->getEncryptionKey()); } - + public function generateRegistrationAccessToken($clientId, $privateKey) { $issuer = $this->config->getServer()->get(OidcMeta::ISSUER); @@ -42,8 +47,8 @@ public function generateRegistrationAccessToken($clientId, $privateKey) { return $token->toString(); } - - public function generateIdToken($accessToken, $clientId, $subject, $nonce, $privateKey, $dpopKey=null) { + + public function generateIdToken($accessToken, $clientId, $subject, $nonce, $privateKey, $dpopKey, $now=null) { $issuer = $this->config->getServer()->get(OidcMeta::ISSUER); $jwks = $this->getJwks(); @@ -51,9 +56,10 @@ public function generateIdToken($accessToken, $clientId, $subject, $nonce, $priv // Create JWT $jwtConfig = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($privateKey)); - $now = new DateTimeImmutable(); + $now = $now ?? new DateTimeImmutable(); $useAfter = $now->sub(new \DateInterval('PT1S')); - $expire = $now->add(new \DateInterval('PT' . 14*24*60*60 . 'S')); + + $expire = $now->add($this->validFor); $token = $jwtConfig->builder() ->issuedBy($issuer) @@ -75,7 +81,7 @@ public function generateIdToken($accessToken, $clientId, $subject, $nonce, $priv ->getToken($jwtConfig->signer(), $jwtConfig->signingKey()); return $token->toString(); } - + public function respondToRegistration($registration, $privateKey) { /* Expects in $registration: @@ -94,10 +100,10 @@ public function respondToRegistration($registration, $privateKey) { 'token_endpoint_auth_method' => 'client_secret_basic', 'registration_access_token' => $registration_access_token, ); - + return array_merge($registrationBase, $registration); } - + public function addIdTokenToResponse($response, $clientId, $subject, $nonce, $privateKey, $dpopKey=null) { if ($response->hasHeader("Location")) { $value = $response->getHeaderLine("Location"); @@ -111,7 +117,7 @@ public function addIdTokenToResponse($response, $clientId, $subject, $nonce, $pr $privateKey, $dpopKey ); - $value = preg_replace("/#access_token=(.*?)&/", "#access_token=\$1&id_token=$idToken&", $value); + $value = preg_replace("/#access_token=(.*?)&/", "#access_token=\$1&id_token=$idToken&", $value); $response = $response->withHeader("Location", $value); } else if (preg_match("/code=(.*?)&/", $value, $matches)) { $idToken = $this->generateIdToken( @@ -153,12 +159,13 @@ public function addIdTokenToResponse($response, $clientId, $subject, $nonce, $pr public function getCodeInfo($code) { return json_decode($this->decrypt($code), true); } + ///////////////////////////// HELPER FUNCTIONS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ private function generateJti() { return substr(md5((string)time()), 12); // FIXME: generate unique jti values } - + private function generateTokenHash($accessToken) { $atHash = hash('sha256', $accessToken); $atHash = substr($atHash, 0, 32); diff --git a/src/Utils/DPop.php b/src/Utils/DPop.php index cebd9b2..6a45f56 100644 --- a/src/Utils/DPop.php +++ b/src/Utils/DPop.php @@ -2,18 +2,22 @@ namespace Pdsinterop\Solid\Auth\Utils; -use Lcobucci\JWT\Configuration; -use Lcobucci\Clock\SystemClock; -use DateTimeImmutable; use DateInterval; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; -use Lcobucci\JWT\Validation\Constraint\LooseValidAt; -use Lcobucci\JWT\Validation\Constraint\SignedWith; - +use Exception; use Jose\Component\Core\JWK; use Jose\Component\Core\Util\ECKey; use Jose\Component\Core\Util\RSAKey; +use Lcobucci\Clock\SystemClock; +use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Signer\Ecdsa\Sha256; +use Lcobucci\JWT\Signer\Key\InMemory; +use Lcobucci\JWT\Token\InvalidTokenStructure; +use Lcobucci\JWT\Validation\Constraint\LooseValidAt; +use Lcobucci\JWT\Validation\Constraint\SignedWith; +use Lcobucci\JWT\Validation\RequiredConstraintsViolated; +use Pdsinterop\Solid\Auth\Exception\AuthorizationHeaderException; +use Pdsinterop\Solid\Auth\Exception\InvalidTokenException; +use Psr\Http\Message\ServerRequestInterface; /** * This class contains code to fetch the WebId from a request @@ -21,33 +25,46 @@ * that matches the access token */ class DPop { - + + private JtiValidator $jtiValidator; + + public function __construct(JtiValidator $jtiValidator) + { + $this->jtiValidator = $jtiValidator; + } + /** * This method fetches the WebId from a request and verifies * that the request has a valid DPoP token that matches * the access token. - * @param Psr\Http\Message\ServerRequestInterface $request Server Request + * + * @param ServerRequestInterface $request Server Request + * * @return string the WebId, or "public" if no WebId is found - * @throws \Exception "Invalid token" when the DPoP token is invalid - * @throws \Exception "Missng DPoP token" when the DPoP token is missing, but the Authorisation header in the request specifies it + * + * @throws Exception "Invalid token" when the DPoP token is invalid + * @throws Exception "Missing DPoP token" when the DPoP token is missing, but the Authorisation header in the request specifies it */ public function getWebId($request) { - $auth = explode(" ", $request->getServerParams()['HTTP_AUTHORIZATION']); - $jwt = $auth[1] ?? false; - - if (strtolower($auth[0]) == "dpop") { - $dpop = $request->getServerParams()['HTTP_DPOP']; - //@FIXME: check that there is just one DPoP token in the request - if ($dpop) { - $dpopKey = $this->getDpopKey($dpop, $request); - try { - $this->validateJwtDpop($jwt, $dpopKey); - } catch (Lcobucci\JWT\Validation\RequiredConstraintsViolated $e) { - throw new \Exception("Invalid token", $e); - } - } else { - throw new \Exception("Missing DPoP token"); - } + $serverParams = $request->getServerParams(); + + $this->validateRequestHeaders($serverParams); + + [, $jwt] = explode(" ", $serverParams['HTTP_AUTHORIZATION'], 2); + + $dpop = $serverParams['HTTP_DPOP']; + + //@FIXME: check that there is just one DPoP token in the request + try { + $dpopKey = $this->getDpopKey($dpop, $request); + } catch (InvalidTokenStructure $e) { + throw new InvalidTokenException("Invalid JWT token: {$e->getMessage()}", 0, $e); + } + + try { + $this->validateJwtDpop($jwt, $dpopKey); + } catch (RequiredConstraintsViolated $e) { + throw new InvalidTokenException($e->getMessage(), 0, $e); } if ($jwt) { @@ -62,31 +79,46 @@ public function getWebId($request) { /** * Returns the "kid" from the "jwk" header in the DPoP token. * The DPoP token must be valid. - * @param string $dpop The DPoP token - * @param Psr\Http\Message\ServerRequestInterface $request Server Request + * + * @param string $dpop The DPoP token + * @param ServerRequestInterface $request Server Request + * * @return string the "kid" from the "jwk" header in the DPoP token. - * @throws Lcobucci\JWT\Validation\RequiredConstraintsViolated + * + * @throws RequiredConstraintsViolated */ public function getDpopKey($dpop, $request) { $this->validateDpop($dpop, $request); // 1. the string value is a well-formed JWT, - $jwtConfig = $configuration = Configuration::forUnsecuredSigner(); + $jwtConfig = Configuration::forUnsecuredSigner(); $dpop = $jwtConfig->parser()->parse($dpop); $jwk = $dpop->headers()->get("jwk"); - + + if (isset($jwk['kid']) === false) { + throw new InvalidTokenException('Key ID is missing from JWK header'); + } + return $jwk['kid']; } private function validateJwtDpop($jwt, $dpopKey) { - $jwtConfig = $configuration = Configuration::forUnsecuredSigner(); + $jwtConfig = Configuration::forUnsecuredSigner(); $jwt = $jwtConfig->parser()->parse($jwt); $cnf = $jwt->claims()->get("cnf"); - - if ($cnf['jkt'] == $dpopKey) { - return true; + + if ($cnf === null) { + throw new InvalidTokenException('JWT Confirmation claim (cnf) is missing'); + } + + if (isset($cnf['jkt']) === false) { + throw new InvalidTokenException('JWT Confirmation claim (cnf) is missing Thumbprint (jkt)'); + } + + if ($cnf['jkt'] !== $dpopKey) { + throw new InvalidTokenException('JWT Confirmation claim (cnf) provided Thumbprint (jkt) does not match Key ID from JWK header'); } - + //@FIXME: add check for "ath" claim in DPoP token, per https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-7 return false; } @@ -94,10 +126,13 @@ private function validateJwtDpop($jwt, $dpopKey) { /** * Validates that the DPOP token matches all requirements from * https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2 - * @param string $dpop The DPOP token - * @param Psr\Http\Message\ServerRequestInterface $request Server Request - * @return bool True if the DPOP token is valid, false otherwise - * @throws Lcobucci\JWT\Validation\RequiredConstraintsViolated + * + * @param string $dpop The DPOP token + * @param ServerRequestInterface $request Server Request + * + * @return bool True if the DPOP token is valid, false otherwise + * + * @throws RequiredConstraintsViolated */ public function validateDpop($dpop, $request) { /* @@ -126,64 +161,64 @@ public function validateDpop($dpop, $request) { claim, with a hash of the access token */ // 1. the string value is a well-formed JWT, - $jwtConfig = $configuration = Configuration::forUnsecuredSigner(); + $jwtConfig = Configuration::forUnsecuredSigner(); $dpop = $jwtConfig->parser()->parse($dpop); - + // 2. all required claims are contained in the JWT, $htm = $dpop->claims()->get("htm"); // http method if (!$htm) { - throw new \Exception("missing htm"); + throw new InvalidTokenException("missing htm"); } $htu = $dpop->claims()->get("htu"); // http uri if (!$htu) { - throw new \Exception("missing htu"); + throw new InvalidTokenException("missing htu"); } $typ = $dpop->headers()->get("typ"); if (!$typ) { - throw new \Exception("missing typ"); + throw new InvalidTokenException("missing typ"); } $alg = $dpop->headers()->get("alg"); if (!$alg) { - throw new \Exception("missing alg"); + throw new InvalidTokenException("missing alg"); } // 3. the "typ" field in the header has the value "dpop+jwt", if ($typ != "dpop+jwt") { - throw new \Exception("typ is not dpop+jwt"); + throw new InvalidTokenException("typ is not dpop+jwt"); } // 4. the algorithm in the header of the JWT indicates an asymmetric // digital signature algorithm, is not "none", is supported by the // application, and is deemed secure, if ($alg == "none") { - throw new \Exception("alg is none"); + throw new InvalidTokenException("alg is none"); } - + // 5. that the JWT is signed using the public key contained in the // "jwk" header of the JWT, $jwk = $dpop->headers()->get("jwk"); - $webTokenJwk = \Jose\Component\Core\JWK::createFromJson(json_encode($jwk)); + $webTokenJwk = JWK::createFromJson(json_encode($jwk)); switch ($alg) { case "RS256": - $pem = \Jose\Component\Core\Util\RSAKey::createFromJWK($webTokenJwk)->toPEM(); + $pem = RSAKey::createFromJWK($webTokenJwk)->toPEM(); $signer = new \Lcobucci\JWT\Signer\Rsa\Sha256(); break; case "ES256": - $pem = \Jose\Component\Core\Util\ECKey::convertToPEM($webTokenJwk); - $signer = \Lcobucci\JWT\Signer\Ecdsa\Sha256::create(); + $pem = ECKey::convertToPEM($webTokenJwk); + $signer = Sha256::create(); break; default: - throw new \Exception("unsupported algorithm"); + throw new InvalidTokenException("unsupported algorithm"); break; } $key = InMemory::plainText($pem); $validationConstraints = []; $validationConstraints[] = new SignedWith($signer, $key); - + // 6. the "htm" claim matches the HTTP method value of the HTTP request // in which the JWT was received (case-insensitive), if (strtolower($htm) != strtolower($request->getMethod())) { - throw new \Exception("htm http method is invalid"); + throw new InvalidTokenException("htm http method is invalid"); } // 7. the "htu" claims matches the HTTP URI value for the HTTP request @@ -195,36 +230,64 @@ public function validateDpop($dpop, $request) { //error_log("REQUESTED HTU $htu"); //error_log("REQUESTED PATH $requestedPath"); if ($htu != $requestedPath) { - throw new \Exception("htu does not match requested path"); + throw new InvalidTokenException("htu does not match requested path"); } // 8. the token was issued within an acceptable timeframe (see Section 9.1), and - $leeway = new \DateInterval("PT60S"); // allow 60 seconds clock skew + $leeway = new DateInterval("PT60S"); // allow 60 seconds clock skew $clock = SystemClock::fromUTC(); - $validationsConstraints[] = new LooseValidAt($clock, $leeway); // It will use the current time to validate (iat, nbf and exp) + $validationConstraints[] = new LooseValidAt($clock, $leeway); // It will use the current time to validate (iat, nbf and exp) if (!$jwtConfig->validator()->validate($dpop, ...$validationConstraints)) { $jwtConfig->validator()->assert($dpop, ...$validationConstraints); // throws an explanatory exception } // 9. that, within a reasonable consideration of accuracy and resource utilization, a JWT with the same "jti" value has not been received previously (see Section 9.1). - // TODO: Check if we know the jti; + $jti = $dpop->claims()->get("jti"); + if ($jti === null) { + throw new InvalidTokenException("jti is missing"); + } + $isJtiValid = $this->jtiValidator->validate($jti, (string) $request->getUri()); + if (! $isJtiValid) { + throw new InvalidTokenException("jti is invalid"); + } // 10. that, if used with an access token, it also contains the 'ath' claim, with a hash of the access token // TODO: implement return true; } - + private function getSubjectFromJwt($jwt) { - $jwtConfig = $configuration = Configuration::forUnsecuredSigner(); + $jwtConfig = Configuration::forUnsecuredSigner(); try { $jwt = $jwtConfig->parser()->parse($jwt); - } catch(\Exception $e) { - return $this->server->getResponse()->withStatus(409, "Invalid JWT token"); + } catch(Exception $e) { + throw new InvalidTokenException("Invalid JWT token", 409, $e); } $sub = $jwt->claims()->get("sub"); + if ($sub === null) { + throw new InvalidTokenException('Missing "SUB"'); + } return $sub; } + + private function validateRequestHeaders($serverParams) { + if (isset($serverParams['HTTP_AUTHORIZATION']) === false) { + throw new AuthorizationHeaderException("Authorization Header missing"); + } + + if (str_contains($serverParams['HTTP_AUTHORIZATION'], ' ') === false) { + throw new AuthorizationHeaderException("Authorization Header does not contain parameters"); + } + + if (str_starts_with(strtolower($serverParams['HTTP_AUTHORIZATION']), 'dpop') === false) { + throw new AuthorizationHeaderException('Only "dpop" authorization scheme is supported'); + } + + if (isset($serverParams['HTTP_DPOP']) === false) { + throw new AuthorizationHeaderException("Missing DPoP token"); + } + } } diff --git a/src/Utils/JtiValidator.php b/src/Utils/JtiValidator.php new file mode 100644 index 0000000..8c78ce3 --- /dev/null +++ b/src/Utils/JtiValidator.php @@ -0,0 +1,33 @@ + 12 && $strlen < 256) { + // @CHECKME: Should we fail silently (return false) or loudly (throw InvalidTokeException)? + $isValid = $this->replayDetector->detect($jti, $targetUri) === false; + } + + return $isValid; + } +} diff --git a/src/Utils/Jwks.php b/src/Utils/Jwks.php index a56f355..ea33ac8 100644 --- a/src/Utils/Jwks.php +++ b/src/Utils/Jwks.php @@ -26,7 +26,7 @@ final public function __toString() : string return (string) json_encode($this); } - final public function jsonSerialize() + final public function jsonSerialize(): array { return $this->create(); } diff --git a/tests/unit/AbstractTestCase.php b/tests/unit/AbstractTestCase.php new file mode 100644 index 0000000..484fd64 --- /dev/null +++ b/tests/unit/AbstractTestCase.php @@ -0,0 +1,46 @@ +assertEquals($expected, $decoded); + } + + public function expectArgumentCountError(int $argumentCount): void + { + $this->expectException(ArgumentCountError::class); + + $this->expectExceptionMessageMatches('/Too few arguments [^,]+, ' . ($argumentCount - 1) . ' passed/'); + } +} diff --git a/tests/unit/TokenGeneratorTest.php b/tests/unit/TokenGeneratorTest.php new file mode 100644 index 0000000..0d5a7cc --- /dev/null +++ b/tests/unit/TokenGeneratorTest.php @@ -0,0 +1,332 @@ + + * + * @uses \Pdsinterop\Solid\Auth\Utils\Base64Url + */ +class TokenGeneratorTest extends AbstractTestCase +{ + ////////////////////////////////// FIXTURES \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + private MockObject|Config $mockConfig; + private MockObject|KeysInterface $mockKeys; + + private function createTokenGenerator($interval = null): TokenGenerator + { + $this->mockConfig = $this->getMockBuilder(Config::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $mockInterval = $this->getMockBuilder(\DateInterval::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $this->mockKeys = $this->getMockBuilder(KeysInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $this->mockConfig->expects($this->atLeast(1)) + ->method('getKeys') + ->willReturn($this->mockKeys) + ; + + $this->mockKeys->expects($this->once()) + ->method('getEncryptionKey') + ->willReturn('mock encryption key') + ; + + return new TokenGenerator($this->mockConfig, $interval??$mockInterval); + } + + /////////////////////////////////// TESTS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + /** + * @testdox Token Generator SHOULD complain WHEN instantiated without Config + * + * @coversNothing + */ + final public function testInstantiateWithoutConfig(): void + { + $this->expectArgumentCountError(1); + + new TokenGenerator(); + } + + /** + * @testdox Token Generator SHOULD complain WHEN instantiated without validity period + * + * @coversNothing + */ + final public function testInstantiateWithoutValidFor(): void + { + $this->expectArgumentCountError(2); + + $mockConfig = $this->getMockBuilder(Config::class) + ->disableOriginalConstructor() + ->getMock(); + + new TokenGenerator($mockConfig); + } + + /** + * @testdox Token Generator SHOULD be created WHEN instantiated with Config and validity period + * + * @covers ::__construct + */ + final public function testInstantiation(): void + { + $actual = $this->createTokenGenerator(); + + $expected = TokenGenerator::class; + + $this->assertInstanceOf($expected, $actual); + } + + /** + * @testdox Token Generator SHOULD complain WHEN asked to generate a RegistrationAccessToken without clientId + * + * @covers ::generateRegistrationAccessToken + */ + final public function testRegistrationAccessTokenGenerationWithoutClientId(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $this->expectArgumentCountError(1); + + $tokenGenerator->generateRegistrationAccessToken(); + } + + /** + * @testdox Token Generator SHOULD complain WHEN asked to generate a RegistrationAccessToken without privateKey + * + * @covers ::generateRegistrationAccessToken + */ + final public function testRegistrationAccessTokenGenerationWithoutPrivateKey(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $this->expectArgumentCountError(2); + + $tokenGenerator->generateRegistrationAccessToken('mock client ID'); + } + + /** + * @testdox Token Generator SHOULD return a RegistrationAccessToken WHEN asked to generate a RegistrationAccessToken with clientId and privateKey + * + * @covers ::generateRegistrationAccessToken + */ + final public function testRegistrationAccessTokenGeneration(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $mockServer = $this->getMockBuilder(ServerInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $this->mockConfig->expects($this->once()) + ->method('getServer') + ->willReturn($mockServer) + ; + + $mockServer->expects($this->once()) + ->method('get') + ->with(OidcMeta::ISSUER) + ->willReturn('mock issuer') + ; + + $privateKey = file_get_contents(__DIR__.'/../fixtures/keys/private.key'); + + $actual = $tokenGenerator->generateRegistrationAccessToken('mock client ID', $privateKey); + + $this->assertJwtEquals([[ + "alg" => "RS256", + "typ" => "JWT", + ], [ + "iss" => "mock issuer", + "aud" => "mock client ID", + "sub" => "mock client ID", + ]], $actual); + } + + /** + * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without accessToken + * + * @covers ::generateIdToken + */ + final public function testIdTokenGenerationWithoutAccesToken(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $this->expectArgumentCountError(1); + + $tokenGenerator->generateIdToken(); + } + + /** + * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without clientId + * + * @covers ::generateIdToken + */ + final public function testIdTokenGenerationWithoutClientId(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $this->expectArgumentCountError(2); + + $tokenGenerator->generateIdToken('mock access token'); + } + + /** + * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without subject + * + * @covers ::generateIdToken + */ + final public function testIdTokenGenerationWithoutSubject(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $this->expectArgumentCountError(3); + + $tokenGenerator->generateIdToken('mock access token', 'mock clientId'); + } + + /** + * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without nonce + * + * @covers ::generateIdToken + */ + final public function testIdTokenGenerationWithoutNonce(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $this->expectArgumentCountError(4); + + $tokenGenerator->generateIdToken('mock access token', 'mock clientId', 'mock subject'); + } + + /** + * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without privateKey, $dpopKey + * + * @covers ::generateIdToken + */ + final public function testIdTokenGenerationWithoutPrivateKey(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $this->expectArgumentCountError(5); + + $tokenGenerator->generateIdToken( + 'mock access token', + 'mock clientId', + 'mock subject', + 'mock nonce' + ); + } + + /** + * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without dpopKey + * + * @covers ::generateIdToken + */ + final public function testIdTokenGenerationWithoutDpopKey(): void + { + $tokenGenerator = $this->createTokenGenerator(); + + $this->expectArgumentCountError(6); + + $tokenGenerator->generateIdToken( + 'mock access token', + 'mock clientId', + 'mock subject', + 'mock nonce', + 'mock private key' + ); + } + + /** + * @testdox Token Generator SHOULD return a IdToken WHEN asked to generate a IdToken with clientId and privateKey + * + * @covers ::generateIdToken + * + * @uses \Pdsinterop\Solid\Auth\Utils\Jwks + */ + final public function testIdTokenGeneration(): void + { + $validFor = new \DateInterval('PT1S'); + + $tokenGenerator = $this->createTokenGenerator($validFor); + + $mockKey = \Lcobucci\JWT\Signer\Key\InMemory::file(__DIR__.'/../fixtures/keys/public.key'); + + $this->mockKeys->expects($this->once()) + ->method('getPublicKey') + ->willReturn($mockKey) + ; + + $mockServer = $this->getMockBuilder(ServerInterface::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $this->mockConfig->expects($this->once()) + ->method('getServer') + ->willReturn($mockServer) + ; + + $mockServer->expects($this->once()) + ->method('get') + ->with(OidcMeta::ISSUER) + ->willReturn('mock issuer') + ; + + $privateKey = file_get_contents(__DIR__.'/../fixtures/keys/private.key'); + + $now = new \DateTimeImmutable('1234-01-01 12:34:56.789'); + + $actual = $tokenGenerator->generateIdToken( + 'mock access token', + 'mock clientId', + 'mock subject', + 'mock nonce', + $privateKey, + 'mock dpop', + $now + ); + + $this->assertJwtEquals([[ + "alg"=>"RS256", + "kid"=>"0c3932ca20f3a00ad2eb72035f6cc9cb", + "typ"=>"JWT", + ],[ + 'aud' => 'mock clientId', + 'azp' => 'mock clientId', + 'c_hash' => '1EZBnvsFWlK8ESkgHQsrIQ', + 'at_hash' => '1EZBnvsFWlK8ESkgHQsrIQ', + 'cnf' => ["jkt" => "mock dpop"], + 'exp' => -23225829903.789, + 'iat' => -23225829904.789, + 'iss' => 'mock issuer', + 'jti' => '4dc20036dbd8313ed055', + 'nbf' => -23225829905.789, + 'nonce' => 'mock nonce', + 'sub' => 'mock subject', + ]], $actual); + } +} diff --git a/tests/unit/Utils/DPOPTest.php b/tests/unit/Utils/DPOPTest.php index 4070c2c..bfb5553 100644 --- a/tests/unit/Utils/DPOPTest.php +++ b/tests/unit/Utils/DPOPTest.php @@ -2,53 +2,35 @@ namespace Pdsinterop\Solid\Auth\Utils; -use PHPUnit\Framework\TestCase; +use Laminas\Diactoros\ServerRequest; use Lcobucci\JWT\Validation\RequiredConstraintsViolated; +use Pdsinterop\Solid\Auth\AbstractTestCase; +use Pdsinterop\Solid\Auth\Enum\Jwk\Parameter as JwkParameter; +use Pdsinterop\Solid\Auth\Exception\AuthorizationHeaderException; +use Pdsinterop\Solid\Auth\Exception\InvalidTokenException; /** * @coversDefaultClass \Pdsinterop\Solid\Auth\Utils\DPop * @covers :: + * @covers ::__construct + * * @uses \Pdsinterop\Solid\Auth\Utils\Base64Url + * @uses \Pdsinterop\Solid\Auth\Utils\JtiValidator */ -class DPOPTest extends TestCase +class DPOPTest extends AbstractTestCase { + ////////////////////////////////// FIXTURES \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ - private $dpop; - private $url; - private $serverRequest; + const MOCK_SUBJECT = 'mock sub'; + const MOCK_THUMBPRINT = 'Mock Thumbprint'; - protected function sign($dpop, $privateKey=null) - { - $keyPath = __DIR__ . '/../../fixtures/keys'; - if (!$privateKey) { - $privateKey = file_get_contents($keyPath . '/private.key'); - } + private $dpop; + private $url; + private $serverRequest; - $signature = ''; - $success = \openssl_sign( - Base64Url::encode(json_encode($dpop['header'])).'.'. - Base64Url::encode(json_encode($dpop['payload'])), - $signature, - $privateKey, - OPENSSL_ALGO_SHA256 - ); - - if (!$success) { - throw new \Exception('unable to sign dpop'); - } - $token = Base64Url::encode(json_encode($dpop['header'])).'.'. - Base64Url::encode(json_encode($dpop['payload'])).'.'. - Base64Url::encode($signature); - - return array_merge($dpop, [ - 'signature' => $signature, - 'token' => $token - ]); - } - - protected function setUp(): void - { - $keyPath = __DIR__ . '/../../fixtures/keys'; + protected function setUp(): void + { + $keyPath = __DIR__ . '/../../fixtures/keys'; $privateKey = file_get_contents($keyPath . '/private.key'); $publicKey = file_get_contents($keyPath . '/public.key'); @@ -56,13 +38,13 @@ protected function setUp(): void $jwk = [ 'kty' => 'RSA', 'n' => Base64Url::encode($keyInfo['rsa']['n']), - 'e' => Base64Url::encode($keyInfo['rsa']['e']) + 'e' => Base64Url::encode($keyInfo['rsa']['e']), ]; $header = [ 'typ' => 'dpop+jwt', 'alg' => 'RS256', - 'jwk' => $jwk + 'jwk' => $jwk, ]; $payload = [ @@ -72,89 +54,513 @@ protected function setUp(): void 'htu' => 'https://www.example.com', 'iat' => time(), 'nbf' => time(), - 'exp' => time()+3600 + 'exp' => time()+3600, ]; $this->dpop = $this->sign([ - 'header' => $header, - 'payload' => $payload + 'header' => $header, + 'payload' => $payload, ]); - $this->url = 'https://www.example.com'; - $this->serverRequest = new \Laminas\Diactoros\ServerRequest(array(),array(), $this->url); - - } + $this->url = 'https://www.example.com'; + $this->serverRequest = new ServerRequest(array(),array(), $this->url); + } - private function getWrongKey() { - $keyPath = __DIR__ . '/../../fixtures/keys'; - $wrongKey = file_get_contents($keyPath . '/wrong.key'); + private function getWrongKey() + { + $keyPath = __DIR__ . '/../../fixtures/keys'; + $wrongKey = file_get_contents($keyPath . '/wrong.key'); $keyInfo = \openssl_pkey_get_details(\openssl_pkey_get_public($wrongKey)); return $keyInfo; } + /////////////////////////////////// TESTS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + /** + * @testdox Dpop SHOULD complain WHEN instantiated without JtiValidator + */ + final public function testInstantiationWithoutJtiValidator(): void + { + $this->expectArgumentCountError(1); + + new DPop(); + } + + /** + * @testdox Dpop SHOULD be created WHEN instantiated with JtiValidator + */ + final public function testInstantiation(): void + { + $mockJtiValidator = $this->createMockJtiValidator(); + $actual = new DPop($mockJtiValidator); + $expected = DPop::class; + + $this->assertInstanceOf($expected, $actual); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to validate DPOP without JWT given + * + * @covers ::validateDpop + */ + final public function testValidateDpopWithoutJwt(): void + { + $this->expectArgumentCountError(1); + + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $dpop->validateDpop(); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to validate DPOP without Request given + * + * @covers ::validateDpop + */ + final public function testValidateDpopWithoutRequest(): void + { + $this->expectArgumentCountError(2); + + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $dpop->validateDpop('mock jwt'); + } + /** + * @testdox Dpop SHOULD complain WHEN asked to validate a DPOP with wrong header type + * * @covers ::validateDpop */ - public function testWrongTyp(): void + public function testValidateDpopWithWrongTyp(): void { $this->dpop['header']['typ'] = 'jwt'; $token = $this->sign($this->dpop); - $dpop = new DPop(); - $this->expectException(\Exception::class); + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $this->expectException(InvalidTokenException::class); $this->expectExceptionMessage('typ is not dpop+jwt'); $result = $dpop->validateDpop($token['token'], $this->serverRequest); } /** + * @testdox Dpop SHOULD complain WHEN asked to validate a DPOP with encryption algorithm "none" + * * @covers ::validateDpop */ - public function testAlgNone(): void + public function testValidateDpopWithAlgNone(): void { $this->dpop['header']['alg'] = 'none'; $token = $this->sign($this->dpop); - $dpop = new DPop(); - $this->expectException(\Exception::class); + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $this->expectException(InvalidTokenException::class); $this->expectExceptionMessage('alg is none'); $result = $dpop->validateDpop($token['token'], $this->serverRequest); $this->assertTrue($result); } /** + * @testdox Dpop SHOULD complain WHEN asked to validate a DPOP with mismatched public key + * * @covers ::validateDpop */ - public function testWrongKey(): void + public function testValidateDpopWithWrongKey(): void { $theWrongKey = $this->getWrongKey(); $this->dpop['header']['jwk'] = [ 'kty' => 'RSA', 'n' => Base64Url::encode($theWrongKey['rsa']['n']), - 'e' => Base64Url::encode($theWrongKey['rsa']['e']) + 'e' => Base64Url::encode($theWrongKey['rsa']['e']), ]; $token = $this->sign($this->dpop); - $dpop = new DPop(); + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + try { - $dpop->validateDpop($token['token'], $this->serverRequest); - } catch(RequiredConstraintsViolated $e) { + $dpop->validateDpop($token['token'], $this->serverRequest); + } catch(RequiredConstraintsViolated $e) { // need to check the actual violation in the exception, so expectExceptionMessage is not sufficient - $this->assertSame($e->violations()[0]->getMessage(),'Token signature mismatch'); - } + $this->assertSame($e->violations()[0]->getMessage(),'Token signature mismatch'); + } } /** + * @testdox Dpop SHOULD return true WHEN asked to validate a valid DPOP + * * @covers ::validateDpop */ - public function testCorrectToken(): void + public function testValidateDpopWithCorrectToken(): void { + $this->dpop['payload']['jti'] = 'mock jti'; + $token = $this->sign($this->dpop); - $dpop = new DPop(); + $mockJtiValidator = $this->createMockJtiValidator(); + + $mockJtiValidator->expects($this->once()) + ->method('validate') + ->willReturn(true) + ; + + $dpop = new DPop($mockJtiValidator); + $result = $dpop->validateDpop($token['token'], $this->serverRequest); + $this->assertTrue($result); } -} \ No newline at end of file + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId without Request given + * + * @covers ::getWebId + */ + final public function testGetWebIdWithoutRequest(): void + { + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $this->expectArgumentCountError(1); + + $dpop->getWebId(); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request without Authorization Header + * + * @covers ::getWebId + */ + final public function testGetWebIdWithoutHttpAuthorizationHeader(): void + { + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $request = new ServerRequest(array(),array(), $this->url); + + $this->expectException(AuthorizationHeaderException::class); + $this->expectExceptionMessage('Authorization Header missing'); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request with incorrect Authorization Header format + * + * @covers ::getWebId + */ + final public function testGetWebIdWithIncorrectAuthHeaderFormat(): void + { + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $request = new ServerRequest(array('HTTP_AUTHORIZATION' => 'IncorrectAuthorizationFormat'),array(), $this->url); + + $this->expectException(AuthorizationHeaderException::class); + $this->expectExceptionMessage('Authorization Header does not contain parameters'); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request with invalid JWT + * + * @covers ::getWebId + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::getDpopKey + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::validateDpop + */ + final public function testGetWebIdWithInvalidJwt(): void + { + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Invalid JWT token'); + + $request = new ServerRequest(array( + 'HTTP_AUTHORIZATION' => "dpop Invalid JWT", + 'HTTP_DPOP' => 'Mock dpop', + ),array(), $this->url); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request without DPOP authorization + * + * @covers ::getWebId + */ + final public function testGetWebIdWithoutDpop(): void + { + $mockJtiValidator = $this->createMockJtiValidator(); + $dpop = new DPop($mockJtiValidator); + + $this->expectException(AuthorizationHeaderException::class); + $this->expectExceptionMessage('Only "dpop" authorization scheme is supported'); + + $request = new ServerRequest(array('HTTP_AUTHORIZATION' => "Basic YWxhZGRpbjpvcGVuc2VzYW1l"),array(), $this->url); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request with valid DPOP without JWT Key Id + * + * @covers ::getWebId + * + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::getDpopKey + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::validateDpop + */ + final public function testGetWebIdWithDpopWithoutKeyId(): void + { + $this->dpop['payload']['cnf'] = ['jkt' => self::MOCK_THUMBPRINT]; + $this->dpop['payload']['jti'] = 'mock jti'; + $this->dpop['payload']['sub'] = self::MOCK_SUBJECT; + + $token = $this->sign($this->dpop); + + $mockJtiValidator = $this->createMockJtiValidator(); + + $mockJtiValidator->expects($this->once()) + ->method('validate') + ->willReturn(true) + ; + + $dpop = new DPop($mockJtiValidator); + + $request = new ServerRequest(array( + 'HTTP_AUTHORIZATION' => "dpop {$token['token']}", + 'HTTP_DPOP' => $token['token'], + ),array(), $this->url); + + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Key ID is missing from JWK header'); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request with valid DPOP without Confirmation Claim + * + * @covers ::getWebId + * + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::getDpopKey + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::validateDpop + */ + final public function testGetWebIdWithDpopWithoutConfirmationClaim(): void + { + $this->dpop['header']['jwk'][JwkParameter::KEY_ID] = self::MOCK_THUMBPRINT; + $this->dpop['payload']['jti'] = 'mock jti'; + $this->dpop['payload']['sub'] = self::MOCK_SUBJECT; + + $token = $this->sign($this->dpop); + + $mockJtiValidator = $this->createMockJtiValidator(); + + $mockJtiValidator->expects($this->once()) + ->method('validate') + ->willReturn(true) + ; + + $dpop = new DPop($mockJtiValidator); + + $request = new ServerRequest(array( + 'HTTP_AUTHORIZATION' => "dpop {$token['token']}", + 'HTTP_DPOP' => $token['token'], + ),array(), $this->url); + + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('JWT Confirmation claim (cnf) is missing'); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request with valid DPOP without JWT Key Thumbprint + * + * @covers ::getWebId + * + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::getDpopKey + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::validateDpop + */ + final public function testGetWebIdWithDpopWithoutThumbprint(): void + { + $this->dpop['header']['jwk'][JwkParameter::KEY_ID] = self::MOCK_THUMBPRINT; + $this->dpop['payload']['cnf'] = []; + $this->dpop['payload']['jti'] = 'mock jti'; + $this->dpop['payload']['sub'] = self::MOCK_SUBJECT; + + $token = $this->sign($this->dpop); + + $mockJtiValidator = $this->createMockJtiValidator(); + $mockJtiValidator->expects($this->once()) + ->method('validate') + ->willReturn(true) + ; + $dpop = new DPop($mockJtiValidator); + + $request = new ServerRequest(array( + 'HTTP_AUTHORIZATION' => "dpop {$token['token']}", + 'HTTP_DPOP' => $token['token'], + ),array(), $this->url); + + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('JWT Confirmation claim (cnf) is missing Thumbprint (jkt)'); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request with valid DPOP with Thumbprint not matching Key Id + * + * @covers ::getWebId + * + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::getDpopKey + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::validateDpop + */ + final public function testGetWebIdWithDpopWithMismatchingThumbprintAndKeyId(): void + { + $this->dpop['header']['jwk'][JwkParameter::KEY_ID] = self::MOCK_THUMBPRINT . 'Mismatch'; + $this->dpop['payload']['cnf'] = ['jkt' => self::MOCK_THUMBPRINT]; + $this->dpop['payload']['jti'] = 'mock jti'; + $this->dpop['payload']['sub'] = self::MOCK_SUBJECT; + + $token = $this->sign($this->dpop); + + $mockJtiValidator = $this->createMockJtiValidator(); + $mockJtiValidator->expects($this->once()) + ->method('validate') + ->willReturn(true) + ; + $dpop = new DPop($mockJtiValidator); + + $request = new ServerRequest(array( + 'HTTP_AUTHORIZATION' => "dpop {$token['token']}", + 'HTTP_DPOP' => $token['token'], + ),array(), $this->url); + + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('JWT Confirmation claim (cnf) provided Thumbprint (jkt) does not match Key ID from JWK header'); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD complain WHEN asked to get WebId from Request with valid DPOP without "sub" + * + * @covers ::getWebId + * + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::getDpopKey + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::validateDpop + */ + final public function testGetWebIdWithDpopWithoutSub(): void + { + $this->dpop['header']['jwk'][JwkParameter::KEY_ID] = self::MOCK_THUMBPRINT; + $this->dpop['payload']['cnf'] = ['jkt' => self::MOCK_THUMBPRINT]; + $this->dpop['payload']['jti'] = 'mock jti'; + + $token = $this->sign($this->dpop); + + $mockJtiValidator = $this->createMockJtiValidator(); + $mockJtiValidator->expects($this->once()) + ->method('validate') + ->willReturn(true) + ; + $dpop = new DPop($mockJtiValidator); + + $request = new ServerRequest(array( + 'HTTP_AUTHORIZATION' => "dpop {$token['token']}", + 'HTTP_DPOP' => $token['token'], + ),array(), $this->url); + + $this->expectException(InvalidTokenException::class); + $this->expectExceptionMessage('Missing "SUB"'); + + $dpop->getWebId($request); + } + + /** + * @testdox Dpop SHOULD return given "sub" WHEN asked to get WebId from Request with complete DPOP + * + * @covers ::getWebId + * + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::getDpopKey + * @uses \Pdsinterop\Solid\Auth\Utils\DPop::validateDpop + */ + final public function testGetWebIdWithDpop(): void + { + $this->dpop['header']['jwk'][JwkParameter::KEY_ID] = self::MOCK_THUMBPRINT; + $this->dpop['payload']['cnf'] = ['jkt' => self::MOCK_THUMBPRINT]; + $this->dpop['payload']['jti'] = 'mock jti'; + $this->dpop['payload']['sub'] = self::MOCK_SUBJECT; + + $token = $this->sign($this->dpop); + + $mockJtiValidator = $this->createMockJtiValidator(); + + $mockJtiValidator->expects($this->once()) + ->method('validate') + ->willReturn(true) + ; + + $dpop = new DPop($mockJtiValidator); + + $request = new ServerRequest(array( + 'HTTP_AUTHORIZATION' => "dpop {$token['token']}", + 'HTTP_DPOP' => $token['token'], + ),array(), $this->url); + + $actual = $dpop->getWebId($request); + + $this->assertEquals(self::MOCK_SUBJECT, $actual); + } + + ////////////////////////////// MOCKS AND STUBS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + private function createMockJtiValidator() + { + $mockJtiValidator = $this->getMockBuilder(JtiValidator::class) + ->disableOriginalConstructor() + ->getMock(); + + return $mockJtiValidator; + } + + ///////////////////////////// HELPER FUNCTIONS \\\\\\\\\\\\\\\\\\\\\\\\\\\\\ + + protected function sign($dpop, $privateKey = null) + { + $keyPath = __DIR__ . '/../../fixtures/keys'; + if (!$privateKey) { + $privateKey = file_get_contents($keyPath . '/private.key'); + } + + $signature = ''; + $success = \openssl_sign( + Base64Url::encode(json_encode($dpop['header'])).'.'. + Base64Url::encode(json_encode($dpop['payload'])), + $signature, + $privateKey, + OPENSSL_ALGO_SHA256 + ); + + if (!$success) { + throw new \Exception('unable to sign dpop'); + } + $token = Base64Url::encode(json_encode($dpop['header'])).'.'. + Base64Url::encode(json_encode($dpop['payload'])).'.'. + Base64Url::encode($signature); + + return array_merge($dpop, [ + 'signature' => $signature, + 'token' => $token, + ]); + } +}