diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e0d383dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea +/vendor +/composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f88360..3185924c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [Unreleased] +## [0.9.2] ### Added +* Support for [PKCE](https://tools.ietf.org/html/rfc7636). Currently the supported methods are 'plain' and 'S256'. + +## [0.9.1] + +### Added +* Add support for MS Azure Active Directory B2C user flows + +### Changed +* Fix at_hash verification #200 +* Getters for public parameters #204 +* Removed client ID query parameter when making a token request using Basic Auth +* Use of `random_bytes()` for token generation instead of `uniqid()`; polyfill for PHP < 7.0 provided. + +### Removed +* Removed explicit content-length header - caused issues with proxy servers + + +## [0.9.0] + +### Added +* php 7.4 deprecates array_key_exists on objects, use property_exists in getVerifiedClaims and requestUserInfo * Adding a header to indicate JSON as the return type for userinfo endpoint #151 * ~Updated OpenIDConnectClient to conditionally verify nonce #146~ * Add possibility to change enc_type parameter for http_build_query #155 @@ -14,6 +35,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Add optional parameters clientId/clientSecret for introspection #157 & #158 * Adding OAuth 2.0 Token Revocation #160 * Adding issuer validator #145 +* Adding signing algorithm PS256 #180 +* Check http status of request user info #186 +* URL encode clientId and clientSecret when using basic authentication, according to https://tools.ietf.org/html/rfc6749#section-2.3.1 #192 +* Adjust PHPDoc to state that null is also allowed #193 ### Changed * Bugfix/code cleanup #152 @@ -26,9 +51,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). * Fix indent #e9cdf56 * Cleanup conditional code flow for better readability #107f3fb * Added strict type comparisons #167 - -### Removed -* +* Bugfix: required `openid` scope was omitted when additional scopes were registered using `addScope` method. This resulted in failing OpenID process. ## [0.8.0] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 51fca54c..00000000 --- a/LICENSE.txt +++ /dev/null @@ -1,11 +0,0 @@ -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/README.md b/README.md index 8ea18a9d..7044f216 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,20 @@ if (!$data->active) { ``` +## Example 8: PKCE Client ## + +```php +use Jumbojett\OpenIDConnectClient; + +$oidc = new OpenIDConnectClient('https://id.provider.com', + 'ClientIDHere', + null); +$oidc->setCodeChallengeMethod('S256'); +$oidc->authenticate(); +$name = $oidc->requestUserInfo('given_name'); + +``` + ## Development Environments ## In some cases you may need to disable SSL security on on your development systems. diff --git a/composer.json b/composer.json index 6fbe8446..9c52f558 100644 --- a/composer.json +++ b/composer.json @@ -5,8 +5,14 @@ "require": { "php": ">=5.4", "phpseclib/phpseclib" : "~2.0", + "paragonie/random_compat":"2.0.19", "ext-json": "*", - "ext-curl": "*" + "ext-curl": "*", + "paragonie/random_compat": ">=2" + }, + "require-dev": { + "phpunit/phpunit": "^4.8", + "roave/security-advisories": "dev-master" }, "archive" : { "exclude" : [ @@ -15,5 +21,10 @@ }, "autoload" : { "classmap": [ "src/"] + }, + "config" : { + "platform": { + "php": "5.4" } + } } diff --git a/src/OpenIDConnectClient.php b/src/OpenIDConnectClient.php index c4a72889..ee81b02c 100644 --- a/src/OpenIDConnectClient.php +++ b/src/OpenIDConnectClient.php @@ -1,7 +1,7 @@ @@ -132,7 +132,7 @@ class OpenIDConnectClient /** * @var string if we acquire an access token it will be stored here */ - private $accessToken; + protected $accessToken; /** * @var string if we acquire a refresh token it will be stored here @@ -142,7 +142,7 @@ class OpenIDConnectClient /** * @var string if we acquire an id token it will be stored here */ - private $idToken; + protected $idToken; /** * @var string stores the token response @@ -184,6 +184,12 @@ class OpenIDConnectClient */ private $wellKnown = false; + /** + * @var mixed holds well-known opendid configuration parameters, like policy for MS Azure AD B2C User Flow + * @see https://docs.microsoft.com/en-us/azure/active-directory-b2c/user-flow-overview + */ + private $wellKnownConfigParameters = array(); + /** * @var int timeout (seconds) */ @@ -202,7 +208,7 @@ class OpenIDConnectClient /** * @var array holds verified jwt claims */ - private $verifiedClaims = array(); + protected $verifiedClaims = array(); /** * @var callable validator function for issuer claim @@ -218,7 +224,18 @@ class OpenIDConnectClient */ private $redirectURL; - private $enc_type = PHP_QUERY_RFC1738; + protected $enc_type = PHP_QUERY_RFC1738; + + /** + * @var string holds code challenge method for PKCE mode + * @see https://tools.ietf.org/html/rfc7636 + */ + private $codeChallengeMethod = false; + + /** + * @var array holds PKCE supported algorithms + */ + private $pkceAlgs = array('S256' => 'sha256', 'plain' => false); /** * @param $provider_url string optional @@ -316,6 +333,12 @@ public function authenticate() { user_error('Warning: JWT signature verification unavailable.'); } + // Save the id token + $this->idToken = $token_json->id_token; + + // Save the access token + $this->accessToken = $token_json->access_token; + // If this is a valid claim if ($this->verifyJWTclaims($claims, $token_json->access_token)) { @@ -325,12 +348,6 @@ public function authenticate() { // Save the full response $this->tokenResponse = $token_json; - // Save the id token - $this->idToken = $token_json->id_token; - - // Save the access token - $this->accessToken = $token_json->access_token; - // Save the verified claims $this->verifiedClaims = $claims; @@ -378,15 +395,15 @@ public function authenticate() { user_error('Warning: JWT signature verification unavailable.'); } + // Save the id token + $this->idToken = $id_token; + // If this is a valid claim if ($this->verifyJWTclaims($claims, $accessToken)) { // Clean up the session a little $this->unsetNonce(); - // Save the id token - $this->idToken = $id_token; - // Save the verified claims $this->verifiedClaims = $claims; @@ -414,7 +431,7 @@ public function authenticate() { * (the client application). * * @param string $accessToken ID token (obtained at login) - * @param string $redirect URL to which the RP is requesting that the End-User's User Agent + * @param string|null $redirect URL to which the RP is requesting that the End-User's User Agent * be redirected after a logout has been performed. The value MUST have been previously * registered with the OP. Value can be null. * @@ -474,7 +491,7 @@ protected function addAdditionalJwk($jwk) { * @return string * */ - private function getProviderConfigValue($param, $default = null) { + protected function getProviderConfigValue($param, $default = null) { // If the configuration value is not available, attempt to fetch it from a well known config endpoint // This is also known as auto "discovery" @@ -500,6 +517,9 @@ private function getWellKnownConfigValue($param, $default = null) { // This is also known as auto "discovery" if(!$this->wellKnown) { $well_known_config_url = rtrim($this->getProviderURL(), '/') . '/.well-known/openid-configuration'; + if (count($this->wellKnownConfigParameters) > 0){ + $well_known_config_url .= '?' . http_build_query($this->wellKnownConfigParameters) ; + } $this->wellKnown = json_decode($this->fetchURL($well_known_config_url)); } @@ -520,6 +540,16 @@ private function getWellKnownConfigValue($param, $default = null) { throw new OpenIDConnectClientException("The provider {$param} could not be fetched. Make sure your provider has a well known configuration available."); } + /** + * Set optionnal parameters for .well-known/openid-configuration + * + * @param string $param + * + */ + public function setWellKnownConfigParameters(array $params = []){ + $this->wellKnownConfigParameters=$params; + } + /** * @param string $url Sets redirect URL for auth flow @@ -580,9 +610,18 @@ public function getRedirectURL() { * Used for arbitrary value generation for nonces and state * * @return string + * @throws OpenIDConnectClientException */ protected function generateRandString() { - return md5(uniqid(rand(), TRUE)); + // Error and Exception need to be catched in this order, see https://github.com/paragonie/random_compat/blob/master/README.md + // random_compat polyfill library should be removed if support for PHP versions < 7 is dropped + try { + return \bin2hex(\random_bytes(16)); + } catch (Error $e) { + throw new OpenIDConnectClientException('Random token generation failed.'); + } catch (Exception $e) { + throw new OpenIDConnectClientException('Random token generation failed.'); + }; } /** @@ -613,7 +652,7 @@ private function requestAuthorization() { // If the client has been registered with additional scopes if (count($this->scopes) > 0) { - $auth_params = array_merge($auth_params, array('scope' => implode(' ', $this->scopes))); + $auth_params = array_merge($auth_params, array('scope' => implode(' ', array_merge($this->scopes, array('openid'))))); } // If the client has been registered with additional response types @@ -621,6 +660,21 @@ private function requestAuthorization() { $auth_params = array_merge($auth_params, array('response_type' => implode(' ', $this->responseTypes))); } + // If the client supports Proof Key for Code Exchange (PKCE) + if (!empty($this->getCodeChallengeMethod()) && in_array($this->getCodeChallengeMethod(), $this->getProviderConfigValue('code_challenge_methods_supported'))) { + $codeVerifier = bin2hex(random_bytes(64)); + $this->setCodeVerifier($codeVerifier); + if (!empty($this->pkceAlgs[$this->getCodeChallengeMethod()])) { + $codeChallenge = rtrim(strtr(base64_encode(hash($this->pkceAlgs[$this->getCodeChallengeMethod()], $codeVerifier, true)), '+/', '-_'), '='); + } else { + $codeChallenge = $codeVerifier; + } + $auth_params = array_merge($auth_params, array( + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => $this->getCodeChallengeMethod() + )); + } + $auth_endpoint .= (strpos($auth_endpoint, '?') === false ? '?' : '&') . http_build_query($auth_params, null, '&', $this->enc_type); $this->commitSession(); @@ -695,7 +749,7 @@ public function requestResourceOwnerToken($bClientAuth = FALSE) { * @return mixed * @throws OpenIDConnectClientException */ - private function requestTokens($code) { + protected function requestTokens($code) { $token_endpoint = $this->getProviderConfigValue('token_endpoint'); $token_endpoint_auth_methods_supported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); @@ -713,15 +767,26 @@ private function requestTokens($code) { # Consider Basic authentication if provider config is set this way if (in_array('client_secret_basic', $token_endpoint_auth_methods_supported, true)) { - $headers = ['Authorization: Basic ' . base64_encode($this->clientID . ':' . $this->clientSecret)]; + $headers = ['Authorization: Basic ' . base64_encode(urlencode($this->clientID) . ':' . urlencode($this->clientSecret))]; unset($token_params['client_secret']); + unset($token_params['client_id']); + } + + if (!empty($this->getCodeChallengeMethod()) && !empty($this->getCodeVerifier())) { + $headers = []; + unset($token_params['client_secret']); + $token_params = array_merge($token_params, array( + 'client_id' => $this->clientID, + 'code_verifier' => $this->getCodeVerifier() + )); } // Convert token params to string format $token_params = http_build_query($token_params, null, '&', $this->enc_type); - return json_decode($this->fetchURL($token_endpoint, $token_params, $headers)); + $this->tokenResponse = json_decode($this->fetchURL($token_endpoint, $token_params, $headers)); + return $this->tokenResponse; } /** @@ -803,10 +868,11 @@ private function get_key_for_header($keys, $header) { * @param object $key * @param $payload * @param $signature + * @param $signatureType * @return bool * @throws OpenIDConnectClientException */ - private function verifyRSAJWTsignature($hashtype, $key, $payload, $signature) { + private function verifyRSAJWTsignature($hashtype, $key, $payload, $signature, $signatureType) { if (!class_exists('\phpseclib\Crypt\RSA') && !class_exists('Crypt_RSA')) { throw new OpenIDConnectClientException('Crypt_RSA support unavailable.'); } @@ -824,13 +890,19 @@ private function verifyRSAJWTsignature($hashtype, $key, $payload, $signature) { if(class_exists('Crypt_RSA', false)) { $rsa = new Crypt_RSA(); $rsa->setHash($hashtype); + if ($signatureType === 'PSS') { + $rsa->setMGFHash($hashtype); + } $rsa->loadKey($public_key_xml, Crypt_RSA::PUBLIC_FORMAT_XML); - $rsa->signatureMode = Crypt_RSA::SIGNATURE_PKCS1; + $rsa->signatureMode = $signatureType === 'PSS' ? Crypt_RSA::SIGNATURE_PSS : Crypt_RSA::SIGNATURE_PKCS1; } else { $rsa = new \phpseclib\Crypt\RSA(); $rsa->setHash($hashtype); + if ($signatureType === 'PSS') { + $rsa->setMGFHash($hashtype); + } $rsa->loadKey($public_key_xml, \phpseclib\Crypt\RSA::PUBLIC_FORMAT_XML); - $rsa->signatureMode = \phpseclib\Crypt\RSA::SIGNATURE_PKCS1; + $rsa->signatureMode = $signatureType === 'PSS' ? \phpseclib\Crypt\RSA::SIGNATURE_PSS : \phpseclib\Crypt\RSA::SIGNATURE_PKCS1; } return $rsa->verify($payload, $signature); } @@ -889,13 +961,15 @@ public function verifyJWTsignature($jwt) { } switch ($header->alg) { case 'RS256': + case 'PS256': case 'RS384': case 'RS512': $hashtype = 'sha' . substr($header->alg, 2); + $signatureType = $header->alg === 'PS256' ? 'PSS' : ''; $verified = $this->verifyRSAJWTsignature($hashtype, $this->get_key_for_header($jwks->keys, $header), - $payload, $signature); + $payload, $signature, $signatureType); break; case 'HS256': case 'HS512': @@ -913,26 +987,25 @@ public function verifyJWTsignature($jwt) { * @param object $claims * @param string|null $accessToken * @return bool - * @throws OpenIDConnectClientException */ - private function verifyJWTclaims($claims, $accessToken = null) { + protected function verifyJWTclaims($claims, $accessToken = null) { if(isset($claims->at_hash) && isset($accessToken)){ - if(isset($this->getAccessTokenHeader()->alg) && $this->getAccessTokenHeader()->alg !== 'none'){ - $bit = substr($this->getAccessTokenHeader()->alg, 2, 3); + if(isset($this->getIdTokenHeader()->alg) && $this->getIdTokenHeader()->alg !== 'none'){ + $bit = substr($this->getIdTokenHeader()->alg, 2, 3); }else{ // TODO: Error case. throw exception??? $bit = '256'; } $len = ((int)$bit)/16; - $expecte_at_hash = $this->urlEncode(substr(hash('sha'.$bit, $accessToken, true), 0, $len)); + $expected_at_hash = $this->urlEncode(substr(hash('sha'.$bit, $accessToken, true), 0, $len)); } return (($this->issuerValidator->__invoke($claims->iss)) && (($claims->aud === $this->clientID) || in_array($this->clientID, $claims->aud, true)) && ($claims->nonce === $this->getNonce()) && ( !isset($claims->exp) || ((gettype($claims->exp) === 'integer') && ($claims->exp >= time() - $this->leeway))) && ( !isset($claims->nbf) || ((gettype($claims->nbf) === 'integer') && ($claims->nbf <= time() + $this->leeway))) - && ( !isset($claims->at_hash) || $claims->at_hash === $expecte_at_hash ) - ); + && ( !isset($claims->at_hash) || $claims->at_hash === $expected_at_hash ) + ); } /** @@ -951,7 +1024,7 @@ protected function urlEncode($str) { * @param int $section the section we would like to decode * @return object */ - private function decodeJWT($jwt, $section = 0) { + protected function decodeJWT($jwt, $section = 0) { $parts = explode('.', $jwt); return json_decode(base64url_decode($parts[$section])); @@ -959,27 +1032,27 @@ private function decodeJWT($jwt, $section = 0) { /** * - * @param string $attribute optional + * @param string|null $attribute optional * - * Attribute Type Description - * user_id string REQUIRED Identifier for the End-User at the Issuer. - * name string End-User's full name in displayable form including all name parts, ordered according to End-User's locale and preferences. - * given_name string Given name or first name of the End-User. - * family_name string Surname or last name of the End-User. - * middle_name string Middle name of the End-User. - * nickname string Casual name of the End-User that may or may not be the same as the given_name. For instance, a nickname value of Mike might be returned alongside a given_name value of Michael. - * profile string URL of End-User's profile page. - * picture string URL of the End-User's profile picture. - * website string URL of End-User's web page or blog. - * email string The End-User's preferred e-mail address. - * verified boolean True if the End-User's e-mail address has been verified; otherwise false. - * gender string The End-User's gender: Values defined by this specification are female and male. Other values MAY be used when neither of the defined values are applicable. - * birthday string The End-User's birthday, represented as a date string in MM/DD/YYYY format. The year MAY be 0000, indicating that it is omitted. - * zoneinfo string String from zoneinfo [zoneinfo] time zone database. For example, Europe/Paris or America/Los_Angeles. - * locale string The End-User's locale, represented as a BCP47 [RFC5646] language tag. This is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, separated by a dash. For example, en-US or fr-CA. As a compatibility note, some implementations have used an underscore as the separator rather than a dash, for example, en_US; Implementations MAY choose to accept this locale syntax as well. - * phone_number string The End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of this Claim. For example, +1 (425) 555-1212 or +56 (2) 687 2400. - * address JSON object The End-User's preferred address. The value of the address member is a JSON [RFC4627] structure containing some or all of the members defined in Section 2.4.2.1. - * updated_time string Time the End-User's information was last updated, represented as a RFC 3339 [RFC3339] datetime. For example, 2011-01-03T23:58:42+0000. + * Attribute Type Description + * user_id string REQUIRED Identifier for the End-User at the Issuer. + * name string End-User's full name in displayable form including all name parts, ordered according to End-User's locale and preferences. + * given_name string Given name or first name of the End-User. + * family_name string Surname or last name of the End-User. + * middle_name string Middle name of the End-User. + * nickname string Casual name of the End-User that may or may not be the same as the given_name. For instance, a nickname value of Mike might be returned alongside a given_name value of Michael. + * profile string URL of End-User's profile page. + * picture string URL of the End-User's profile picture. + * website string URL of End-User's web page or blog. + * email string The End-User's preferred e-mail address. + * verified boolean True if the End-User's e-mail address has been verified; otherwise false. + * gender string The End-User's gender: Values defined by this specification are female and male. Other values MAY be used when neither of the defined values are applicable. + * birthday string The End-User's birthday, represented as a date string in MM/DD/YYYY format. The year MAY be 0000, indicating that it is omitted. + * zoneinfo string String from zoneinfo [zoneinfo] time zone database. For example, Europe/Paris or America/Los_Angeles. + * locale string The End-User's locale, represented as a BCP47 [RFC5646] language tag. This is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, separated by a dash. For example, en-US or fr-CA. As a compatibility note, some implementations have used an underscore as the separator rather than a dash, for example, en_US; Implementations MAY choose to accept this locale syntax as well. + * phone_number string The End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of this Claim. For example, +1 (425) 555-1212 or +56 (2) 687 2400. + * address JSON object The End-User's preferred address. The value of the address member is a JSON [RFC4627] structure containing some or all of the members defined in Section 2.4.2.1. + * updated_time string Time the End-User's information was last updated, represented as a RFC 3339 [RFC3339] datetime. For example, 2011-01-03T23:58:42+0000. * * @return mixed * @@ -998,14 +1071,16 @@ public function requestUserInfo($attribute = null) { 'Accept: application/json']; $user_json = json_decode($this->fetchURL($user_info_endpoint,null,$headers)); - + if ($this->getResponseCode() <> 200) { + throw new OpenIDConnectClientException('The communication to retrieve user data has failed with status code '.$this->getResponseCode()); + } $this->userInfo = $user_json; if($attribute === null) { return $this->userInfo; } - if (array_key_exists($attribute, $this->userInfo)) { + if (property_exists($this->userInfo, $attribute)) { return $this->userInfo->$attribute; } @@ -1014,19 +1089,19 @@ public function requestUserInfo($attribute = null) { /** * - * @param string $attribute optional + * @param string|null $attribute optional * * Attribute Type Description - * exp int Expires at - * nbf int Not before - * ver string Version - * iss string Issuer - * sub string Subject - * aud string Audience - * nonce string nonce - * iat int Issued At - * auth_time int Authenatication time - * oid string Object id + * exp int Expires at + * nbf int Not before + * ver string Version + * iss string Issuer + * sub string Subject + * aud string Audience + * nonce string nonce + * iat int Issued At + * auth_time int Authenatication time + * oid string Object id * * @return mixed * @@ -1037,7 +1112,7 @@ public function getVerifiedClaims($attribute = null) { return $this->verifiedClaims; } - if (array_key_exists($attribute, $this->verifiedClaims)) { + if (property_exists($this->verifiedClaims, $attribute)) { return $this->verifiedClaims->$attribute; } @@ -1074,7 +1149,6 @@ protected function fetchURL($url, $post_body = null, $headers = array()) { // Add POST-specific headers $headers[] = "Content-Type: {$content_type}"; - $headers[] = 'Content-Length: ' . strlen($post_body); } @@ -1130,7 +1204,7 @@ protected function fetchURL($url, $post_body = null, $headers = array()) { $this->responseCode = $info['http_code']; if ($output === false) { - throw new OpenIDConnectClientException('Curl error: ' . curl_error($ch)); + throw new OpenIDConnectClientException('Curl error: (' . curl_errno($ch) . ') ' . curl_error($ch)); } // Close the cURL resource, and free system resources @@ -1351,7 +1425,7 @@ public function introspectToken($token, $token_type_hint = '', $clientId = null, // Convert token params to string format $post_params = http_build_query($post_data, null, '&'); - $headers = ['Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret), + $headers = ['Authorization: Basic ' . base64_encode(urlencode($clientId) . ':' . urlencode($clientSecret)), 'Accept: application/json']; return json_decode($this->fetchURL($introspection_endpoint, $post_params, $headers)); @@ -1382,7 +1456,7 @@ public function revokeToken($token, $token_type_hint = '', $clientId = null, $cl // Convert token params to string format $post_params = http_build_query($post_data, null, '&'); - $headers = ['Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret), + $headers = ['Authorization: Basic ' . base64_encode(urlencode($clientId) . ':' . urlencode($clientSecret)), 'Accept: application/json']; return json_decode($this->fetchURL($revocation_endpoint, $post_params, $headers)); @@ -1549,6 +1623,35 @@ protected function unsetState() { $this->unsetSessionKey('openid_connect_state'); } + /** + * Stores $codeVerifier + * + * @param string $codeVerifier + * @return string + */ + protected function setCodeVerifier($codeVerifier) { + $this->setSessionKey('openid_connect_code_verifier', $codeVerifier); + return $codeVerifier; + } + + /** + * Get stored codeVerifier + * + * @return string + */ + protected function getCodeVerifier() { + return $this->getSessionKey('openid_connect_code_verifier'); + } + + /** + * Cleanup state + * + * @return void + */ + protected function unsetCodeVerifier() { + $this->unsetSessionKey('openid_connect_code_verifier'); + } + /** * Get the response code from last action/curl request. * @@ -1662,4 +1765,58 @@ public function setUrlEncoding($curEncoding) } } + + /** + * @return array + */ + public function getScopes() + { + return $this->scopes; + } + + /** + * @return array + */ + public function getResponseTypes() + { + return $this->responseTypes; + } + + /** + * @return array + */ + public function getAuthParams() + { + return $this->authParams; + } + + /** + * @return callable + */ + public function getIssuerValidator() + { + return $this->issuerValidator; + } + + /** + * @return int + */ + public function getLeeway() + { + return $this->leeway; + } + + /** + * @return string + */ + public function getCodeChallengeMethod() { + return $this->codeChallengeMethod; + } + + /** + * @param string $codeChallengeMethod + */ + public function setCodeChallengeMethod($codeChallengeMethod) { + $this->codeChallengeMethod = $codeChallengeMethod; + } } diff --git a/tests/TokenVerificationTest.php b/tests/TokenVerificationTest.php new file mode 100644 index 00000000..a10392be --- /dev/null +++ b/tests/TokenVerificationTest.php @@ -0,0 +1,32 @@ +getMockBuilder(OpenIDConnectClient::class)->setMethods(['fetchUrl'])->getMock(); + $client->method('fetchUrl')->willReturn(file_get_contents(__DIR__ . "/data/jwks-$alg.json")); + $client->setProviderURL('https://jwt.io/'); + $client->providerConfigParam(['jwks_uri' => 'https://jwt.io/.well-known/jwks.json']); + $verified = $client->verifyJWTsignature($jwt); + self::assertTrue($verified); + $client->setAccessToken($jwt); + } + + public function providesTokens() + { + return [ + 'PS256' => ['ps256', 'eyJhbGciOiJQUzI1NiIsImtpZCI6Imtvbm5lY3RkLXRva2Vucy1zaWduaW5nLWtleSIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJrcG9wLWh0dHBzOi8va29wYW5vLmRlbW8vbWVldC8iLCJleHAiOjE1NjgzNzE0NjEsImp0aSI6IkpkR0tDbEdOTXl2VXJpcmlRRUlWUXZCVmttT2FfQkRjIiwiaWF0IjoxNTY4MzcxMjIxLCJpc3MiOiJodHRwczovL2tvcGFuby5kZW1vIiwic3ViIjoiUHpUVWp3NHBlXzctWE5rWlBILXJxVHE0MTQ1Z3lDdlRvQmk4V1E5bFBrcW5rbEc1aktvRU5LM21Qb0I1WGY1ZTM5dFRMR2RKWXBMNEJubXFnelpaX0FAa29ubmVjdCIsImtjLmlzQWNjZXNzVG9rZW4iOnRydWUsImtjLmF1dGhvcml6ZWRTY29wZXMiOlsicHJvZmlsZSIsImVtYWlsIiwia29wYW5vL2t3bSIsImtvcGFuby9nYyIsImtvcGFuby9rdnMiLCJvcGVuaWQiXSwia2MuYXV0aG9yaXplZENsYWltcyI6eyJpZF90b2tlbiI6eyJuYW1lIjpudWxsfX0sImtjLmlkZW50aXR5Ijp7ImtjLmkuZG4iOiJKb25hcyBCcmVra2UiLCJrYy5pLmlkIjoiQUFBQUFLd2hxVkJBMCs1SXN4bjdwMU13UkNVQkFBQUFCZ0FBQUJzQUFBQk5VVDA5QUFBQUFBPT0iLCJrYy5pLnVuIjoidXNlcjEiLCJrYy5pLnVzIjoiTVEifSwia2MucHJvdmlkZXIiOiJpZGVudGlmaWVyLWtjIn0.hGRuXvul2kOiALHexwYp5MBEJVwz1YV3ehyM3AOuwCoK2w5sJxdciqqY_TfXCKyO6nAEbYLK3J0CBOjfup_IG0aCZcwzjto8khYlc4ezXkGnFsbJBNQdDGkpHtWnioWx-OJ3cXvY9F8aOvjaq0gw11ZDAcqQl0g7LTbJ9-J_yx0pmy3NGai2JB30Fh1OgSDzYfxWnE0RRgZG-x68e65RXfSBaEGW85OUh4wihxO2zdTGAHJ3Iq_-QAG4yRbXZtLx3ZspG7LNmqG-YE3huy3Rd8u3xrJNhmUOfEnz3x07q7VW0cj9NedX98BAbj3iNvksQsE0oG0J_f_Tu8Ai8VbWB72sJuXZWxANDKdz0BBYLzXhsjXkNByRq9x3zqDVsX-cVHei_XudxEOVRBjhkvW2MmIjcAHNKCKsdar865-gFG9McP4PCcBlY28tC0Cvnzyi83LBfpGRXdl6MJunnUsKQ1C79iCoVI1doK1erFN959Q-TGJfJA3Tr5LNpuGawB5rpe1nDGWvmYhg3uYfNl8uTTyvNgvvejcflEb2DURuXdqABuSiP7RkDWYtzx6mq49G0tRxelBbvyjQ2id2QjmRRdQ6dHEZ2NCJ51b8OFoDJBtxN1CD62TTxa3FUqCdZAPAUR3hHn_69vYq82MR514s-Gb67A6j2PbMPFATQP2UdK8'] + ]; + } +} diff --git a/tests/data/jwks-ps256.json b/tests/data/jwks-ps256.json new file mode 100644 index 00000000..a73a1e08 --- /dev/null +++ b/tests/data/jwks-ps256.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "kid": "konnectd-tokens-signing-key", + "n": "10hb3pFUVcqJcS-d1pLCkFTyTqVD1GavlAai582CoRwFcyIQxCPJz0LJVgkUNwxSRkY0g0PcgFN_MmuuzpFXMkkiMIC9O_KwnuL34FrbijZvcGpnDn7kb9KAM883OVTr_w3wFeQIyh0ksSwVQ9CxVQ-ZeCXP73CCGk99uDb8SeF8_vncXJmaak99pK6HKJteSLkA-Ywxo9HOINZK2vW06UYcSkeoQnSI27Cd5-T6GVgqKH0Su4c5Ydou_w0tL_UkbZA4fIbMZC6dtWmBQf6tyYsCM9fbWNIVOj_7WlWcAOSTFNF2We2dxJrOzt6vDND3k1nCgg_EEM6cgBO3swUCktTFuQxo1sryYX5WXz9wnJb38b9mTXhOeF0bd9y_VQq8erSlcyRu8UGzX65tIf534hLL16KQaHbjROGSQvzqFrISmSBjBTjkPedTZSYOhiVJ95-em_Y6uLi-T7V4bs4dcg3oa0H_glXltoC9JxzS6gfMGGLgh-NpGEOdC_QosyzVVfzT70TurOGnsB1_VcAm_fK-T1Zv_ztpr5OZNfXWXC3Pfq_3sxP5HDKMk8luZ7LOWk7HVSYBdCFmOM1A3KmHNS2fEs-QHIr-XjYQ7QrXsRFP3dmoEPfiYlu03m8Xs3UMB70eGeGQx7OhZSuogxV_oCfApV5EJfuz97tVmOg8iMs", + "e": "AQAB" + } + ], + "kty": "" +}