-
Notifications
You must be signed in to change notification settings - Fork 397
Add support for EdDSA #457
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -173,7 +173,7 @@ class OpenIDConnectClient | |
|
|
||
| /** | ||
| * @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 | ||
| * @see https://docs.microsoft.com/en-us/azure/active-directory-b2c/user-flow-overview | ||
| */ | ||
| private $wellKnownConfigParameters = []; | ||
|
|
||
|
|
@@ -253,6 +253,8 @@ class OpenIDConnectClient | |
| */ | ||
| private $token_endpoint_auth_methods_supported = ['client_secret_basic']; | ||
|
|
||
| private $statePayload = []; | ||
|
|
||
| /** | ||
| * @param string|null $provider_url optional | ||
| * @param string|null $client_id optional | ||
|
|
@@ -306,9 +308,10 @@ public function authenticate(): bool | |
|
|
||
| // If we have an authorization code then proceed to request a token | ||
| if (isset($_REQUEST['code'])) { | ||
| $state = $this->claimState(); | ||
|
|
||
| $code = $_REQUEST['code']; | ||
| $token_json = $this->requestTokens($code); | ||
| $token_json = $this->requestTokens($code, $state); | ||
|
|
||
| // Throw an error if the server returns one | ||
| if (isset($token_json->error)) { | ||
|
|
@@ -318,14 +321,6 @@ public function authenticate(): bool | |
| throw new OpenIDConnectClientException('Got response: ' . $token_json->error); | ||
| } | ||
|
|
||
| // Do an OpenID Connect session check | ||
| 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.'); | ||
| } | ||
|
|
@@ -349,10 +344,7 @@ public function authenticate(): bool | |
| $this->accessToken = $token_json->access_token; | ||
|
|
||
| // If this is a valid claim | ||
| if ($this->verifyJWTClaims($claims, $token_json->access_token)) { | ||
|
|
||
| // Clean up the session a little | ||
| $this->unsetNonce(); | ||
| if ($this->verifyJWTClaims($claims, $token_json->access_token, $state)) { | ||
|
|
||
| // Save the full response | ||
| $this->tokenResponse = $token_json; | ||
|
|
@@ -378,13 +370,7 @@ public function authenticate(): bool | |
|
|
||
| $accessToken = $_REQUEST['access_token'] ?? null; | ||
|
|
||
| // Do an OpenID Connect session check | ||
| if (!isset($_REQUEST['state']) || ($_REQUEST['state'] !== $this->getState())) { | ||
| throw new OpenIDConnectClientException('Unable to determine state'); | ||
| } | ||
|
|
||
| // Cleanup state | ||
| $this->unsetState(); | ||
| $state = $this->claimState(); | ||
|
|
||
| $claims = $this->decodeJWT($id_token, 1); | ||
|
|
||
|
|
@@ -395,10 +381,7 @@ public function authenticate(): bool | |
| $this->idToken = $id_token; | ||
|
|
||
| // If this is a valid claim | ||
| if ($this->verifyJWTClaims($claims, $accessToken)) { | ||
|
|
||
| // Clean up the session a little | ||
| $this->unsetNonce(); | ||
| if ($this->verifyJWTClaims($claims, $accessToken, $state)) { | ||
|
|
||
| // Save the verified claims | ||
| $this->verifiedClaims = $claims; | ||
|
|
@@ -686,7 +669,7 @@ public function getRedirectURL(): string | |
| } else { | ||
| $protocol = 'http'; | ||
| } | ||
|
|
||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a whitespace fix |
||
| if (isset($_SERVER['HTTP_X_FORWARDED_PORT'])) { | ||
| $port = (int)$_SERVER['HTTP_X_FORWARDED_PORT']; | ||
| } elseif (isset($_SERVER['SERVER_PORT'])) { | ||
|
|
@@ -709,7 +692,7 @@ public function getRedirectURL(): string | |
| } | ||
|
|
||
| $port = (443 === $port) || (80 === $port) ? '' : ':' . $port; | ||
|
|
||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a whitespace fix |
||
| $explodedRequestUri = isset($_SERVER['REQUEST_URI']) ? explode('?', $_SERVER['REQUEST_URI']) : []; | ||
| return sprintf('%s://%s%s/%s', $protocol, $host, $port, trim(reset($explodedRequestUri), '/')); | ||
| } | ||
|
|
@@ -742,19 +725,16 @@ 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 | ||
| $nonce = $this->setNonce($this->generateRandString()); | ||
|
|
||
| // State essentially acts as a session key for OIDC | ||
| $state = $this->setState($this->generateRandString()); | ||
| $state = $this->setState( | ||
| new StateData($this->generateRandString(), $this->generateRandString(), $this->statePayload) | ||
| ); | ||
|
|
||
| $auth_params = array_merge($this->authParams, [ | ||
| 'response_type' => $response_type, | ||
| 'redirect_uri' => $this->getRedirectURL(), | ||
| 'client_id' => $this->clientID, | ||
| 'nonce' => $nonce, | ||
| 'state' => $state, | ||
| 'nonce' => $state->getNonce(), | ||
| 'state' => $state->getId(), | ||
| 'scope' => 'openid' | ||
| ]); | ||
|
|
||
|
|
@@ -772,7 +752,7 @@ private function requestAuthorization() { | |
| $codeChallengeMethod = $this->getCodeChallengeMethod(); | ||
| if (!empty($codeChallengeMethod) && in_array($codeChallengeMethod, $this->getProviderConfigValue('code_challenge_methods_supported', []), true)) { | ||
| $codeVerifier = bin2hex(random_bytes(64)); | ||
| $this->setCodeVerifier($codeVerifier); | ||
| $this->setCodeVerifier($state, $codeVerifier); | ||
| if (!empty($this->pkceAlgs[$codeChallengeMethod])) { | ||
| $codeChallenge = rtrim(strtr(base64_encode(hash($this->pkceAlgs[$codeChallengeMethod], $codeVerifier, true)), '+/', '-_'), '='); | ||
| } else { | ||
|
|
@@ -863,7 +843,7 @@ public function requestResourceOwnerToken(bool $bClientAuth = false) { | |
| * @return mixed | ||
| * @throws OpenIDConnectClientException | ||
| */ | ||
| protected function requestTokens(string $code, array $headers = []) { | ||
| protected function requestTokens(string $code, StateData $state, array $headers = []) { | ||
| $token_endpoint = $this->getProviderConfigValue('token_endpoint'); | ||
| $token_endpoint_auth_methods_supported = $this->getProviderConfigValue('token_endpoint_auth_methods_supported', ['client_secret_basic']); | ||
|
|
||
|
|
@@ -906,7 +886,7 @@ protected function requestTokens(string $code, array $headers = []) { | |
| } | ||
|
|
||
| $ccm = $this->getCodeChallengeMethod(); | ||
| $cv = $this->getCodeVerifier(); | ||
| $cv = $state->getCodeVerifier(); | ||
| if (!empty($ccm) && !empty($cv)) { | ||
| $cs = $this->getClientSecret(); | ||
| if (empty($cs)) { | ||
|
|
@@ -915,7 +895,7 @@ protected function requestTokens(string $code, array $headers = []) { | |
| } | ||
| $token_params = array_merge($token_params, [ | ||
| 'client_id' => $this->clientID, | ||
| 'code_verifier' => $this->getCodeVerifier() | ||
| 'code_verifier' => $state->getCodeVerifier() | ||
| ]); | ||
| } | ||
|
|
||
|
|
@@ -1087,6 +1067,18 @@ private function verifyRSAJWTSignature(string $hashType, stdClass $key, $payload | |
| return $key->verify($payload, $signature); | ||
| } | ||
|
|
||
| private function verifyEdDSAJWTsignature($key, $payload, $signature) { | ||
| if (!(property_exists($key, 'x'))) { | ||
| throw new OpenIDConnectClientException('Malformed key object'); | ||
| } | ||
|
|
||
| if (!function_exists("sodium_crypto_sign_verify_detached")) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can be handled via composer as well. Please adjust composer.json and add |
||
| throw new OpenIDConnectClientException('sodium_crypto_sign_verify_detached support unavailable.'); | ||
| } | ||
|
|
||
| return sodium_crypto_sign_verify_detached($signature, $payload, base64url_decode($key->x)); | ||
| } | ||
|
|
||
| private function verifyHMACJWTSignature(string $hashType, string $key, string $payload, string $signature): bool | ||
| { | ||
| $expected = hash_hmac($hashType, $payload, $key, true); | ||
|
|
@@ -1145,6 +1137,24 @@ public function verifyJWTSignature(string $jwt): bool | |
| $jwk, | ||
| $payload, $signature, $signatureType); | ||
| break; | ||
| case 'EdDSA': | ||
| if (isset($header->jwk)) { | ||
| $jwk = $header->jwk; | ||
| $this->verifyJWKHeader($jwk); | ||
| } else { | ||
| $jwks = json_decode($this->fetchURL($this->getProviderConfigValue('jwks_uri'))); | ||
| if ($jwks === NULL) { | ||
| throw new OpenIDConnectClientException('Error decoding JSON from jwks_uri'); | ||
| } | ||
| $jwk = $this->getKeyForHeader($jwks->keys, $header); | ||
| } | ||
|
|
||
| $verified = $this->verifyEdDSAJWTsignature( | ||
| $jwk, | ||
| $payload, | ||
| $signature | ||
| ); | ||
| break; | ||
| case 'HS256': | ||
| case 'HS512': | ||
| case 'HS384': | ||
|
|
@@ -1189,15 +1199,22 @@ protected function validateIssuer(string $iss): bool | |
| * @return bool | ||
| * @throws OpenIDConnectClientException | ||
| */ | ||
| protected function verifyJWTClaims($claims, string $accessToken = null): bool | ||
| protected function verifyJWTClaims($claims, string $accessToken = null, StateData $state = 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 { | ||
| // TODO: Error case. throw exception??? | ||
| $bit = '256'; | ||
| switch($this->getIdTokenHeader()->alg ?? '') { | ||
| case 'EdDSA': | ||
| $bit = '512'; | ||
| break; | ||
| case 'none': | ||
| case '': | ||
| // TODO: Error case. throw exception??? | ||
| $bit = '256'; | ||
| break; | ||
| default: | ||
| $bit = substr($this->getIdTokenHeader()->alg, 2, 3); | ||
| } | ||
|
|
||
| $len = ((int)$bit)/16; | ||
| $expected_at_hash = $this->urlEncode(substr(hash('sha'.$bit, $accessToken, true), 0, $len)); | ||
| } | ||
|
|
@@ -1206,7 +1223,7 @@ protected function verifyJWTClaims($claims, string $accessToken = null): bool | |
| 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->nonce) || ($state !== null && $claims->nonce === $state->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 ) | ||
|
|
@@ -1785,48 +1802,27 @@ public function getTokenResponse() { | |
| } | ||
|
|
||
| /** | ||
| * Stores nonce | ||
| * Stores $state | ||
| */ | ||
| protected function setNonce(string $nonce): string | ||
| protected function setState(StateData $state): StateData | ||
| { | ||
| $this->setSessionKey('openid_connect_nonce', $nonce); | ||
| return $nonce; | ||
| } | ||
|
|
||
| /** | ||
| * Get stored nonce | ||
| * | ||
| * @return string | ||
| */ | ||
| protected function getNonce() { | ||
| return $this->getSessionKey('openid_connect_nonce'); | ||
| } | ||
| $this->setSessionKey('openid_connect_state', $state->toArray()); | ||
|
|
||
| /** | ||
| * Cleanup nonce | ||
| * | ||
| * @return void | ||
| */ | ||
| protected function unsetNonce() { | ||
| $this->unsetSessionKey('openid_connect_nonce'); | ||
| return $state; | ||
| } | ||
|
|
||
| /** | ||
| * Stores $state | ||
| */ | ||
| protected function setState(string $state): string | ||
| protected function claimState(): StateData | ||
| { | ||
| $this->setSessionKey('openid_connect_state', $state); | ||
| return $state; | ||
| } | ||
| // Do an OpenID Connect session check | ||
| $data = $this->getSessionKey('openid_connect_state'); | ||
| if (!isset($_REQUEST['state']) || $_REQUEST['state'] !== ($data['id'] ?? null)) { | ||
| throw new OpenIDConnectClientException('Unable to determine state'); | ||
| } | ||
|
|
||
| /** | ||
| * Get stored state | ||
| * | ||
| * @return string | ||
| */ | ||
| protected function getState() { | ||
| return $this->getSessionKey('openid_connect_state'); | ||
| // Cleanup state | ||
| $this->unsetState(); | ||
|
|
||
| return StateData::fromArray($data); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -1841,28 +1837,13 @@ protected function unsetState() { | |
| /** | ||
| * Stores $codeVerifier | ||
| */ | ||
| protected function setCodeVerifier(string $codeVerifier): string | ||
| protected function setCodeVerifier(StateData $state, string $codeVerifier): string | ||
| { | ||
| $this->setSessionKey('openid_connect_code_verifier', $codeVerifier); | ||
| return $codeVerifier; | ||
| } | ||
|
|
||
| /** | ||
| * Get stored codeVerifier | ||
| * | ||
| * @return string | ||
| */ | ||
| protected function getCodeVerifier() { | ||
| return $this->getSessionKey('openid_connect_code_verifier'); | ||
| } | ||
| $this->setState( | ||
| $state->setCodeVerifier($codeVerifier) | ||
| ); | ||
|
|
||
| /** | ||
| * Cleanup state | ||
| * | ||
| * @return void | ||
| */ | ||
| protected function unsetCodeVerifier() { | ||
| $this->unsetSessionKey('openid_connect_code_verifier'); | ||
| return $codeVerifier; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -2005,14 +1986,14 @@ public function getAuthParams(): array | |
| return $this->authParams; | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * @return callable | ||
| */ | ||
| public function getIssuerValidator() { | ||
| return $this->issuerValidator; | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * @return callable | ||
| */ | ||
|
|
@@ -2078,4 +2059,11 @@ protected function getUserAgent(): string | |
| { | ||
| return "jumbojett/OpenID-Connect-PHP"; | ||
| } | ||
|
|
||
| public function setStatePayload(array $data): self | ||
| { | ||
| $this->statePayload = $data; | ||
|
|
||
| return $this; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
a whitespace fix