From 5b1e46a762ca77b9700c993737f894883bb5fd1e Mon Sep 17 00:00:00 2001 From: Lex Lim Date: Fri, 6 Dec 2024 10:58:57 +0000 Subject: [PATCH] Update for MW 1.42.0 --- extension.json | 2 +- includes/ApiOIDCAvatar.php | 4 - includes/IsekaiOIDCAuth.php | 15 +- includes/IsekaiOIDCAuthHooks.php | 11 +- includes/SpecialIsekaiOIDCCallback.php | 2 +- .../OpenIDConnectClient.php | 1807 +++++++++++------ 6 files changed, 1204 insertions(+), 637 deletions(-) diff --git a/extension.json b/extension.json index f25cfb9..83e3cbe 100644 --- a/extension.json +++ b/extension.json @@ -8,7 +8,7 @@ "license-name": "MIT", "type": "other", "requires": { - "MediaWiki": ">= 1.35.0" + "MediaWiki": ">= 1.40.0" }, "MessagesDirs": { "IsekaiOIDC": [ diff --git a/includes/ApiOIDCAvatar.php b/includes/ApiOIDCAvatar.php index 7d84ea4..1738a24 100644 --- a/includes/ApiOIDCAvatar.php +++ b/includes/ApiOIDCAvatar.php @@ -2,13 +2,9 @@ namespace Isekai\OIDC; -use ApiBase; use ApiQueryBase; -use Exception; -use Exif; use MediaWiki\MediaWikiServices; use Wikimedia\ParamValidator\ParamValidator; -use User; class ApiOIDCAvatar extends ApiQueryBase { public function __construct( $main, $method ) { diff --git a/includes/IsekaiOIDCAuth.php b/includes/IsekaiOIDCAuth.php index 4f5ae47..130ad24 100644 --- a/includes/IsekaiOIDCAuth.php +++ b/includes/IsekaiOIDCAuth.php @@ -9,12 +9,13 @@ use MediaWiki\Auth\AuthenticationResponse; use MediaWiki\Auth\AuthManager; use MediaWiki\MediaWikiServices; use MediaWiki\Session\SessionManager; +use MediaWiki\User\User; +use MediaWiki\Title\Title; +use MediaWiki\SpecialPage\SpecialPage; +use MediaWiki\Parser\Sanitizer; +use MediaWiki\Context\RequestContext; +use MediaWiki\Message\Message; use StatusValue; -use User; -use Title; -use SpecialPage; -use Sanitizer; -use RequestContext; class IsekaiOIDCAuth extends AbstractPrimaryAuthenticationProvider { @@ -164,7 +165,8 @@ class IsekaiOIDCAuth extends AbstractPrimaryAuthenticationProvider { $authManager->setAuthenticationSessionData(self::USERNAME_SESSION_KEY, $username); return AuthenticationResponse::newPass($username); } else { - return AuthenticationResponse::newFail(wfMessage('isekaioidc-login-failed')); + $message = Message::newFromSpecifier('isekaioidc-login-failed'); + return AuthenticationResponse::newFail($message); } } @@ -580,7 +582,6 @@ class IsekaiOIDCAuth extends AbstractPrimaryAuthenticationProvider { $userIdentityLookup = MediaWikiServices::getInstance()->getUserIdentityLookup(); $userIdentity = $userIdentityLookup->getUserIdentityByName( $preferred_username ); if ( !$userIdentity || !$userIdentity->isRegistered() ) { - return $preferred_username; } diff --git a/includes/IsekaiOIDCAuthHooks.php b/includes/IsekaiOIDCAuthHooks.php index 3871c8c..8b85088 100644 --- a/includes/IsekaiOIDCAuthHooks.php +++ b/includes/IsekaiOIDCAuthHooks.php @@ -1,11 +1,12 @@ response()->setCookie(self::SYNLOGOUT_SESSIONKEY, 1); } - public static function onGetPreferences(\User $user, &$preferences) { + public static function onGetPreferences(User $user, &$preferences) { $services = MediaWikiServices::getInstance(); $wgIsekaiOIDC = $services->getMainConfig()->get('IsekaiOIDC'); diff --git a/includes/SpecialIsekaiOIDCCallback.php b/includes/SpecialIsekaiOIDCCallback.php index 6d19f87..c735b40 100644 --- a/includes/SpecialIsekaiOIDCCallback.php +++ b/includes/SpecialIsekaiOIDCCallback.php @@ -3,7 +3,7 @@ namespace Isekai\OIDC; use MediaWiki\Auth\AuthManager; use MediaWiki\Logger\LoggerFactory; -use LoginSignupSpecialPage; +use MediaWiki\SpecialPage\LoginSignupSpecialPage; use LoginHelper; class SpecialIsekaiOIDCCallback extends LoginSignupSpecialPage { diff --git a/lib/openid-connect-php/OpenIDConnectClient.php b/lib/openid-connect-php/OpenIDConnectClient.php index bb42b5a..ac6bf18 100644 --- a/lib/openid-connect-php/OpenIDConnectClient.php +++ b/lib/openid-connect-php/OpenIDConnectClient.php @@ -1,10 +1,9 @@ * * Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -23,34 +22,23 @@ namespace Jumbojett; -use stdClass; - -/** - * Use session to manage a nonce - */ -if (!isset($_SESSION)) { - @session_start(); -} - -/** - * - * 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('\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, * which is not the same alphabet as base64. + * @param string $base64url + * @return bool|string */ -function base64url_decode($base64url) -{ +function base64url_decode(string $base64url) { return base64_decode(b64url2b64($base64url)); } @@ -59,14 +47,15 @@ function base64url_decode($base64url) * alphabet". This just replaces characters 62 and 63. None of the * reference implementations seem to restore the padding if necessary, * but we'll do it anyway. - * + * @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) { - $base64url .= str_repeat("=", 4 - $padding); + $base64url .= str_repeat('=', 4 - $padding); } return strtr($base64url, '-_', '+/'); } @@ -75,20 +64,10 @@ function b64url2b64($base64url) /** * OpenIDConnect Exception Class */ -class OpenIDConnectClientException extends \Exception +class OpenIDConnectClientException extends Exception { } -/** - * Require the CURL and JSON PHP extentions 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.'); -} - /** * * Please note this class stores nonces by default in $_SESSION['openid_connect_nonce'] @@ -102,7 +81,7 @@ class OpenIDConnectClient */ private $clientID; - /* + /** * @var string arbitrary name value */ private $clientName; @@ -115,7 +94,7 @@ class OpenIDConnectClient /** * @var array holds the provider configuration */ - private $providerConfig = array(); + private $providerConfig = []; /** * @var string http proxy if necessary @@ -138,69 +117,95 @@ class OpenIDConnectClient private $verifyHost = true; /** - * @var string if we aquire an access token it will be stored here + * @var string if we acquire an access token it will be stored here */ - private $accessToken; + protected $accessToken; /** - * @var string if we aquire a refresh token it will be stored here + * @var string if we acquire a refresh token it will be stored here */ private $refreshToken; /** * @var string if we acquire an id token it will be stored here */ - private $idToken; + protected $idToken; /** - * @var string stores the token response + * @var object stores the token response */ private $tokenResponse; /** * @var array holds scopes */ - private $scopes = array(); + private $scopes = []; /** * @var int|null Response code from the server */ - private $responseCode = null; + protected $responseCode; /** - * @var array holds response types + * @var string|null Content type from the server */ - private $responseTypes = array(); + protected $responseContentType; /** - * @var array holds a cache of info returned from the user info endpoint + * @var array holds response types */ - private $userInfo = array(); + private $responseTypes = []; /** * @var array holds authentication parameters */ - private $authParams = array(); + private $authParams = []; + + /** + * @var array holds additional registration parameters for example post_logout_redirect_uris + */ + private $registrationParams = []; /** * @var mixed holds well-known openid server properties */ private $wellKnown = false; + /** + * @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 = []; + /** * @var int timeout (seconds) */ protected $timeOut = 60; /** - * @var stdClass holds response types + * @var int leeway (seconds) + */ + private $leeway = 300; + + /** + * @var array holds response types + */ + private $additionalJwks = []; + + /** + * @var object holds verified jwt claims */ - private $additionalJwks; + protected $verifiedClaims = []; /** - * @var stdClass holds verified jwt claims + * @var callable|null validator function for issuer claim */ - private $verifiedClaims; + 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 @@ -208,35 +213,82 @@ class OpenIDConnectClient private $allowImplicitFlow = false; /** - * @param $provider_url string optional - * - * @param $client_id string optional - * @param $client_secret string optional - * + * @var string */ - public function __construct($provider_url = null, $client_id = null, $client_secret = null) - { + private $redirectURL; + + /** + * @var int defines which URL-encoding http_build_query() uses + */ + protected $encType = PHP_QUERY_RFC1738; + + /** + * @var bool Enable or disable upgrading to HTTPS by paying attention to HTTP header HTTP_UPGRADE_INSECURE_REQUESTS + */ + protected $httpUpgradeInsecureRequests = true; + + /** + * @var string holds code challenge method for PKCE mode + * @see https://tools.ietf.org/html/rfc7636 + */ + private $codeChallengeMethod = false; + + /** + * @var array holds PKCE supported algorithms + */ + private $pkceAlgs = ['S256' => 'sha256', 'plain' => false]; + + /** + * @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 + */ + 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); + } else { + $this->setIssuer($issuer); + } + $this->clientID = $client_id; $this->clientSecret = $client_secret; - - $this->additionalJwks = new stdClass(); - $this->verifiedClaims = new stdClass(); } /** * @param $provider_url */ - public function setProviderURL($provider_url) - { - $this->providerConfig['issuer'] = $provider_url; + public function setProviderURL($provider_url) { + $this->providerConfig['providerUrl'] = $provider_url; + } + + /** + * @param $issuer + */ + public function setIssuer($issuer) { + $this->providerConfig['issuer'] = $issuer; } /** * @param $response_types */ - public function setResponseTypes($response_types) - { + public function setResponseTypes($response_types) { $this->responseTypes = array_merge($this->responseTypes, (array)$response_types); } @@ -244,19 +296,18 @@ class OpenIDConnectClient * @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'] : ""; - throw new OpenIDConnectClientException("Error: " . $_REQUEST['error'] . $desc); + $desc = isset($_REQUEST['error_description']) ? ' Description: ' . $_REQUEST['error_description'] : ''; + throw new OpenIDConnectClientException('Error: ' . $_REQUEST['error'] .$desc); } // If we have an authorization code then proceed to request a token - if (isset($_REQUEST["code"])) { + if (isset($_REQUEST['code'])) { - $code = $_REQUEST["code"]; + $code = $_REQUEST['code']; $token_json = $this->requestTokens($code); // Throw an error if the server returns one @@ -268,33 +319,37 @@ class OpenIDConnectClient } // Do an OpenID Connect session check - if ($_REQUEST['state'] != $this->getState()) { - throw new OpenIDConnectClientException("Unable to determine state"); + if (!isset($_REQUEST['state']) || ($_REQUEST['state'] !== $this->getState())) { + throw new OpenIDConnectClientException('Unable to determine state'); } // Cleanup state $this->unsetState(); if (!property_exists($token_json, 'id_token')) { - throw new OpenIDConnectClientException("User did not authorize openid scope."); + throw new OpenIDConnectClientException('User did not authorize openid scope.'); + } + + $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($token_json->id_token, 1); + $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 = $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(); @@ -302,35 +357,30 @@ class OpenIDConnectClient // Save the full response $this->tokenResponse = $token_json; - // Save the id token - $this->idToken = $token_json->id_token; - - // Save the access token - $this->accessToken = $token_json->access_token; - // Save the verified claims $this->verifiedClaims = $claims; // Save the refresh token, if we got one - if (isset($token_json->refresh_token)) $this->refreshToken = $token_json->refresh_token; + if (isset($token_json->refresh_token)) { + $this->refreshToken = $token_json->refresh_token; + } // Success! return true; - } else { - throw new OpenIDConnectClientException("Unable to verify JWT claims"); } - } elseif ($this->allowImplicitFlow && isset($_REQUEST["id_token"])) { + + throw new OpenIDConnectClientException ('Unable to verify JWT claims'); + } + + if ($this->allowImplicitFlow && isset($_REQUEST['id_token'])) { // if we have no code but an id_token use that - $id_token = $_REQUEST["id_token"]; + $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()) { - throw new OpenIDConnectClientException("Unable to determine state"); + if (!isset($_REQUEST['state']) || ($_REQUEST['state'] !== $this->getState())) { + throw new OpenIDConnectClientException('Unable to determine state'); } // Cleanup state @@ -339,43 +389,35 @@ class OpenIDConnectClient $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(); - // Save the id token - $this->idToken = $id_token; - // Save the verified claims $this->verifiedClaims = $claims; // Save the access token - if ($accessToken) $this->accessToken = $accessToken; - - // Save the refresh token, if we got one - if (isset($_REQUEST['refresh_token'])) $this->refreshToken = $_REQUEST['refresh_token']; + if ($accessToken) { + $this->accessToken = $accessToken; + } // Success! return true; - } else { - throw new OpenIDConnectClientException("Unable to verify JWT claims"); + } - } else { - return false; + + throw new OpenIDConnectClientException ('Unable to verify JWT claims'); } + + $this->requestAuthorization(); + return false; } /** @@ -383,66 +425,169 @@ class OpenIDConnectClient * Connect provider that the end-user has logged out of the relying party site * (the client application). * - * @param string $accessToken ID token (obtained at login) - * @param string $redirect URL to which the RP is requesting that the End-User's User Agent + * @param string $idToken ID token (obtained at login) + * @param string|null $redirect URL to which the RP is requesting that the End-User's User Agent * be redirected after a logout has been performed. The value MUST have been previously * registered with the OP. Value can be null. * + * @throws OpenIDConnectClientException */ - public function signOut($accessToken, $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 = array('id_token_hint' => $accessToken); - } else { - $signout_params = array( - 'id_token_hint' => $accessToken, - 'post_logout_redirect_uri' => $redirect - ); + if($redirect === null){ + $signout_params = ['id_token_hint' => $idToken]; + } + else { + $signout_params = [ + 'id_token_hint' => $idToken, + 'post_logout_redirect_uri' => $redirect]; } - $signout_endpoint .= (strpos($signout_endpoint, '?') === false ? '?' : '&') . http_build_query($signout_params); - $this->redirect($signout_endpoint); + $sign_out_endpoint .= (strpos($sign_out_endpoint, '?') === false ? '?' : '&') . http_build_query( $signout_params, '', '&', $this->encType); + $this->redirect($sign_out_endpoint); } + /** - * @param $scope - example: openid, given_name, etc... + * 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 addScope($scope) + public function verifyLogoutToken(): bool { - $this->scopes = array_merge($this->scopes, (array)$scope); + 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'); } /** - * @param $param - example: prompt=login + * Verify each claim in the logout token according to the + * spec for back-channel logout. + * + * @param object $claims + * @return bool + * @throws OpenIDConnectClientException */ - public function addAuthParam($param) + public function verifyLogoutTokenClaims($claims): bool { - $this->authParams = array_merge($this->authParams, (array)$param); + // 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(array $scope) { + $this->scopes = array_merge($this->scopes, $scope); + } + + /** + * @param array $param - example: prompt=login + */ + public function addAuthParam(array $param) { + $this->authParams = array_merge($this->authParams, $param); } /** - * @param $jwk object - example: (object) array('kid' => ..., 'nbf' => ..., 'use' => 'sig', 'kty' => "RSA", 'e' => "", 'n' => "") + * @param array $param - example: post_logout_redirect_uris=[http://example.com/successful-logout] */ - protected function addAdditionalJwk($jwk) + 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; + } + + /** + * @param $jwk object - example: (object) ['kid' => ..., 'nbf' => ..., 'use' => 'sig', 'kty' => "RSA", 'e' => "", 'n' => ""] + */ + protected function addAdditionalJwk($jwk) { $this->additionalJwks[] = $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 $param - * @param string $default optional - * @throws OpenIDConnectClientException - * @return mixed + * @param string $param + * @param string|string[]|bool|null $default optional + * @return string|string[]|bool * + * @throws OpenIDConnectClientException */ - private 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])) { @@ -453,46 +598,54 @@ class OpenIDConnectClient } /** - * 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 $param - * @param string $default optional - * @throws OpenIDConnectClientException - * @return string + * @param string $param + * @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" - if (!$this->wellKnown) { - $well_known_config_url = rtrim($this->getProviderURL(), "/") . "/.well-known/openid-configuration"; - $this->wellKnown = json_decode($this->fetchURL($well_known_config_url)); + if(!$this->wellKnown) { + $well_known_config_url = rtrim($this->getProviderURL(), '/') . '/.well-known/openid-configuration'; + if (count($this->wellKnownConfigParameters) > 0){ + $well_known_config_url .= '?' . http_build_query($this->wellKnownConfigParameters) ; + } + $this->wellKnown = json_decode($this->fetchURL($well_known_config_url), false); } - $value = false; - if (isset($this->wellKnown->{$param})) { - $value = $this->wellKnown->{$param}; - } + $value = $this->wellKnown->{$param} ?? false; if ($value) { return $value; - } elseif (isset($default)) { + } + + if (isset($default)) { // Uses default value if provided return $default; - } else { - 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 optional parameters for .well-known/openid-configuration + */ + public function setWellKnownConfigParameters(array $params = []){ + $this->wellKnownConfigParameters=$params; } /** * @param string $url Sets redirect URL for auth flow */ - public function setRedirectURL($url) - { - if (parse_url($url, PHP_URL_HOST) !== false) { + public function setRedirectURL(string $url) { + if (parse_url($url,PHP_URL_HOST) !== false) { $this->redirectURL = $url; } } @@ -502,9 +655,8 @@ class OpenIDConnectClient * * @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; @@ -523,45 +675,72 @@ class OpenIDConnectClient * Support of 'ProxyReverse' configurations. */ - if (isset($_SERVER["HTTP_UPGRADE_INSECURE_REQUESTS"]) && ($_SERVER['HTTP_UPGRADE_INSECURE_REQUESTS'] == 1)) { + if ($this->httpUpgradeInsecureRequests && isset($_SERVER['HTTP_UPGRADE_INSECURE_REQUESTS']) && ($_SERVER['HTTP_UPGRADE_INSECURE_REQUESTS'] === '1')) { + $protocol = 'https'; + } elseif (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + $protocol = $_SERVER['HTTP_X_FORWARDED_PROTO']; + } elseif (isset($_SERVER['REQUEST_SCHEME'])) { + $protocol = $_SERVER['REQUEST_SCHEME']; + } elseif (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { $protocol = 'https'; } else { - $protocol = @$_SERVER['HTTP_X_FORWARDED_PROTO'] - ?: @$_SERVER['REQUEST_SCHEME'] - ?: ((isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] == "on") ? "https" : "http"); + $protocol = 'http'; + } + + if (isset($_SERVER['HTTP_X_FORWARDED_PORT'])) { + $port = (int)$_SERVER['HTTP_X_FORWARDED_PORT']; + } elseif (isset($_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 { + $port = 80; } - $port = @intval($_SERVER['HTTP_X_FORWARDED_PORT']) - ?: @intval($_SERVER["SERVER_PORT"]) - ?: (($protocol === 'https') ? 443 : 80); - - $host = @explode(":", $_SERVER['HTTP_HOST'])[0] - ?: @$_SERVER['SERVER_NAME'] - ?: @$_SERVER['SERVER_ADDR']; - - $port = (443 == $port) || (80 == $port) ? '' : ':' . $port; + if (isset($_SERVER['HTTP_HOST'])) { + $host = explode(':', $_SERVER['HTTP_HOST'])[0]; + } elseif (isset($_SERVER['SERVER_NAME'])) { + $host = $_SERVER['SERVER_NAME']; + } elseif (isset($_SERVER['SERVER_ADDR'])) { + $host = $_SERVER['SERVER_ADDR']; + } else { + return 'http:///'; + } - return sprintf('%s://%s%s/%s', $protocol, $host, $port, @trim(reset(explode("?", $_SERVER['REQUEST_URI'])), '/')); + $port = (443 === $port) || (80 === $port) ? '' : ':' . $port; + + $explodedRequestUri = isset($_SERVER['REQUEST_URI']) ? explode('?', $_SERVER['REQUEST_URI']) : []; + return sprintf('%s://%s%s/%s', $protocol, $host, $port, trim(reset($explodedRequestUri), '/')); } /** * Used for arbitrary value generation for nonces and state * * @return string + * @throws OpenIDConnectClientException */ - protected function generateRandString() + protected function generateRandString(): string { - return md5(uniqid(rand(), TRUE)); + try { + return bin2hex(random_bytes(16)); + } catch (Error $e) { + throw new OpenIDConnectClientException('Random token generation failed.'); + } catch (Exception $e) { + throw new OpenIDConnectClientException('Random token generation failed.'); + } } /** * Start Here * @return void + * @throws OpenIDConnectClientException + * @throws Exception */ - public function getAuthorizationUrl() - { - $auth_endpoint = $this->getProviderConfigValue("authorization_endpoint"); - $response_type = "code"; + private function requestAuthorization() { + + $auth_endpoint = $this->getProviderConfigValue('authorization_endpoint'); + $response_type = 'code'; // Generate and store a nonce in the session // The nonce is an arbitrary value @@ -570,289 +749,407 @@ class OpenIDConnectClient // State essentially acts as a session key for OIDC $state = $this->setState($this->generateRandString()); - $auth_params = array_merge($this->authParams, array( + $auth_params = array_merge($this->authParams, [ 'response_type' => $response_type, 'redirect_uri' => $this->getRedirectURL(), 'client_id' => $this->clientID, 'nonce' => $nonce, 'state' => $state, 'scope' => 'openid' - )); + ]); // If the client has been registered with additional scopes - if (sizeof($this->scopes) > 0) { - $auth_params = array_merge($auth_params, array('scope' => implode(' ', $this->scopes))); + if (count($this->scopes) > 0) { + $auth_params = array_merge($auth_params, ['scope' => implode(' ', array_merge($this->scopes, ['openid']))]); } // If the client has been registered with additional response types - if (sizeof($this->responseTypes) > 0) { - $auth_params = array_merge($auth_params, array('response_type' => implode(' ', $this->responseTypes))); + if (count($this->responseTypes) > 0) { + $auth_params = array_merge($auth_params, ['response_type' => implode(' ', $this->responseTypes)]); + } + + // If the client supports Proof Key for Code Exchange (PKCE) + $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[$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' => $codeChallengeMethod + ]); } - $auth_endpoint .= (strpos($auth_endpoint, '?') === false ? '?' : '&') . http_build_query($auth_params); + $auth_endpoint .= (strpos($auth_endpoint, '?') === false ? '?' : '&') . http_build_query($auth_params, '', '&', $this->encType); - session_commit(); - return $auth_endpoint; + $this->commitSession(); + $this->redirect($auth_endpoint); } /** * Requests a client credentials token * + * @throws OpenIDConnectClientException */ - public function requestClientCredentialsToken() - { - $token_endpoint = $this->getProviderConfigValue("token_endpoint"); + public function requestClientCredentialsToken() { + $token_endpoint = $this->getProviderConfigValue('token_endpoint'); $headers = []; - $grant_type = "client_credentials"; + $grant_type = 'client_credentials'; - $post_data = array( + $post_data = [ 'grant_type' => $grant_type, 'client_id' => $this->clientID, 'client_secret' => $this->clientSecret, 'scope' => implode(' ', $this->scopes) - ); + ]; // Convert token params to string format - $post_params = http_build_query($post_data); + $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); } - /** * Requests a resource owner token * (Defined in https://tools.ietf.org/html/rfc6749#section-4.3) - * - * @param $bClientAuth boolean Indicates that the Client ID and Secret be used for client authentication + * + * @param boolean $bClientAuth Indicates that the Client ID and Secret be used for client authentication + * @return mixed + * @throws OpenIDConnectClientException */ - public function requestResourceOwnerToken($bClientAuth = FALSE) - { - $token_endpoint = $this->getProviderConfigValue("token_endpoint"); + public function requestResourceOwnerToken(bool $bClientAuth = false) { + $token_endpoint = $this->getProviderConfigValue('token_endpoint'); $headers = []; - $grant_type = "password"; + $grant_type = 'password'; - $post_data = array( + $post_data = [ 'grant_type' => $grant_type, 'username' => $this->authParams['username'], 'password' => $this->authParams['password'], 'scope' => implode(' ', $this->scopes) - ); + ]; //For client authentication include the client values - if ($bClientAuth) { - $post_data['client_id'] = $this->clientID; - $post_data['client_secret'] = $this->clientSecret; + if($bClientAuth) { + $token_endpoint_auth_methods_supported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); + 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; + $post_data['client_secret'] = $this->clientSecret; + } } // Convert token params to string format - $post_params = http_build_query($post_data); + $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); } - - /** * Requests ID and Access tokens * - * @param $code + * @param string $code + * @param string[] $headers Extra HTTP headers to pass to the token endpoint * @return mixed + * @throws OpenIDConnectClientException */ - private function requestTokens($code) - { - $token_endpoint = $this->getProviderConfigValue("token_endpoint"); - $token_endpoint_auth_methods_supported = $this->getProviderConfigValue("token_endpoint_auth_methods_supported", ['client_secret_basic']); - - $headers = []; + 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']); - $grant_type = "authorization_code"; + $grant_type = 'authorization_code'; - $token_params = array( + $token_params = [ 'grant_type' => $grant_type, 'code' => $code, 'redirect_uri' => $this->getRedirectURL(), 'client_id' => $this->clientID, 'client_secret' => $this->clientSecret - ); + ]; + $authorizationHeader = null; # Consider Basic authentication if provider config is set this way - if (in_array('client_secret_basic', $token_endpoint_auth_methods_supported)) { - $headers = ['Authorization: Basic ' . base64_encode($this->clientID . ':' . $this->clientSecret)]; + 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'], $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)) { + $cs = $this->getClientSecret(); + if (empty($cs)) { + $authorizationHeader = null; + unset($token_params['client_secret']); + } + $token_params = array_merge($token_params, [ + 'client_id' => $this->clientID, + 'code_verifier' => $this->getCodeVerifier() + ]); } // Convert token params to string format - $token_params = http_build_query($token_params); + $token_params = http_build_query($token_params, '', '&', $this->encType); + + if (null !== $authorizationHeader) { + $headers[] = $authorizationHeader; + } - return json_decode($this->fetchURL($token_endpoint, $token_params, $headers)); + $this->tokenResponse = json_decode($this->fetchURL($token_endpoint, $token_params, $headers), false); + + return $this->tokenResponse; } /** - * Requests Access token with refresh token + * Request RFC8693 Token Exchange + * https://datatracker.ietf.org/doc/html/rfc8693 * - * @param $code + * @param string $subjectToken + * @param string $subjectTokenType + * @param string $audience * @return mixed + * @throws OpenIDConnectClientException */ - public function refreshToken($refresh_token) - { - $token_endpoint = $this->getProviderConfigValue("token_endpoint"); - - $grant_type = "refresh_token"; + 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 = []; + $grant_type = 'urn:ietf:params:oauth:grant-type:token-exchange'; - $token_params = array( - 'grant_type' => $grant_type, - 'refresh_token' => $refresh_token, + $post_data = array( + 'grant_type' => $grant_type, + 'subject_token_type' => $subjectTokenType, + 'subject_token' => $subjectToken, 'client_id' => $this->clientID, 'client_secret' => $this->clientSecret, + 'scope' => implode(' ', $this->scopes) ); - // Convert token params to string format - $token_params = http_build_query($token_params); + if (!empty($audience)) { + $post_data['audience'] = $audience; + } + + # Consider Basic authentication if provider config is set this way + 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'], $post_data['client_id']); + } - $json = json_decode($this->fetchURL($token_endpoint, $token_params)); - $this->refreshToken = $json->refresh_token; - $this->accessToken = $json->access_token; + // Convert token params to string format + $post_params = http_build_query($post_data, null, '&', $this->encType); - return $json; + return json_decode($this->fetchURL($token_endpoint, $post_params, $headers), false); } + /** - * @param array $keys - * @param stdClass $header + * Requests Access token with refresh token + * + * @param string $refresh_token + * @return mixed * @throws OpenIDConnectClientException - * @return object */ - private function get_key_for_header($keys, $header) - { + 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']); + + $headers = []; + + $grant_type = 'refresh_token'; + + $token_params = [ + 'grant_type' => $grant_type, + 'refresh_token' => $refresh_token, + 'client_id' => $this->clientID, + 'client_secret' => $this->clientSecret, + 'scope' => implode(' ', $this->scopes), + ]; + + # Consider Basic authentication if provider config is set this way + 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'], $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), false); + + if (isset($json->access_token)) { + $this->accessToken = $json->access_token; + } + + if (isset($json->refresh_token)) { + $this->refreshToken = $json->refresh_token; + } + + return $json; + } + + /** + * @throws OpenIDConnectClientException + */ + 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 ($key->alg == $header->alg && $key->kid == $header->kid) { + 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; } } if ($this->additionalJwks) { foreach ($this->additionalJwks as $key) { - if ($key->kty == 'RSA') { - if (!isset($header->kid) || $key->kid == $header->kid) { - return $key; - } - } else { - if ($key->alg == $header->alg && $key->kid == $header->kid) { + 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; } } } if (isset($header->kid)) { throw new OpenIDConnectClientException('Unable to find a key for (algorithm, kid):' . $header->alg . ', ' . $header->kid . ')'); - } else { - throw new OpenIDConnectClientException('Unable to find a key for RSA'); } - } - + throw new OpenIDConnectClientException('Unable to find a key for RSA'); + } /** - * @param string $hashtype - * @param object $key * @throws OpenIDConnectClientException - * @return bool */ - private function verifyRSAJWTsignature($hashtype, $key, $payload, $signature) + private function verifyRSAJWTSignature(string $hashType, stdClass $key, $payload, $signature, $signatureType): bool { - if (!class_exists('\phpseclib\Crypt\RSA') && !class_exists('Crypt_RSA')) { - throw new OpenIDConnectClientException('Crypt_RSA support unavailable.'); - } - if (!(property_exists($key, 'n') and property_exists($key, 'e'))) { + 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('Crypt_RSA')) { - $rsa = new Crypt_RSA(); - $rsa->setHash($hashtype); - $rsa->loadKey($public_key_xml, Crypt_RSA::PUBLIC_FORMAT_XML); - $rsa->signatureMode = Crypt_RSA::SIGNATURE_PKCS1; + $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); - $rsa->loadKey($public_key_xml, \phpseclib\Crypt\RSA::PUBLIC_FORMAT_XML); - $rsa->signatureMode = \phpseclib\Crypt\RSA::SIGNATURE_PKCS1; + $key = $key->withPadding(RSA::SIGNATURE_PKCS1); } - return $rsa->verify($payload, $signature); + return $key->verify($payload, $signature); } - /** - * @param string $hashtype - * @param string $key - * @throws OpenIDConnectClientException - * @return bool - */ - 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); - } else { - return self::hashEquals($signature, $expected); - } + $expected = hash_hmac($hashType, $payload, $key, true); + return hash_equals($signature, $expected); } /** - * @param $jwt string encoded JWT - * @throws OpenIDConnectClientException + * @param string $jwt encoded JWT * @return bool + * @throws OpenIDConnectClientException */ - private function verifyJWTsignature($jwt) + public function verifyJWTSignature(string $jwt): bool { - $parts = explode(".", $jwt); + $parts = explode('.', $jwt); + if (!isset($parts[0])) { + throw new OpenIDConnectClientException('Error missing part 0 in token'); + } $signature = base64url_decode(array_pop($parts)); - $header = json_decode(base64url_decode($parts[0])); - $payload = implode(".", $parts); - $jwks = json_decode($this->fetchURL($this->getProviderConfigValue('jwks_uri'))); - if ($jwks === NULL) { - throw new OpenIDConnectClientException('Error decoding JSON from jwks_uri'); + if (false === $signature || '' === $signature) { + throw new OpenIDConnectClientException('Error decoding signature from token'); + } + $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)) { + throw new OpenIDConnectClientException('Error missing signature type in token header'); } - $verified = false; + + $payload = implode('.', $parts); switch ($header->alg) { case 'RS256': + case 'PS256': + case 'PS512': case 'RS384': case 'RS512': - $hashtype = 'sha' . substr($header->alg, 2); - - $verified = $this->verifyRSAJWTsignature( - $hashtype, - $this->get_key_for_header($jwks->keys, $header), - $payload, - $signature - ); + $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 { + $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, + $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); @@ -860,173 +1157,233 @@ class OpenIDConnectClient 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(string $iss): bool + { + if ($this->issuerValidator !== null) { + return $this->issuerValidator->__invoke($iss); + } + + return ($iss === $this->getIssuer() || $iss === $this->getWellKnownIssuer() || $iss === $this->getWellKnownIssuer(true)); + } + /** * @param object $claims + * @param string|null $accessToken * @return bool + * @throws OpenIDConnectClientException */ - private function verifyJWTclaims($claims, $accessToken = null) + protected function verifyJWTClaims($claims, string $accessToken = null): bool { - if (isset($claims->at_hash) && isset($accessToken)) { - if (isset($this->getAccessTokenHeader()->alg) && $this->getAccessTokenHeader()->alg != 'none') { - $bit = substr($this->getAccessTokenHeader()->alg, 2, 3); + if(isset($claims->at_hash, $accessToken)) { + if(isset($this->getIdTokenHeader()->alg) && $this->getIdTokenHeader()->alg !== 'none') { + $bit = substr($this->getIdTokenHeader()->alg, 2, 3); } else { // TODO: Error case. throw exception??? $bit = '256'; } - $len = ((int)$bit) / 16; - $expecte_at_hash = $this->urlEncode(substr(hash('sha' . $bit, $accessToken, true), 0, $len)); + $len = ((int)$bit)/16; + $expected_at_hash = $this->urlEncode(substr(hash('sha'.$bit, $accessToken, true), 0, $len)); } - return (($claims->iss == $this->getProviderURL() || $claims->iss == $this->getWellKnownIssuer() || $claims->iss == $this->getWellKnownIssuer(true)) - && (($claims->aud == $this->clientID) || (in_array($this->clientID, $claims->aud))) - && ($claims->nonce == $this->getNonce()) - && (!isset($claims->exp) || $claims->exp >= time()) - && (!isset($claims->nbf) || $claims->nbf <= time()) - && (!isset($claims->at_hash) || $claims->at_hash == $expecte_at_hash)); + $auds = $claims->aud; + $auds = is_array( $auds ) ? $auds : [ $auds ]; + return (($this->validateIssuer($claims->iss)) + && (in_array($this->clientID, $auds, true)) + && ($claims->sub === $this->getIdTokenPayload()->sub) + && (!isset($claims->nonce) || $claims->nonce === $this->getNonce()) + && ( !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; + $enc = rtrim($enc, '='); + return strtr($enc, '+/', '-_'); } /** - * @param $jwt string encoded JWT + * @param string $jwt encoded JWT * @param int $section the section we would like to decode - * @return object + * @return object|string|null */ - private function decodeJWT($jwt, $section = 0) - { - - $parts = explode(".", $jwt); - return json_decode(base64url_decode($parts[$section])); + protected function decodeJWT(string $jwt, int $section = 0) { + $parts = explode('.', $jwt); + return json_decode(base64url_decode($parts[$section] ?? ''), false); } /** * - * @param $attribute string optional + * @param string|null $attribute optional * - * Attribute Type Description - * user_id string REQUIRED Identifier for the End-User at the Issuer. - * name string End-User's full name in displayable form including all name parts, ordered according to End-User's locale and preferences. - * given_name string Given name or first name of the End-User. - * family_name string Surname or last name of the End-User. - * middle_name string Middle name of the End-User. - * nickname string Casual name of the End-User that may or may not be the same as the given_name. For instance, a nickname value of Mike might be returned alongside a given_name value of Michael. - * profile string URL of End-User's profile page. - * picture string URL of the End-User's profile picture. - * website string URL of End-User's web page or blog. - * email string The End-User's preferred e-mail address. - * verified boolean True if the End-User's e-mail address has been verified; otherwise false. - * gender string The End-User's gender: Values defined by this specification are female and male. Other values MAY be used when neither of the defined values are applicable. - * birthday string The End-User's birthday, represented as a date string in MM/DD/YYYY format. The year MAY be 0000, indicating that it is omitted. - * zoneinfo string String from zoneinfo [zoneinfo] time zone database. For example, Europe/Paris or America/Los_Angeles. - * locale string The End-User's locale, represented as a BCP47 [RFC5646] language tag. This is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, separated by a dash. For example, en-US or fr-CA. As a compatibility note, some implementations have used an underscore as the separator rather than a dash, for example, en_US; Implementations MAY choose to accept this locale syntax as well. - * phone_number string The End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of this Claim. For example, +1 (425) 555-1212 or +56 (2) 687 2400. - * address JSON object The End-User's preferred address. The value of the address member is a JSON [RFC4627] structure containing some or all of the members defined in Section 2.4.2.1. - * updated_time string Time the End-User's information was last updated, represented as a RFC 3339 [RFC3339] datetime. For example, 2011-01-03T23:58:42+0000. + * Attribute Type Description + * user_id string REQUIRED Identifier for the End-User at the Issuer. + * name string End-User's full name in displayable form including all name parts, ordered according to End-User's locale and preferences. + * given_name string Given name or first name of the End-User. + * family_name string Surname or last name of the End-User. + * middle_name string Middle name of the End-User. + * nickname string Casual name of the End-User that may or may not be the same as the given_name. For instance, a nickname value of Mike might be returned alongside a given_name value of Michael. + * profile string URL of End-User's profile page. + * picture string URL of the End-User's profile picture. + * website string URL of End-User's web page or blog. + * email string The End-User's preferred e-mail address. + * verified boolean True if the End-User's e-mail address has been verified; otherwise false. + * gender string The End-User's gender: Values defined by this specification are female and male. Other values MAY be used when neither of the defined values are applicable. + * birthday string The End-User's birthday, represented as a date string in MM/DD/YYYY format. The year MAY be 0000, indicating that it is omitted. + * zoneinfo string String from zoneinfo [zoneinfo] time zone database. For example, Europe/Paris or America/Los_Angeles. + * locale string The End-User's locale, represented as a BCP47 [RFC5646] language tag. This is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, separated by a dash. For example, en-US or fr-CA. As a compatibility note, some implementations have used an underscore as the separator rather than a dash, for example, en_US; Implementations MAY choose to accept this locale syntax as well. + * phone_number string The End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of this Claim. For example, +1 (425) 555-1212 or +56 (2) 687 2400. + * address JSON object The End-User's preferred address. The value of the address member is a JSON [RFC4627] structure containing some or all of the members defined in Section 2.4.2.1. + * updated_time string Time the End-User's information was last updated, represented as an RFC 3339 [RFC3339] datetime. For example, 2011-01-03T23:58:42+0000. * * @return mixed * + * @throws OpenIDConnectClientException */ - public function requestUserInfo($attribute = null) - { - if (!$this->userInfo) { - $user_info_endpoint = $this->getProviderConfigValue("userinfo_endpoint"); - $schema = 'openid'; + public function requestUserInfo(string $attribute = null) { - $user_info_endpoint .= "?schema=" . $schema; + $user_info_endpoint = $this->getProviderConfigValue('userinfo_endpoint'); + $schema = 'openid'; - //The accessToken has to be send in the Authorization header, so we create a new array with only this header. - $headers = array("Authorization: Bearer {$this->accessToken}"); + $user_info_endpoint .= '?schema=' . $schema; - $user_json = json_decode($this->fetchURL($user_info_endpoint, null, $headers)); + //The accessToken has to be sent in the Authorization header. + // Accept json to indicate response type + $headers = ["Authorization: Bearer $this->accessToken", + 'Accept: application/json']; - $this->userInfo = $user_json; + $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()); } - if ($attribute === null) { - return $this->userInfo; - } else if (isset($this->userInfo->$attribute)) { - return $this->userInfo->$attribute; + // 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 { - return null; + $user_json = json_decode($response, false); + } + + $userInfo = $user_json; + + if($attribute === null) { + return $userInfo; + } + + if (property_exists($userInfo, $attribute)) { + return $userInfo->$attribute; } + + return null; } /** * - * @param $attribute string optional + * @param string|null $attribute optional * * Attribute Type Description - * exp int Expires at - * nbf int Not before - * ver string Version - * iss string Issuer - * sub string Subject - * aud string Audience - * nonce string nonce - * iat int Issued At - * auth_time int Authenatication time - * oid string Object id + * exp int Expires at + * nbf int Not before + * ver string Version + * iss string Issuer + * sub string Subject + * aud string Audience + * nonce string nonce + * iat int Issued At + * auth_time int Authentication time + * oid string Object id * * @return mixed * */ - public function getVerifiedClaims($attribute = null) - { + public function getVerifiedClaims(string $attribute = null) { - if ($attribute === null) { + if($attribute === null) { return $this->verifiedClaims; - } else if (isset($this->verifiedClaims->$attribute)) { + } + + if (property_exists($this->verifiedClaims, $attribute)) { return $this->verifiedClaims->$attribute; - } else { - return null; } + + return null; } /** - * @param $url - * @param 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 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 sent with the request. Format as 'NameHeader: ValueHeader' + * @return bool|string * @throws OpenIDConnectClientException - * @return mixed */ - protected function fetchURL($url, $post_body = null, $headers = array()) - { - + 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(); // Determine whether this is a GET or POST - if ($post_body != null) { + if ($post_body !== null) { // curl_setopt($ch, CURLOPT_POST, 1); - // Alows to keep the POST method even after redirect - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); + // Allows to keep the POST method even after redirect + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST'); curl_setopt($ch, CURLOPT_POSTFIELDS, $post_body); // Default content type is form encoded $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-Length: ' . strlen($post_body); + $headers[] = "Content-Type: $content_type"; } - // If we set some heaers include them - if (count($headers) > 0) { + // 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); } @@ -1051,13 +1408,13 @@ class OpenIDConnectClient curl_setopt($ch, CURLOPT_CAINFO, $this->certPath); } - if ($this->verifyHost) { + if($this->verifyHost) { curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); } else { curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); } - if ($this->verifyPeer) { + if($this->verifyPeer) { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); } else { curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); @@ -1075,9 +1432,10 @@ class OpenIDConnectClient // 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_error($ch)); + throw new OpenIDConnectClientException('Curl error: (' . curl_errno($ch) . ') ' . curl_error($ch)); } // Close the cURL resource, and free system resources @@ -1087,106 +1445,116 @@ class OpenIDConnectClient } /** - * @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 getProviderURL() + public function getIssuer(): string { if (!isset($this->providerConfig['issuer'])) { - throw new OpenIDConnectClientException("The provider URL has not been set"); - } else { - return $this->providerConfig['issuer']; + throw new OpenIDConnectClientException('The issuer has not been set'); } + + return $this->providerConfig['issuer']; } /** - * @param $url + * @return mixed + * @throws OpenIDConnectClientException */ - public function redirect($url) - { + public function getProviderURL() { + if (!isset($this->providerConfig['providerUrl'])) { + throw new OpenIDConnectClientException('The provider URL has not been set'); + } + + return $this->providerConfig['providerUrl']; + } + + public function redirect(string $url) { header('Location: ' . $url); exit; } - /** - * @param $httpProxy - */ - public function setHttpProxy($httpProxy) - { + public function setHttpProxy(string $httpProxy) { $this->httpProxy = $httpProxy; } - /** - * @param $certPath - */ - public function setCertPath($certPath) - { + public function setCertPath(string $certPath) { $this->certPath = $certPath; } /** * @return string|null */ - public function getCertPath() - { + 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; } /** - * @return bool + * Controls whether http header HTTP_UPGRADE_INSECURE_REQUESTS should be considered + * defaults to true */ - public function getVerifyHost() + public function setHttpUpgradeInsecureRequests(bool $httpUpgradeInsecureRequests) { + $this->httpUpgradeInsecureRequests = $httpUpgradeInsecureRequests; + } + + public function getVerifyHost(): bool { return $this->verifyHost; } - /** - * @return bool - */ - public function getVerifyPeer() + public function getVerifyPeer(): bool { return $this->verifyPeer; } + public function getHttpUpgradeInsecureRequests(): bool + { + return $this->httpUpgradeInsecureRequests; + } + /** - * @param bool $allowImplicitFlow + * 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 */ - public function setAllowImplicitFlow($allowImplicitFlow) - { + public function setIssuerValidator(callable $issuerValidator) { + $this->issuerValidator = $issuerValidator; + } + + /** + * 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 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; } @@ -1195,27 +1563,18 @@ class OpenIDConnectClient * * Use this to alter a provider's endpoints and other attributes * - * @param $array + * @param array $array * simple key => value */ - public function providerConfigParam($array) - { + public function providerConfigParam(array $array) { $this->providerConfig = array_merge($this->providerConfig, $array); } - /** - * @param $clientSecret - */ - public function setClientSecret($clientSecret) - { + public function setClientSecret(string $clientSecret) { $this->clientSecret = $clientSecret; } - /** - * @param $clientID - */ - public function setClientID($clientID) - { + public function setClientID(string $clientID) { $this->clientID = $clientID; } @@ -1225,24 +1584,25 @@ class OpenIDConnectClient * * @throws OpenIDConnectClientException */ - public function register() - { + public function register() { $registration_endpoint = $this->getProviderConfigValue('registration_endpoint'); - $send_object = (object)array( - 'redirect_uris' => array($this->getRedirectURL()), + $send_object = (object ) array_merge($this->registrationParams, [ + 'redirect_uris' => [$this->getRedirectURL()], 'client_name' => $this->getClientName() - ); + ]); $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) { - throw new OpenIDConnectClientException("Error registering: JSON response received from the server was invalid."); - } elseif (isset($json_response->{'error_description'})) { + throw new OpenIDConnectClientException('Error registering: JSON response received from the server was invalid.'); + } + + if (isset($json_response->{'error_description'})) { throw new OpenIDConnectClientException($json_response->{'error_description'}); } @@ -1253,67 +1613,119 @@ class OpenIDConnectClient 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'); } } /** + * Introspect a given token - either access token or refresh token. + * @see https://tools.ietf.org/html/rfc7662 + * + * @param string $token + * @param string $token_type_hint + * @param string|null $clientId + * @param string|null $clientSecret * @return mixed + * @throws OpenIDConnectClientException + * @throws Exception */ - public function getClientName() - { - return $this->clientName; + 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 ?? $this->clientID; + $clientSecret = $clientSecret ?? $this->clientSecret; + + // Convert token params to string format + $headers = ['Authorization: Basic ' . base64_encode(urlencode($clientId) . ':' . urlencode($clientSecret)), + 'Accept: application/json']; + + 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); } /** - * @param $clientName + * Revoke a given token - either access token or refresh token. + * @see https://tools.ietf.org/html/rfc7009 + * + * @param string $token + * @param string $token_type_hint + * @param string|null $clientId + * @param string|null $clientSecret + * @return mixed + * @throws OpenIDConnectClientException */ - public function setClientName($clientName) - { - $this->clientName = $clientName; + 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]; + + if ($token_type_hint) { + $post_data['token_type_hint'] = $token_type_hint; + } + $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), false); } /** - * @return string + * @return string|null */ - public function getClientID() + public function getClientName() { - return $this->clientID; + return $this->clientName; + } + + public function setClientName(string $clientName) { + $this->clientName = $clientName; } /** - * @return string + * @return string|null */ - public function getClientSecret() - { - return $this->clientSecret; + public function getClientID() { + return $this->clientID; } /** - * @return bool + * @return string|null */ - public function canVerifySignatures() - { - return class_exists('\phpseclib\Crypt\RSA') || class_exists('Crypt_RSA'); + public function getClientSecret() { + return $this->clientSecret; } /** * Set the access token. * * May be required for subclasses of this Client. - * - * @param mixed $accessToken - * - * @return void */ - public function setAccessToken($accessToken) - { + public function setAccessToken(string $accessToken) { $this->accessToken = $accessToken; } /** - * @return string + * @return string|null */ public function getAccessToken() { @@ -1321,29 +1733,19 @@ class OpenIDConnectClient } /** - * Set the refresh token. - * - * May be required for subclasses of this Client. - * - * @param mixed $refreshToken - * - * @return void - */ - public function setRefreshToken($refreshToken) - { - $this->refreshToken = $refreshToken; - } - - /** - * @return string + * @return string|null */ public function getRefreshToken() { return $this->refreshToken; } + public function setIdToken(string $idToken) { + $this->idToken = $idToken; + } + /** - * @return string + * @return string|null */ public function getIdToken() { @@ -1351,53 +1753,46 @@ class OpenIDConnectClient } /** - * @return stdClass + * @return object */ - public function getAccessTokenHeader() - { - return $this->decodeJWT($this->accessToken, 0); + public function getAccessTokenHeader() { + return $this->decodeJWT($this->accessToken); } /** - * @return stdClass + * @return object|string|null */ - public function getAccessTokenPayload() - { + public function getAccessTokenPayload() { return $this->decodeJWT($this->accessToken, 1); } /** - * @return stdClass + * @return object|string|null */ - public function getIdTokenHeader() - { - return $this->decodeJWT($this->idToken, 0); + public function getIdTokenHeader() { + return $this->decodeJWT($this->idToken); } /** - * @return stdClass + * @return object|string|null */ - public function getIdTokenPayload() - { + public function getIdTokenPayload() { return $this->decodeJWT($this->idToken, 1); } + /** - * @return stdClass + * @return object */ - public function getTokenResponse() - { + public function getTokenResponse() { return $this->tokenResponse; } /** * Stores nonce - * - * @param string $nonce - * @return string */ - protected function setNonce($nonce) + protected function setNonce(string $nonce): string { - $_SESSION['openid_connect_nonce'] = $nonce; + $this->setSessionKey('openid_connect_nonce', $nonce); return $nonce; } @@ -1406,9 +1801,8 @@ class OpenIDConnectClient * * @return string */ - protected function getNonce() - { - return $_SESSION['openid_connect_nonce']; + protected function getNonce() { + return $this->getSessionKey('openid_connect_nonce'); } /** @@ -1416,20 +1810,16 @@ class OpenIDConnectClient * * @return void */ - protected function unsetNonce() - { - unset($_SESSION['openid_connect_nonce']); + protected function unsetNonce() { + $this->unsetSessionKey('openid_connect_nonce'); } /** * Stores $state - * - * @param string $state - * @return string */ - protected function setState($state) + protected function setState(string $state): string { - $_SESSION['openid_connect_state'] = $state; + $this->setSessionKey('openid_connect_state', $state); return $state; } @@ -1438,9 +1828,8 @@ class OpenIDConnectClient * * @return string */ - protected function getState() - { - return $_SESSION['openid_connect_state']; + protected function getState() { + return $this->getSessionKey('openid_connect_state'); } /** @@ -1448,9 +1837,35 @@ class OpenIDConnectClient * * @return void */ - protected function unsetState() + protected function unsetState() { + $this->unsetSessionKey('openid_connect_state'); + } + + /** + * Stores $codeVerifier + */ + protected function setCodeVerifier(string $codeVerifier): string { - unset($_SESSION['openid_connect_state']); + $this->setSessionKey('openid_connect_code_verifier', $codeVerifier); + return $codeVerifier; + } + + /** + * Get stored codeVerifier + * + * @return string + */ + protected function getCodeVerifier() { + return $this->getSessionKey('openid_connect_code_verifier'); + } + + /** + * Cleanup state + * + * @return void + */ + protected function unsetCodeVerifier() { + $this->unsetSessionKey('openid_connect_code_verifier'); } /** @@ -1458,58 +1873,212 @@ class OpenIDConnectClient * * @return int */ - public function getResponseCode() + public function getResponseCode(): int { return $this->responseCode; } + /** + * Get the content type from last action/curl request. + * + * @return string|null + */ + public function getResponseContentType() + { + return $this->responseContentType; + } + /** * Set timeout (seconds) * * @param int $timeout */ - public function setTimeout($timeout) - { + public function setTimeout(int $timeout) { $this->timeOut = $timeout; } - public function getTimeout() + public function getTimeout(): int { return $this->timeOut; } /** - * Safely calculate length of binary string - * @param string - * @return int + * Use session to manage a nonce + */ + protected function startSession() { + if (session_status() === PHP_SESSION_NONE) { + @session_start(); + } + } + + protected function commitSession() { + $this->startSession(); + + session_write_close(); + } + + protected function getSessionKey(string $key) { + $this->startSession(); + + if (array_key_exists($key, $_SESSION)) { + return $_SESSION[$key]; + } + return false; + } + + protected function setSessionKey(string $key, $value) { + $this->startSession(); + + $_SESSION[$key] = $value; + } + + protected function unsetSessionKey(string $key) { + $this->startSession(); + + unset($_SESSION[$key]); + } + + /** + * @throws Exception */ - private static function safeLength($str) + protected function getJWTClientAssertion($aud): string { - if (function_exists('mb_strlen')) { - return mb_strlen($str, '8bit'); + $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) + { + case PHP_QUERY_RFC1738: + $this->encType = PHP_QUERY_RFC1738; + break; + + case PHP_QUERY_RFC3986: + $this->encType = PHP_QUERY_RFC3986; + break; + + default: + break; } - return strlen($str); + } + + public function getScopes(): array + { + return $this->scopes; + } + + public function getResponseTypes(): array + { + return $this->responseTypes; + } + + public function getAuthParams(): array + { + return $this->authParams; } /** - * Where has_equals is not available, this provides a timing-attack safe string comparison - * @param $str1 - * @param $str2 - * @return bool + * @return callable + */ + public function getIssuerValidator() { + return $this->issuerValidator; + } + + + /** + * @return callable + */ + public function getPrivateKeyJwtGenerator() { + return $this->privateKeyJwtGenerator; + } + + public function getLeeway(): int + { + return $this->leeway; + } + + /** + * @return string + */ + public function getCodeChallengeMethod() { + return $this->codeChallengeMethod; + } + + public function setCodeChallengeMethod(string $codeChallengeMethod) { + $this->codeChallengeMethod = $codeChallengeMethod; + } + + /** + * @throws OpenIDConnectClientException */ - private static function hashEquals($str1, $str2) + 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 { - $len1 = static::safeLength($str1); - $len2 = static::safeLength($str2); + return $this->backChannelSid; + } + + public function getSubjectFromBackChannel(): string + { + return $this->backChannelSubject; + } - //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])); + 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; } - //if strings were different lengths, we fail - $status |= ($len1 ^ $len2); - return ($status === 0); + + return in_array($auth_method, $token_endpoint_auth_methods_supported, true); } -} + + protected function getUserAgent(): string + { + return "jumbojett/OpenID-Connect-PHP"; + } +} \ No newline at end of file