From d678307d2d8a016dccd2634337529033d5b5205d Mon Sep 17 00:00:00 2001 From: Yurii Date: Wed, 25 Feb 2026 15:07:54 +0200 Subject: [PATCH] oidc pkce support --- application/Espo/Binding.php | 5 ++ .../Oidc/ConfigDataProvider.php | 5 ++ .../Espo/Core/Authentication/Oidc/Login.php | 11 +++ .../Core/Authentication/Oidc/PkceUtil.php | 54 ++++++++++++ .../Espo/Core/Session/DefaultSession.php | 84 +++++++++++++++++++ application/Espo/Core/Session/Session.php | 48 +++++++++++ .../Espo/Resources/defaults/config.php | 1 + .../Espo/Resources/i18n/en_US/Settings.json | 1 + .../Espo/Resources/metadata/app/config.json | 3 + .../metadata/app/containerServices.json | 3 + .../metadata/authenticationMethods/Oidc.json | 4 +- .../entityDefs/AuthenticationProvider.json | 4 + .../metadata/entityDefs/Settings.json | 4 + application/Espo/Tools/Oidc/Service.php | 19 ++++- client/src/handlers/login/oidc.js | 11 ++- 15 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 application/Espo/Core/Authentication/Oidc/PkceUtil.php create mode 100644 application/Espo/Core/Session/DefaultSession.php create mode 100644 application/Espo/Core/Session/Session.php diff --git a/application/Espo/Binding.php b/application/Espo/Binding.php index e20f4e5854..e54ac2cb29 100644 --- a/application/Espo/Binding.php +++ b/application/Espo/Binding.php @@ -242,6 +242,11 @@ class Binding implements BindingProcessor 'Espo\\Core\\Utils\\Config\\ApplicationConfig', 'applicationConfig' ); + + $binder->bindService( + 'Espo\\Core\\Session\\Session', + 'session' + ); } private function bindCore(Binder $binder): void diff --git a/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php b/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php index 863212857a..0182b46e7d 100644 --- a/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php +++ b/application/Espo/Core/Authentication/Oidc/ConfigDataProvider.php @@ -222,6 +222,11 @@ class ConfigDataProvider return $this->object->get('oidcAuthorizationPrompt') ?? 'consent'; } + public function useAuthorizationPkce(): bool + { + return (bool) $this->object->get('oidcAuthorizationPkce'); + } + public function getAuthorizationMaxAge(): ?int { return $this->config->get('oidcAuthorizationMaxAge'); diff --git a/application/Espo/Core/Authentication/Oidc/Login.php b/application/Espo/Core/Authentication/Oidc/Login.php index fcf6760efe..c6c54526ee 100644 --- a/application/Espo/Core/Authentication/Oidc/Login.php +++ b/application/Espo/Core/Authentication/Oidc/Login.php @@ -41,6 +41,7 @@ use Espo\Core\Authentication\Jwt\Validator; use Espo\Core\Authentication\Oidc\UserProvider\UserInfo; use Espo\Core\Authentication\Result; use Espo\Core\Authentication\Result\FailReason; +use Espo\Core\Session\Session; use Espo\Core\Utils\Json; use Espo\Core\Utils\Log; use JsonException; @@ -57,6 +58,8 @@ class Login implements LoginInterface private const REQUEST_TIMEOUT = 10; private const NONCE_HEADER = 'X-Oidc-Authorization-Nonce'; + public const string SESSION_KEY_CODE_VERIFIER = 'oidcCodeVerifier'; + public function __construct( private Espo $espoLogin, private Log $log, @@ -66,6 +69,7 @@ class Login implements LoginInterface private UserProvider $userProvider, private ApplicationState $applicationState, private UserInfoDataProvider $userInfoDataProvider, + private Session $session, ) {} public function login(Data $data, Request $request): Result @@ -214,6 +218,7 @@ class Login implements LoginInterface string $redirectUri, string $clientSecret ): array { + $params = [ 'grant_type' => 'authorization_code', 'client_id' => $clientId, @@ -222,6 +227,12 @@ class Login implements LoginInterface 'redirect_uri' => $redirectUri, ]; + if ($this->configDataProvider->useAuthorizationPkce()) { + $codeVerifier = $this->session->get(self::SESSION_KEY_CODE_VERIFIER); + + $params['code_verifier'] = $codeVerifier; + } + $curl = curl_init(); curl_setopt_array($curl, [ diff --git a/application/Espo/Core/Authentication/Oidc/PkceUtil.php b/application/Espo/Core/Authentication/Oidc/PkceUtil.php new file mode 100644 index 0000000000..e365649cfc --- /dev/null +++ b/application/Espo/Core/Authentication/Oidc/PkceUtil.php @@ -0,0 +1,54 @@ +. + * + * 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), '+/', '-_'), '='); + } +} diff --git a/application/Espo/Core/Session/DefaultSession.php b/application/Espo/Core/Session/DefaultSession.php new file mode 100644 index 0000000000..c441ee7e8a --- /dev/null +++ b/application/Espo/Core/Session/DefaultSession.php @@ -0,0 +1,84 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Core/Session/Session.php b/application/Espo/Core/Session/Session.php new file mode 100644 index 0000000000..6de4de3924 --- /dev/null +++ b/application/Espo/Core/Session/Session.php @@ -0,0 +1,48 @@ +. + * + * 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; +} diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index 8f15ab6098..a052dde67a 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -298,6 +298,7 @@ return [ 'oidcFallback' => true, 'oidcScopes' => ['profile', 'email', 'phone'], 'oidcAuthorizationPrompt' => 'consent', + 'oidcAuthorizationPkce' => true, 'listViewSettingsDisabled' => false, 'cleanupDeletedRecords' => true, 'phoneNumberNumericSearch' => true, diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index 3b690c4fc1..c606dbc71c 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -166,6 +166,7 @@ "oidcAllowAdminUser": "OIDC Allow OIDC login for admin users", "oidcLogoutUrl": "OIDC Logout URL", "oidcAuthorizationPrompt": "OIDC Authorization Prompt", + "oidcAuthorizationPkce": "OIDC Use PKCE", "pdfEngine": "PDF Engine", "quickSearchFullTextAppendWildcard": "Append wildcard in quick search", "authIpAddressCheck": "Restrict access by IP address", diff --git a/application/Espo/Resources/metadata/app/config.json b/application/Espo/Resources/metadata/app/config.json index 2dd46e4a59..2973f3b414 100644 --- a/application/Espo/Resources/metadata/app/config.json +++ b/application/Espo/Resources/metadata/app/config.json @@ -130,6 +130,9 @@ "oidcLogoutUrl": { "level": "admin" }, + "oidcAuthorizationPkce": { + "level": "admin" + }, "apiCorsAllowedMethodList": { "level": "admin" }, diff --git a/application/Espo/Resources/metadata/app/containerServices.json b/application/Espo/Resources/metadata/app/containerServices.json index 3b3715713e..2f844ffead 100644 --- a/application/Espo/Resources/metadata/app/containerServices.json +++ b/application/Espo/Resources/metadata/app/containerServices.json @@ -91,5 +91,8 @@ }, "applicationConfig": { "className": "Espo\\Core\\Utils\\Config\\ApplicationConfig" + }, + "session": { + "className": "Espo\\Core\\Session\\DefaultSession" } } diff --git a/application/Espo/Resources/metadata/authenticationMethods/Oidc.json b/application/Espo/Resources/metadata/authenticationMethods/Oidc.json index e9460df13d..8eb8cb8a94 100644 --- a/application/Espo/Resources/metadata/authenticationMethods/Oidc.json +++ b/application/Espo/Resources/metadata/authenticationMethods/Oidc.json @@ -104,7 +104,9 @@ { "name": "oidcAuthorizationPrompt" }, - false + { + "name": "oidcAuthorizationPkce" + } ] ] }, diff --git a/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json b/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json index 4fa8005a60..448bf172b8 100644 --- a/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json +++ b/application/Espo/Resources/metadata/entityDefs/AuthenticationProvider.json @@ -84,6 +84,10 @@ "select_account" ], "maxLength": 14 + }, + "oidcAuthorizationPkce": { + "type": "bool", + "default": true } } } diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index ae6e4c3c6f..5a523c5471 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -912,6 +912,10 @@ "select_account" ] }, + "oidcAuthorizationPkce": { + "type": "bool", + "default": true + }, "pdfEngine": { "type": "enum", "view": "views/settings/fields/pdf-engine" diff --git a/application/Espo/Tools/Oidc/Service.php b/application/Espo/Tools/Oidc/Service.php index 797cc68726..ad46a236a7 100644 --- a/application/Espo/Tools/Oidc/Service.php +++ b/application/Espo/Tools/Oidc/Service.php @@ -33,9 +33,11 @@ use Espo\Core\Authentication\Jwt\Exceptions\Invalid; use Espo\Core\Authentication\Oidc\ConfigDataProvider; use Espo\Core\Authentication\Oidc\Login as OidcLogin; use Espo\Core\Authentication\Oidc\BackchannelLogout; +use Espo\Core\Authentication\Oidc\PkceUtil; use Espo\Core\Authentication\Util\MethodProvider; use Espo\Core\Exceptions\Error; use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Session\Session; use Espo\Core\Utils\Json; class Service @@ -43,7 +45,8 @@ class Service public function __construct( private BackchannelLogout $backchannelLogout, private MethodProvider $methodProvider, - private ConfigDataProvider $configDataProvider + private ConfigDataProvider $configDataProvider, + private Session $session, ) {} /** @@ -55,6 +58,8 @@ class Service * claims: ?string, * prompt: 'none'|'login'|'consent'|'select_account', * maxAge: ?int, + * codeChallenge: ?string, + * codeChallengeMethod: ?string, * } * @throws Forbidden * @throws Error @@ -70,6 +75,7 @@ class Service $scopes = $this->configDataProvider->getScopes(); $groupClaim = $this->configDataProvider->getGroupClaim(); $redirectUri = $this->configDataProvider->getRedirectUri(); + $codeChallenge = $this->configDataProvider->useAuthorizationPkce() ? $this->prepareCodeChallenge() : null; if (!$clientId) { throw new Error("No client ID."); @@ -105,6 +111,8 @@ class Service 'claims' => $claims, 'prompt' => $prompt, 'maxAge' => $maxAge, + 'codeChallenge' => $codeChallenge, + 'codeChallengeMethod' => $codeChallenge ? 'S256' : null, ]; } @@ -123,4 +131,13 @@ class Service 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); + } } diff --git a/client/src/handlers/login/oidc.js b/client/src/handlers/login/oidc.js index 7927eb79bc..26cace5dda 100644 --- a/client/src/handlers/login/oidc.js +++ b/client/src/handlers/login/oidc.js @@ -82,9 +82,11 @@ class OidcLoginHandler extends LoginHandler { * clientId: string, * redirectUri: string, * scopes: string[], - * claims: ?string, + * claims: string|null, * prompt: 'login'|'consent'|'select_account', - * maxAge: ?Number, + * maxAge: Number|null, + * codeChallenge: string|null, + * codeChallengeMethod: string|null * }} data * @param {WindowProxy} proxy * @return {Promise<{code: string, nonce: string}>} @@ -103,6 +105,11 @@ class OidcLoginHandler extends LoginHandler { 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) { params.max_age = data.maxAge; }