diff --git a/src/OAuth2/AbstractProvider.php b/src/OAuth2/AbstractProvider.php index 50105b88..d73cd966 100644 --- a/src/OAuth2/AbstractProvider.php +++ b/src/OAuth2/AbstractProvider.php @@ -25,6 +25,8 @@ abstract class AbstractProvider extends AbstractBaseProvider */ protected $requestHttpMethod = 'POST'; + protected bool $pkce = false; + /** * @return string */ @@ -47,9 +49,33 @@ public function getAuthUrlParameters(): array $parameters['redirect_uri'] = $this->getRedirectUrl(); $parameters['response_type'] = 'code'; + if ($this->pkce) { + $codeVerifier = $this->generatePKCECodeVerifier(); + $this->session->set('code_verifier', $codeVerifier); + + $parameters['code_challenge'] = $this->generatePKCECodeChallenge($codeVerifier); + $parameters['code_challenge_method'] = 'S256'; + } + return $parameters; } + private function generatePKCECodeVerifier(int $length = 128) + { + if ($length < 43 || $length > 128) { + throw new \Exception("Length must be between 43 and 128"); + } + + $randomBytes = random_bytes($length); + return rtrim(strtr(base64_encode($randomBytes), '+/', '-_'), '='); + } + + private function generatePKCECodeChallenge(string $codeVerifier) + { + $hash = hash('sha256', $codeVerifier, true); + return rtrim(strtr(base64_encode($hash), '+/', '-_'), '='); + } + /** * {@inheritdoc} */ @@ -60,7 +86,7 @@ public function makeAuthUrl(): string if (!$this->getBoolOption('stateless', false)) { $this->session->set( 'oauth2_state', - $urlParameters['state'] = $this->generateState() + $urlParameters['state'] = $this->generateState(), ); } @@ -110,6 +136,18 @@ protected function makeAccessTokenRequest(string $code): RequestInterface 'redirect_uri' => $this->getRedirectUrl() ]; + if ($this->pkce) { + $codeVerifier = $this->session->get('code_verifier'); + if (!$codeVerifier) { + throw new \RuntimeException('PKCE code verifier not found in session'); + } + + $parameters['code_verifier'] = $codeVerifier; + $parameters['device_id'] = $this->session->get('device_id'); + + $this->session->delete('code_verifier'); + } + return $this->httpStack->createRequest($this->requestHttpMethod, $this->getRequestTokenUri()) ->withHeader('Content-Type', 'application/x-www-form-urlencoded') ->withBody($this->httpStack->createStream(http_build_query($parameters, '', '&'))) @@ -153,6 +191,10 @@ public function getAccessTokenByRequestParameters(array $parameters) throw new Unauthorized('Unknown code'); } + if (isset($parameters['device_id'])) { + $this->session->set('device_id', $parameters['device_id']); + } + if (!$this->getBoolOption('stateless', false)) { $state = $this->session->get('oauth2_state'); if (!$state) { diff --git a/src/OAuth2/Provider/Vk.php b/src/OAuth2/Provider/Vk.php index 26fe4b29..19f5cce9 100644 --- a/src/OAuth2/Provider/Vk.php +++ b/src/OAuth2/Provider/Vk.php @@ -18,21 +18,26 @@ class Vk extends \SocialConnect\OAuth2\AbstractProvider /** * {@inheritdoc} */ - protected $requestHttpMethod = 'GET'; + protected $requestHttpMethod = 'POST'; + + /** + * {@inheritdoc} + */ + protected bool $pkce = true; public function getBaseUri() { - return 'https://api.vk.com/'; + return 'https://id.vk.com/'; } public function getAuthorizeUri() { - return 'https://api.vk.com/oauth/authorize'; + return 'https://id.vk.com/authorize'; } public function getRequestTokenUri() { - return 'https://api.vk.com/oauth/token'; + return 'https://id.vk.com/oauth2/auth'; } public function getName() @@ -56,38 +61,36 @@ public function prepareRequest(string $method, string $uri, array &$headers, arr public function getIdentity(AccessTokenInterface $accessToken) { $query = [ - 'v' => '5.100' + 'client_id' => $this->consumer->getKey(), ]; - $fields = $this->getArrayOption('identity.fields', []); - if ($fields) { - $query['fields'] = implode(',', $fields); - } - - $response = $this->request('GET', 'method/users.get', $query, $accessToken); + $response = $this->request('POST', 'oauth2/user_info', $query, null, [ + 'access_token' => $accessToken->getToken(), + ]); $hydrator = new ArrayHydrator([ - 'id' => 'id', + 'user_id' => 'id', 'first_name' => 'firstname', 'last_name' => 'lastname', - 'bdate' => static function ($value, User $user) { + 'birthday' => static function ($value, User $user) { + list($day, $month, $year) = array_map( + fn (string $value) => (int) $value, + explode('.', $value), + ); $user->setBirthday( - new \DateTime($value) + (new \DateTime())->setDate($year, $month, $day)->setTime(12, 0) ); }, 'sex' => static function ($value, User $user) { $user->setSex($value === 1 ? User::SEX_FEMALE : User::SEX_MALE); }, + 'email' => 'email', 'screen_name' => 'username', - 'photo_max_orig' => 'pictureURL', + 'avatar' => 'pictureURL', ]); /** @var User $user */ - $user = $hydrator->hydrate(new User(), $response['response'][0]); - - // Vk returns email inside AccessToken - $user->email = $accessToken->getEmail(); - $user->emailVerified = true; + $user = $hydrator->hydrate(new User(), $response['user']); return $user; } diff --git a/src/OAuth2/Provider/Yandex.php b/src/OAuth2/Provider/Yandex.php index 09451617..30ccd85a 100644 --- a/src/OAuth2/Provider/Yandex.php +++ b/src/OAuth2/Provider/Yandex.php @@ -67,16 +67,23 @@ public function getIdentity(AccessTokenInterface $accessToken) $result = $this->request('GET', 'info', [], $accessToken); $hydrator = new ArrayHydrator([ + 'id' => 'id', 'first_name' => 'firstname', 'last_name' => 'lastname', 'default_email' => 'email', 'real_name' => 'fullname', + 'sex' => static function ($value, User $user) { + $user->setSex($value === 'male' ? User::SEX_MALE : User::SEX_FEMALE); + }, 'birthday' => static function ($value, User $user) { $user->setBirthday( new \DateTime($value) ); }, 'login' => 'username', + 'default_avatar_id' => static function ($value, User $user) { + $user->pictureURL = 'https://avatars.yandex.net/get-yapic/'.$value.'/islands-200'; + } ]); return $hydrator->hydrate(new User(), $result); diff --git a/tests/Test/AbstractTestCase.php b/tests/Test/AbstractTestCase.php index 32b60300..e0f774ed 100644 --- a/tests/Test/AbstractTestCase.php +++ b/tests/Test/AbstractTestCase.php @@ -16,6 +16,7 @@ use SocialConnect\HttpClient\Response; use SocialConnect\HttpClient\StreamFactory; use SocialConnect\Common\HttpStack; +use SocialConnect\Provider\Session\SessionInterface; abstract class AbstractTestCase extends \PHPUnit\Framework\TestCase { @@ -68,6 +69,22 @@ protected function mockClientResponse($responseData, int $responseCode = 200) return $mockedHttpClient; } + /** + * @param array $values + * @return SessionInterface|\PHPUnit\Framework\MockObject\MockObject + */ + protected function mockSession(array $values) + { + $mockedSession = $this->getMockBuilder(SessionInterface::class) + ->getMock(); + + $mockedSession->expects($this->exactly(count($values))) + ->method('get') + ->willReturn(...$values); + + return $mockedSession; + } + /** * @param object $object * @param string $name diff --git a/tests/Test/OAuth2/Provider/VkTest.php b/tests/Test/OAuth2/Provider/VkTest.php index 92a25505..015dabfb 100644 --- a/tests/Test/OAuth2/Provider/VkTest.php +++ b/tests/Test/OAuth2/Provider/VkTest.php @@ -8,6 +8,8 @@ use Psr\Http\Message\ResponseInterface; use SocialConnect\OAuth2\AccessToken; +use SocialConnect\Provider\Exception\InvalidResponse; +use SocialConnect\Provider\Session\SessionInterface; class VkTest extends AbstractProviderTestCase { @@ -24,13 +26,12 @@ public function testGetIdentitySuccess() $mockedHttpClient = $this->mockClientResponse( json_encode( [ - 'response' => [ - [ - 'id' => $expectedId = 12321312312312, - 'first_name' => $expectedFirstname = 'Dmitry', - 'last_name' => $expectedLastname = 'Patsura', - 'sex' => 1, - ] + 'user' => [ + 'user_id' => $expectedId = 12321312312312, + 'first_name' => $expectedFirstname = 'Dmitry', + 'last_name' => $expectedLastname = 'Patsura', + 'sex' => 1, + 'birthday' => $birthday = '01.03.1993', ] ] ) @@ -49,6 +50,7 @@ public function testGetIdentitySuccess() parent::assertSame($expectedFirstname, $result->firstname); parent::assertSame($expectedLastname, $result->lastname); parent::assertSame('female', $result->getSex()); + parent::assertSame($birthday, $result->getBirthday()->format('d.m.Y')); } /** @@ -62,4 +64,19 @@ protected function getTestResponseForGetIdentity(): ResponseInterface ]) ); } + + public function testGetAccessTokenResponseInternalServerErrorFail() + { + $this->expectException(InvalidResponse::class); + $this->expectExceptionMessage('API response with error code'); + + $client = $this->mockClientResponse( + null, + 500 + ); + + $session = $this->mockSession(['abc', 'device_id']); + + $this->getProvider($client, $session)->getAccessToken('XXXXXXXXXXXX'); + } }