diff --git a/src/TokenGenerator.php b/src/TokenGenerator.php index e6fc26f..04c09bb 100644 --- a/src/TokenGenerator.php +++ b/src/TokenGenerator.php @@ -5,11 +5,11 @@ use Pdsinterop\Solid\Auth\Exception\InvalidTokenException; use Pdsinterop\Solid\Auth\Utils\DPop; use Pdsinterop\Solid\Auth\Utils\Jwks; +use Pdsinterop\Solid\Auth\Utils\Base64Url; use Pdsinterop\Solid\Auth\Enum\OpenId\OpenIdConnectMetadata as OidcMeta; use Laminas\Diactoros\Response\JsonResponse; use League\OAuth2\Server\CryptTrait; -use DateTimeImmutable; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; @@ -18,7 +18,7 @@ class TokenGenerator { ////////////////////////////// CLASS PROPERTIES \\\\\\\\\\\\\\\\\\\\\\\\\\\\ - use CryptTrait; + use CryptTrait; // Used to decrypt the 'code' information; public Config $config; @@ -36,180 +36,213 @@ final public function __construct( $this->dpopUtil = $dpopUtil; $this->validFor = $validFor; + // Set the decryption key for the CryptTrait, used to decrypt the 'code' information $this->setEncryptionKey($this->config->getKeys()->getEncryptionKey()); } - public function generateRegistrationAccessToken($clientId, $privateKey) { - $issuer = $this->config->getServer()->get(OidcMeta::ISSUER); + public function generateRegistrationAccessToken($clientId, $privateKey) { + $issuer = $this->config->getServer()->get(OidcMeta::ISSUER); - // Create JWT - $jwtConfig = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($privateKey)); - $token = $jwtConfig->builder() - ->issuedBy($issuer) - ->permittedFor($clientId) - ->relatedTo($clientId) - ->getToken($jwtConfig->signer(), $jwtConfig->signingKey()); + // Create JWT + $jwtConfig = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($privateKey)); + $token = $jwtConfig->builder() + ->issuedBy($issuer) + ->permittedFor($clientId) + ->relatedTo($clientId) + ->getToken($jwtConfig->signer(), $jwtConfig->signingKey()); - return $token->toString(); - } + return $token->toString(); + } /** * Please note that the DPOP _is not_ required when requesting a token to * authorize a client but the DPOP _is_ required when requesting an access * token. */ - public function generateIdToken($accessToken, $clientId, $subject, $nonce, $privateKey, $dpop=null, $now=null) { - $issuer = $this->config->getServer()->get(OidcMeta::ISSUER); - - $tokenHash = $this->generateTokenHash($accessToken); - - // Create JWT - $jwtConfig = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($privateKey)); - $now = $now ?? new DateTimeImmutable(); - $useAfter = $now->sub(new \DateInterval('PT1S')); - - $expire = $now->add($this->validFor); - - $token = $jwtConfig->builder() - ->issuedBy($issuer) - ->permittedFor($clientId) - ->issuedAt($now) - ->canOnlyBeUsedAfter($useAfter) - ->expiresAt($expire) - ->withClaim("azp", $clientId) - ->relatedTo($subject) - ->identifiedBy($this->generateJti()) - ->withClaim("nonce", $nonce) - ->withClaim("at_hash", $tokenHash) //FIXME: at_hash should only be added if the response_type is a token - ->withClaim("c_hash", $tokenHash) // FIXME: c_hash should only be added if the response_type is a code - ; - - if ($dpop !== null) { - $jkt = $this->makeJwkThumbprint($dpop); - $token = $token->withClaim("cnf", [ - "jkt" => $jkt, - ]); - } else { - // legacy mode - $jwks = $this->getJwks(); - $token = $token->withHeader('kid', $jwks['keys'][0]['kid']); - } - - return $token->getToken($jwtConfig->signer(), $jwtConfig->signingKey())->toString(); - } - - public function respondToRegistration($registration, $privateKey) { - /* - Expects in $registration: - client_id - client_id_issued_at - redirect_uris - registration_client_uri - */ - $registration_access_token = $this->generateRegistrationAccessToken($registration['client_id'], $privateKey); - - $registrationBase = array( - 'response_types' => array("id_token token"), - 'grant_types' => array("implicit"), - 'application_type' => 'web', - 'id_token_signed_response_alg' => "RS256", - '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, $dpop=null) { - if ($response->hasHeader("Location")) { - $value = $response->getHeaderLine("Location"); - - if (preg_match("/#access_token=(.*?)&/", $value, $matches)) { - $idToken = $this->generateIdToken( - $matches[1], - $clientId, - $subject, - $nonce, - $privateKey, - $dpop - ); - $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( - $matches[1], - $clientId, - $subject, - $nonce, - $privateKey, - $dpop - ); - $value = preg_replace("/code=(.*?)&/", "code=\$1&id_token=$idToken&", $value); - $response = $response->withHeader("Location", $value); - } - } else { - $response->getBody()->rewind(); - $responseBody = $response->getBody()->getContents(); - try { - $body = json_decode($responseBody, true); - if (isset($body['access_token'])) { - $body['id_token'] = $this->generateIdToken( - $body['access_token'], - $clientId, - $subject, - $nonce, - $privateKey, - $dpop - ); - - $body['access_token'] = $body['id_token']; - return new JsonResponse($body); - } - } catch (\Exception $e) { - // leave the response as it was; - } - } - return $response; - } - - 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); - $atHash = hex2bin($atHash); - $atHash = base64_encode($atHash); - $atHash = rtrim($atHash, '='); - $atHash = str_replace('/', '_', $atHash); - $atHash = str_replace('+', '-', $atHash); - - return $atHash; - } - - private function makeJwkThumbprint($dpop): string - { - $dpopConfig = Configuration::forUnsecuredSigner(); - $parsedDpop = $dpopConfig->parser()->parse($dpop); - $jwk = $parsedDpop->headers()->get("jwk"); - - if (empty($jwk)) { - throw new InvalidTokenException('Required JWK header missing in DPOP'); - } - - return $this->dpopUtil->makeJwkThumbprint($jwk); - } - - private function getJwks() { - $key = $this->config->getKeys()->getPublicKey(); - $jwks = new Jwks($key); - return json_decode((string) $jwks, true); - } + + public function generateAccessToken($clientId, $subject) { + $issuer = $this->config->getServer()->get(OidcMeta::ISSUER); + return [ + "header" => [], + "payload" => [ + "iss" => $issuer, + "aud" => "solid", + "sub" => $subject, + "exp" => time()+3600, + "iat" => time(), + "jti" => $this->generateJti(), + "client_id" => $clientId, + "webid" => $subject + ] + ]; + } + + public function bindDpop($dpop, $accessToken) { + if ($dpop) { + $jkt = $this->makeJwkThumbprint($dpop); + $accessToken['payload']['cnf'] = [ + 'jkt' => $jkt + ]; + } + return $accessToken; + } + + public function generateIdToken($clientId, $subject) { + $issuer = $this->config->getServer()->get(OidcMeta::ISSUER); + + return [ + "header" => [], + "payload" => [ + "iss" => $issuer, + "aud" => $clientId, + "azp" => $clientId, + "sub" => $subject, + "exp" => time()+3600, + "iat" => time(), + "jti" => $this->generateJti(), + ] + ]; + } + + public function bindCode($code, $idToken) { + $tokenHash = $this->generateTokenHash($code); + $idToken['payload']['c_hash'] = $tokenHash; + return $idToken; + } + + public function bindNonce($nonce, $idToken) { + if ($nonce) { + $idToken['payload']['nonce'] = $nonce; + } + return $idToken; + } + + public function bindAccessToken($accessToken, $idToken) { + $tokenHash = $this->generateTokenHash($accessToken); + $idToken['payload']['at_hash'] = $tokenHash; + return $idToken; + } + + public function signToken($token) { + $jwks = $this->getJwks(); + $token['header']['alg'] = "RS256"; + $token['header']['kid'] = $jwks['keys'][0]['kid']; // FIXME: Use the kid from the privateKey we are signing with; + + $header = Base64Url::encode(json_encode($token['header'])); + $payload = Base64Url::encode(json_encode($token['payload'])); + + $signature = ''; + $key = $this->config->getKeys()->getPrivateKey()->getKeyContents(); + + $signingKey = openssl_pkey_get_private($key); + openssl_sign("$header.$payload", $signature, $signingKey, OPENSSL_ALGO_SHA256); + $signature = Base64Url::encode($signature); + + $jwt = "$header.$payload.$signature"; + return $jwt; + } + + public function respondToRegistration($registration, $privateKey) { + /* + Expects in $registration: + client_id + client_id_issued_at + redirect_uris + registration_client_uri + */ + $registration_access_token = $this->generateRegistrationAccessToken($registration['client_id'], $privateKey); + + $registrationBase = array( + 'response_types' => array("id_token token"), + 'grant_types' => array("implicit"), + 'application_type' => 'web', + 'id_token_signed_response_alg' => "RS256", + '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, $dpop=null) { + if ($response->hasHeader("Location")) { + $value = $response->getHeaderLine("Location"); + if (preg_match("/#access_token=(.*?)&/", $value, $matches)) { + $idToken = $this->generateIdToken($clientId, $subject); + $idToken = $this->bindAccessToken($matches[1], $idToken); + $idToken = $this->bindNonce($nonce, $idToken); + $idToken = $this->signToken($idToken); + $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($clientId, $subject); + $idToken = $this->bindCode($matches[1], $idToken); + $idToken = $this->bindNonce($nonce, $idToken); + $idToken = $this->signToken($idToken); + $value = preg_replace("/code=(.*?)&/", "code=\$1&id_token=$idToken&", $value); + $response = $response->withHeader("Location", $value); + } + } else { + $response->getBody()->rewind(); + $responseBody = $response->getBody()->getContents(); + try { + $body = json_decode($responseBody, true); + + $accessToken = $this->generateAccessToken($clientId, $subject); + $accessToken = $this->bindDpop($dpop, $accessToken); + $accessToken = $this->signToken($accessToken); + + $idToken = $this->generateIdToken($clientId, $subject); + $idToken = $this->bindAccessToken($accessToken, $idToken); + $idToken = $this->bindNonce($nonce, $idToken); + $idToken = $this->signToken($idToken); + + $body['access_token'] = $accessToken; + $body['id_token'] = $idToken; + // $body['refresh_token'] = str_repeat('a', 209); // FIXME: Remove this, DO NOT MERGE. Podpro doesn't like refresh tokens longer than 209 characters; + + return new JsonResponse($body); + } catch (\Exception $e) { + // leave the response as it was; + } + } + return $response; + } + + 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); + $atHash = hex2bin($atHash); + $atHash = Base64Url::encode($atHash); + return $atHash; + } + + private function makeJwkThumbprint($dpop): string + { + $dpopConfig = Configuration::forUnsecuredSigner(); + $parsedDpop = $dpopConfig->parser()->parse($dpop); + $jwk = $parsedDpop->headers()->get("jwk"); + + if (empty($jwk)) { + throw new InvalidTokenException('Required JWK header missing in DPOP'); + } + + return $this->dpopUtil->makeJwkThumbprint($jwk); + } + + private function getJwks() { + $key = $this->config->getKeys()->getPublicKey(); + $jwks = new Jwks($key); + return json_decode((string) $jwks, true); + } } diff --git a/tests/unit/TokenGeneratorTest.php b/tests/unit/TokenGeneratorTest.php index bcbba41..aab691e 100644 --- a/tests/unit/TokenGeneratorTest.php +++ b/tests/unit/TokenGeneratorTest.php @@ -203,7 +203,7 @@ final public function testRegistrationAccessTokenGeneration(): void } /** - * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without accessToken + * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without clientId * * @covers ::generateIdToken */ @@ -217,7 +217,7 @@ final public function testIdTokenGenerationWithoutAccesToken(): void } /** - * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without clientId + * @testdox Token Generator SHOULD complain WHEN asked to generate a IdToken without subject * * @covers ::generateIdToken */ @@ -227,54 +227,7 @@ final public function testIdTokenGenerationWithoutClientId(): void $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' - ); + $tokenGenerator->generateIdToken('mock clientId'); } /** @@ -283,6 +236,8 @@ final public function testIdTokenGenerationWithoutPrivateKey(): void * @covers ::generateIdToken * * @uses \Pdsinterop\Solid\Auth\Utils\Jwks + * @uses \Pdsinterop\Solid\Auth\TokenGenerator + * @uses \Pdsinterop\Solid\Auth\Utils\Base64Url */ final public function testIdTokenGenerationWithoutDpopKey(): void { @@ -290,7 +245,6 @@ final public function testIdTokenGenerationWithoutDpopKey(): void $tokenGenerator = $this->createTokenGenerator($validFor); - $mockServer = $this->getMockBuilder(ServerInterface::class) ->disableOriginalConstructor() ->getMock() @@ -307,39 +261,53 @@ final public function testIdTokenGenerationWithoutDpopKey(): void ->willReturn('mock issuer') ; + $privateKey = file_get_contents(__DIR__.'/../fixtures/keys/private.key'); $publicKey = file_get_contents(__DIR__.'/../fixtures/keys/public.key'); - + + $mockPrivateKey = $this->getMockBuilder(\League\OAuth2\Server\CryptKey::class) + ->disableOriginalConstructor() + ->getMock() + ; $mockPublicKey = $this->getMockBuilder(\Lcobucci\JWT\Signer\Key::class) + ->disableOriginalConstructor() ->getMock() ; + $mockPrivateKey->expects($this->once()) + ->method('getKeyContents') + ->willReturn($privateKey) + ; + $mockPublicKey->expects($this->once()) ->method('contents') ->willReturn($publicKey) ; + $this->mockKeys->expects($this->once()) + ->method('getPrivateKey') + ->willReturn($mockPrivateKey) + ; + $this->mockKeys->expects($this->once()) ->method('getPublicKey') ->willReturn($mockPublicKey) ; - $privateKey = file_get_contents(__DIR__.'/../fixtures/keys/private.key'); - - $now = new \DateTimeImmutable('1234-01-01 12:34:56.789'); + $this->mockConfig->expects($this->atLeast(1)) + ->method('getKeys') + ->willReturn($this->mockKeys) + ; - $token = $tokenGenerator->generateIdToken( - 'mock access token', + $idToken = $tokenGenerator->generateIdToken( 'mock clientId', - 'mock subject', - 'mock nonce', - $privateKey, - null, - $now, + 'mock subject' ); + $idToken = $tokenGenerator->bindAccessToken('mock access token', $idToken); + $idToken = $tokenGenerator->signToken($idToken); $this->assertJwtEquals([ [ - 'typ' => 'JWT', +// 'typ' => 'JWT', 'alg' => 'RS256', 'kid' => '0c3932ca20f3a00ad2eb72035f6cc9cb' ], @@ -347,16 +315,14 @@ final public function testIdTokenGenerationWithoutDpopKey(): void 'at_hash' => '1EZBnvsFWlK8ESkgHQsrIQ', 'aud' => 'mock clientId', 'azp' => 'mock clientId', - 'c_hash' => '1EZBnvsFWlK8ESkgHQsrIQ', - 'exp' => -23225829903.789, - 'iat' => -23225829904.789, + 'exp' => 4834, + 'iat' => 1234, 'iss' => 'mock issuer', 'jti' => '4dc20036dbd8313ed055', - 'nbf' => -23225829905.789, - 'nonce' => 'mock nonce', +// 'nonce' => 'mock nonce', 'sub' => 'mock subject', ], - ], $token); + ], $idToken); } /** @@ -365,12 +331,14 @@ final public function testIdTokenGenerationWithoutDpopKey(): void * @covers ::generateIdToken * * @uses \Pdsinterop\Solid\Auth\Utils\Jwks + * @uses \Pdsinterop\Solid\Auth\TokenGenerator + * @uses \Pdsinterop\Solid\Auth\Utils\Base64Url */ final public function testIdTokenGeneration(): void { $validFor = new \DateInterval('PT1S'); - $tokenGenerator = $this->createTokenGenerator($validFor, self::MOCK_JKT); + $tokenGenerator = $this->createTokenGenerator($validFor); $mockServer = $this->getMockBuilder(ServerInterface::class) ->disableOriginalConstructor() @@ -388,8 +356,6 @@ final public function testIdTokenGeneration(): void ->willReturn('mock issuer') ; - $privateKey = file_get_contents(__DIR__.'/../fixtures/keys/private.key'); - $now = new \DateTimeImmutable('1234-01-01 12:34:56.789'); $encodedDpop = vsprintf("%s.%s.%s", [ @@ -398,32 +364,66 @@ final public function testIdTokenGeneration(): void 'signature' => Base64Url::encode('mock signature') ]); - $actual = $tokenGenerator->generateIdToken( - 'mock access token', + $privateKey = file_get_contents(__DIR__.'/../fixtures/keys/private.key'); + $publicKey = file_get_contents(__DIR__.'/../fixtures/keys/public.key'); + + $mockPrivateKey = $this->getMockBuilder(\League\OAuth2\Server\CryptKey::class) + ->disableOriginalConstructor() + ->getMock() + ; + $mockPublicKey = $this->getMockBuilder(\Lcobucci\JWT\Signer\Key::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $mockPrivateKey->expects($this->once()) + ->method('getKeyContents') + ->willReturn($privateKey) + ; + + $mockPublicKey->expects($this->once()) + ->method('contents') + ->willReturn($publicKey) + ; + + $this->mockKeys->expects($this->once()) + ->method('getPrivateKey') + ->willReturn($mockPrivateKey) + ; + + $this->mockKeys->expects($this->once()) + ->method('getPublicKey') + ->willReturn($mockPublicKey) + ; + + $this->mockConfig->expects($this->atLeast(1)) + ->method('getKeys') + ->willReturn($this->mockKeys) + ; + + $idToken = $tokenGenerator->generateIdToken( 'mock clientId', - 'mock subject', - 'mock nonce', - $privateKey, - $encodedDpop, - $now + 'mock subject' ); + $idToken = $tokenGenerator->bindAccessToken('mock access token', $idToken); + $idToken = $tokenGenerator->signToken($idToken); $this->assertJwtEquals([[ "alg"=>"RS256", - "typ"=>"JWT", + 'kid' => '0c3932ca20f3a00ad2eb72035f6cc9cb' +// "typ"=>"JWT", ],[ 'at_hash' => '1EZBnvsFWlK8ESkgHQsrIQ', 'aud' => 'mock clientId', 'azp' => 'mock clientId', - 'c_hash' => '1EZBnvsFWlK8ESkgHQsrIQ', - 'cnf' => ["jkt" => self::MOCK_JKT], - 'exp' => -23225829903.789, - 'iat' => -23225829904.789, +// 'cnf' => ["jkt" => self::MOCK_JKT], + 'exp' => 4834, + 'iat' => 1234, 'iss' => 'mock issuer', 'jti' => '4dc20036dbd8313ed055', - 'nbf' => -23225829905.789, - 'nonce' => 'mock nonce', +// 'nbf' => -23225829905.789, +// 'nonce' => 'mock nonce', 'sub' => 'mock subject', - ]], $actual); + ]], $idToken); } }