From 571b4fc8f93aa806341ba75cd655c6522b713f90 Mon Sep 17 00:00:00 2001 From: Muhammad Lukman bin Nasaruddin Date: Sat, 28 Jan 2023 16:30:00 +0800 Subject: [PATCH] Added support for OAuth 2.0 PKCE --- src/Client/OAuth2PKCEClient.php | 95 +++++++++++++++++++++ tests/Client/OAuth2PKCEClientTest.php | 117 ++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/Client/OAuth2PKCEClient.php create mode 100644 tests/Client/OAuth2PKCEClientTest.php diff --git a/src/Client/OAuth2PKCEClient.php b/src/Client/OAuth2PKCEClient.php new file mode 100644 index 00000000..20d31202 --- /dev/null +++ b/src/Client/OAuth2PKCEClient.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KnpU\OAuth2ClientBundle\Client; + +use League\OAuth2\Client\Provider\AbstractProvider; +use League\OAuth2\Client\Token\AccessToken; +use League\OAuth2\Client\Token\AccessTokenInterface; +use LogicException; +use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\SessionInterface; + +/** + * Subclass of OAuth2Client for handling OAuth 2.0 providers using PKCE extension (https://oauth.net/2/pkce/). + * + * @author Muhammad Lukman Nasaruddin (https://github.com/MLukman/) + */ +class OAuth2PKCEClient extends OAuth2Client +{ + public const VERIFIER_KEY = 'pkce_code_verifier'; + + /** @var RequestStack */ + private $requestStack; + + public function __construct(AbstractProvider $provider, + RequestStack $requestStack) + { + parent::__construct($provider, $requestStack); + $this->requestStack = $requestStack; + } + + /** + * Enhance the RedirectResponse prepared by OAuth2Client::redirect() with + * PKCE code challenge and code challenge method parameters. + * + * @see OAuth2Client::redirect() + * @param array $scopes + * @param array $options + * @return RedirectResponse + */ + public function redirect(array $scopes = [], array $options = []) + { + $this->getSession()->set(static::VERIFIER_KEY, $code_verifier = bin2hex(random_bytes(64))); + $pkce = [ + 'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $code_verifier, true)), '+/', '-_'), '='), + 'code_challenge_method' => 'S256', + ]; + + return parent::redirect($scopes, $options + $pkce); + } + + /** + * Enhance the token exchange calls by OAuth2Client::getAccessToken() with + * PKCE code verifier parameter + * + * @see OAuth2Client::getAccessToken() + * @param array $options + * @return AccessToken|AccessTokenInterface + * @throws LogicException When there is no code verifier found in the session + */ + public function getAccessToken(array $options = []) + { + if (!$this->getSession()->has(static::VERIFIER_KEY)) { + throw new LogicException('Unable to fetch token from OAuth2 server because there is no PKCE code verifier stored in the session'); + } + $pkce = ['code_verifier' => $this->getSession()->get(static::VERIFIER_KEY)]; + $this->getSession()->remove(static::VERIFIER_KEY); + return parent::getAccessToken($options + $pkce); + } + + /** + * @return SessionInterface + * @throws LogicException When there is no current request + * @throws SessionNotFoundException When session is not set properly [thrown by Request::getSession()] + */ + protected function getSession() + { + $request = $this->requestStack->getCurrentRequest(); + + if (!$request) { + throw new LogicException('There is no "current request", and it is needed to perform this action'); + } + + return $request->getSession(); + } +} diff --git a/tests/Client/OAuth2PKCEClientTest.php b/tests/Client/OAuth2PKCEClientTest.php new file mode 100644 index 00000000..b6d391ae --- /dev/null +++ b/tests/Client/OAuth2PKCEClientTest.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KnpU\OAuth2ClientBundle\tests\Client; + +use KnpU\OAuth2ClientBundle\Client\OAuth2PKCEClient; +use League\OAuth2\Client\Provider\AbstractProvider; +use League\OAuth2\Client\Provider\ResourceOwnerInterface; +use League\OAuth2\Client\Token\AccessToken; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; + +class OAuth2PKCEClientTest extends TestCase +{ + private $requestStack; + private $session; + private $provider; + + public function setup(): void + { + $this->session = new Session(new MockArraySessionStorage()); + $this->request = new Request(); + $this->request->setSession($this->session); + $this->requestStack = new RequestStack(); + $this->requestStack->push($this->request); + $this->provider = new class extends AbstractProvider { + + protected function checkResponse(ResponseInterface $response, $data): void + { + // not needed + } + + protected function createResourceOwner(array $response, + AccessToken $token): ResourceOwnerInterface + { + // not needed + } + + protected function getDefaultScopes(): array + { + return []; + } + + public function getBaseAccessTokenUrl(array $params): string + { + // not needed + } + + public function getBaseAuthorizationUrl(): string + { + return 'http://coolOAuthServer.com/authorize'; + } + + public function getResourceOwnerDetailsUrl(AccessToken $token): string + { + // not needed + } + + public function getAccessToken($grant, array $options = []) + { + // hijack this method to just create a new AccessToken using the $options + return new AccessToken($options + ['access_token' => 'DUMMY_ACCESS_TOKEN']); + } + }; + } + + public function testRedirect() + { + $client = new OAuth2PKCEClient( + $this->provider, + $this->requestStack + ); + $client->setAsStateless(); + + $response = $client->redirect(); + $this->assertInstanceOf( + 'Symfony\Component\HttpFoundation\RedirectResponse', + $response + ); + + // assert the code_challenge & code_challenge_method parameters are in the redirect response + $queries = []; + parse_str(parse_url($response->getTargetUrl(), PHP_URL_QUERY), $queries); + $this->assertArrayHasKey('code_challenge', $queries); + $this->assertArrayHasKey('code_challenge_method', $queries); + } + + public function testGetAccessToken() + { + $client = new OAuth2PKCEClient( + $this->provider, + $this->requestStack + ); + // skip state checking + $client->setAsStateless(); + + // ensure code verifier is generated by redirect() and stored in session first + $client->redirect(); + // OAuth2Client::getAccessToken() requires 'code' query parameter + $this->request->query->set('code', 'DUMMY_CODE'); + + // assert the code_verifier parameter is passed and returned back by the hijacked provider + $accessToken = $client->getAccessToken(); + $this->assertArrayHasKey('code_verifier', $accessToken->getValues()); + } +}