From a72c8ae664f504bafd96cea9ac494e6100575c12 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 9 May 2023 15:14:26 -0700 Subject: [PATCH 1/7] feat: add pkce support --- .gitignore | 1 + src/OAuth2.php | 75 +++++++++++++++++++++++++++++++++++++++++++- tests/OAuth2Test.php | 49 +++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a1c524c33..b958dd074 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ composer.lock .cache .docs .gitmodules +.phpunit.result.cache # IntelliJ .idea diff --git a/src/OAuth2.php b/src/OAuth2.php index 4ac296ad5..1862a2d9c 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -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. * @@ -357,6 +367,7 @@ public function __construct(array $config) 'signingAlgorithm' => null, 'scope' => null, 'additionalClaims' => [], + 'codeVerifier' => null, ], $config); $this->setAuthorizationUri($opts['authorizationUri']); @@ -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); } @@ -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': @@ -675,7 +690,7 @@ public function updateToken(array $config) /** * Builds the authorization Uri that the user should be redirected to. * - * @param array $config configuration options that customize the return url + * @param array Configuration options that customize the return url. * @return UriInterface the authorization Url. * @throws InvalidArgumentException */ @@ -710,6 +725,13 @@ public function buildFullAuthorizationUri(array $config = []) 'prompt and approval_prompt are mutually exclusive' ); } + if ($this->codeVerifier) { + // throw new \Exception('here'); + $params['code_challenge'] = $this->getCodeChallenge( + $params['code_verifier'] ?? $this->codeVerifier + ); + $params['code_challenge_method'] = $this->getCodeChallengeMethod(); + } // Construct the uri object; return it if it is valid. $result = clone $this->authorizationUri; @@ -728,6 +750,57 @@ public function buildFullAuthorizationUri(array $config = []) return $result; } + /** + * @return string + */ + 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 + */ + public function setCodeVerifier(?string $codeVerifier): void + { + $this->codeVerifier = $codeVerifier; + } + + public function generateCodeVerifier(): string + { + return $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 = ''; + 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. diff --git a/tests/OAuth2Test.php b/tests/OAuth2Test.php index db05a97ad..34bf857ce 100644 --- a/tests/OAuth2Test.php +++ b/tests/OAuth2Test.php @@ -151,6 +151,30 @@ 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 testIncludesTheScope() { $with_strings = array_merge($this->minimal, ['scope' => 'scope1 scope2']); @@ -666,6 +690,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 From d9d9fbda91a4e5aeb7b8369dabe9c4bd87005e71 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 9 May 2023 15:20:33 -0700 Subject: [PATCH 2/7] fix cs, add test --- src/OAuth2.php | 5 +++-- tests/OAuth2Test.php | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index 1862a2d9c..cbd1ca8fb 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -690,7 +690,7 @@ public function updateToken(array $config) /** * Builds the authorization Uri that the user should be redirected to. * - * @param array Configuration options that customize the return url. + * @param array $config Configuration options that customize the return url. * @return UriInterface the authorization Url. * @throws InvalidArgumentException */ @@ -792,9 +792,10 @@ private function getCodeChallengeMethod(): string private function generateRandomString(int $length): string { - $validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"; + $validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; $validCharsLen = strlen($validChars); $str = ''; + $i = 0; while ($i++ < $length) { $str .= $validChars[random_int(0, $validCharsLen - 1)]; } diff --git a/tests/OAuth2Test.php b/tests/OAuth2Test.php index 34bf857ce..1b5b822cb 100644 --- a/tests/OAuth2Test.php +++ b/tests/OAuth2Test.php @@ -175,6 +175,14 @@ public function testAuthorizationUriWithCodeVerifier() $this->assertEquals('S256', $q['code_challenge_method']); } + public function testGenerateCodeVerifier() + { + $o = new OAuth2($this->minimal); + $codeVerifier = $o->generateCodeVerifier(); + $this->assertEquals(128, strlen($codeVerifier)); + $this->assertNotEquals($codeVerifier, $o->generateCodeVerifier()); + } + public function testIncludesTheScope() { $with_strings = array_merge($this->minimal, ['scope' => 'scope1 scope2']); @@ -704,7 +712,7 @@ public function testTokenUriWithCodeVerifier() $req = $o->generateCredentialsRequest(); $fields = Query::parse((string) $req->getBody()); $this->assertArrayHasKey('code_verifier', $fields); - $this->assertEquals($codeVerifier, $fields['code_verifier']);; + $this->assertEquals($codeVerifier, $fields['code_verifier']); // test in settter $o = new OAuth2($this->tokenRequestMinimal); @@ -713,7 +721,7 @@ public function testTokenUriWithCodeVerifier() $req = $o->generateCredentialsRequest(); $q = Query::parse((string) $req->getBody()); $this->assertArrayHasKey('code_verifier', $q); - $this->assertEquals($codeVerifier, $q['code_verifier']);; + $this->assertEquals($codeVerifier, $q['code_verifier']); } } From f574c2d88ad1920f8da97f67edd5e227feb27f18 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 9 May 2023 15:26:22 -0700 Subject: [PATCH 3/7] cleanup --- .gitignore | 1 - src/OAuth2.php | 1 - 2 files changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index b958dd074..a1c524c33 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ composer.lock .cache .docs .gitmodules -.phpunit.result.cache # IntelliJ .idea diff --git a/src/OAuth2.php b/src/OAuth2.php index cbd1ca8fb..52953f017 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -726,7 +726,6 @@ public function buildFullAuthorizationUri(array $config = []) ); } if ($this->codeVerifier) { - // throw new \Exception('here'); $params['code_challenge'] = $this->getCodeChallenge( $params['code_verifier'] ?? $this->codeVerifier ); From cb46923da44acf38b97f3abb931c9c43f4fb1911 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 9 May 2023 15:30:39 -0700 Subject: [PATCH 4/7] phpdoc for generateCodeVerifier --- src/OAuth2.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/OAuth2.php b/src/OAuth2.php index 52953f017..d0acfaaea 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -774,6 +774,14 @@ 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. + * + * @return string + */ public function generateCodeVerifier(): string { return $this->generateRandomString(128); From 3098de7e11aa84b613bfd6c8a0587e37939a879a Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 9 May 2023 17:33:55 -0700 Subject: [PATCH 5/7] Update OAuth2.php --- src/OAuth2.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index d0acfaaea..5134a2b4c 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -690,7 +690,7 @@ public function updateToken(array $config) /** * Builds the authorization Uri that the user should be redirected to. * - * @param array $config Configuration options that customize the return url. + * @param array $config configuration options that customize the return url. * @return UriInterface the authorization Url. * @throws InvalidArgumentException */ @@ -726,9 +726,7 @@ public function buildFullAuthorizationUri(array $config = []) ); } if ($this->codeVerifier) { - $params['code_challenge'] = $this->getCodeChallenge( - $params['code_verifier'] ?? $this->codeVerifier - ); + $params['code_challenge'] = $this->getCodeChallenge($this->codeVerifier); $params['code_challenge_method'] = $this->getCodeChallengeMethod(); } From f8399580fd64246faa5d859d4e42f201d1f15454 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 9 May 2023 15:30:39 -0700 Subject: [PATCH 6/7] set the code verifier on the object when its generated --- src/OAuth2.php | 4 +++- tests/OAuth2Test.php | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index 5134a2b4c..a0a278a0a 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -778,11 +778,13 @@ public function setCodeVerifier(?string $codeVerifier): void * 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->generateRandomString(128); + return $this->codeVerifier = $this->generateRandomString(128); } private function getCodeChallenge(string $randomString): string diff --git a/tests/OAuth2Test.php b/tests/OAuth2Test.php index 1b5b822cb..7e2c96702 100644 --- a/tests/OAuth2Test.php +++ b/tests/OAuth2Test.php @@ -180,7 +180,12 @@ 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() From 033ab7dd439c16e888d558427a04298be5acbca6 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Thu, 11 May 2023 12:30:19 -0700 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: Vishwaraj Anand --- src/OAuth2.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index a0a278a0a..d74ffa577 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -748,7 +748,7 @@ public function buildFullAuthorizationUri(array $config = []) } /** - * @return string + * @return string|null */ public function getCodeVerifier(): ?string { @@ -765,7 +765,7 @@ public function getCodeVerifier(): ?string * * @see https://datatracker.ietf.org/doc/html/rfc7636 * - * @param string $codeVerifier + * @param string|null $codeVerifier */ public function setCodeVerifier(?string $codeVerifier): void {