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 19, 2021
1 parent 80b0bfa commit efee4e9
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 0 deletions.
16 changes: 16 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ if (!isset($_GET['code'])) {

}
```
### Authorization Code Grant with PKCE

To enable PKCE (Proof Key for Code Exchange) you can set the `pkceMethod` option for the provider.
Supported methods are:
- `S256` Recommended method. The code challenge will be hashed with sha256.
- `plain` **NOT** recommended. The code challenge will be sent as plain text. Only use this if no other option is possible.

You can configure the PKCE method as follows:
```php
$provider = new \League\OAuth2\Client\Provider\GenericProvider([
// ...
// other options
// ...
'pkceMethod' => \League\OAuth2\Client\Provider\GenericProvider::PKCE_METHOD_S256
]);
```

Refreshing a Token
------------------
Expand Down
84 changes: 84 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 @@ -58,6 +59,19 @@ abstract class AbstractProvider
*/
const METHOD_POST = 'POST';

/**
* @var string PKCE method used to fetch authorization token.
* The PKCE code challenge will be hashed with sha256 (recommended).
*/
const PKCE_METHOD_S256 = 'S256';

/**
* @var string PKCE method used to fetch authorization token.
* The PKCE code challenge will be sent as plain text, this is NOT recommended.
* Only use `plain` if no other option is possible.
*/
const PKCE_METHOD_PLAIN = 'plain';

/**
* @var string
*/
Expand All @@ -78,6 +92,11 @@ abstract class AbstractProvider
*/
protected $state;

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

/**
* @var GrantFactory
*/
Expand Down Expand Up @@ -264,6 +283,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 +336,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 +378,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 +415,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 === static::PKCE_METHOD_S256) {
$options['code_challenge'] = trim(
strtr(
base64_encode(hash('sha256', $this->pkceCode, true)),
'+/',
'-_'
),
'='
);
} elseif ($pkceMethod === static::PKCE_METHOD_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 +612,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 [
[
AbstractProvider::PKCE_METHOD_S256,
'1234567890123456789012345678901234567890',
'pOvdVBRUuEzGcMnx9VCLr2f_0_5ZuIMmeAh4H5kqCx0',
],
[
AbstractProvider::PKCE_METHOD_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 efee4e9

Please sign in to comment.