Skip to content

Commit

Permalink
Added support for PKCE
Browse files Browse the repository at this point in the history
  • Loading branch information
rhertogh committed Aug 16, 2021
1 parent 80b0bfa commit fb68f1f
Show file tree
Hide file tree
Showing 5 changed files with 200 additions and 0 deletions.
71 changes: 71 additions & 0 deletions src/Provider/AbstractProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\ClientInterface as HttpClientInterface;
use GuzzleHttp\Exception\BadResponseException;
use InvalidArgumentException;
use League\OAuth2\Client\Grant\AbstractGrant;
use League\OAuth2\Client\Grant\GrantFactory;
use League\OAuth2\Client\OptionProvider\OptionProviderInterface;
Expand Down Expand Up @@ -78,6 +79,11 @@ abstract class AbstractProvider
*/
protected $state;

/**
* @var string
*/
protected $pkceCode;

/**
* @var GrantFactory
*/
Expand Down Expand Up @@ -264,6 +270,18 @@ public function getState()
return $this->state;
}

/**
* Returns the current value of the pkceCode parameter.
*
* This can be accessed by the redirect handler during authorization.
*
* @return string
*/
public function getPkceCode()
{
return $this->pkceCode;
}

/**
* Returns the base URL for authorizing a client.
*
Expand Down Expand Up @@ -305,6 +323,27 @@ protected function getRandomState($length = 32)
return bin2hex(random_bytes($length / 2));
}

/**
* Returns a new random string to use as PKCE code_verifier and
* hashed as code_challenge parameters in an authorization flow.
* Must be between 43 and 128 characters long.
*
* @param int $length Length of the random string to be generated.
* @return string
*/
protected function getRandomPkceCode($length = 64)
{
return substr(
strtr(
base64_encode(random_bytes($length)),
'+/',
'-_'
),
0,
$length
);
}

/**
* Returns the default scopes used by this provider.
*
Expand All @@ -326,6 +365,14 @@ protected function getScopeSeparator()
return ',';
}

/**
* @return string|null
*/
protected function getPkceMethod()
{
return null;
}

/**
* Returns authorization parameters based on provided options.
*
Expand Down Expand Up @@ -355,6 +402,26 @@ protected function getAuthorizationParameters(array $options)
// Store the state as it may need to be accessed later on.
$this->state = $options['state'];

$pkceMethod = $this->getPkceMethod();
if (!empty($pkceMethod)) {
$this->pkceCode = $this->getRandomPkceCode();
if ($pkceMethod === 'S256') {
$options['code_challenge'] = trim(
strtr(
base64_encode(hash('sha256', $this->pkceCode, true)),
'+/',
'-_'
),
'='
);
} elseif ($pkceMethod === 'plain') {
$options['code_challenge'] = $this->pkceCode;
} else {
throw new InvalidArgumentException('Unknown PKCE method "' . $pkceMethod . '".');
}
$options['code_challenge_method'] = $pkceMethod;
}

// Business code layer might set a different redirect_uri parameter
// depending on the context, leave it as-is
if (!isset($options['redirect_uri'])) {
Expand Down Expand Up @@ -532,6 +599,10 @@ public function getAccessToken($grant, array $options = [])
'redirect_uri' => $this->redirectUri,
];

if (!empty($this->pkceCode)) {
$params['code_verifier'] = $this->pkceCode;
}

$params = $grant->prepareRequestParameters($params, $options);
$request = $this->getAccessTokenRequest($params);
$response = $this->getParsedResponse($request);
Expand Down
14 changes: 14 additions & 0 deletions src/Provider/GenericProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ class GenericProvider extends AbstractProvider
*/
private $responseResourceOwnerId = 'id';

/**
* @var string
*/
private $pkceMethod = null;

/**
* @param array $options
* @param array $collaborators
Expand Down Expand Up @@ -114,6 +119,7 @@ protected function getConfigurableOptions()
'responseCode',
'responseResourceOwnerId',
'scopes',
'pkceMethod',
]);
}

Expand Down Expand Up @@ -205,6 +211,14 @@ protected function getScopeSeparator()
return $this->scopeSeparator ?: parent::getScopeSeparator();
}

/**
* @inheritdoc
*/
protected function getPkceMethod()
{
return $this->pkceMethod ?: parent::getPkceMethod();
}

/**
* @inheritdoc
*/
Expand Down
86 changes: 86 additions & 0 deletions test/src/Provider/AbstractProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,92 @@ public function testAuthorizationStateIsRandom()
}
}

/**
* @dataProvider pkceMethodProvider
*/
public function testPkceMethod($pkceMethod, $pkceCode, $expectedChallenge)
{
$provider = $this->getMockProvider();
$provider->setPkceMethod($pkceMethod);
$provider->setFixedPkceCode($pkceCode);

$url = $provider->getAuthorizationUrl();
$this->assertSame($pkceCode, $provider->getPkceCode());

parse_str(parse_url($url, PHP_URL_QUERY), $qs);
$this->assertArrayHasKey('code_challenge', $qs);
$this->assertArrayHasKey('code_challenge_method', $qs);
$this->assertSame($pkceMethod, $qs['code_challenge_method']);
$this->assertSame($expectedChallenge, $qs['code_challenge']);


$raw_response = ['access_token' => 'okay', 'expires' => time() + 3600, 'resource_owner_id' => 3];
$stream = Mockery::mock(StreamInterface::class);
$stream
->shouldReceive('__toString')
->once()
->andReturn(json_encode($raw_response));

$response = Mockery::mock(ResponseInterface::class);
$response
->shouldReceive('getBody')
->once()
->andReturn($stream);
$response
->shouldReceive('getHeader')
->once()
->with('content-type')
->andReturn('application/json');

$client = Mockery::spy(ClientInterface::class, [
'send' => $response,
]);

$provider->setHttpClient($client);
$provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']);

$client
->shouldHaveReceived('send')
->once()
->withArgs(function ($request) use ($pkceCode) {
parse_str((string)$request->getBody(), $body);
return $body['code_verifier'] === $pkceCode;
});
}

public function pkceMethodProvider()
{
return [
[
'S256',
'1234567890123456789012345678901234567890',
'pOvdVBRUuEzGcMnx9VCLr2f_0_5ZuIMmeAh4H5kqCx0',
],
[
'plain',
'1234567890123456789012345678901234567890',
'1234567890123456789012345678901234567890',
],
];
}

public function testPkceCodeIsRandom()
{
$last = null;
$provider = $this->getMockProvider();
$provider->setPkceMethod('S256');

for ($i = 0; $i < 100; $i++) {
// Repeat the test multiple times to verify code_challenge changes
$url = $provider->getAuthorizationUrl();

parse_str(parse_url($url, PHP_URL_QUERY), $qs);
$this->assertTrue(1 === preg_match('/^[a-zA-Z0-9-_]{43}$/', $qs['code_challenge']));
$this->assertNotSame($qs['code_challenge'], $last);
$last = $qs['code_challenge'];
}
}

public function testErrorResponsesCanBeCustomizedAtTheProvider()
{
$provider = new MockProvider([
Expand Down
24 changes: 24 additions & 0 deletions test/src/Provider/Fake.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class Fake extends AbstractProvider

private $accessTokenMethod = 'POST';

private $pkceMethod = null;

private $fixedPkceCode = null;

public function getClientId()
{
return $this->clientId;
Expand Down Expand Up @@ -59,6 +63,26 @@ public function getAccessTokenMethod()
return $this->accessTokenMethod;
}

public function setPkceMethod($method)
{
$this->pkceMethod = $method;
}

public function getPkceMethod()
{
return $this->pkceMethod;
}

public function setFixedPkceCode($code)
{
return $this->fixedPkceCode = $code;
}

protected function getRandomPkceCode($length = 64)
{
return $this->fixedPkceCode ?: parent::getRandomPkceCode($length);
}

protected function createResourceOwner(array $response, AccessToken $token)
{
return new Fake\User($response);
Expand Down
5 changes: 5 additions & 0 deletions test/src/Provider/GenericProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public function testConfigurableOptions()
'responseCode' => 'mock_code',
'responseResourceOwnerId' => 'mock_response_uid',
'scopes' => ['mock', 'scopes'],
'pkceMethod' => 'S256',
];

$provider = new GenericProvider($options + [
Expand Down Expand Up @@ -88,6 +89,10 @@ public function testConfigurableOptions()
$getScopeSeparator = $reflection->getMethod('getScopeSeparator');
$getScopeSeparator->setAccessible(true);
$this->assertEquals($options['scopeSeparator'], $getScopeSeparator->invoke($provider));

$getPkceMethod = $reflection->getMethod('getPkceMethod');
$getPkceMethod->setAccessible(true);
$this->assertEquals($options['pkceMethod'], $getPkceMethod->invoke($provider));
}

public function testResourceOwnerDetails()
Expand Down

0 comments on commit fb68f1f

Please sign in to comment.