oidc pkce support

This commit is contained in:
Yurii
2026-02-25 15:07:54 +02:00
parent 74b1c2ba98
commit d678307d2d
15 changed files with 253 additions and 4 deletions

View File

@@ -242,6 +242,11 @@ class Binding implements BindingProcessor
'Espo\\Core\\Utils\\Config\\ApplicationConfig', 'Espo\\Core\\Utils\\Config\\ApplicationConfig',
'applicationConfig' 'applicationConfig'
); );
$binder->bindService(
'Espo\\Core\\Session\\Session',
'session'
);
} }
private function bindCore(Binder $binder): void private function bindCore(Binder $binder): void

View File

@@ -222,6 +222,11 @@ class ConfigDataProvider
return $this->object->get('oidcAuthorizationPrompt') ?? 'consent'; return $this->object->get('oidcAuthorizationPrompt') ?? 'consent';
} }
public function useAuthorizationPkce(): bool
{
return (bool) $this->object->get('oidcAuthorizationPkce');
}
public function getAuthorizationMaxAge(): ?int public function getAuthorizationMaxAge(): ?int
{ {
return $this->config->get('oidcAuthorizationMaxAge'); return $this->config->get('oidcAuthorizationMaxAge');

View File

@@ -41,6 +41,7 @@ use Espo\Core\Authentication\Jwt\Validator;
use Espo\Core\Authentication\Oidc\UserProvider\UserInfo; use Espo\Core\Authentication\Oidc\UserProvider\UserInfo;
use Espo\Core\Authentication\Result; use Espo\Core\Authentication\Result;
use Espo\Core\Authentication\Result\FailReason; use Espo\Core\Authentication\Result\FailReason;
use Espo\Core\Session\Session;
use Espo\Core\Utils\Json; use Espo\Core\Utils\Json;
use Espo\Core\Utils\Log; use Espo\Core\Utils\Log;
use JsonException; use JsonException;
@@ -57,6 +58,8 @@ class Login implements LoginInterface
private const REQUEST_TIMEOUT = 10; private const REQUEST_TIMEOUT = 10;
private const NONCE_HEADER = 'X-Oidc-Authorization-Nonce'; private const NONCE_HEADER = 'X-Oidc-Authorization-Nonce';
public const string SESSION_KEY_CODE_VERIFIER = 'oidcCodeVerifier';
public function __construct( public function __construct(
private Espo $espoLogin, private Espo $espoLogin,
private Log $log, private Log $log,
@@ -66,6 +69,7 @@ class Login implements LoginInterface
private UserProvider $userProvider, private UserProvider $userProvider,
private ApplicationState $applicationState, private ApplicationState $applicationState,
private UserInfoDataProvider $userInfoDataProvider, private UserInfoDataProvider $userInfoDataProvider,
private Session $session,
) {} ) {}
public function login(Data $data, Request $request): Result public function login(Data $data, Request $request): Result
@@ -214,6 +218,7 @@ class Login implements LoginInterface
string $redirectUri, string $redirectUri,
string $clientSecret string $clientSecret
): array { ): array {
$params = [ $params = [
'grant_type' => 'authorization_code', 'grant_type' => 'authorization_code',
'client_id' => $clientId, 'client_id' => $clientId,
@@ -222,6 +227,12 @@ class Login implements LoginInterface
'redirect_uri' => $redirectUri, 'redirect_uri' => $redirectUri,
]; ];
if ($this->configDataProvider->useAuthorizationPkce()) {
$codeVerifier = $this->session->get(self::SESSION_KEY_CODE_VERIFIER);
$params['code_verifier'] = $codeVerifier;
}
$curl = curl_init(); $curl = curl_init();
curl_setopt_array($curl, [ curl_setopt_array($curl, [

View File

@@ -0,0 +1,54 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Authentication\Oidc;
class PkceUtil
{
private const string CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
private const int CODE_LENGTH = 64;
public static function generateCodeVerifier(): string
{
$output = '';
for ($i = 0; $i < self::CODE_LENGTH; $i++) {
$output .= self::CHARACTERS[random_int(0, strlen(self::CHARACTERS) - 1)];
}
return $output;
}
public static function hashAndEncodeCodeVerifier(string $codeVerifier): string
{
$code = hash('sha256', $codeVerifier, true);
return rtrim(strtr(base64_encode($code), '+/', '-_'), '=');
}
}

View File

@@ -0,0 +1,84 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Session;
use const PHP_SESSION_NONE;
/**
* Do not use directly. Require the Session interface instead.
*
* @internal
*/
class DefaultSession implements Session
{
public function __construct(
?string $cacheLimiter = null,
?int $cacheExpire = null,
) {
if (session_status() === PHP_SESSION_NONE) {
if ($cacheLimiter !== null) {
session_cache_limiter($cacheLimiter);
}
if ($cacheExpire !== null) {
session_cache_expire($cacheExpire);
}
session_start();
}
}
public function get(string $key): mixed
{
return $_SESSION[$key] ?? null;
}
public function set(string $key, mixed $value): Session
{
$_SESSION[$key] = $value;
return $this;
}
public function clear(string $key): void
{
unset($_SESSION[$key]);
}
public function clearAll(): void
{
session_unset();
}
public function has(string $key): bool
{
return array_key_exists($key, $_SESSION);
}
}

View File

@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Session;
/**
* A session wrapper.
*
* @since 9.4.0
*/
interface Session
{
public function get(string $key): mixed;
public function set(string $key, mixed $value): self;
public function clear(string $key): void;
public function clearAll(): void;
public function has(string $key): bool;
}

View File

@@ -298,6 +298,7 @@ return [
'oidcFallback' => true, 'oidcFallback' => true,
'oidcScopes' => ['profile', 'email', 'phone'], 'oidcScopes' => ['profile', 'email', 'phone'],
'oidcAuthorizationPrompt' => 'consent', 'oidcAuthorizationPrompt' => 'consent',
'oidcAuthorizationPkce' => true,
'listViewSettingsDisabled' => false, 'listViewSettingsDisabled' => false,
'cleanupDeletedRecords' => true, 'cleanupDeletedRecords' => true,
'phoneNumberNumericSearch' => true, 'phoneNumberNumericSearch' => true,

View File

@@ -166,6 +166,7 @@
"oidcAllowAdminUser": "OIDC Allow OIDC login for admin users", "oidcAllowAdminUser": "OIDC Allow OIDC login for admin users",
"oidcLogoutUrl": "OIDC Logout URL", "oidcLogoutUrl": "OIDC Logout URL",
"oidcAuthorizationPrompt": "OIDC Authorization Prompt", "oidcAuthorizationPrompt": "OIDC Authorization Prompt",
"oidcAuthorizationPkce": "OIDC Use PKCE",
"pdfEngine": "PDF Engine", "pdfEngine": "PDF Engine",
"quickSearchFullTextAppendWildcard": "Append wildcard in quick search", "quickSearchFullTextAppendWildcard": "Append wildcard in quick search",
"authIpAddressCheck": "Restrict access by IP address", "authIpAddressCheck": "Restrict access by IP address",

View File

@@ -130,6 +130,9 @@
"oidcLogoutUrl": { "oidcLogoutUrl": {
"level": "admin" "level": "admin"
}, },
"oidcAuthorizationPkce": {
"level": "admin"
},
"apiCorsAllowedMethodList": { "apiCorsAllowedMethodList": {
"level": "admin" "level": "admin"
}, },

View File

@@ -91,5 +91,8 @@
}, },
"applicationConfig": { "applicationConfig": {
"className": "Espo\\Core\\Utils\\Config\\ApplicationConfig" "className": "Espo\\Core\\Utils\\Config\\ApplicationConfig"
},
"session": {
"className": "Espo\\Core\\Session\\DefaultSession"
} }
} }

View File

@@ -104,7 +104,9 @@
{ {
"name": "oidcAuthorizationPrompt" "name": "oidcAuthorizationPrompt"
}, },
false {
"name": "oidcAuthorizationPkce"
}
] ]
] ]
}, },

View File

@@ -84,6 +84,10 @@
"select_account" "select_account"
], ],
"maxLength": 14 "maxLength": 14
},
"oidcAuthorizationPkce": {
"type": "bool",
"default": true
} }
} }
} }

View File

@@ -912,6 +912,10 @@
"select_account" "select_account"
] ]
}, },
"oidcAuthorizationPkce": {
"type": "bool",
"default": true
},
"pdfEngine": { "pdfEngine": {
"type": "enum", "type": "enum",
"view": "views/settings/fields/pdf-engine" "view": "views/settings/fields/pdf-engine"

View File

@@ -33,9 +33,11 @@ use Espo\Core\Authentication\Jwt\Exceptions\Invalid;
use Espo\Core\Authentication\Oidc\ConfigDataProvider; use Espo\Core\Authentication\Oidc\ConfigDataProvider;
use Espo\Core\Authentication\Oidc\Login as OidcLogin; use Espo\Core\Authentication\Oidc\Login as OidcLogin;
use Espo\Core\Authentication\Oidc\BackchannelLogout; use Espo\Core\Authentication\Oidc\BackchannelLogout;
use Espo\Core\Authentication\Oidc\PkceUtil;
use Espo\Core\Authentication\Util\MethodProvider; use Espo\Core\Authentication\Util\MethodProvider;
use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Session\Session;
use Espo\Core\Utils\Json; use Espo\Core\Utils\Json;
class Service class Service
@@ -43,7 +45,8 @@ class Service
public function __construct( public function __construct(
private BackchannelLogout $backchannelLogout, private BackchannelLogout $backchannelLogout,
private MethodProvider $methodProvider, private MethodProvider $methodProvider,
private ConfigDataProvider $configDataProvider private ConfigDataProvider $configDataProvider,
private Session $session,
) {} ) {}
/** /**
@@ -55,6 +58,8 @@ class Service
* claims: ?string, * claims: ?string,
* prompt: 'none'|'login'|'consent'|'select_account', * prompt: 'none'|'login'|'consent'|'select_account',
* maxAge: ?int, * maxAge: ?int,
* codeChallenge: ?string,
* codeChallengeMethod: ?string,
* } * }
* @throws Forbidden * @throws Forbidden
* @throws Error * @throws Error
@@ -70,6 +75,7 @@ class Service
$scopes = $this->configDataProvider->getScopes(); $scopes = $this->configDataProvider->getScopes();
$groupClaim = $this->configDataProvider->getGroupClaim(); $groupClaim = $this->configDataProvider->getGroupClaim();
$redirectUri = $this->configDataProvider->getRedirectUri(); $redirectUri = $this->configDataProvider->getRedirectUri();
$codeChallenge = $this->configDataProvider->useAuthorizationPkce() ? $this->prepareCodeChallenge() : null;
if (!$clientId) { if (!$clientId) {
throw new Error("No client ID."); throw new Error("No client ID.");
@@ -105,6 +111,8 @@ class Service
'claims' => $claims, 'claims' => $claims,
'prompt' => $prompt, 'prompt' => $prompt,
'maxAge' => $maxAge, 'maxAge' => $maxAge,
'codeChallenge' => $codeChallenge,
'codeChallengeMethod' => $codeChallenge ? 'S256' : null,
]; ];
} }
@@ -123,4 +131,13 @@ class Service
throw new Forbidden("OIDC logout: Invalid JWT. " . $e->getMessage()); throw new Forbidden("OIDC logout: Invalid JWT. " . $e->getMessage());
} }
} }
private function prepareCodeChallenge(): string
{
$codeVerifier = PkceUtil::generateCodeVerifier();
$this->session->set(OidcLogin::SESSION_KEY_CODE_VERIFIER, $codeVerifier);
return PkceUtil::hashAndEncodeCodeVerifier($codeVerifier);
}
} }

View File

@@ -82,9 +82,11 @@ class OidcLoginHandler extends LoginHandler {
* clientId: string, * clientId: string,
* redirectUri: string, * redirectUri: string,
* scopes: string[], * scopes: string[],
* claims: ?string, * claims: string|null,
* prompt: 'login'|'consent'|'select_account', * prompt: 'login'|'consent'|'select_account',
* maxAge: ?Number, * maxAge: Number|null,
* codeChallenge: string|null,
* codeChallengeMethod: string|null
* }} data * }} data
* @param {WindowProxy} proxy * @param {WindowProxy} proxy
* @return {Promise<{code: string, nonce: string}>} * @return {Promise<{code: string, nonce: string}>}
@@ -103,6 +105,11 @@ class OidcLoginHandler extends LoginHandler {
prompt: data.prompt, prompt: data.prompt,
}; };
if (data.codeChallenge && data.codeChallengeMethod) {
params.code_challenge = data.codeChallenge;
params.code_challenge_method = data.codeChallengeMethod;
}
if (data.maxAge || data.maxAge === 0) { if (data.maxAge || data.maxAge === 0) {
params.max_age = data.maxAge; params.max_age = data.maxAge;
} }