Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed
- Stop adding ?schema=openid to userinfo endpoint URL. #449
- Add support for EdDSA signed JWTs
- Add support for a payload to be added to the state

## [1.0.1] - 2024-09-13

Expand Down
192 changes: 90 additions & 102 deletions src/OpenIDConnectClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a whitespace fix

*/
private $wellKnownConfigParameters = [];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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.');
}
Expand All @@ -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;
Expand All @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -686,7 +669,7 @@ public function getRedirectURL(): string
} else {
$protocol = 'http';
}

Copy link
Author

Choose a reason for hiding this comment

The 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'])) {
Expand All @@ -709,7 +692,7 @@ public function getRedirectURL(): string
}

$port = (443 === $port) || (80 === $port) ? '' : ':' . $port;

Copy link
Author

Choose a reason for hiding this comment

The 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), '/'));
}
Expand Down Expand Up @@ -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'
]);

Expand All @@ -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 {
Expand Down Expand Up @@ -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']);

Expand Down Expand Up @@ -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)) {
Expand All @@ -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()
]);
}

Expand Down Expand Up @@ -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")) {
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
"ext-sodium": "*",

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);
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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));
}
Expand All @@ -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 )
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -2005,14 +1986,14 @@ public function getAuthParams(): array
return $this->authParams;
}


/**
* @return callable
*/
public function getIssuerValidator() {
return $this->issuerValidator;
}


/**
* @return callable
*/
Expand Down Expand Up @@ -2078,4 +2059,11 @@ protected function getUserAgent(): string
{
return "jumbojett/OpenID-Connect-PHP";
}

public function setStatePayload(array $data): self
{
$this->statePayload = $data;

return $this;
}
}
Loading