Skip to content

Commit

Permalink
feature #386 Added support for OAuth 2.0 PKCE (MLukman)
Browse files Browse the repository at this point in the history
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
weaverryan committed Jan 31, 2023
2 parents 6413cd7 + 571b4fc commit d162e3d
Show file tree
Hide file tree
Showing 2 changed files with 212 additions and 0 deletions.
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());
}
}

0 comments on commit d162e3d

Please sign in to comment.