From 9d4ad05d5764b39e67a03f4d0ed773e6efe274e7 Mon Sep 17 00:00:00 2001 From: Sonia Zorba <sonia.zorba@inaf.it> Date: Fri, 5 Jul 2019 17:27:29 +0200 Subject: [PATCH] Changes for JWT tokens --- .gitignore | 2 + README.md | 6 + classes/IdTokenBuilder.php | 19 +-- classes/JWKSHandler.php | 34 ++++-- classes/OAuth2RequestHandler.php | 115 +++++++++--------- classes/datalayer/AccessTokenDAO.php | 2 + .../datalayer/mysql/MySQLAccessTokenDAO.php | 66 ++++++---- classes/datalayer/mysql/MySQLJWKSDAO.php | 2 +- classes/model/OAuth2Data.php | 1 + classes/model/User.php | 1 - include/front-controller.php | 28 ++++- phpunit.xml | 7 ++ tests/IdTokenBuilderTest.php | 15 ++- tests/OAuth2RequestHandlerTest.php | 53 ++++++-- 14 files changed, 226 insertions(+), 125 deletions(-) create mode 100644 phpunit.xml diff --git a/.gitignore b/.gitignore index 5d63bee..c014544 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ vendor/ client-icons/ /nbproject/ *.pem +/build/ +.phpunit.result.cache diff --git a/README.md b/README.md index 8003cd8..b14acbf 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,12 @@ Create the logs directory and assign ownership to the Apache user (usually www-d mkdir logs sudo chown www-data logs +### Run Unit Tests and build code coverage report + +(XDebug or another code coverage driver needs to be installed; e.g. `sudo apt install php-xdebug`) + + ./vendor/bin/phpunit --bootstrap vendor/autoload.php --coverage-html build/coverage-report tests/ + ## Additional information and developer guide See the wiki: https://www.ict.inaf.it/gitlab/zorba/rap-ia2/wikis/home diff --git a/classes/IdTokenBuilder.php b/classes/IdTokenBuilder.php index 9b35851..4150c0c 100644 --- a/classes/IdTokenBuilder.php +++ b/classes/IdTokenBuilder.php @@ -12,22 +12,13 @@ class IdTokenBuilder { $this->locator = $locator; } - public function getIdToken(AccessToken $accessToken, string $alg): string { - - $head = array("alg" => $alg, "typ" => "JWT"); - - $header = base64_encode(json_encode($head)); + public function getIdToken(AccessToken $accessToken): string { $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair(); - $payloadArr = $this->createPayloadArray($accessToken); - $payloadArr['kid'] = $keyPair->keyId; - - $payload = base64_encode(json_encode($payloadArr)); - - $token_value = $header . "." . $payload; + $payload = $this->createPayloadArray($accessToken); - return JWT::encode($token_value, $keyPair->privateKey, $alg); + return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId); } private function createPayloadArray(AccessToken $accessToken) { @@ -48,7 +39,9 @@ class IdTokenBuilder { if (in_array("profile", $accessToken->scope)) { $payloadArr['given_name'] = $user->getName(); $payloadArr['family_name'] = $user->getSurname(); - $payloadArr['org'] = $user->getInstitution(); + if ($user->getInstitution() !== null) { + $payloadArr['org'] = $user->getInstitution(); + } } return $payloadArr; diff --git a/classes/JWKSHandler.php b/classes/JWKSHandler.php index 18ec90a..fb7c87e 100644 --- a/classes/JWKSHandler.php +++ b/classes/JWKSHandler.php @@ -20,7 +20,7 @@ class JWKSHandler { $rsa = new RSA(); $rsa->setPrivateKeyFormat(RSA::PRIVATE_FORMAT_PKCS1); - $rsa->setPublicKeyFormat(RSA::PUBLIC_FORMAT_PKCS1); + $rsa->setPublicKeyFormat(RSA::PUBLIC_FORMAT_PKCS8); $result = $rsa->createKey(); $keyPair = new RSAKeyPair(); @@ -41,25 +41,37 @@ class JWKSHandler { $keyPairs = $dao->getRSAKeyPairs(); - $jwks = []; + $keys = []; foreach ($keyPairs as $keyPair) { - $publicKey = str_replace("\n", "", $keyPair->publicKey); - $publicKey = str_replace("\r", "", $publicKey); - $publicKey = str_replace('-----BEGIN RSA PUBLIC KEY-----', '', $publicKey); - $publicKey = str_replace('-----END RSA PUBLIC KEY-----', '', $publicKey); + $rsa = new RSA(); + $rsa->loadKey($keyPair->publicKey); + $rsa->setPublicKey(); + $publicKeyXML = $rsa->getPublicKey(RSA::PUBLIC_FORMAT_XML); + + $rsaModulus = $this->getTagContent($publicKeyXML, "Modulus"); + $rsaExponent = $this->getTagContent($publicKeyXML, "Exponent"); $jwk = []; $jwk['kty'] = "RSA"; - $jwk['kid'] = $keyPair->id; + $jwk['kid'] = $keyPair->keyId; $jwk['use'] = "sig"; - $jwk['n'] = $publicKey; - $jwk['e'] = "AQAB"; + $jwk['n'] = $rsaModulus; + $jwk['e'] = $rsaExponent; - array_push($jwks, $jwk); + array_push($keys, $jwk); } - return $jwks; + return [ + "keys" => $keys + ]; + } + + private function getTagContent(string $publicKeyXML, string $tagname): string { + $matches = []; + $pattern = "#<\s*?$tagname\b[^>]*>(.*?)</$tagname\b[^>]*>#s"; + preg_match($pattern, $publicKeyXML, $matches); + return $matches[1]; } } diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php index 5a3bbd9..ab21bf2 100644 --- a/classes/OAuth2RequestHandler.php +++ b/classes/OAuth2RequestHandler.php @@ -10,44 +10,31 @@ class OAuth2RequestHandler { $this->locator = $locator; } - public function handleAuthorizeRequest() { + public function handleAuthorizeRequest($params) { - if (!isset($_REQUEST['client_id'])) { + if ($params['client_id'] === null) { throw new BadRequestException("Client id is required"); } - if (!isset($_REQUEST['redirect_uri'])) { + if ($params['redirect_uri'] === null) { throw new BadRequestException("Redirect URI is required"); } - $clientId = $_REQUEST['client_id']; - $redirectUrl = $_REQUEST['redirect_uri']; - - $client = $this->locator->getDAO()->getOAuth2ClientByClientId($clientId); + $client = $this->locator->getDAO()->getOAuth2ClientByClientId($params['client_id']); if ($client === null) { - throw new BadRequestException("Invalid client id: " . $clientId); + throw new BadRequestException("Invalid client id: " . $params['client_id']); } - if ($client->redirectUrl !== $redirectUrl) { - throw new BadRequestException("Invalid client redirect URI: " . $redirectUrl); + if ($client->redirectUrl !== $params['redirect_uri']) { + throw new BadRequestException("Invalid client redirect URI: " . $params['redirect_uri']); } - $alg; - if (isset($_REQUEST['alg'])) { - $alg = $_REQUEST['alg']; - } else { + $alg = $params['alg']; + if ($alg === null) { $alg = "RS256"; } - if (isset($_GET['code'])) { - - } else { - $this->executeStateFlow($client); - } - } - - private function executeStateFlow(OAuth2Client $client) { - - if (!isset($_REQUEST['state'])) { + $state = $params['state']; + if ($state === null) { throw new BadRequestException("State is required"); } @@ -55,7 +42,12 @@ class OAuth2RequestHandler { $oauth2Data = new \RAP\OAuth2Data(); $oauth2Data->clientId = $client->client; $oauth2Data->redirectUrl = $client->redirectUrl; - $oauth2Data->state = $_REQUEST['state']; + $oauth2Data->state = $state; + + $scope = $params['scope']; + if ($scope !== null) { + $oauth2Data->scope = explode(' ', $scope); + } $session = $this->locator->getSession(); $session->setOAuth2Data($oauth2Data); @@ -71,7 +63,7 @@ class OAuth2RequestHandler { $accessToken->userId = $session->user->id; $accessToken->clientId = $session->getOAuth2Data()->clientId; $accessToken->redirectUri = $session->getOAuth2Data()->redirectUrl; - //$accessToken->scope = + $accessToken->scope = $session->getOAuth2Data()->scope; $this->locator->getAccessTokenDAO()->createAccessToken($accessToken); @@ -83,46 +75,48 @@ class OAuth2RequestHandler { return $redirectUrl; } - public function handleAccessTokenRequest(): array { + public function handleAccessTokenRequest($params): array { - $this->validateAccessTokenRequest(); + $this->validateAccessTokenRequest($params); - $code = filter_input(INPUT_POST, 'code', FILTER_SANITIZE_STRING); - $accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($code); - - if($accessToken === null) { + $accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($params['code']); + + if ($accessToken === null) { throw new BadRequestException("No token for given code"); } - - $this->validateParametersMatching(); + + if ($accessToken->redirectUri !== $params['redirect_uri']) { + throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']); + } $token = []; - $token['access_token'] = $accessToken->token; + //$token['access_token'] = $accessToken->token; $token['token_type'] = 'bearer'; - $token['expires_in'] = 300; - error_log($accessToken->creationTime); - error_log($accessToken->expirationTime); + $token['expires_in'] = $this->getExpiresIn($accessToken); if ($accessToken->scope !== null) { - $token['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken->userId, 'RS256'); + $token['access_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken); + //$token['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken); + } else { + $token['access_token'] = $accessToken->token; } return $token; } - private function validateAccessTokenRequest() { + private function validateAccessTokenRequest($params) { - if (!isset($_POST['grant_type'])) { - throw new BadRequestException("Client id is required"); - } else if ($_POST['grant_type'] !== 'authorization_code') { + if ($params['grant_type'] === null) { + throw new BadRequestException("grant_type is required"); + } else if ($params['grant_type'] !== 'authorization_code') { throw new BadRequestException("grant_type must be authorization_code"); } - if (!isset($_POST['code'])) { - throw new BadRequestException("Client id is required"); + if ($params['code'] === null) { + throw new BadRequestException("code id is required"); } - if (!isset($_POST['redirect_uri'])) { + if ($params['redirect_uri'] === null) { throw new BadRequestException("Redirect URI is required"); } @@ -130,27 +124,32 @@ class OAuth2RequestHandler { // however some clients don't send it } - private function validateParametersMatching() { - - } - - public function handleCheckTokenRequest(): array { + public function handleCheckTokenRequest($token): array { if (!isset($_POST['token'])) { throw new BadRequestException("Access token id is required"); } - $accessToken = filter_input(INPUT_POST, 'token', FILTER_SANITIZE_STRING); - - //if($accessToken) + $accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($token); + $user = $this->locator->getDAO()->findUserById($accessToken->userId); $result = []; - $result['exp'] = 3600; - $result['user_name'] = "test"; - $result['client_id'] = "gms"; - $result['scope'] = "profile"; + $result['exp'] = $this->getExpiresIn($accessToken); + $result['user_name'] = $user->id; + $result['client_id'] = $accessToken->clientId; + + if ($accessToken->scope !== null) { + $result['scope'] = $accessToken->scope; + $result['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken); + } return $result; } + private function getExpiresIn(AccessToken $accessToken) { + $expTime = strtotime($accessToken->expirationTime); + $now = time(); + return $expTime - $now; + } + } diff --git a/classes/datalayer/AccessTokenDAO.php b/classes/datalayer/AccessTokenDAO.php index e9f6033..9525d1c 100644 --- a/classes/datalayer/AccessTokenDAO.php +++ b/classes/datalayer/AccessTokenDAO.php @@ -13,6 +13,8 @@ interface AccessTokenDAO { function retrieveAccessTokenFromCode(string $code): ?AccessToken; + function getAccessToken(string $token): ?AccessToken; + /** * Delete an access token from the database. This happens when the caller * application has received the token and used it for retrieving user diff --git a/classes/datalayer/mysql/MySQLAccessTokenDAO.php b/classes/datalayer/mysql/MySQLAccessTokenDAO.php index 6b90c12..3c757c7 100644 --- a/classes/datalayer/mysql/MySQLAccessTokenDAO.php +++ b/classes/datalayer/mysql/MySQLAccessTokenDAO.php @@ -17,7 +17,7 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { $scope = null; if ($accessToken->scope !== null) { - $scope = join(',', $accessToken->scope); + $scope = join(' ', $accessToken->scope); } $params = array( @@ -42,34 +42,54 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO { $dbh = $this->getDBHandler(); // Access token can be retrieved from code in 1 minute from the creation - $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time" - . " FROM access_token WHERE code = :code AND CURRENT_TIMESTAMP < TIMESTAMPADD(MINUTE, 1, creation_time)"); + $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope" + . " FROM access_token WHERE code = :code"); // AND CURRENT_TIMESTAMP < TIMESTAMPADD(MINUTE, 1, creation_time) $stmt->bindParam(':code', $code); $stmt->execute(); - foreach ($stmt->fetchAll() as $row) { - $token = new AccessToken(); - $token->token = $row['token']; - $token->code = $row['code']; - $token->userId = $row['user_id']; - $token->redirectUri = $row['redirect_uri']; - $token->clientId = $row['client_id']; - $token->creationTime = $row['creation_time']; - $token->expirationTime = $row['expiration_time']; - - $scope = null; - if (isset($row['scope'])) { - $scope = $row['scope']; - } - if ($scope !== null && $scope !== '') { - $token->scope = explode(',', $scope); - } - - return $token; + $row = $stmt->fetch(); + return $this->getAccessTokenFromRow($row); + } + + public function getAccessToken(string $token): ?AccessToken { + + $dbh = $this->getDBHandler(); + + $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope" + . " FROM access_token WHERE token = :token"); + $stmt->bindParam(':token', $token); + + $stmt->execute(); + + $row = $stmt->fetch(); + return $this->getAccessTokenFromRow($row); + } + + private function getAccessTokenFromRow(?array $row): ?AccessToken { + + if ($row === null) { + return null; + } + + $token = new AccessToken(); + $token->token = $row['token']; + $token->code = $row['code']; + $token->userId = $row['user_id']; + $token->redirectUri = $row['redirect_uri']; + $token->clientId = $row['client_id']; + $token->creationTime = $row['creation_time']; + $token->expirationTime = $row['expiration_time']; + + $scope = null; + if (isset($row['scope'])) { + $scope = $row['scope']; + } + if ($scope !== null && $scope !== '') { + $token->scope = explode(' ', $scope); } - return null; + return $token; } public function deleteAccessToken($token): void { diff --git a/classes/datalayer/mysql/MySQLJWKSDAO.php b/classes/datalayer/mysql/MySQLJWKSDAO.php index 65618ca..cb4cf4b 100644 --- a/classes/datalayer/mysql/MySQLJWKSDAO.php +++ b/classes/datalayer/mysql/MySQLJWKSDAO.php @@ -57,7 +57,7 @@ class MySQLJWKSDAO extends BaseMySQLDAO implements JWKSDAO { private function getRSAKeyPairFromResultRow(array $row): RSAKeyPair { $keyPair = new RSAKeyPair(); - $keyPair->id = $row['id']; + $keyPair->keyId = $row['id']; $keyPair->privateKey = $row['private_key']; $keyPair->publicKey = $row['public_key']; $keyPair->alg = $row['alg']; diff --git a/classes/model/OAuth2Data.php b/classes/model/OAuth2Data.php index 57de8c5..f4ae310 100644 --- a/classes/model/OAuth2Data.php +++ b/classes/model/OAuth2Data.php @@ -7,5 +7,6 @@ class OAuth2Data { public $clientId; public $redirectUrl; public $state; + public $scope; } diff --git a/classes/model/User.php b/classes/model/User.php index c1b11a3..8f27e9c 100644 --- a/classes/model/User.php +++ b/classes/model/User.php @@ -114,7 +114,6 @@ class User { */ public function getInstitution(): ?string { - $institution = null; foreach ($this->identities as $identity) { diff --git a/include/front-controller.php b/include/front-controller.php index efd208d..e9d7936 100644 --- a/include/front-controller.php +++ b/include/front-controller.php @@ -55,8 +55,16 @@ Flight::route('GET /auth/oauth2/authorize', function() { session_start(); global $locator; + $params = [ + "client_id" => filter_input(INPUT_GET, 'client_id', FILTER_SANITIZE_STRING), + "redirect_uri" => filter_input(INPUT_GET, 'redirect_uri', FILTER_SANITIZE_STRING), + "alg" => filter_input(INPUT_GET, 'alg', FILTER_SANITIZE_STRING), + "state" => filter_input(INPUT_GET, 'state', FILTER_SANITIZE_STRING), + "scope" => filter_input(INPUT_GET, 'scope', FILTER_SANITIZE_STRING) + ]; + $requestHandler = new \RAP\OAuth2RequestHandler($locator); - $requestHandler->handleAuthorizeRequest(); + $requestHandler->handleAuthorizeRequest($params); Flight::redirect('/?action=oaut2client'); }); @@ -65,24 +73,32 @@ Flight::route('POST /auth/oauth2/token', function() { global $locator; + $params = [ + "grant_type" => filter_input(INPUT_POST, "grant_type", FILTER_SANITIZE_STRING), + "code" => filter_input(INPUT_POST, "code", FILTER_SANITIZE_STRING), + "redirect_uri" => filter_input(INPUT_POST, "redirect_uri", FILTER_SANITIZE_STRING) + ]; + $requestHandler = new \RAP\OAuth2RequestHandler($locator); - $token = $requestHandler->handleAccessTokenRequest(); + $token = $requestHandler->handleAccessTokenRequest($params); Flight::json($token); }); Flight::route('POST /auth/oauth2/check_token', function() { - + global $locator; + $token = filter_input(INPUT_POST, 'token', FILTER_SANITIZE_STRING); + $requestHandler = new \RAP\OAuth2RequestHandler($locator); - $token = $requestHandler->handleCheckTokenRequest(); + $result = $requestHandler->handleCheckTokenRequest($token); - Flight::json($token); + Flight::json($result); }); Flight::route('GET /auth/oidc/jwks', function() { - + global $locator; $jwksHandler = new \RAP\JWKSHandler($locator); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..b5124ad --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,7 @@ +<phpunit colors="true"> + <filter> + <whitelist processUncoveredFilesFromWhitelist="true"> + <directory suffix=".php">./classes/</directory> + </whitelist> + </filter> +</phpunit> diff --git a/tests/IdTokenBuilderTest.php b/tests/IdTokenBuilderTest.php index 015617c..aa79a56 100644 --- a/tests/IdTokenBuilderTest.php +++ b/tests/IdTokenBuilderTest.php @@ -1,6 +1,7 @@ <?php use PHPUnit\Framework\TestCase; +use \Firebase\JWT\JWT; final class IdTokenBuilderTest extends TestCase { @@ -23,6 +24,7 @@ final class IdTokenBuilderTest extends TestCase { $identity->name = "Name"; $identity->surname = "Surname"; $identity->primary = true; + $identity->institution = "INAF"; $user->addIdentity($identity); $daoStub = $this->createMock(\RAP\DAO::class); @@ -37,9 +39,18 @@ final class IdTokenBuilderTest extends TestCase { $accessToken->userId = "user_id"; $tokenBuilder = new \RAP\IdTokenBuilder($locatorStub); - $result = $tokenBuilder->getIdToken($accessToken, 'RS256'); + $jwt = $tokenBuilder->getIdToken($accessToken); - $this->assertNotNull($result); + $this->assertNotNull($jwt); + + $payload = JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]); + + $this->assertEquals("issuer", $payload->iss); + $this->assertEquals($user->id, $payload->sub); + $this->assertEquals($user->getCompleteName(), $payload->name); + $this->assertEquals($identity->name, $payload->given_name); + $this->assertEquals($identity->surname, $payload->family_name); + $this->assertEquals($identity->institution, $payload->org); } } diff --git a/tests/OAuth2RequestHandlerTest.php b/tests/OAuth2RequestHandlerTest.php index a380151..8095f1b 100644 --- a/tests/OAuth2RequestHandlerTest.php +++ b/tests/OAuth2RequestHandlerTest.php @@ -8,19 +8,27 @@ final class OAuth2RequestHandlerTest extends TestCase { $this->expectException(\RAP\BadRequestException::class); + $params = [ + "client_id" => null + ]; + $locatorStub = $this->createMock(\RAP\Locator::class); $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); - $requestHandler->handleAuthorizeRequest(); + $requestHandler->handleAuthorizeRequest($params); } public function testInvalidClientRedirectURI(): void { $this->expectException(\RAP\BadRequestException::class); - $_REQUEST['client_id'] = "client_id"; - $_REQUEST['redirect_uri'] = "redirect_uri"; - $_REQUEST['state'] = "state"; + $params = [ + "client_id" => "client_id", + "redirect_uri" => "redirect_uri", + "state" => "state", + "alg" => null, + "scope" => "email%20profile" + ]; $daoStub = $this->createMock(\RAP\DAO::class); $daoStub->method('getOAuth2ClientByClientId')->willReturn(new \RAP\OAuth2Client()); @@ -29,14 +37,18 @@ final class OAuth2RequestHandlerTest extends TestCase { $locatorStub->method('getDAO')->willReturn($daoStub); $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); - $requestHandler->handleAuthorizeRequest(); + $requestHandler->handleAuthorizeRequest($params); } public function testExecuteOAuthStateFlow(): void { - $_REQUEST['client_id'] = "client_id"; - $_REQUEST['redirect_uri'] = "redirect_uri"; - $_REQUEST['state'] = "state"; + $params = [ + "client_id" => "client_id", + "redirect_uri" => "redirect_uri", + "state" => "state", + "alg" => null, + "scope" => "email%20profile" + ]; $daoStub = $this->createMock(\RAP\DAO::class); $client = new \RAP\OAuth2Client(); @@ -44,7 +56,6 @@ final class OAuth2RequestHandlerTest extends TestCase { $daoStub->method('getOAuth2ClientByClientId')->willReturn($client); $sessionStub = $this->createMock(\RAP\SessionData::class); - $sessionStub->method('setOAuth2Data'); //->willReturn(new \RAP\OAuth2Data()); $locatorStub = $this->createMock(\RAP\Locator::class); $locatorStub->method('getDAO')->willReturn($daoStub); @@ -54,7 +65,29 @@ final class OAuth2RequestHandlerTest extends TestCase { ->method('setOAuth2Data')->with($this->anything()); $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); - $requestHandler->handleAuthorizeRequest(); + $requestHandler->handleAuthorizeRequest($params); + } + + public function testExpiresIn(): void { + + $locatorStub = $this->createMock(\RAP\Locator::class); + $requestHandler = new \RAP\OAuth2RequestHandler($locatorStub); + + $accessToken = new \RAP\AccessToken(); + + $expDate = new \DateTime(); + $expDate->add(new \DateInterval('PT1H')); + + $accessToken->expirationTime = $expDate->format("Y-m-d H:i:s"); + + // testing private method using reflection + $reflection = new \ReflectionClass(get_class($requestHandler)); + $method = $reflection->getMethod('getExpiresIn'); + $method->setAccessible(true); + + $exp = $method->invokeArgs($requestHandler, [$accessToken]); + + $this->assertEquals(3600, $exp); } } -- GitLab