diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a54473b4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 + +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + # Maintain dependencies for composer + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2033968d..38080af1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,13 @@ --- name: build -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: + branches: + - master env: DEFAULT_COMPOSER_FLAGS: "--prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi" @@ -14,20 +20,20 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - php: ['5.5', '5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1'] + php: ['7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - name: Get composer cache directory id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -35,4 +41,4 @@ jobs: - name: Install dependencies run: composer update $DEFAULT_COMPOSER_FLAGS - name: Run unit tests - run: vendor/bin/phpunit --verbose --colors=always tests + run: vendor/bin/phpunit --colors=always tests diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f09105..0e1c7105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,161 +1,194 @@ # Changelog 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/). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [unreleased] +## [1.0.1] - 2024-09-13 -## [0.9.7] +### Fixed +- Cast `$_SERVER['SERVER_PORT']` to integer to prevent adding 80 or 443 port to redirect URL. #437 -### Added +## [1.0.1] - 2024-09-05 + +### Fixed +- Fix JWT decode of non JWT tokens #428 +- Fix method signatures #427 +- Cast `$_SERVER['SERVER_PORT']` to integer to prevent adding 80 or 443 port to redirect URL. #403 +- Check subject when verifying JWT #406 +- Removed duplicate check on jwks_uri and only check if jwks_uri exists when needed #373 -* Support for Self-Contained JWTs. #308 -* Support for RFC8693 Token Exchange Request. #275 +## [1.0.0] - 2023-12-13 + +### Added +- PHP 7.0 is required. #327 +- Support for signed and encrypted UserInfo response and ID Token. #305 +- Allow to set User-Agent header. #370 ### Fixed +- User-Agent is set for any HTTP method in fetchURL() (not just POST). #382 +- Update visibility of getWellKnownConfigValue to protected. #363 +- Fixed issue on authentication for php8. #354 +- Update construct typehint in docblock. #364 +- Fixed LogoutToken verification for single value aud claims. #334 +- Update well known config value function response types. #376 -* PHP 5.4 compatibility. #304 -* Use session_status(). #306 +## [0.9.10] - 2022-09-30 + +### Fixed +- `private_key_jwt` and `client_secret_jwt` need to explicitly be enabled #331 -## [0.9.6] +## [0.9.9] - 2022-09-28 ### Added +- Added support for back-channel logout. #302 +- Added support for `private_key_jwt` Client Authentication method #322 +- Added support for `client_secret_jwt` Client Authentication method #324 +- Added PS512 encryption support #342 -* Support for [phpseclib/phpseclib](https://phpseclib.com/) version **3**. #260 -* Support client_secret on token endpoint with PKCE. #293 -* Added new parameter to `requestTokens()` to pass custom HTTP headers #297 +### Fixed +- Harden self-signed JWK header usage. #323 -### Changed +## [0.9.8] - 2022-08-05 -* Allow serializing `OpenIDConnectClient` using `serialize()` #295 +### Fixed +- Do not use PKCE if IdP does not support it. #317 -## [0.9.5] +## [0.9.7] - 2022-07-13 -### Changed +### Added +- Support for Self-Contained JWTs. #308 +- Support for RFC8693 Token Exchange Request. #275 -* signOut() Method parameter $accessToken -> $idToken to prevent confusion about access and id tokens usage. #127 -* Fixed issue where missing nonce within the claims was causing an exception. #280 +### Fixed +- PHP 5.4 compatibility. #304 +- Use session_status(). #306 -## [0.9.4] +## [0.9.6] - 2022-05-08 ### Added +- Support for [phpseclib/phpseclib](https://phpseclib.com/) version **3**. #260 +- Support client_secret on token endpoint with PKCE. #293 +- Added new parameter to `requestTokens()` to pass custom HTTP headers #297 + +### Changed +- Allow serializing `OpenIDConnectClient` using `serialize()` #295 + +## [0.9.5] - 2021-11-24 -* Enabled `client_secret_basic` authentication on `refreshToken()` #215 -* Basic auth support for requestResourceOwnerToken #271 +### Changed +- signOut() Method parameter $accessToken -> $idToken to prevent confusion about access and id tokens usage. #127 +- Fixed issue where missing nonce within the claims was causing an exception. #280 -## [0.9.3] +## [0.9.4] - 2021-11-21 ### Added +- Enabled `client_secret_basic` authentication on `refreshToken()` #215 +- Basic auth support for requestResourceOwnerToken #271 + +## [0.9.3] - 2021-11-20 -* getRedirectURL() will not log a warning for PHP 7.1+ #179 -* it is now possible to disable upgrading from HTTP to HTTPS for development purposes by calling `setHttpUpgradeInsecureRequests(false)` #241 -* bugfix in getSessionKey when _SESSION key does not exist #251 -* Added scope parameter to refresh token request #225 -* bugfix in verifyJWTclaims when $accessToken is empty and $claims->at_hash is not #276 -* bugfix with the `empty` function in PHP 5.4 #267 +### Added +- getRedirectURL() will not log a warning for PHP 7.1+ #179 +- it is now possible to disable upgrading from HTTP to HTTPS for development purposes by calling `setHttpUpgradeInsecureRequests(false)` #241 +- bugfix in getSessionKey when _SESSION key does not exist #251 +- Added scope parameter to refresh token request #225 +- bugfix in `verifyJWTclaims` when $accessToken is empty and $claims->at_hash is not #276 +- bugfix with the `empty` function in PHP 5.4 #267 -## [0.9.2] +## [0.9.2] - 2020-11-16 ### Added -* Support for [PKCE](https://tools.ietf.org/html/rfc7636). Currently the supported methods are 'plain' and 'S256'. +- Support for [PKCE](https://tools.ietf.org/html/rfc7636). Currently, the supported methods are 'plain' and 'S256'. -## [0.9.1] +## [0.9.1] - 2020-08-27 ### Added -* Add support for MS Azure Active Directory B2C user flows +- 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. +- 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 - +- Removed explicit content-length header - caused issues with proxy servers -## [0.9.0] +## [0.9.0] - 2020-03-09 ### 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 -* Adding OAuth 2.0 Token Introspection #156 -* 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 +- 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 +- Adding OAuth 2.0 Token Introspection #156 +- 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 - * Cleanup PHPDoc #46e5b59 - * Replace unnecessary double quotes with single quotes #2a76b57 - * Use original function names instead of aliases #1f37892 - * Remove unnecessary default values #5ab801e - * Explicit declare field $redirectURL #9187c0b - * Remove unused code #1e65384 - * Fix indent #e9cdf56 - * Cleanup conditional code flow for better readability #107f3fb - * Added strict type comparisons #167 -* Bugfix: required `openid` scope was omitted when additional scopes were registered using `addScope` method. This resulted in failing OpenID process. - -## [0.8.0] +- Bugfix/code cleanup #152 +- Cleanup PHPDoc #46e5b59 +- Replace unnecessary double quotes with single quotes #2a76b57 +- Use original function names instead of aliases #1f37892 +- Remove unnecessary default values #5ab801e +- Explicit declare field $redirectURL #9187c0b +- Remove unused code #1e65384 +- Fix indent #e9cdf56 +- Cleanup conditional code flow for better readability #107f3fb +- Added strict type comparisons #167 +- Bugfix: required `openid` scope was omitted when additional scopes were registered using `addScope` method. This resulted in failing OpenID process. + +## [0.8.0] - 2019-01-02 ### Added -* Fix `verifyJWTsignature()`: verify JWT to prevent php errors and warnings on invalid token +- Fix `verifyJWTsignature()`: verify JWT to prevent php errors and warnings on invalid token ### Changed -* Decouple session manipulation, it's allow use of other session libraries #134 -* Broaden version requirements of the phpseclib/phpseclib package. #144 +- Decouple session manipulation, it's allow use of other session libraries #134 +- Broaden version requirements of the phpseclib/phpseclib package. #144 -## [0.7.0] +## [0.7.0] - 2018-10-15 ### Added -* Add "license" field to composer.json #138 -* Ensure key_alg is set when getting key #139 -* Add option to send additional registration parameters like post_logout_redirect_uris. #140 +- Add "license" field to composer.json #138 +- Ensure key_alg is set when getting key #139 +- Add option to send additional registration parameters like post_logout_redirect_uris. #140 ### Changed -* disabled autoload for Crypt_RSA + makre refreshToken() method tolerant for errors #137 - -### Removed -* +- disabled autoload for Crypt_RSA + make refreshToken() method tolerant for errors #137 -## [0.6.0] +## [0.6.0] - 2018-07-17 ### Added -* Added five minutes leeway due to clock skew between openidconnect server and client. -* Fix save access_token from request in implicit flow authentication #129 -* verifyJWTsignature() method private -> public #126 -* Support for providers where provider/login URL is not the same as the issuer URL. #125 -* Support for providers that has a different login URL from the issuer URL, for instance Azure Active Directory. Here, the provider URL is on the format: https://login.windows.net/(tenant-id), while the issuer claim actually is on the format: https://sts.windows.net/(tenant-id). +- Added five minutes leeway due to clock skew between openidconnect server and client. +- Fix save access_token from request in implicit flow authentication #129 +- `verifyJWTsignature()` method private -> public #126 +- Support for providers where provider/login URL is not the same as the issuer URL. #125 +- Support for providers that has a different login URL from the issuer URL, for instance Azure Active Directory. Here, the provider URL is on the format: https://login.windows.net/(tenant-id), while the issuer claim actually is on the format: https://sts.windows.net/(tenant-id). ### Changed -* refreshToken method update #124 +- refreshToken method update #124 -### Removed -* - -## [0.5.0] -## Added -* Implement Azure AD B2C Implicit Workflow +## [0.5.0] - 2018-04-09 -## [0.4.1] -## Changed -* Documentation updates for include path. - -## [0.4] ### Added -* Timeout is configurable via setTimeout method. This addresses issue #94. -* Add the ability to authenticate using the Resource Owner flow (with or without the Client ID and ClientSecret). This addresses issue #98 -* Add support for HS256, HS512 and HS384 signatures -* Removed unused calls to $this->getProviderConfigValue("token_endpoint_… +- Implement Azure AD B2C Implicit Workflow + +## [0.4.1] - 2018-02-16 ### Changed +- Documentation updates for include path. -### Removed +## [0.4.0] - 2018-02-15 + +### Added +- Timeout is configurable via setTimeout method. This addresses issue #94. +- Add the ability to authenticate using the Resource Owner flow (with or without the Client ID and ClientSecret). This addresses issue #98 +- Add support for HS256, HS512 and HS384 signatures +- Removed unused calls to $this->getProviderConfigValue("token_endpoint_… diff --git a/README.md b/README.md index 6689f46d..904b83ec 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,22 @@ PHP OpenID Connect Basic Client ======================== A simple library that allows an application to authenticate a user through the basic OpenID Connect flow. This library hopes to encourage OpenID Connect use by making it simple enough for a developer with little knowledge of -the OpenID Connect protocol to setup authentication. +the OpenID Connect protocol to set up authentication. A special thanks goes to Justin Richer and Amanda Anganes for their help and support of the protocol. # Requirements # - 1. PHP 5.4 or greater + 1. PHP 7.0 or greater 2. CURL extension 3. JSON extension ## Install ## - 1. Install library using composer +1. Install library using composer ``` composer require jumbojett/openid-connect-php ``` - 2. Include composer autoloader + +2. Include composer autoloader ```php require __DIR__ . '/vendor/autoload.php'; ``` @@ -68,8 +69,8 @@ use Jumbojett\OpenIDConnectClient; $oidc = new OpenIDConnectClient('https://id.provider.com', 'ClientIDHere', 'ClientSecretHere'); -$oidc->providerConfigParam(array('token_endpoint'=>'https://id.provider.com/connect/token')); -$oidc->addScope('my_scope'); +$oidc->providerConfigParam(['token_endpoint'=>'https://id.provider.com/connect/token']); +$oidc->addScope(['my_scope']); // this assumes success (to validate check if the access_token property is there and a valid JWT) : $clientCredentialsToken = $oidc->requestClientCredentialsToken()->access_token; @@ -84,12 +85,12 @@ use Jumbojett\OpenIDConnectClient; $oidc = new OpenIDConnectClient('https://id.provider.com', 'ClientIDHere', 'ClientSecretHere'); -$oidc->providerConfigParam(array('token_endpoint'=>'https://id.provider.com/connect/token')); -$oidc->addScope('my_scope'); +$oidc->providerConfigParam(['token_endpoint'=>'https://id.provider.com/connect/token']); +$oidc->addScope(['my_scope']); //Add username and password -$oidc->addAuthParam(array('username'=>'')); -$oidc->addAuthParam(array('password'=>'')); +$oidc->addAuthParam(['username'=>'']); +$oidc->addAuthParam(['password'=>'']); //Perform the auth and return the token (to validate check if the access_token property is there and a valid JWT) : $token = $oidc->requestResourceOwnerToken(TRUE)->access_token; @@ -104,10 +105,9 @@ use Jumbojett\OpenIDConnectClient; $oidc = new OpenIDConnectClient('https://id.provider.com', 'ClientIDHere', 'ClientSecretHere'); -$oidc->setResponseTypes(array('id_token')); -$oidc->addScope(array('openid')); +$oidc->setResponseTypes(['id_token']); $oidc->setAllowImplicitFlow(true); -$oidc->addAuthParam(array('response_mode' => 'form_post')); +$oidc->addAuthParam(['response_mode' => 'form_post']); $oidc->setCertPath('/path/to/my.cert'); $oidc->authenticate(); $sub = $oidc->getVerifiedClaims('sub'); @@ -143,6 +143,75 @@ $name = $oidc->requestUserInfo('given_name'); ``` +## Example 9: Back-channel logout ## + +Back-channel authentication assumes you can end a session on the server side on behalf of the user (without relying +on their browser). The request is a POST from the OP direct to your RP. In this way, the use of this library can +ensure your RP performs 'single sign out' for the user even if they didn't have your RP open in a browser or other +device, but still had an active session there. + +Either the sid or the sub may be accessible from the logout token sent from the OP. You can use either +`getSidFromBackChannel()` or `getSubjectFromBackChannel()` to retrieve them if it is helpful to match them to a session +in order to destroy it. + +The below ensures the use of this library to ensure validation of the back-channel logout token, but is afterward +just a hypothetical way of finding such a session and destroying it. Adjust it to the needs of your RP. + +```php + +function handleLogout() { + // NOTE: assumes that $this->oidc is an instance of OpenIDConnectClient() + if ($this->oidc->verifyLogoutToken()) { + $sid = $this->oidc->getSidFromBackChannel(); + + if (isset($sid)) { + // Somehow find the session based on the $sid and + // destroy it. This depends on your RP's design, + // there is nothing in the OIDC spec to mandate how. + // + // In this example, we find a Redis key, which was + // previously stored using the sid we obtained from + // the access token after login. + // + // The value of the Redis key is that of the user's + // session ID specific to this hypothetical RP app. + // + // We then switch to that session and destroy it. + $this->redis->connect('127.0.0.1', 6379); + $session_id_to_destroy = $this->redis->get($sid); + if ($session_id_to_destroy) { + session_commit(); + session_id($session_id_to_destroy); // switches to that session + session_start(); + $_SESSION = []; // effectively ends the session + } + } + } +} + +``` + +## Example 10: Enable Token Endpoint Auth Methods ## + +By default, only `client_secret_basic` is enabled on client side which was the only supported for a long time. +Recently `client_secret_jwt` and `private_key_jwt` have been added, but they remain disabled until explicitly enabled. + +```php +use Jumbojett\OpenIDConnectClient; + +$oidc = new OpenIDConnectClient('https://id.provider.com', + 'ClientIDHere', + null); +# enable 'client_secret_basic' and 'client_secret_jwt' +$oidc->setTokenEndpointAuthMethodsSupported(['client_secret_basic', 'client_secret_jwt']); + +# for 'private_key_jwt' in addition also the generator function has to be set. +$oidc->setTokenEndpointAuthMethodsSupported(['private_key_jwt']); +$oidc->setPrivateKeyJwtGenerator(function(string $token_endpoint) { + # TODO: what ever is necessary +}) +``` + ## Development Environments ## In some cases you may need to disable SSL security on your development systems. diff --git a/composer.json b/composer.json index 6d218ccf..64825884 100644 --- a/composer.json +++ b/composer.json @@ -3,15 +3,15 @@ "description": "Bare-bones OpenID Connect client", "license": "Apache-2.0", "require": { - "php": ">=5.4", - "phpseclib/phpseclib" : "~2.0 || ^3.0", + "php": ">=7.0", "ext-json": "*", "ext-curl": "*", - "paragonie/random_compat": ">=2" + "phpseclib/phpseclib": "^3.0.7" }, "require-dev": { - "roave/security-advisories": "dev-master", - "yoast/phpunit-polyfills": "^1.0" + "phpunit/phpunit": "<10", + "roave/security-advisories": "dev-latest", + "yoast/phpunit-polyfills": "^2.0" }, "archive" : { "exclude" : [ diff --git a/src/OpenIDConnectClient.php b/src/OpenIDConnectClient.php index 6fd6d117..07c00540 100644 --- a/src/OpenIDConnectClient.php +++ b/src/OpenIDConnectClient.php @@ -3,7 +3,7 @@ * * Copyright MITRE 2020 * - * OpenIDConnectClient for PHP5 + * OpenIDConnectClient for PHP7+ * Author: Michael Jett * * Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -22,18 +22,15 @@ namespace Jumbojett; -/** - * - * JWT signature verification support by Jonathan Reed - * Licensed under the same license as the rest of this file. - * - * phpseclib is required to validate the signatures of some tokens. - * It can be downloaded from: http://phpseclib.sourceforge.net/ - */ -if (!class_exists('\phpseclib3\Crypt\RSA') && !class_exists('\phpseclib\Crypt\RSA') && !class_exists('Crypt_RSA')) { - user_error('Unable to find phpseclib Crypt/RSA.php. Ensure phpseclib is installed and in include_path before you include this file'); -} +use Error; +use Exception; +use phpseclib3\Crypt\RSA; +use phpseclib3\Math\BigInteger; +use stdClass; +use function bin2hex; +use function is_object; +use function random_bytes; /** * A wrapper around base64_decode which decodes Base64URL-encoded data, @@ -41,7 +38,7 @@ * @param string $base64url * @return bool|string */ -function base64url_decode($base64url) { +function base64url_decode(string $base64url) { return base64_decode(b64url2b64($base64url)); } @@ -53,7 +50,8 @@ function base64url_decode($base64url) { * @param string $base64url * @return string */ -function b64url2b64($base64url) { +function b64url2b64(string $base64url): string +{ // "Shouldn't" be necessary, but why not $padding = strlen($base64url) % 4; if ($padding > 0) { @@ -66,19 +64,8 @@ function b64url2b64($base64url) { /** * OpenIDConnect Exception Class */ -class OpenIDConnectClientException extends \Exception +class OpenIDConnectClientException extends Exception { - -} - -/** - * Require the CURL and JSON PHP extensions to be installed - */ -if (!function_exists('curl_init')) { - throw new OpenIDConnectClientException('OpenIDConnect needs the CURL PHP extension.'); -} -if (!function_exists('json_decode')) { - throw new OpenIDConnectClientException('OpenIDConnect needs the JSON PHP extension.'); } /** @@ -145,7 +132,7 @@ class OpenIDConnectClient protected $idToken; /** - * @var string stores the token response + * @var object stores the token response */ private $tokenResponse; @@ -157,17 +144,17 @@ class OpenIDConnectClient /** * @var int|null Response code from the server */ - private $responseCode; + protected $responseCode; /** - * @var array holds response types + * @var string|null Content type from the server */ - private $responseTypes = []; + protected $responseContentType; /** - * @var array holds a cache of info returned from the user info endpoint + * @var array holds response types */ - private $userInfo = []; + private $responseTypes = []; /** * @var array holds authentication parameters @@ -185,7 +172,7 @@ class OpenIDConnectClient private $wellKnown = false; /** - * @var mixed holds well-known opendid configuration parameters, like policy for MS Azure AD B2C User Flow + * @var mixed holds well-known openid 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 = []; @@ -206,7 +193,7 @@ class OpenIDConnectClient private $additionalJwks = []; /** - * @var array holds verified jwt claims + * @var object holds verified jwt claims */ protected $verifiedClaims = []; @@ -215,6 +202,11 @@ class OpenIDConnectClient */ private $issuerValidator; + /** + * @var callable|null generator function for private key jwt client authentication + */ + private $privateKeyJwtGenerator; + /** * @var bool Allow OAuth 2 implicit flow; see http://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth */ @@ -247,13 +239,27 @@ class OpenIDConnectClient private $pkceAlgs = ['S256' => 'sha256', 'plain' => false]; /** - * @param $provider_url string optional - * - * @param $client_id string optional - * @param $client_secret string optional - * @param null $issuer + * @var string if we acquire a sid in back-channel logout it will be stored here + */ + private $backChannelSid; + + /** + * @var string if we acquire a sub in back-channel logout it will be stored here + */ + private $backChannelSubject; + + /** + * @var array list of supported auth methods */ - public function __construct($provider_url = null, $client_id = null, $client_secret = null, $issuer = null) { + private $token_endpoint_auth_methods_supported = ['client_secret_basic']; + + /** + * @param string|null $provider_url optional + * @param string|null $client_id optional + * @param string|null $client_secret optional + * @param string|null $issuer + */ + public function __construct(string $provider_url = null, string $client_id = null, string $client_secret = null, string $issuer = null) { $this->setProviderURL($provider_url); if ($issuer === null) { $this->setIssuer($provider_url); @@ -290,8 +296,8 @@ public function setResponseTypes($response_types) { * @return bool * @throws OpenIDConnectClientException */ - public function authenticate() { - + public function authenticate(): bool + { // Do a preemptive check to see if the provider has thrown an error from a previous redirect if (isset($_REQUEST['error'])) { $desc = isset($_REQUEST['error_description']) ? ' Description: ' . $_REQUEST['error_description'] : ''; @@ -313,7 +319,7 @@ public function authenticate() { } // Do an OpenID Connect session check - if ($_REQUEST['state'] !== $this->getState()) { + if (!isset($_REQUEST['state']) || ($_REQUEST['state'] !== $this->getState())) { throw new OpenIDConnectClientException('Unable to determine state'); } @@ -324,28 +330,26 @@ public function authenticate() { throw new OpenIDConnectClientException('User did not authorize openid scope.'); } - $claims = $this->decodeJWT($token_json->id_token, 1); + $id_token = $token_json->id_token; + $idTokenHeaders = $this->decodeJWT($id_token); + if (isset($idTokenHeaders->enc)) { + // Handle JWE + $id_token = $this->handleJweResponse($id_token); + } + + $claims = $this->decodeJWT($id_token, 1); // Verify the signature - if ($this->canVerifySignatures()) { - if (!$this->getProviderConfigValue('jwks_uri')) { - throw new OpenIDConnectClientException ('Unable to verify signature due to no jwks_uri being defined'); - } - if (!$this->verifyJWTsignature($token_json->id_token)) { - throw new OpenIDConnectClientException ('Unable to verify signature'); - } - } else { - user_error('Warning: JWT signature verification unavailable.'); - } + $this->verifySignatures($id_token); // Save the id token - $this->idToken = $token_json->id_token; + $this->idToken = $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)) { + if ($this->verifyJWTClaims($claims, $token_json->access_token)) { // Clean up the session a little $this->unsetNonce(); @@ -363,7 +367,6 @@ public function authenticate() { // Success! return true; - } throw new OpenIDConnectClientException ('Unable to verify JWT claims'); @@ -373,13 +376,10 @@ public function authenticate() { // if we have no code but an id_token use that $id_token = $_REQUEST['id_token']; - $accessToken = null; - if (isset($_REQUEST['access_token'])) { - $accessToken = $_REQUEST['access_token']; - } + $accessToken = $_REQUEST['access_token'] ?? null; // Do an OpenID Connect session check - if ($_REQUEST['state'] !== $this->getState()) { + if (!isset($_REQUEST['state']) || ($_REQUEST['state'] !== $this->getState())) { throw new OpenIDConnectClientException('Unable to determine state'); } @@ -389,22 +389,13 @@ public function authenticate() { $claims = $this->decodeJWT($id_token, 1); // Verify the signature - if ($this->canVerifySignatures()) { - if (!$this->getProviderConfigValue('jwks_uri')) { - throw new OpenIDConnectClientException ('Unable to verify signature due to no jwks_uri being defined'); - } - if (!$this->verifyJWTsignature($id_token)) { - throw new OpenIDConnectClientException ('Unable to verify signature'); - } - } else { - user_error('Warning: JWT signature verification unavailable.'); - } + $this->verifySignatures($id_token); // Save the id token $this->idToken = $id_token; // If this is a valid claim - if ($this->verifyJWTclaims($claims, $accessToken)) { + if ($this->verifyJWTClaims($claims, $accessToken)) { // Clean up the session a little $this->unsetNonce(); @@ -441,10 +432,9 @@ public function authenticate() { * * @throws OpenIDConnectClientException */ - public function signOut($idToken, $redirect) { - $signout_endpoint = $this->getProviderConfigValue('end_session_endpoint'); + public function signOut(string $idToken, $redirect) { + $sign_out_endpoint = $this->getProviderConfigValue('end_session_endpoint'); - $signout_params = null; if($redirect === null){ $signout_params = ['id_token_hint' => $idToken]; } @@ -454,29 +444,130 @@ public function signOut($idToken, $redirect) { 'post_logout_redirect_uri' => $redirect]; } - $signout_endpoint .= (strpos($signout_endpoint, '?') === false ? '?' : '&') . http_build_query( $signout_params, '', '&', $this->encType); - $this->redirect($signout_endpoint); + $sign_out_endpoint .= (strpos($sign_out_endpoint, '?') === false ? '?' : '&') . http_build_query( $signout_params, '', '&', $this->encType); + $this->redirect($sign_out_endpoint); + } + + + /** + * Decode and then verify a logout token sent as part of + * back-channel logout flows. + * + * This function should be evaluated as a boolean check + * in your route that receives the POST request for back-channel + * logout executed from the OP. + * + * @return bool + * @throws OpenIDConnectClientException + */ + public function verifyLogoutToken(): bool + { + if (isset($_REQUEST['logout_token'])) { + $logout_token = $_REQUEST['logout_token']; + + $claims = $this->decodeJWT($logout_token, 1); + + // Verify the signature + $this->verifySignatures($logout_token); + + // Verify Logout Token Claims + if ($this->verifyLogoutTokenClaims($claims)) { + $this->verifiedClaims = $claims; + return true; + } + + return false; + } + + throw new OpenIDConnectClientException('Back-channel logout: There was no logout_token in the request'); + } + + /** + * Verify each claim in the logout token according to the + * spec for back-channel logout. + * + * @param object $claims + * @return bool + * @throws OpenIDConnectClientException + */ + public function verifyLogoutTokenClaims($claims): bool + { + // Verify that the Logout Token doesn't contain a nonce Claim. + if (isset($claims->nonce)) { + return false; + } + + // Verify that the logout token contains a sub or sid, or both + if (!isset($claims->sid) && !isset($claims->sub)) { + return false; + } + // Set the sid, which could be used to map to a session in + // the RP, and therefore be used to help destroy the RP's + // session. + if (isset($claims->sid)) { + $this->backChannelSid = $claims->sid; + } + + // Set the sub, which could be used to map to a session in + // the RP, and therefore be used to help destroy the RP's + // session. + if (isset($claims->sub)) { + $this->backChannelSubject = $claims->sub; + } + + // Verify that the Logout Token contains an events Claim whose + // value is a JSON object containing the member name + // http://schemas.openid.net/event/backchannel-logout + if (isset($claims->events)) { + $events = (array) $claims->events; + if (!isset($events['http://schemas.openid.net/event/backchannel-logout']) || + !is_object($events['http://schemas.openid.net/event/backchannel-logout'])) { + return false; + } + } + + // Validate the iss + if (!$this->validateIssuer($claims->iss)) { + return false; + } + // Validate the aud + $auds = $claims->aud; + $auds = is_array( $auds ) ? $auds : [ $auds ]; + if (!in_array($this->clientID, $auds, true)) { + return false; + } + // Validate the iat. At this point we can return true if it is ok + if (isset($claims->iat) && ((is_int($claims->iat)) && ($claims->iat <= time() + $this->leeway))) { + return true; + } + + return false; } /** * @param array $scope - example: openid, given_name, etc... */ - public function addScope($scope) { - $this->scopes = array_merge($this->scopes, (array)$scope); + public function addScope(array $scope) { + $this->scopes = array_merge($this->scopes, $scope); } /** * @param array $param - example: prompt=login */ - public function addAuthParam($param) { - $this->authParams = array_merge($this->authParams, (array)$param); + public function addAuthParam(array $param) { + $this->authParams = array_merge($this->authParams, $param); } /** * @param array $param - example: post_logout_redirect_uris=[http://example.com/successful-logout] */ - public function addRegistrationParam($param) { - $this->registrationParams = array_merge($this->registrationParams, (array)$param); + public function addRegistrationParam(array $param) { + $this->registrationParams = array_merge($this->registrationParams, $param); + } + + public function setTokenEndpointAuthMethodsSupported(array $token_endpoint_auth_methods_supported) + { + $this->token_endpoint_auth_methods_supported = $token_endpoint_auth_methods_supported; } /** @@ -487,16 +578,16 @@ protected function addAdditionalJwk($jwk) { } /** - * Get's anything that we need configuration wise including endpoints, and other values + * Gets anything that we need configuration wise including endpoints, and other values * * @param string $param - * @param string $default optional - * @throws OpenIDConnectClientException - * @return string + * @param string|string[]|bool|null $default optional + * @return string|string[]|bool * + * @throws OpenIDConnectClientException */ - protected function getProviderConfigValue($param, $default = null) { - + protected function getProviderConfigValue(string $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" if (!isset($this->providerConfig[$param])) { @@ -507,15 +598,16 @@ protected function getProviderConfigValue($param, $default = null) { } /** - * Get's anything that we need configuration wise including endpoints, and other values + * Gets anything that we need configuration wise including endpoints, and other values * * @param string $param - * @param string $default optional - * @throws OpenIDConnectClientException - * @return string + * @param string|string[]|bool|null $default optional + * @return string|string[]|bool * + * @throws OpenIDConnectClientException */ - private function getWellKnownConfigValue($param, $default = null) { + protected function getWellKnownConfigValue(string $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" @@ -524,13 +616,10 @@ private function getWellKnownConfigValue($param, $default = null) { if (count($this->wellKnownConfigParameters) > 0){ $well_known_config_url .= '?' . http_build_query($this->wellKnownConfigParameters) ; } - $this->wellKnown = json_decode($this->fetchURL($well_known_config_url)); + $this->wellKnown = json_decode($this->fetchURL($well_known_config_url), false); } - $value = false; - if(isset($this->wellKnown->{$param})){ - $value = $this->wellKnown->{$param}; - } + $value = $this->wellKnown->{$param} ?? false; if ($value) { return $value; @@ -541,14 +630,11 @@ private function getWellKnownConfigValue($param, $default = null) { return $default; } - throw new OpenIDConnectClientException("The provider {$param} could not be fetched. Make sure your provider has a well known configuration available."); + 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 - * + * Set optional parameters for .well-known/openid-configuration */ public function setWellKnownConfigParameters(array $params = []){ $this->wellKnownConfigParameters=$params; @@ -558,7 +644,7 @@ public function setWellKnownConfigParameters(array $params = []){ /** * @param string $url Sets redirect URL for auth flow */ - public function setRedirectURL ($url) { + public function setRedirectURL(string $url) { if (parse_url($url,PHP_URL_HOST) !== false) { $this->redirectURL = $url; } @@ -569,8 +655,8 @@ public function setRedirectURL ($url) { * * @return string */ - public function getRedirectURL() { - + public function getRedirectURL(): string + { // If the redirect URL has been set then return it. if (property_exists($this, 'redirectURL') && $this->redirectURL) { return $this->redirectURL; @@ -602,9 +688,10 @@ public function getRedirectURL() { } if (isset($_SERVER['HTTP_X_FORWARDED_PORT'])) { - $port = intval($_SERVER['HTTP_X_FORWARDED_PORT']); + $port = (int)$_SERVER['HTTP_X_FORWARDED_PORT']; } elseif (isset($_SERVER['SERVER_PORT'])) { - $port = intval($_SERVER['SERVER_PORT']); + # keep this case - even if some tool claim it is unnecessary + $port = (int)$_SERVER['SERVER_PORT']; } elseif ($protocol === 'https') { $port = 443; } else { @@ -633,22 +720,22 @@ public function getRedirectURL() { * @return string * @throws OpenIDConnectClientException */ - protected function generateRandString() { - // 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 + protected function generateRandString(): string + { try { - return \bin2hex(\random_bytes(16)); + 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.'); - }; + } } /** * Start Here * @return void * @throws OpenIDConnectClientException + * @throws Exception */ private function requestAuthorization() { @@ -682,18 +769,18 @@ private function requestAuthorization() { } // If the client supports Proof Key for Code Exchange (PKCE) - $ccm = $this->getCodeChallengeMethod(); - if (!empty($ccm) && in_array($this->getCodeChallengeMethod(), $this->getProviderConfigValue('code_challenge_methods_supported'))) { + $codeChallengeMethod = $this->getCodeChallengeMethod(); + if (!empty($codeChallengeMethod) && in_array($codeChallengeMethod, $this->getProviderConfigValue('code_challenge_methods_supported', []), true)) { $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)), '+/', '-_'), '='); + if (!empty($this->pkceAlgs[$codeChallengeMethod])) { + $codeChallenge = rtrim(strtr(base64_encode(hash($this->pkceAlgs[$codeChallengeMethod], $codeVerifier, true)), '+/', '-_'), '='); } else { $codeChallenge = $codeVerifier; } $auth_params = array_merge($auth_params, [ 'code_challenge' => $codeChallenge, - 'code_challenge_method' => $this->getCodeChallengeMethod() + 'code_challenge_method' => $codeChallengeMethod ]); } @@ -725,7 +812,7 @@ public function requestClientCredentialsToken() { // Convert token params to string format $post_params = http_build_query($post_data, '', '&', $this->encType); - return json_decode($this->fetchURL($token_endpoint, $post_params, $headers)); + return json_decode($this->fetchURL($token_endpoint, $post_params, $headers), false); } /** @@ -736,7 +823,7 @@ public function requestClientCredentialsToken() { * @return mixed * @throws OpenIDConnectClientException */ - public function requestResourceOwnerToken($bClientAuth = FALSE) { + public function requestResourceOwnerToken(bool $bClientAuth = false) { $token_endpoint = $this->getProviderConfigValue('token_endpoint'); $headers = []; @@ -753,7 +840,7 @@ public function requestResourceOwnerToken($bClientAuth = FALSE) { //For client authentication include the client values if($bClientAuth) { $token_endpoint_auth_methods_supported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); - if (in_array('client_secret_basic', $token_endpoint_auth_methods_supported, true)) { + if ($this->supportsAuthMethod('client_secret_basic', $token_endpoint_auth_methods_supported)) { $headers = ['Authorization: Basic ' . base64_encode(urlencode($this->clientID) . ':' . urlencode($this->clientSecret))]; } else { $post_data['client_id'] = $this->clientID; @@ -764,7 +851,7 @@ public function requestResourceOwnerToken($bClientAuth = FALSE) { // Convert token params to string format $post_params = http_build_query($post_data, '', '&', $this->encType); - return json_decode($this->fetchURL($token_endpoint, $post_params, $headers)); + return json_decode($this->fetchURL($token_endpoint, $post_params, $headers), false); } @@ -776,7 +863,7 @@ public function requestResourceOwnerToken($bClientAuth = FALSE) { * @return mixed * @throws OpenIDConnectClientException */ - protected function requestTokens($code, $headers = array()) { + protected function requestTokens(string $code, array $headers = []) { $token_endpoint = $this->getProviderConfigValue('token_endpoint'); $token_endpoint_auth_methods_supported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); @@ -792,12 +879,32 @@ protected function requestTokens($code, $headers = array()) { $authorizationHeader = null; # Consider Basic authentication if provider config is set this way - if (in_array('client_secret_basic', $token_endpoint_auth_methods_supported, true)) { + if ($this->supportsAuthMethod('client_secret_basic', $token_endpoint_auth_methods_supported)) { $authorizationHeader = 'Authorization: Basic ' . base64_encode(urlencode($this->clientID) . ':' . urlencode($this->clientSecret)); - unset($token_params['client_secret']); - unset($token_params['client_id']); + unset($token_params['client_secret'], $token_params['client_id']); + } + + // When there is a private key jwt generator, and it is supported then use it as client authentication + if ($this->privateKeyJwtGenerator !== null && $this->supportsAuthMethod('private_key_jwt', $token_endpoint_auth_methods_supported)) { + $token_params['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + $token_params['client_assertion'] = $this->privateKeyJwtGenerator->__invoke($token_endpoint); } + if ($this->supportsAuthMethod('client_secret_jwt', $token_endpoint_auth_methods_supported)) { + $client_assertion_type = $this->getProviderConfigValue('client_assertion_type'); + + if(isset($this->providerConfig['client_assertion'])){ + $client_assertion = $this->getProviderConfigValue('client_assertion'); + } + else{ + $client_assertion = $this->getJWTClientAssertion($this->getProviderConfigValue('token_endpoint')); + } + + $token_params['client_assertion_type'] = $client_assertion_type; + $token_params['client_assertion'] = $client_assertion; + unset($token_params['client_secret']); + } + $ccm = $this->getCodeChallengeMethod(); $cv = $this->getCodeVerifier(); if (!empty($ccm) && !empty($cv)) { @@ -819,7 +926,7 @@ protected function requestTokens($code, $headers = array()) { $headers[] = $authorizationHeader; } - $this->tokenResponse = json_decode($this->fetchURL($token_endpoint, $token_params, $headers)); + $this->tokenResponse = json_decode($this->fetchURL($token_endpoint, $token_params, $headers), false); return $this->tokenResponse; } @@ -834,7 +941,7 @@ protected function requestTokens($code, $headers = array()) { * @return mixed * @throws OpenIDConnectClientException */ - public function requestTokenExchange($subjectToken, $subjectTokenType, $audience = '') { + public function requestTokenExchange(string $subjectToken, string $subjectTokenType, string $audience = '') { $token_endpoint = $this->getProviderConfigValue('token_endpoint'); $token_endpoint_auth_methods_supported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); $headers = []; @@ -854,16 +961,15 @@ public function requestTokenExchange($subjectToken, $subjectTokenType, $audience } # Consider Basic authentication if provider config is set this way - if (in_array('client_secret_basic', $token_endpoint_auth_methods_supported, true)) { + if ($this->supportsAuthMethod('client_secret_basic', $token_endpoint_auth_methods_supported)) { $headers = ['Authorization: Basic ' . base64_encode(urlencode($this->clientID) . ':' . urlencode($this->clientSecret))]; - unset($post_data['client_secret']); - unset($post_data['client_id']); + unset($post_data['client_secret'], $post_data['client_id']); } // Convert token params to string format - $post_params = http_build_query($post_data, null, '&', $this->enc_type); + $post_params = http_build_query($post_data, null, '&', $this->encType); - return json_decode($this->fetchURL($token_endpoint, $post_params, $headers)); + return json_decode($this->fetchURL($token_endpoint, $post_params, $headers), false); } @@ -874,7 +980,7 @@ public function requestTokenExchange($subjectToken, $subjectTokenType, $audience * @return mixed * @throws OpenIDConnectClientException */ - public function refreshToken($refresh_token) { + public function refreshToken(string $refresh_token) { $token_endpoint = $this->getProviderConfigValue('token_endpoint'); $token_endpoint_auth_methods_supported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); @@ -891,16 +997,29 @@ public function refreshToken($refresh_token) { ]; # Consider Basic authentication if provider config is set this way - if (in_array('client_secret_basic', $token_endpoint_auth_methods_supported, true)) { + if ($this->supportsAuthMethod('client_secret_basic', $token_endpoint_auth_methods_supported)) { $headers = ['Authorization: Basic ' . base64_encode(urlencode($this->clientID) . ':' . urlencode($this->clientSecret))]; - unset($token_params['client_secret']); - unset($token_params['client_id']); + unset($token_params['client_secret'], $token_params['client_id']); } + if ($this->supportsAuthMethod('client_secret_jwt', $token_endpoint_auth_methods_supported)) { + $client_assertion_type = $this->getProviderConfigValue('client_assertion_type'); + $client_assertion = $this->getJWTClientAssertion($this->getProviderConfigValue('token_endpoint')); + + $token_params["grant_type"] = "urn:ietf:params:oauth:grant-type:token-exchange"; + $token_params["subject_token"] = $refresh_token; + $token_params["audience"] = $this->clientID; + $token_params["subject_token_type"] = "urn:ietf:params:oauth:token-type:refresh_token"; + $token_params["requested_token_type"] = "urn:ietf:params:oauth:token-type:access_token"; + $token_params['client_assertion_type']=$client_assertion_type; + $token_params['client_assertion'] = $client_assertion; + + unset($token_params['client_secret'], $token_params['client_id']); + } // Convert token params to string format $token_params = http_build_query($token_params, '', '&', $this->encType); - $json = json_decode($this->fetchURL($token_endpoint, $token_params, $headers)); + $json = json_decode($this->fetchURL($token_endpoint, $token_params, $headers), false); if (isset($json->access_token)) { $this->accessToken = $json->access_token; @@ -914,21 +1033,16 @@ public function refreshToken($refresh_token) { } /** - * @param array $keys - * @param array $header * @throws OpenIDConnectClientException - * @return object */ - private function getKeyForHeader($keys, $header) { + private function getKeyForHeader(array $keys, stdClass $header) { foreach ($keys as $key) { if ($key->kty === 'RSA') { if (!isset($header->kid) || $key->kid === $header->kid) { return $key; } - } else { - if (isset($key->alg) && $key->alg === $header->alg && $key->kid === $header->kid) { - return $key; - } + } else if (isset($key->alg) && $key->alg === $header->alg && $key->kid === $header->kid) { + return $key; } } if ($this->additionalJwks) { @@ -937,10 +1051,8 @@ private function getKeyForHeader($keys, $header) { if (!isset($header->kid) || $key->kid === $header->kid) { return $key; } - } else { - if (isset($key->alg) && $key->alg === $header->alg && $key->kid === $header->kid) { - return $key; - } + } else if (isset($key->alg) && $key->alg === $header->alg && $key->kid === $header->kid) { + return $key; } } } @@ -951,94 +1063,43 @@ private function getKeyForHeader($keys, $header) { throw new OpenIDConnectClientException('Unable to find a key for RSA'); } - /** - * @param string $hashtype - * @param object $key - * @param $payload - * @param $signature - * @param $signatureType - * @return bool * @throws OpenIDConnectClientException */ - private function verifyRSAJWTsignature($hashtype, $key, $payload, $signature, $signatureType) { - if (!class_exists('\phpseclib3\Crypt\RSA') && !class_exists('\phpseclib\Crypt\RSA') && !class_exists('Crypt_RSA')) { - throw new OpenIDConnectClientException('Crypt_RSA support unavailable.'); - } + private function verifyRSAJWTSignature(string $hashType, stdClass $key, $payload, $signature, $signatureType): bool + { if (!(property_exists($key, 'n') && property_exists($key, 'e'))) { throw new OpenIDConnectClientException('Malformed key object'); } - /* We already have base64url-encoded data, so re-encode it as - regular base64 and use the XML key format for simplicity. - */ - $public_key_xml = "\r\n". - ' ' . b64url2b64($key->n) . "\r\n" . - ' ' . b64url2b64($key->e) . "\r\n" . - ''; - if (class_exists('\phpseclib3\Crypt\RSA', false)) { - $key = \phpseclib3\Crypt\PublicKeyLoader::load($public_key_xml) - ->withHash($hashtype); - if ($signatureType === 'PSS') { - $key = $key->withMGFHash($hashtype) - ->withPadding(\phpseclib3\Crypt\RSA::SIGNATURE_PSS); - } else { - $key = $key->withPadding(\phpseclib3\Crypt\RSA::SIGNATURE_PKCS1); - } - return $key->verify($payload, $signature); - } elseif (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->setSignatureMode($signatureType === 'PSS' ? Crypt_RSA::SIGNATURE_PSS : Crypt_RSA::SIGNATURE_PKCS1); - return $rsa->verify($payload, $signature); + $key = RSA::load([ + 'publicExponent' => new BigInteger(base64_decode(b64url2b64($key->e)), 256), + 'modulus' => new BigInteger(base64_decode(b64url2b64($key->n)), 256), + 'isPublicKey' => true, + ]) + ->withHash($hashType); + if ($signatureType === 'PSS') { + $key = $key->withMGFHash($hashType) + ->withPadding(RSA::SIGNATURE_PSS); } 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->setSignatureMode($signatureType === 'PSS' ? \phpseclib\Crypt\RSA::SIGNATURE_PSS : \phpseclib\Crypt\RSA::SIGNATURE_PKCS1); - return $rsa->verify($payload, $signature); + $key = $key->withPadding(RSA::SIGNATURE_PKCS1); } + return $key->verify($payload, $signature); } - /** - * @param string $hashtype - * @param object $key - * @param $payload - * @param $signature - * @return bool - * @throws OpenIDConnectClientException - */ - private function verifyHMACJWTsignature($hashtype, $key, $payload, $signature) + private function verifyHMACJWTSignature(string $hashType, string $key, string $payload, string $signature): bool { - if (!function_exists('hash_hmac')) { - throw new OpenIDConnectClientException('hash_hmac support unavailable.'); - } - - $expected=hash_hmac($hashtype, $payload, $key, true); - - if (function_exists('hash_equals')) { - return hash_equals($signature, $expected); - } - - return self::hashEquals($signature, $expected); + $expected = hash_hmac($hashType, $payload, $key, true); + return hash_equals($signature, $expected); } /** * @param string $jwt encoded JWT - * @throws OpenIDConnectClientException * @return bool + * @throws OpenIDConnectClientException */ - public function verifyJWTsignature($jwt) { - if (!\is_string($jwt)) { - throw new OpenIDConnectClientException('Error token is not a string'); - } + public function verifyJWTSignature(string $jwt): bool + { $parts = explode('.', $jwt); if (!isset($parts[0])) { throw new OpenIDConnectClientException('Error missing part 0 in token'); @@ -1047,8 +1108,8 @@ public function verifyJWTsignature($jwt) { if (false === $signature || '' === $signature) { throw new OpenIDConnectClientException('Error decoding signature from token'); } - $header = json_decode(base64url_decode($parts[0])); - if (null === $header || !\is_object($header)) { + $header = json_decode(base64url_decode($parts[0]), false); + if (!is_object($header)) { throw new OpenIDConnectClientException('Error decoding JSON from token header'); } if (!isset($header->alg)) { @@ -1059,30 +1120,36 @@ public function verifyJWTsignature($jwt) { switch ($header->alg) { case 'RS256': case 'PS256': + case 'PS512': case 'RS384': case 'RS512': - $hashtype = 'sha' . substr($header->alg, 2); - $signatureType = $header->alg === 'PS256' ? 'PSS' : ''; - + $hashType = 'sha' . substr($header->alg, 2); + $signatureType = $header->alg === 'PS256' || $header->alg === 'PS512' ? 'PSS' : ''; if (isset($header->jwk)) { $jwk = $header->jwk; + $this->verifyJWKHeader($jwk); } else { - $jwks = json_decode($this->fetchURL($this->getProviderConfigValue('jwks_uri'))); + $jwksUri = $this->getProviderConfigValue('jwks_uri'); + if (!$jwksUri) { + throw new OpenIDConnectClientException ('Unable to verify signature due to no jwks_uri being defined'); + } + + $jwks = json_decode($this->fetchURL($jwksUri), false); if ($jwks === NULL) { throw new OpenIDConnectClientException('Error decoding JSON from jwks_uri'); } $jwk = $this->getKeyForHeader($jwks->keys, $header); } - $verified = $this->verifyRSAJWTsignature($hashtype, + $verified = $this->verifyRSAJWTSignature($hashType, $jwk, $payload, $signature, $signatureType); break; case 'HS256': case 'HS512': case 'HS384': - $hashtype = 'SHA' . substr($header->alg, 2); - $verified = $this->verifyHMACJWTsignature($hashtype, $this->getClientSecret(), $payload, $signature); + $hashType = 'SHA' . substr($header->alg, 2); + $verified = $this->verifyHMACJWTSignature($hashType, $this->getClientSecret(), $payload, $signature); break; default: throw new OpenIDConnectClientException('No support for signature type: ' . $header->alg); @@ -1090,12 +1157,25 @@ public function verifyJWTsignature($jwt) { return $verified; } + /** + * @param string $jwt encoded JWT + * @return void + * @throws OpenIDConnectClientException + */ + public function verifySignatures(string $jwt) + { + if (!$this->verifyJWTSignature($jwt)) { + throw new OpenIDConnectClientException ('Unable to verify signature'); + } + } + /** * @param string $iss * @return bool * @throws OpenIDConnectClientException */ - protected function validateIssuer($iss) { + protected function validateIssuer(string $iss): bool + { if ($this->issuerValidator !== null) { return $this->issuerValidator->__invoke($iss); } @@ -1107,9 +1187,11 @@ protected function validateIssuer($iss) { * @param object $claims * @param string|null $accessToken * @return bool + * @throws OpenIDConnectClientException */ - protected function verifyJWTclaims($claims, $accessToken = null) { - if(isset($claims->at_hash) && isset($accessToken)) { + protected function verifyJWTClaims($claims, string $accessToken = null): bool + { + if(isset($claims->at_hash, $accessToken)) { if(isset($this->getIdTokenHeader()->alg) && $this->getIdTokenHeader()->alg !== 'none') { $bit = substr($this->getIdTokenHeader()->alg, 2, 3); } else { @@ -1119,35 +1201,33 @@ protected function verifyJWTclaims($claims, $accessToken = null) { $len = ((int)$bit)/16; $expected_at_hash = $this->urlEncode(substr(hash('sha'.$bit, $accessToken, true), 0, $len)); } + $auds = $claims->aud; + $auds = is_array( $auds ) ? $auds : [ $auds ]; return (($this->validateIssuer($claims->iss)) - && (($claims->aud === $this->clientID) || in_array($this->clientID, $claims->aud, true)) + && (in_array($this->clientID, $auds, true)) + && ($claims->sub === $this->getIdTokenPayload()->sub) && (!isset($claims->nonce) || $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->exp) || ((is_int($claims->exp)) && ($claims->exp >= time() - $this->leeway))) + && ( !isset($claims->nbf) || ((is_int($claims->nbf)) && ($claims->nbf <= time() + $this->leeway))) && ( !isset($claims->at_hash) || !isset($accessToken) || $claims->at_hash === $expected_at_hash ) ); } - /** - * @param string $str - * @return string - */ - protected function urlEncode($str) { + protected function urlEncode(string $str): string + { $enc = base64_encode($str); $enc = rtrim($enc, '='); - $enc = strtr($enc, '+/', '-_'); - return $enc; + return strtr($enc, '+/', '-_'); } /** * @param string $jwt encoded JWT * @param int $section the section we would like to decode - * @return object + * @return object|string|null */ - protected function decodeJWT($jwt, $section = 0) { - + protected function decodeJWT(string $jwt, int $section = 0) { $parts = explode('.', $jwt); - return json_decode(base64url_decode($parts[$section])); + return json_decode(base64url_decode($parts[$section] ?? ''), false); } /** @@ -1172,13 +1252,13 @@ protected function decodeJWT($jwt, $section = 0) { * 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. + * updated_time string Time the End-User's information was last updated, represented as an RFC 3339 [RFC3339] datetime. For example, 2011-01-03T23:58:42+0000. * * @return mixed * * @throws OpenIDConnectClientException */ - public function requestUserInfo($attribute = null) { + public function requestUserInfo(string $attribute = null) { $user_info_endpoint = $this->getProviderConfigValue('userinfo_endpoint'); $schema = 'openid'; @@ -1187,21 +1267,50 @@ public function requestUserInfo($attribute = null) { //The accessToken has to be sent in the Authorization header. // Accept json to indicate response type - $headers = ["Authorization: Bearer {$this->accessToken}", + $headers = ["Authorization: Bearer $this->accessToken", 'Accept: application/json']; - $user_json = json_decode($this->fetchURL($user_info_endpoint,null,$headers)); - if ($this->getResponseCode() <> 200) { + $response = $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; + + // When we receive application/jwt, the UserInfo Response is signed and/or encrypted. + if ($this->getResponseContentType() === 'application/jwt' ) { + // Check if the response is encrypted + $jwtHeaders = $this->decodeJWT($response); + if (isset($jwtHeaders->enc)) { + // Handle JWE + $jwt = $this->handleJweResponse($response); + } else { + // If the response is not encrypted then it must be signed + $jwt = $response; + } + + // Verify the signature + $this->verifySignatures($jwt); + + // Get claims from JWT + $claims = $this->decodeJWT($jwt, 1); + + // Verify the JWT claims + if (!$this->verifyJWTClaims($claims)) { + throw new OpenIDConnectClientException('Invalid JWT signature'); + } + + $user_json = $claims; + } else { + $user_json = json_decode($response, false); + } + + $userInfo = $user_json; if($attribute === null) { - return $this->userInfo; + return $userInfo; } - if (property_exists($this->userInfo, $attribute)) { - return $this->userInfo->$attribute; + if (property_exists($userInfo, $attribute)) { + return $userInfo->$attribute; } return null; @@ -1220,13 +1329,13 @@ public function requestUserInfo($attribute = null) { * aud string Audience * nonce string nonce * iat int Issued At - * auth_time int Authenatication time + * auth_time int Authentication time * oid string Object id * * @return mixed * */ - public function getVerifiedClaims($attribute = null) { + public function getVerifiedClaims(string $attribute = null) { if($attribute === null) { return $this->verifiedClaims; @@ -1242,11 +1351,11 @@ public function getVerifiedClaims($attribute = null) { /** * @param string $url * @param string | null $post_body string If this is set the post type will be POST - * @param array $headers Extra headers to be send with the request. Format as 'NameHeader: ValueHeader' + * @param array $headers Extra headers to be sent with the request. Format as 'NameHeader: ValueHeader' + * @return bool|string * @throws OpenIDConnectClientException - * @return mixed */ - protected function fetchURL($url, $post_body = null, $headers = []) { + protected function fetchURL(string $url, string $post_body = null, array $headers = []) { // OK cool - then let's create a new cURL resource handle $ch = curl_init(); @@ -1254,7 +1363,7 @@ protected function fetchURL($url, $post_body = null, $headers = []) { // Determine whether this is a GET or POST if ($post_body !== null) { // curl_setopt($ch, CURLOPT_POST, 1); - // Alows to keep the POST method even after redirect + // Allows to keep the POST method even after redirect curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); curl_setopt($ch, CURLOPT_POSTFIELDS, $post_body); @@ -1262,15 +1371,17 @@ protected function fetchURL($url, $post_body = null, $headers = []) { $content_type = 'application/x-www-form-urlencoded'; // Determine if this is a JSON payload and add the appropriate content type - if (is_object(json_decode($post_body))) { + if (is_object(json_decode($post_body, false))) { $content_type = 'application/json'; } // Add POST-specific headers - $headers[] = "Content-Type: {$content_type}"; - + $headers[] = "Content-Type: $content_type"; } + // Set the User-Agent + curl_setopt($ch, CURLOPT_USERAGENT, $this->getUserAgent()); + // If we set some headers include them if(count($headers) > 0) { curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); @@ -1321,6 +1432,7 @@ protected function fetchURL($url, $post_body = null, $headers = []) { // HTTP Response code from server may be required from subclass $info = curl_getinfo($ch); $this->responseCode = $info['http_code']; + $this->responseContentType = $info['content_type']; if ($output === false) { throw new OpenIDConnectClientException('Curl error: (' . curl_errno($ch) . ') ' . curl_error($ch)); @@ -1333,20 +1445,18 @@ protected function fetchURL($url, $post_body = null, $headers = []) { } /** - * @param bool $appendSlash - * @return string * @throws OpenIDConnectClientException */ - public function getWellKnownIssuer($appendSlash = false) { - + public function getWellKnownIssuer(bool $appendSlash = false): string + { return $this->getWellKnownConfigValue('issuer') . ($appendSlash ? '/' : ''); } /** - * @return string * @throws OpenIDConnectClientException */ - public function getIssuer() { + public function getIssuer(): string + { if (!isset($this->providerConfig['issuer'])) { throw new OpenIDConnectClientException('The issuer has not been set'); @@ -1367,25 +1477,16 @@ public function getProviderURL() { return $this->providerConfig['providerUrl']; } - /** - * @param string $url - */ - public function redirect($url) { + public function redirect(string $url) { header('Location: ' . $url); exit; } - /** - * @param string $httpProxy - */ - public function setHttpProxy($httpProxy) { + public function setHttpProxy(string $httpProxy) { $this->httpProxy = $httpProxy; } - /** - * @param string $certPath - */ - public function setCertPath($certPath) { + public function setCertPath(string $certPath) { $this->certPath = $certPath; } @@ -1396,48 +1497,33 @@ public function getCertPath() { return $this->certPath; } - /** - * @param bool $verifyPeer - */ - public function setVerifyPeer($verifyPeer) { + public function setVerifyPeer(bool $verifyPeer) { $this->verifyPeer = $verifyPeer; } - /** - * @param bool $verifyHost - */ - public function setVerifyHost($verifyHost) { + public function setVerifyHost(bool $verifyHost) { $this->verifyHost = $verifyHost; } - /** * Controls whether http header HTTP_UPGRADE_INSECURE_REQUESTS should be considered * defaults to true - * @param bool $httpUpgradeInsecureRequests */ - public function setHttpUpgradeInsecureRequests($httpUpgradeInsecureRequests) { + public function setHttpUpgradeInsecureRequests(bool $httpUpgradeInsecureRequests) { $this->httpUpgradeInsecureRequests = $httpUpgradeInsecureRequests; } - /** - * @return bool - */ - public function getVerifyHost() { + public function getVerifyHost(): bool + { return $this->verifyHost; } - /** - * @return bool - */ - public function getVerifyPeer() { + public function getVerifyPeer(): bool + { return $this->verifyPeer; } - /** - * @return bool - */ - public function getHttpUpgradeInsecureRequests() + public function getHttpUpgradeInsecureRequests(): bool { return $this->httpUpgradeInsecureRequests; } @@ -1446,24 +1532,30 @@ public function getHttpUpgradeInsecureRequests() * Use this for custom issuer validation * The given function should accept the issuer string from the JWT claim as the only argument * and return true if the issuer is valid, otherwise return false - * - * @param callable $issuerValidator */ - public function setIssuerValidator($issuerValidator) { + public function setIssuerValidator(callable $issuerValidator) { $this->issuerValidator = $issuerValidator; } /** - * @param bool $allowImplicitFlow + * Use this for private_key_jwt client authentication + * The given function should accept the token_endpoint string as the only argument + * and return a jwt signed with your private key according to: + * https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication */ - public function setAllowImplicitFlow($allowImplicitFlow) { + public function setPrivateKeyJwtGenerator(callable $privateKeyJwtGenerator) { + $this->privateKeyJwtGenerator = $privateKeyJwtGenerator; + } + + public function setAllowImplicitFlow(bool $allowImplicitFlow) { $this->allowImplicitFlow = $allowImplicitFlow; } /** * @return bool */ - public function getAllowImplicitFlow() { + public function getAllowImplicitFlow(): bool + { return $this->allowImplicitFlow; } @@ -1474,21 +1566,15 @@ public function getAllowImplicitFlow() { * @param array $array * simple key => value */ - public function providerConfigParam($array) { + public function providerConfigParam(array $array) { $this->providerConfig = array_merge($this->providerConfig, $array); } - /** - * @param string $clientSecret - */ - public function setClientSecret($clientSecret) { + public function setClientSecret(string $clientSecret) { $this->clientSecret = $clientSecret; } - /** - * @param string $clientID - */ - public function setClientID($clientID) { + public function setClientID(string $clientID) { $this->clientID = $clientID; } @@ -1509,7 +1595,7 @@ public function register() { $response = $this->fetchURL($registration_endpoint, json_encode($send_object)); - $json_response = json_decode($response); + $json_response = json_decode($response, false); // Throw some errors if we encounter them if ($json_response === false) { @@ -1527,10 +1613,8 @@ public function register() { if (isset($json_response->{'client_secret'})) { $this->setClientSecret($json_response->{'client_secret'}); } else { - throw new OpenIDConnectClientException('Error registering: - Please contact the OpenID Connect provider and obtain a Client ID and Secret directly from them'); + throw new OpenIDConnectClientException('Error registering: Please contact the OpenID Connect provider and obtain a Client ID and Secret directly from them'); } - } /** @@ -1543,24 +1627,36 @@ public function register() { * @param string|null $clientSecret * @return mixed * @throws OpenIDConnectClientException + * @throws Exception */ - public function introspectToken($token, $token_type_hint = '', $clientId = null, $clientSecret = null) { + public function introspectToken(string $token, string $token_type_hint = '', string $clientId = null, string $clientSecret = null) { $introspection_endpoint = $this->getProviderConfigValue('introspection_endpoint'); + $token_endpoint_auth_methods_supported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); $post_data = ['token' => $token]; if ($token_type_hint) { $post_data['token_type_hint'] = $token_type_hint; } - $clientId = $clientId !== null ? $clientId : $this->clientID; - $clientSecret = $clientSecret !== null ? $clientSecret : $this->clientSecret; + $clientId = $clientId ?? $this->clientID; + $clientSecret = $clientSecret ?? $this->clientSecret; // Convert token params to string format - $post_params = http_build_query($post_data, '', '&'); $headers = ['Authorization: Basic ' . base64_encode(urlencode($clientId) . ':' . urlencode($clientSecret)), 'Accept: application/json']; - return json_decode($this->fetchURL($introspection_endpoint, $post_params, $headers)); + if ($this->supportsAuthMethod('client_secret_jwt', $token_endpoint_auth_methods_supported)) { + $client_assertion_type = $this->getProviderConfigValue('client_assertion_type'); + $client_assertion = $this->getJWTClientAssertion($this->getProviderConfigValue('introspection_endpoint')); + + $post_data['client_assertion_type']=$client_assertion_type; + $post_data['client_assertion'] = $client_assertion; + $headers = ['Accept: application/json']; + } + + $post_params = http_build_query($post_data, '', '&'); + + return json_decode($this->fetchURL($introspection_endpoint, $post_params, $headers), false); } /** @@ -1574,7 +1670,7 @@ public function introspectToken($token, $token_type_hint = '', $clientId = null, * @return mixed * @throws OpenIDConnectClientException */ - public function revokeToken($token, $token_type_hint = '', $clientId = null, $clientSecret = null) { + public function revokeToken(string $token, string $token_type_hint = '', string $clientId = null, string $clientSecret = null) { $revocation_endpoint = $this->getProviderConfigValue('revocation_endpoint'); $post_data = ['token' => $token]; @@ -1582,82 +1678,77 @@ public function revokeToken($token, $token_type_hint = '', $clientId = null, $cl if ($token_type_hint) { $post_data['token_type_hint'] = $token_type_hint; } - $clientId = $clientId !== null ? $clientId : $this->clientID; - $clientSecret = $clientSecret !== null ? $clientSecret : $this->clientSecret; + $clientId = $clientId ?? $this->clientID; + $clientSecret = $clientSecret ?? $this->clientSecret; // Convert token params to string format $post_params = http_build_query($post_data, '', '&'); $headers = ['Authorization: Basic ' . base64_encode(urlencode($clientId) . ':' . urlencode($clientSecret)), 'Accept: application/json']; - return json_decode($this->fetchURL($revocation_endpoint, $post_params, $headers)); + return json_decode($this->fetchURL($revocation_endpoint, $post_params, $headers), false); } /** - * @return string + * @return string|null */ - public function getClientName() { + public function getClientName() + { return $this->clientName; } - /** - * @param string $clientName - */ - public function setClientName($clientName) { + public function setClientName(string $clientName) { $this->clientName = $clientName; } /** - * @return string + * @return string|null */ public function getClientID() { return $this->clientID; } /** - * @return string + * @return string|null */ public function getClientSecret() { return $this->clientSecret; } - /** - * @return bool - */ - public function canVerifySignatures() { - return class_exists('\phpseclib3\Crypt\RSA') || class_exists('\phpseclib\Crypt\RSA') || class_exists('Crypt_RSA'); - } - /** * Set the access token. * * May be required for subclasses of this Client. - * - * @param string $accessToken - * @return void */ - public function setAccessToken($accessToken) { + public function setAccessToken(string $accessToken) { $this->accessToken = $accessToken; } /** - * @return string + * @return string|null */ - public function getAccessToken() { + public function getAccessToken() + { return $this->accessToken; } /** - * @return string + * @return string|null */ - public function getRefreshToken() { + public function getRefreshToken() + { return $this->refreshToken; } + public function setIdToken(string $idToken) { + $this->idToken = $idToken; + } + /** - * @return string + * @return string|null */ - public function getIdToken() { + public function getIdToken() + { return $this->idToken; } @@ -1669,28 +1760,28 @@ public function getAccessTokenHeader() { } /** - * @return object + * @return object|string|null */ public function getAccessTokenPayload() { return $this->decodeJWT($this->accessToken, 1); } /** - * @return object + * @return object|string|null */ public function getIdTokenHeader() { return $this->decodeJWT($this->idToken); } /** - * @return object + * @return object|string|null */ public function getIdTokenPayload() { return $this->decodeJWT($this->idToken, 1); } /** - * @return string + * @return object */ public function getTokenResponse() { return $this->tokenResponse; @@ -1698,11 +1789,9 @@ public function getTokenResponse() { /** * Stores nonce - * - * @param string $nonce - * @return string */ - protected function setNonce($nonce) { + protected function setNonce(string $nonce): string + { $this->setSessionKey('openid_connect_nonce', $nonce); return $nonce; } @@ -1727,11 +1816,9 @@ protected function unsetNonce() { /** * Stores $state - * - * @param string $state - * @return string */ - protected function setState($state) { + protected function setState(string $state): string + { $this->setSessionKey('openid_connect_state', $state); return $state; } @@ -1756,11 +1843,9 @@ protected function unsetState() { /** * Stores $codeVerifier - * - * @param string $codeVerifier - * @return string */ - protected function setCodeVerifier($codeVerifier) { + protected function setCodeVerifier(string $codeVerifier): string + { $this->setSessionKey('openid_connect_code_verifier', $codeVerifier); return $codeVerifier; } @@ -1788,57 +1873,33 @@ protected function unsetCodeVerifier() { * * @return int */ - public function getResponseCode() { + public function getResponseCode(): int + { return $this->responseCode; } /** - * Set timeout (seconds) + * Get the content type from last action/curl request. * - * @param int $timeout - */ - public function setTimeout($timeout) { - $this->timeOut = $timeout; - } - - /** - * @return int + * @return string|null */ - public function getTimeout() { - return $this->timeOut; + public function getResponseContentType() + { + return $this->responseContentType; } /** - * Safely calculate length of binary string - * @param string $str - * @return int + * Set timeout (seconds) + * + * @param int $timeout */ - private static function safeLength($str) { - if (function_exists('mb_strlen')) { - return mb_strlen($str, '8bit'); - } - return strlen($str); + public function setTimeout(int $timeout) { + $this->timeOut = $timeout; } - /** - * Where hash_equals is not available, this provides a timing-attack safe string comparison - * @param string $str1 - * @param string $str2 - * @return bool - */ - private static function hashEquals($str1, $str2) { - $len1=static::safeLength($str1); - $len2=static::safeLength($str2); - - //compare strings without any early abort... - $len = min($len1, $len2); - $status = 0; - for ($i = 0; $i < $len; $i++) { - $status |= (ord($str1[$i]) ^ ord($str2[$i])); - } - //if strings were different lengths, we fail - $status |= ($len1 ^ $len2); - return ($status === 0); + public function getTimeout(): int + { + return $this->timeOut; } /** @@ -1856,7 +1917,7 @@ protected function commitSession() { session_write_close(); } - protected function getSessionKey($key) { + protected function getSessionKey(string $key) { $this->startSession(); if (array_key_exists($key, $_SESSION)) { @@ -1865,18 +1926,57 @@ protected function getSessionKey($key) { return false; } - protected function setSessionKey($key, $value) { + protected function setSessionKey(string $key, $value) { $this->startSession(); $_SESSION[$key] = $value; } - protected function unsetSessionKey($key) { + protected function unsetSessionKey(string $key) { $this->startSession(); unset($_SESSION[$key]); } + /** + * @throws Exception + */ + protected function getJWTClientAssertion($aud): string + { + $jti = hash('sha256',bin2hex(random_bytes(64))); + + $now = time(); + + $header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']); + $payload = json_encode([ + 'sub' => $this->getClientID(), + 'iss' => $this->getClientID(), + 'aud' => $aud, + 'jti' => $jti, + 'exp' => $now + 3600, + 'iat' => $now, + ]); + // Encode Header to Base64Url String + $base64UrlHeader = $this->urlEncode($header); + + + // Encode Payload to Base64Url String + $base64UrlPayload = $this->urlEncode($payload); + + // Create Signature Hash + $signature = hash_hmac( + 'sha256', + $base64UrlHeader . "." . $base64UrlPayload, + $this->getClientSecret(), + true + ); + + // Encode Signature to Base64Url String + $base64UrlSignature = $this->urlEncode($signature); + + return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature; + } + public function setUrlEncoding($curEncoding) { switch ($curEncoding) { @@ -1891,27 +1991,20 @@ public function setUrlEncoding($curEncoding) { default: break; } - } - /** - * @return array - */ - public function getScopes() { + public function getScopes(): array + { return $this->scopes; } - /** - * @return array - */ - public function getResponseTypes() { + public function getResponseTypes(): array + { return $this->responseTypes; } - /** - * @return array - */ - public function getAuthParams() { + public function getAuthParams(): array + { return $this->authParams; } @@ -1922,10 +2015,16 @@ public function getIssuerValidator() { return $this->issuerValidator; } + /** - * @return int + * @return callable */ - public function getLeeway() { + public function getPrivateKeyJwtGenerator() { + return $this->privateKeyJwtGenerator; + } + + public function getLeeway(): int + { return $this->leeway; } @@ -1936,10 +2035,50 @@ public function getCodeChallengeMethod() { return $this->codeChallengeMethod; } + public function setCodeChallengeMethod(string $codeChallengeMethod) { + $this->codeChallengeMethod = $codeChallengeMethod; + } + /** - * @param string $codeChallengeMethod + * @throws OpenIDConnectClientException */ - public function setCodeChallengeMethod($codeChallengeMethod) { - $this->codeChallengeMethod = $codeChallengeMethod; + protected function verifyJWKHeader($jwk) + { + throw new OpenIDConnectClientException('Self signed JWK header is not valid'); + } + + /** + * @param string $jwe The JWE to decrypt + * @return string the JWT payload + * @throws OpenIDConnectClientException + */ + protected function handleJweResponse(string $jwe): string + { + throw new OpenIDConnectClientException('JWE response is not supported, please extend the class and implement this method'); + } + + public function getSidFromBackChannel(): string + { + return $this->backChannelSid; + } + + public function getSubjectFromBackChannel(): string + { + return $this->backChannelSubject; + } + + public function supportsAuthMethod(string $auth_method, array $token_endpoint_auth_methods_supported): bool + { + # client_secret_jwt has to explicitly be enabled + if (!in_array($auth_method, $this->token_endpoint_auth_methods_supported, true)) { + return false; + } + + return in_array($auth_method, $token_endpoint_auth_methods_supported, true); + } + + protected function getUserAgent(): string + { + return "jumbojett/OpenID-Connect-PHP"; } } diff --git a/tests/OpenIDConnectClientTest.php b/tests/OpenIDConnectClientTest.php index 7abf3116..4b46923d 100644 --- a/tests/OpenIDConnectClientTest.php +++ b/tests/OpenIDConnectClientTest.php @@ -7,9 +7,90 @@ class OpenIDConnectClientTest extends TestCase { - /** - * @return void - */ + public function testValidateClaims() + { + $client = new class extends OpenIDConnectClient { + public function testVerifyJWTClaims($claims): bool + { + return $this->verifyJWTClaims($claims); + } + public function getIdTokenPayload() + { + return (object)[ + 'sub' => 'sub' + ]; + } + }; + $client->setClientID('client-id'); + $client->setIssuer('issuer'); + $client->setIdToken(''); + + # simple aud + $valid = $client->testVerifyJWTClaims((object)[ + 'aud' => 'client-id', + 'iss' => 'issuer', + 'sub' => 'sub', + ]); + self::assertTrue($valid); + + # array aud + $valid = $client->testVerifyJWTClaims((object)[ + 'aud' => ['client-id'], + 'iss' => 'issuer', + 'sub' => 'sub', + ]); + self::assertTrue($valid); + + # aud not matching + $valid = $client->testVerifyJWTClaims((object)[ + 'aud' => ['ipsum'], + 'iss' => 'issuer', + 'sub' => 'sub', + ]); + self::assertFalse($valid); + } + public function testJWTDecode() + { + $client = new OpenIDConnectClient(); + # access token + $client->setAccessToken(''); + $header = $client->getAccessTokenHeader(); + self::assertEquals('', $header); + $payload = $client->getAccessTokenPayload(); + self::assertEquals('', $payload); + + # id token + $client->setIdToken(''); + $header = $client->getIdTokenHeader(); + self::assertEquals('', $header); + $payload = $client->getIdTokenPayload(); + self::assertEquals('', $payload); + } + + public function testGetNull() + { + $client = new OpenIDConnectClient(); + self::assertNull($client->getAccessToken()); + self::assertNull($client->getRefreshToken()); + self::assertNull($client->getIdToken()); + self::assertNull($client->getClientName()); + self::assertNull($client->getClientID()); + self::assertNull($client->getClientSecret()); + self::assertNull($client->getCertPath()); + } + + public function testResponseTypes() + { + $client = new OpenIDConnectClient(); + self::assertEquals([], $client->getResponseTypes()); + + $client->setResponseTypes('foo'); + self::assertEquals(['foo'], $client->getResponseTypes()); + + $client->setResponseTypes(['bar', 'ipsum']); + self::assertEquals(['foo', 'bar', 'ipsum'], $client->getResponseTypes()); + } + public function testGetRedirectURL() { $client = new OpenIDConnectClient(); @@ -18,14 +99,19 @@ public function testGetRedirectURL() $_SERVER['SERVER_NAME'] = 'domain.test'; $_SERVER['REQUEST_URI'] = '/path/index.php?foo=bar&baz#fragment'; + $_SERVER['SERVER_PORT'] = '443'; self::assertSame('http://domain.test/path/index.php', $client->getRedirectURL()); + + $_SERVER['SERVER_PORT'] = '8888'; + self::assertSame('http://domain.test:8888/path/index.php', $client->getRedirectURL()); } public function testAuthenticateDoesNotThrowExceptionIfClaimsIsMissingNonce() { - $fakeClaims = new \StdClass(); + $fakeClaims = new StdClass(); $fakeClaims->iss = 'fake-issuer'; $fakeClaims->aud = 'fake-client-id'; + $fakeClaims->sub = 'fake-sub'; $fakeClaims->nonce = null; $_REQUEST['id_token'] = 'abc.123.xyz'; @@ -33,10 +119,10 @@ public function testAuthenticateDoesNotThrowExceptionIfClaimsIsMissingNonce() $_SESSION['openid_connect_state'] = false; /** @var OpenIDConnectClient | MockObject $client */ - $client = $this->getMockBuilder(OpenIDConnectClient::class)->setMethods(['decodeJWT', 'getProviderConfigValue', 'verifyJWTsignature'])->getMock(); + $client = $this->getMockBuilder(OpenIDConnectClient::class)->setMethods(['decodeJWT', 'getProviderConfigValue', 'verifyJWTSignature'])->getMock(); $client->method('decodeJWT')->willReturn($fakeClaims); $client->method('getProviderConfigValue')->with('jwks_uri')->willReturn(true); - $client->method('verifyJWTsignature')->willReturn(true); + $client->method('verifyJWTSignature')->willReturn(true); $client->setClientID('fake-client-id'); $client->setIssuer('fake-issuer'); @@ -60,6 +146,181 @@ public function testSerialize() { $client = new OpenIDConnectClient('https://example.com', 'foo', 'bar', 'baz'); $serialized = serialize($client); - $this->assertInstanceOf('Jumbojett\OpenIDConnectClient', unserialize($serialized)); + $this->assertInstanceOf(OpenIDConnectClient::class, unserialize($serialized)); + } + + /** + * @dataProvider provider + */ + public function testAuthMethodSupport($expected, $authMethod, $clientAuthMethods, $idpAuthMethods) + { + $client = new OpenIDConnectClient(); + if ($clientAuthMethods !== null) { + $client->setTokenEndpointAuthMethodsSupported($clientAuthMethods); + } + $this->assertEquals($expected, $client->supportsAuthMethod($authMethod, $idpAuthMethods)); + } + + public function provider(): array + { + return [ + 'client_secret_basic - default config' => [true, 'client_secret_basic', null, ['client_secret_basic']], + + 'client_secret_jwt - default config' => [false, 'client_secret_jwt', null, ['client_secret_basic', 'client_secret_jwt']], + 'client_secret_jwt - explicitly enabled' => [true, 'client_secret_jwt', ['client_secret_jwt'], ['client_secret_basic', 'client_secret_jwt']], + + 'private_key_jwt - default config' => [false, 'private_key_jwt', null, ['client_secret_basic', 'client_secret_jwt', 'private_key_jwt']], + 'private_key_jwt - explicitly enabled' => [true, 'private_key_jwt', ['private_key_jwt'], ['client_secret_basic', 'client_secret_jwt', 'private_key_jwt']], + + ]; + } + + /** + * @covers Jumbojett\\OpenIDConnectClient::verifyLogoutTokenClaims + * @dataProvider provideTestVerifyLogoutTokenClaimsData + * @throws OpenIDConnectClientException + */ + public function testVerifyLogoutTokenClaims( $claims, $expectedResult ) + { + /** @var OpenIDConnectClient | MockObject $client */ + $client = $this->getMockBuilder(OpenIDConnectClient::class)->setMethods(['decodeJWT'])->getMock(); + + $client->setClientID('fake-client-id'); + $client->setIssuer('fake-issuer'); + $client->setIssuerValidator(function() { + return true; + }); + $client->setProviderURL('https://jwt.io/'); + + $actualResult = $client->verifyLogoutTokenClaims( $claims ); + + $this->assertEquals( $expectedResult, $actualResult ); + } + + /** + * @return array + */ + public function provideTestVerifyLogoutTokenClaimsData(): array + { + return [ + 'valid-single-aud' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => 'fake-client-id', + 'sid' => 'fake-client-sid', + 'sub' => 'fake-client-sub', + 'iat' => time(), + 'events' => (object) [ + 'http://schemas.openid.net/event/backchannel-logout' => (object)[] + ], + ], + true + ], + 'valid-multiple-auds' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'sid' => 'fake-client-sid', + 'sub' => 'fake-client-sub', + 'iat' => time(), + 'events' => (object) [ + 'http://schemas.openid.net/event/backchannel-logout' => (object)[] + ], + ], + true + ], + 'invalid-no-sid-and-no-sub' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'iat' => time(), + 'events' => (object) [ + 'http://schemas.openid.net/event/backchannel-logout' => (object)[] + ], + ], + false + ], + 'valid-no-sid' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'sub' => 'fake-client-sub', + 'iat' => time(), + 'events' => (object) [ + 'http://schemas.openid.net/event/backchannel-logout' => (object)[] + ], + ], + true + ], + 'valid-no-sub' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'sid' => 'fake-client-sid', + 'iat' => time(), + 'events' => (object) [ + 'http://schemas.openid.net/event/backchannel-logout' => (object)[] + ], + ], + true + ], + 'invalid-with-nonce' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'sid' => 'fake-client-sid', + 'iat' => time(), + 'events' => (object) [ + 'http://schemas.openid.net/event/backchannel-logout' => (object)[] + ], + 'nonce' => 'must-not-be-set' + ], + false + ], + 'invalid-no-events' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'sid' => 'fake-client-sid', + 'iat' => time(), + 'nonce' => 'must-not-be-set' + ], + false + ], + 'invalid-no-backchannel-event' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'sid' => 'fake-client-sid', + 'iat' => time(), + 'events' => (object) [], + 'nonce' => 'must-not-be-set' + ], + false + ], + 'invalid-no-iat' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'sid' => 'fake-client-sid', + 'events' => (object) [ + 'http://schemas.openid.net/event/backchannel-logout' => (object)[] + ] + ], + false + ], + 'invalid-bad-iat' => [ + (object)[ + 'iss' => 'fake-issuer', + 'aud' => [ 'fake-client-id', 'some-other-aud' ], + 'sid' => 'fake-client-sid', + 'iat' => time() + 301, + 'events' => (object) [ + 'http://schemas.openid.net/event/backchannel-logout' => (object)[] + ] + ], + false + ], + ]; } } diff --git a/tests/TokenVerificationTest.php b/tests/TokenVerificationTest.php index 58449924..0715911e 100644 --- a/tests/TokenVerificationTest.php +++ b/tests/TokenVerificationTest.php @@ -2,6 +2,7 @@ use Jumbojett\OpenIDConnectClient; +use Jumbojett\OpenIDConnectClientException; use PHPUnit\Framework\MockObject\MockObject; use Yoast\PHPUnitPolyfills\TestCases\TestCase; @@ -10,7 +11,7 @@ class TokenVerificationTest extends TestCase /** * @param $alg * @param $jwt - * @throws \Jumbojett\OpenIDConnectClientException + * @throws OpenIDConnectClientException * @dataProvider providesTokens */ public function testTokenVerification($alg, $jwt) @@ -20,12 +21,12 @@ public function testTokenVerification($alg, $jwt) $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); + $verified = $client->verifyJWTSignature($jwt); self::assertTrue($verified); $client->setAccessToken($jwt); } - public function providesTokens() + public function providesTokens(): array { return [ 'PS256' => ['ps256', 'eyJhbGciOiJQUzI1NiIsImtpZCI6Imtvbm5lY3RkLXRva2Vucy1zaWduaW5nLWtleSIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJrcG9wLWh0dHBzOi8va29wYW5vLmRlbW8vbWVldC8iLCJleHAiOjE1NjgzNzE0NjEsImp0aSI6IkpkR0tDbEdOTXl2VXJpcmlRRUlWUXZCVmttT2FfQkRjIiwiaWF0IjoxNTY4MzcxMjIxLCJpc3MiOiJodHRwczovL2tvcGFuby5kZW1vIiwic3ViIjoiUHpUVWp3NHBlXzctWE5rWlBILXJxVHE0MTQ1Z3lDdlRvQmk4V1E5bFBrcW5rbEc1aktvRU5LM21Qb0I1WGY1ZTM5dFRMR2RKWXBMNEJubXFnelpaX0FAa29ubmVjdCIsImtjLmlzQWNjZXNzVG9rZW4iOnRydWUsImtjLmF1dGhvcml6ZWRTY29wZXMiOlsicHJvZmlsZSIsImVtYWlsIiwia29wYW5vL2t3bSIsImtvcGFuby9nYyIsImtvcGFuby9rdnMiLCJvcGVuaWQiXSwia2MuYXV0aG9yaXplZENsYWltcyI6eyJpZF90b2tlbiI6eyJuYW1lIjpudWxsfX0sImtjLmlkZW50aXR5Ijp7ImtjLmkuZG4iOiJKb25hcyBCcmVra2UiLCJrYy5pLmlkIjoiQUFBQUFLd2hxVkJBMCs1SXN4bjdwMU13UkNVQkFBQUFCZ0FBQUJzQUFBQk5VVDA5QUFBQUFBPT0iLCJrYy5pLnVuIjoidXNlcjEiLCJrYy5pLnVzIjoiTVEifSwia2MucHJvdmlkZXIiOiJpZGVudGlmaWVyLWtjIn0.hGRuXvul2kOiALHexwYp5MBEJVwz1YV3ehyM3AOuwCoK2w5sJxdciqqY_TfXCKyO6nAEbYLK3J0CBOjfup_IG0aCZcwzjto8khYlc4ezXkGnFsbJBNQdDGkpHtWnioWx-OJ3cXvY9F8aOvjaq0gw11ZDAcqQl0g7LTbJ9-J_yx0pmy3NGai2JB30Fh1OgSDzYfxWnE0RRgZG-x68e65RXfSBaEGW85OUh4wihxO2zdTGAHJ3Iq_-QAG4yRbXZtLx3ZspG7LNmqG-YE3huy3Rd8u3xrJNhmUOfEnz3x07q7VW0cj9NedX98BAbj3iNvksQsE0oG0J_f_Tu8Ai8VbWB72sJuXZWxANDKdz0BBYLzXhsjXkNByRq9x3zqDVsX-cVHei_XudxEOVRBjhkvW2MmIjcAHNKCKsdar865-gFG9McP4PCcBlY28tC0Cvnzyi83LBfpGRXdl6MJunnUsKQ1C79iCoVI1doK1erFN959Q-TGJfJA3Tr5LNpuGawB5rpe1nDGWvmYhg3uYfNl8uTTyvNgvvejcflEb2DURuXdqABuSiP7RkDWYtzx6mq49G0tRxelBbvyjQ2id2QjmRRdQ6dHEZ2NCJ51b8OFoDJBtxN1CD62TTxa3FUqCdZAPAUR3hHn_69vYq82MR514s-Gb67A6j2PbMPFATQP2UdK8']