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',
'applicationConfig'
);
$binder->bindService(
'Espo\\Core\\Session\\Session',
'session'
);
}
private function bindCore(Binder $binder): void

View File

@@ -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');

View File

@@ -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, [

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,
'oidcScopes' => ['profile', 'email', 'phone'],
'oidcAuthorizationPrompt' => 'consent',
'oidcAuthorizationPkce' => true,
'listViewSettingsDisabled' => false,
'cleanupDeletedRecords' => true,
'phoneNumberNumericSearch' => true,

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -912,6 +912,10 @@
"select_account"
]
},
"oidcAuthorizationPkce": {
"type": "bool",
"default": true
},
"pdfEngine": {
"type": "enum",
"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\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);
}
}

View File

@@ -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;
}