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 27, 2021
1 parent 80b0bfa commit e167cad
Show file tree
Hide file tree
Showing 6 changed files with 275 additions and 0 deletions.
34 changes: 34 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The following example uses the out-of-the-box `GenericProvider` provided by this

The *authorization code* grant type is the most common grant type used when authenticating users with a third-party service. This grant type utilizes a *client* (this library), a *service provider* (the server), and a *resource owner* (the account with credentials to a protected—or owned—resource) to request access to resources owned by the user. This is often referred to as _3-legged OAuth_, since there are three parties involved.

<a name="authorization-code-grant-example"></a>
```php
$provider = new \League\OAuth2\Client\Provider\GenericProvider([
'clientId' => 'XXXXXX', // The client ID assigned to you by the provider
Expand All @@ -37,6 +38,10 @@ if (!isset($_GET['code'])) {
// Get the state generated for you and store it to the session.
$_SESSION['oauth2state'] = $provider->getState();

// Optional, only required when PKCE is enabled.
// Get the PKCE code generated for you and store it to the session.
$_SESSION['oauth2pkceCode'] = $provider->getPkceCode();

// Redirect the user to the authorization URL.
header('Location: ' . $authorizationUrl);
exit;
Expand All @@ -53,6 +58,10 @@ if (!isset($_GET['code'])) {
} else {

try {

// Optional, only required when PKCE is enabled.
// Restore the PKCE code stored in the session.
$provider->setPkceCode($_SESSION['oauth2pkceCode']);

// Try to get an access token using the authorization code grant.
$accessToken = $provider->getAccessToken('authorization_code', [
Expand Down Expand Up @@ -90,6 +99,31 @@ 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
]);
```
The PKCE code needs to be used between requests and therefore be saved and restored, usually via the session.
In the [example](#authorization-code-grant-example) above this is done as follows:
```php
// Store the PKCE code after the `getAuthorizationUrl()` call.
$_SESSION['oauth2pkceCode'] = $provider->getPkceCode();
// ...
// Restore the PKCE code before the `getAccessToken()` call.
$provider->setPkceCode($_SESSION['oauth2pkceCode']);
```

Refreshing a Token
------------------
Expand Down
98 changes: 98 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,32 @@ public function getState()
return $this->state;
}

/**
* Set the value of the pkceCode parameter.
*
* When using PKCE this should be set before requesting an access token.
*
* @param string $pkceCode
* @return self
*/
public function setPkceCode($pkceCode)
{
$this->pkceCode = $pkceCode;
return $this;
}

/**
* 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 +350,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 +392,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 +429,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 +626,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
100 changes: 100 additions & 0 deletions test/src/Provider/AbstractProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,106 @@ public function testAuthorizationStateIsRandom()
}
}

public function testSetGetPkceCode()
{
$pkceCode = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';

$provider = $this->getMockProvider();
$this->assertEquals($provider, $provider->setPkceCode($pkceCode));
$this->assertEquals($pkceCode, $provider->getPkceCode());
}

/**
* @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']);

// Simulate re-initialization of provider after authorization request
$provider = $this->getMockProvider();

$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);

// restore $pkceCode (normally done by client from session)
$provider->setPkceCode($pkceCode);

$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
Loading

0 comments on commit e167cad

Please sign in to comment.