Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for PKCE (Proof Key for Code Exchange [RFC 7636]) #901

Merged
merged 10 commits into from
Sep 12, 2022
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;
rhertogh marked this conversation as resolved.
Show resolved Hide resolved

/**
* @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
rhertogh marked this conversation as resolved.
Show resolved Hide resolved
*/
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
rhertogh marked this conversation as resolved.
Show resolved Hide resolved
*/
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
116 changes: 116 additions & 0 deletions test/src/Provider/AbstractProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,122 @@ 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 testInvalidPkceMethod()
{
$provider = $this->getMockProvider();
$provider->setPkceMethod('non-existing');

$this->expectExceptionMessage('Unknown PKCE method "non-existing".');
$provider->getAuthorizationUrl();
}

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 testPkceMethodIsDisabledByDefault()
{
$provider = $this->getAbstractProviderMock();
$pkceMethod = $provider->getPkceMethod();
$this->assertNull($pkceMethod);
}

public function testErrorResponsesCanBeCustomizedAtTheProvider()
{
$provider = new MockProvider([
Expand Down
Loading