Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for OAuth 2.0 PKCE #386

Merged
merged 1 commit into from
Jan 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/Client/OAuth2PKCEClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

/*
* OAuth2 Client Bundle
* Copyright (c) KnpUniversity <http://knpuniversity.com/>
*
* 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();
}
}
117 changes: 117 additions & 0 deletions tests/Client/OAuth2PKCEClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

/*
* OAuth2 Client Bundle
* Copyright (c) KnpUniversity <http://knpuniversity.com/>
*
* 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());
}
}