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

feat: add pkce support #454

Merged
merged 8 commits into from
May 11, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion src/OAuth2.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,16 @@ class OAuth2 implements FetchAuthTokenInterface
*/
private $additionalClaims;

/**
* The code verifier for PKCE for OAuth 2.0. When set, the authorization
* URI will contain the Code Challenge and Code Challenge Method querystring
* parameters, and the token URI will contain the Code Verifier parameter.
*
* @see https://datatracker.ietf.org/doc/html/rfc7636
* @var ?string
*/
private $codeVerifier;

/**
* Create a new OAuthCredentials.
*
Expand Down Expand Up @@ -357,6 +367,7 @@ public function __construct(array $config)
'signingAlgorithm' => null,
'scope' => null,
'additionalClaims' => [],
'codeVerifier' => null,
], $config);

$this->setAuthorizationUri($opts['authorizationUri']);
Expand All @@ -377,6 +388,7 @@ public function __construct(array $config)
$this->setScope($opts['scope']);
$this->setExtensionParams($opts['extensionParams']);
$this->setAdditionalClaims($opts['additionalClaims']);
$this->setCodeVerifier($opts['codeVerifier']);
$this->updateToken($opts);
}

Expand Down Expand Up @@ -496,6 +508,9 @@ public function generateCredentialsRequest()
case 'authorization_code':
$params['code'] = $this->getCode();
$params['redirect_uri'] = $this->getRedirectUri();
if ($this->codeVerifier) {
$params['code_verifier'] = $this->codeVerifier;
}
$this->addClientCredentials($params);
break;
case 'password':
Expand Down Expand Up @@ -675,7 +690,7 @@ public function updateToken(array $config)
/**
* Builds the authorization Uri that the user should be redirected to.
*
* @param array<mixed> $config configuration options that customize the return url
* @param array<mixed> $config configuration options that customize the return url.
* @return UriInterface the authorization Url.
* @throws InvalidArgumentException
*/
Expand Down Expand Up @@ -710,6 +725,10 @@ public function buildFullAuthorizationUri(array $config = [])
'prompt and approval_prompt are mutually exclusive'
);
}
if ($this->codeVerifier) {
$params['code_challenge'] = $this->getCodeChallenge($this->codeVerifier);
$params['code_challenge_method'] = $this->getCodeChallengeMethod();
}

// Construct the uri object; return it if it is valid.
$result = clone $this->authorizationUri;
Expand All @@ -728,6 +747,68 @@ public function buildFullAuthorizationUri(array $config = [])
return $result;
}

/**
* @return string
bshaffer marked this conversation as resolved.
Show resolved Hide resolved
*/
public function getCodeVerifier(): ?string
{
return $this->codeVerifier;
}

/**
* A cryptographically random string that is used to correlate the
* authorization request to the token request.
*
* The code verifier for PKCE for OAuth 2.0. When set, the authorization
* URI will contain the Code Challenge and Code Challenge Method querystring
* parameters, and the token URI will contain the Code Verifier parameter.
*
* @see https://datatracker.ietf.org/doc/html/rfc7636
*
* @param string $codeVerifier
bshaffer marked this conversation as resolved.
Show resolved Hide resolved
*/
public function setCodeVerifier(?string $codeVerifier): void
{
$this->codeVerifier = $codeVerifier;
}

/**
* Generates a random 128-character string for the "code_verifier" parameter
* in PKCE for OAuth 2.0. This is a cryptographically random string that is
* determined using random_int, hashed using "hash" and sha256, and base64
* encoded.
*
* When this method is called, the code verifier is set on the object.
*
* @return string
*/
public function generateCodeVerifier(): string
{
return $this->codeVerifier = $this->generateRandomString(128);
}

private function getCodeChallenge(string $randomString): string
{
return rtrim(strtr(base64_encode(hash('sha256', $randomString, true)), '+/', '-_'), '=');
}

private function getCodeChallengeMethod(): string
{
return 'S256';
}

private function generateRandomString(int $length): string
{
$validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~';
$validCharsLen = strlen($validChars);
$str = '';
$i = 0;
while ($i++ < $length) {
$str .= $validChars[random_int(0, $validCharsLen - 1)];
}
return $str;
}

/**
* Sets the authorization server's HTTP endpoint capable of authenticating
* the end-user and obtaining authorization.
Expand Down
62 changes: 62 additions & 0 deletions tests/OAuth2Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,43 @@ public function testCanOverrideParams()
$this->assertEquals('o_state', $q['state']);
}

public function testAuthorizationUriWithCodeVerifier()
{
$codeVerifier = 'my_code_verifier';
$expectedCodeChallenge = 'DLIjHQaEUYlb3dD1s35ERX1uDg0eu3_9ggFsQayed5c';

// test in constructor
$config = array_merge($this->minimal, ['codeVerifier' => $codeVerifier]);
$o = new OAuth2($config);
$q = Query::parse($o->buildFullAuthorizationUri()->getQuery());
$this->assertArrayNotHasKey('code_verifier', $q);
$this->assertArrayHasKey('code_challenge', $q);
$this->assertEquals($expectedCodeChallenge, $q['code_challenge']);
$this->assertEquals('S256', $q['code_challenge_method']);

// test in settter
$o = new OAuth2($this->minimal);
$o->setCodeVerifier($codeVerifier);
$q = Query::parse($o->buildFullAuthorizationUri()->getQuery());
$this->assertArrayNotHasKey('code_verifier', $q);
$this->assertArrayHasKey('code_challenge', $q);
$this->assertEquals($expectedCodeChallenge, $q['code_challenge']);
$this->assertEquals('S256', $q['code_challenge_method']);
}

public function testGenerateCodeVerifier()
{
$o = new OAuth2($this->minimal);
$codeVerifier = $o->generateCodeVerifier();
$this->assertEquals(128, strlen($codeVerifier));
// The generated code verifier is set on the object
$this->assertEquals($o->getCodeVerifier(), $codeVerifier);
// When it's called again, it generates a new one
$this->assertNotEquals($codeVerifier, $o->generateCodeVerifier());
// The new code verifier is set on the object
$this->assertNotEquals($codeVerifier, $o->getCodeVerifier());
}

public function testIncludesTheScope()
{
$with_strings = array_merge($this->minimal, ['scope' => 'scope1 scope2']);
Expand Down Expand Up @@ -666,6 +703,31 @@ public function testGeneratesExtendedRequests()
$this->assertEquals('my_value', $fields['my_param']);
$this->assertEquals('urn:my_test_grant_type', $fields['grant_type']);
}

public function testTokenUriWithCodeVerifier()
{
$codeVerifier = 'my_code_verifier';

// test in constructor
$config = array_merge($this->tokenRequestMinimal, [
'codeVerifier' => $codeVerifier,
]);
$o = new OAuth2($config);
$o->setCode('abc123');
$req = $o->generateCredentialsRequest();
$fields = Query::parse((string) $req->getBody());
$this->assertArrayHasKey('code_verifier', $fields);
$this->assertEquals($codeVerifier, $fields['code_verifier']);

// test in settter
$o = new OAuth2($this->tokenRequestMinimal);
$o->setCode('abc123');
$o->setCodeVerifier($codeVerifier);
$req = $o->generateCredentialsRequest();
$q = Query::parse((string) $req->getBody());
$this->assertArrayHasKey('code_verifier', $q);
$this->assertEquals($codeVerifier, $q['code_verifier']);
}
}

class OAuth2FetchAuthTokenTest extends TestCase
Expand Down