-
Notifications
You must be signed in to change notification settings - Fork 146
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature #386 Added support for OAuth 2.0 PKCE (MLukman)
This PR was squashed before being merged into the master branch. Discussion ---------- Added support for OAuth 2.0 PKCE Added OAuth2PKCEClient that supports OAuth 2.0 providers with PKCE extension (https://oauth.net/2/pkce/) Commits ------- 571b4fc Added support for OAuth 2.0 PKCE
- Loading branch information
Showing
2 changed files
with
212 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
} | ||
} |