Skip to content

Commit

Permalink
feat: add pkce support (#454)
Browse files Browse the repository at this point in the history
  • Loading branch information
bshaffer authored May 11, 2023
1 parent a9e8ae3 commit 1326c81
Show file tree
Hide file tree
Showing 2 changed files with 144 additions and 1 deletion.
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|null
*/
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|null $codeVerifier
*/
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

0 comments on commit 1326c81

Please sign in to comment.