oidc userinfo support

This commit is contained in:
Yuri Kuznetsov
2025-04-17 12:14:14 +03:00
parent 2b98eef915
commit 268615d8fe
12 changed files with 267 additions and 50 deletions

View File

@@ -104,6 +104,11 @@ class ConfigDataProvider
return $this->object->get('oidcTokenEndpoint');
}
public function getUserInfoEndpoint(): ?string
{
return $this->object->get('oidcUserInfoEndpoint');
}
public function getJwksEndpoint(): ?string
{
return $this->object->get('oidcJwksEndpoint');

View File

@@ -38,6 +38,7 @@ use Espo\Core\Authentication\Logins\Espo;
use Espo\Core\Authentication\Jwt\Exceptions\Invalid;
use Espo\Core\Authentication\Jwt\Exceptions\SignatureNotVerified;
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\Utils\Json;
@@ -45,6 +46,7 @@ use Espo\Core\Utils\Log;
use JsonException;
use LogicException;
use RuntimeException;
use SensitiveParameter;
use stdClass;
class Login implements LoginInterface
@@ -62,7 +64,8 @@ class Login implements LoginInterface
private Validator $validator,
private TokenValidator $tokenValidator,
private UserProvider $userProvider,
private ApplicationState $applicationState
private ApplicationState $applicationState,
private UserInfoDataProvider $userInfoDataProvider,
) {}
public function login(Data $data, Request $request): Result
@@ -99,7 +102,8 @@ class Login implements LoginInterface
throw new RuntimeException("No client secret.");
}
[$rawToken, $failResult] = $this->requestToken($endpoint, $clientId, $code, $redirectUri, $clientSecret);
[$rawToken, $failResult, $accessToken] =
$this->requestToken($endpoint, $clientId, $code, $redirectUri, $clientSecret);
if ($failResult) {
return $failResult;
@@ -144,7 +148,9 @@ class Login implements LoginInterface
return Result::fail(FailReason::DENIED);
}
$user = $this->userProvider->get($tokenPayload);
$userInfo = $this->getUserInfo($tokenPayload, $accessToken);
$user = $this->userProvider->get($userInfo);
if (!$user) {
return Result::fail(FailReason::USER_NOT_FOUND);
@@ -198,7 +204,7 @@ class Login implements LoginInterface
}
/**
* @return array{?string, ?Result}
* @return array{?string, ?Result, ?string}
*/
private function requestToken(
string $endpoint,
@@ -250,7 +256,7 @@ class Login implements LoginInterface
$this->log->warning(self::composeLogMessage('Token request error.', $status, $response));
return [null, Result::fail(FailReason::DENIED)];
return [null, Result::fail(FailReason::DENIED), null];
}
$parsedResponse = null;
@@ -266,6 +272,7 @@ class Login implements LoginInterface
}
$token = $parsedResponse->id_token ?? null;
$accessToken = $parsedResponse->access_token ?? null;
if (!$token || !is_string($token)) {
$this->log->error(self::composeLogMessage('Bad token response.', $status, $response));
@@ -273,7 +280,7 @@ class Login implements LoginInterface
throw new RuntimeException();
}
return [$token, null];
return [$token, null, $accessToken];
}
private static function composeLogMessage(string $text, ?int $status = null, ?string $response = null): string
@@ -295,4 +302,21 @@ class Login implements LoginInterface
$this->tokenValidator->validateFields($token);
$this->tokenValidator->validateSignature($token);
}
private function getUserInfo(Token\Payload $payload, #[SensitiveParameter] ?string $accessToken): UserInfo
{
$endpoint = $this->configDataProvider->getUserInfoEndpoint();
if (!$endpoint) {
return new UserInfo($payload, []);
}
if (!$accessToken) {
throw new RuntimeException("OIDC: No access token received.");
}
$data = $this->userInfoDataProvider->get($accessToken);
return new UserInfo($payload, $data);
}
}

View File

@@ -0,0 +1,121 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* 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;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\Log;
use JsonException;
use RuntimeException;
use SensitiveParameter;
class UserInfoDataProvider
{
private const REQUEST_TIMEOUT = 10;
public function __construct(
private ConfigDataProvider $configDataProvider,
private Log $log,
) {}
/**
* @return array<string, mixed>
*/
public function get(#[SensitiveParameter] string $accessToken): array
{
return $this->load($accessToken);
}
/**
* @return array<string, mixed>
*/
private function load(#[SensitiveParameter] string $accessToken): array
{
$endpoint = $this->configDataProvider->getUserInfoEndpoint();
if (!$endpoint) {
throw new RuntimeException("No userinfo endpoint.");
}
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $endpoint,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $accessToken,
'Accept: application/json',
],
]);
/** @var string|false $response */
$response = curl_exec($curl);
$error = curl_error($curl);
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($response === false) {
$response = '';
}
if ($error || is_int($status) && ($status >= 400 && $status < 500)) {
$this->log->error(self::composeLogMessage('UserInfo response error.', $status, $response));
throw new RuntimeException("OIDC: Userinfo request error.");
}
$parsedResponse = null;
try {
$parsedResponse = Json::decode($response, true);
} catch (JsonException) {}
if (!is_array($parsedResponse)) {
throw new RuntimeException("OIDC: Bad userinfo response.");
}
return $parsedResponse;
}
private static function composeLogMessage(string $text, ?int $status = null, ?string $response = null): string
{
if ($status === null) {
return "OIDC: $text";
}
return "OIDC: $text; Status: $status; Response: $response";
}
}

View File

@@ -29,10 +29,10 @@
namespace Espo\Core\Authentication\Oidc;
use Espo\Core\Authentication\Jwt\Token\Payload;
use Espo\Core\Authentication\Oidc\UserProvider\UserInfo;
use Espo\Entities\User;
interface UserProvider
{
public function get(Payload $payload): ?User;
public function get(UserInfo $userInfo): ?User;
}

View File

@@ -30,11 +30,11 @@
namespace Espo\Core\Authentication\Oidc\UserProvider;
use Espo\Core\ApplicationState;
use Espo\Core\Authentication\Jwt\Token\Payload;
use Espo\Core\Authentication\Oidc\ConfigDataProvider;
use Espo\Core\Authentication\Oidc\UserProvider;
use Espo\Core\Utils\Log;
use Espo\Entities\User;
use RuntimeException;
class DefaultUserProvider implements UserProvider
@@ -44,30 +44,30 @@ class DefaultUserProvider implements UserProvider
private Sync $sync,
private UserRepository $userRepository,
private ApplicationState $applicationState,
private Log $log
private Log $log,
) {}
public function get(Payload $payload): ?User
public function get(UserInfo $userInfo): ?User
{
$user = $this->findUser($payload);
$user = $this->findUser($userInfo);
if ($user === false) {
return null;
}
if ($user) {
$this->syncUser($user, $payload);
$this->syncUser($user, $userInfo);
return $user;
}
return $this->tryToCreateUser($payload);
return $this->tryToCreateUser($userInfo);
}
/**
* @return User|false|null
*/
private function findUser(Payload $payload): User|bool|null
private function findUser(UserInfo $userInfo): User|bool|null
{
$usernameClaim = $this->configDataProvider->getUsernameClaim();
@@ -75,10 +75,10 @@ class DefaultUserProvider implements UserProvider
throw new RuntimeException("No username claim in config.");
}
$username = $payload->get($usernameClaim);
$username = $userInfo->get($usernameClaim);
if (!$username) {
throw new RuntimeException("No username claim `$usernameClaim` in token.");
throw new RuntimeException("No username claim `$usernameClaim` in token and userinfo.");
}
$username = $this->sync->normalizeUsername($username);
@@ -136,7 +136,7 @@ class DefaultUserProvider implements UserProvider
return $user;
}
private function tryToCreateUser(Payload $payload): ?User
private function tryToCreateUser(UserInfo $userInfo): ?User
{
if (!$this->configDataProvider->createUser()) {
return null;
@@ -148,16 +148,16 @@ class DefaultUserProvider implements UserProvider
throw new RuntimeException("Could not create a user. No OIDC username claim in config.");
}
$username = $payload->get($usernameClaim);
$username = $userInfo->get($usernameClaim);
if (!$username) {
throw new RuntimeException("Could not create a user. No username claim returned in token.");
throw new RuntimeException("Could not create a user. No username claim in token and userinfo.");
}
return $this->sync->createUser($payload);
return $this->sync->createUser($userInfo);
}
private function syncUser(User $user, Payload $payload): void
private function syncUser(User $user, UserInfo $userInfo): void
{
if (
!$this->configDataProvider->sync() &&
@@ -166,6 +166,6 @@ class DefaultUserProvider implements UserProvider
return;
}
$this->sync->syncUser($user, $payload);
$this->sync->syncUser($user, $userInfo);
}
}

View File

@@ -31,7 +31,6 @@ namespace Espo\Core\Authentication\Oidc\UserProvider;
use Espo\Core\Acl\Cache\Clearer as AclCacheClearer;
use Espo\Core\ApplicationState;
use Espo\Core\Authentication\Jwt\Token\Payload;
use Espo\Core\Authentication\Oidc\ConfigDataProvider;
use Espo\Core\Field\LinkMultiple;
use Espo\Core\Name\Field;
@@ -50,30 +49,31 @@ class Sync
private UserRepository $userRepository,
private PasswordHash $passwordHash,
private AclCacheClearer $aclCacheClearer,
private ApplicationState $applicationState
private ApplicationState $applicationState,
) {}
public function createUser(Payload $payload): User
public function createUser(UserInfo $userInfo): User
{
$username = $this->getUsernameFromToken($payload);
$username = $this->getUsernameFromToken($userInfo);
$this->usernameValidator->validate($username);
$user = $this->userRepository->getNew();
$user->set([
'type' => User::TYPE_REGULAR,
'userName' => $username,
$user->setType(User::TYPE_REGULAR);
$user->setUserName($username);
$user->setMultiple([
'password' => $this->passwordHash->hash(Util::generatePassword(10, 4, 2, true)),
]);
$user->set($this->getUserDataFromToken($payload));
$user->set($this->getUserTeamsDataFromToken($payload));
$user->set($this->getUserDataFromToken($userInfo));
$user->set($this->getUserTeamsDataFromToken($userInfo));
if ($this->applicationState->isPortal()) {
$portalId = $this->applicationState->getPortalId();
$user->set('type', User::TYPE_PORTAL);
$user->setType(User::TYPE_PORTAL);
$user->setPortals(LinkMultiple::create()->withAddedId($portalId));
}
@@ -82,7 +82,7 @@ class Sync
return $user;
}
public function syncUser(User $user, Payload $payload): void
public function syncUser(User $user, UserInfo $payload): void
{
$username = $this->getUsernameFromToken($payload);
@@ -116,19 +116,19 @@ class Sync
/**
* @return array<string, mixed>
*/
private function getUserDataFromToken(Payload $payload): array
private function getUserDataFromToken(UserInfo $userInfo): array
{
return [
'emailAddress' => $payload->get('email'),
'phoneNumber' => $payload->get('phone_number'),
'emailAddress' => $userInfo->get('email'),
'phoneNumber' => $userInfo->get('phone_number'),
'emailAddressData' => null,
'phoneNumberData' => null,
'firstName' => $payload->get('given_name'),
'lastName' => $payload->get('family_name'),
'middle_name' => $payload->get('middle_name'),
'firstName' => $userInfo->get('given_name'),
'lastName' => $userInfo->get('family_name'),
'middle_name' => $userInfo->get('middle_name'),
'gender' =>
in_array($payload->get('gender'), ['male', 'female']) ?
ucfirst($payload->get('gender') ?? '') :
in_array($userInfo->get('gender'), ['male', 'female']) ?
ucfirst($userInfo->get('gender') ?? '') :
null,
];
}
@@ -136,14 +136,14 @@ class Sync
/**
* @return array<string, mixed>
*/
private function getUserTeamsDataFromToken(Payload $payload): array
private function getUserTeamsDataFromToken(UserInfo $userInfo): array
{
return [
'teamsIds' => $this->getTeamIdList($payload),
'teamsIds' => $this->getTeamIdList($userInfo),
];
}
private function getUsernameFromToken(Payload $payload): string
private function getUsernameFromToken(UserInfo $userInfo): string
{
$usernameClaim = $this->configDataProvider->getUsernameClaim();
@@ -151,7 +151,7 @@ class Sync
throw new RuntimeException("No OIDC username claim in config.");
}
$username = $payload->get($usernameClaim);
$username = $userInfo->get($usernameClaim);
if (!$username) {
throw new RuntimeException("No username claim returned in token.");
@@ -167,7 +167,7 @@ class Sync
/**
* @return string[]
*/
private function getTeamIdList(Payload $payload): array
private function getTeamIdList(UserInfo $userInfo): array
{
$idList = $this->configDataProvider->getTeamIds() ?? [];
$columns = $this->configDataProvider->getTeamColumns() ?? (object) [];
@@ -176,7 +176,7 @@ class Sync
return [];
}
$groupList = $this->getGroups($payload);
$groupList = $this->getGroups($userInfo);
$resultIdList = [];
@@ -194,7 +194,7 @@ class Sync
/**
* @return string[]
*/
private function getGroups(Payload $payload): array
private function getGroups(UserInfo $userInfo): array
{
$groupClaim = $this->configDataProvider->getGroupClaim();
@@ -202,7 +202,7 @@ class Sync
return [];
}
$value = $payload->get($groupClaim);
$value = $userInfo->get($groupClaim);
if (!$value) {
return [];

View File

@@ -0,0 +1,49 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* 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\UserProvider;
use Espo\Core\Authentication\Jwt\Token\Payload;
class UserInfo
{
/**
* @internal
* @param array<string, mixed> $data
*/
public function __construct(
private Payload $payload,
private array $data,
) {}
public function get(string $name): mixed
{
return $this->payload->get($name) ?? $this->data[$name] ?? null;
}
}

View File

@@ -151,6 +151,7 @@
"oidcAuthorizationRedirectUri": "OIDC Authorization Redirect URI",
"oidcAuthorizationEndpoint": "OIDC Authorization Endpoint",
"oidcTokenEndpoint": "OIDC Token Endpoint",
"oidcUserInfoEndpoint": "OIDC UserInfo Endpoint",
"oidcJwksEndpoint": "OIDC JSON Web Key Set Endpoint",
"oidcJwtSignatureAlgorithmList": "OIDC JWT Allowed Signature Algorithms",
"oidcScopes": "OIDC Scopes",

View File

@@ -70,6 +70,9 @@
"oidcAuthorizationEndpoint": {
"level": "admin"
},
"oidcUserInfoEndpoint": {
"level": "admin"
},
"oidcTokenEndpoint": {
"level": "admin"
},

View File

@@ -48,6 +48,12 @@
"name": "oidcJwtSignatureAlgorithmList"
}
],
[
{
"name": "oidcUserInfoEndpoint"
},
false
],
[
{
"name": "oidcScopes"

View File

@@ -28,6 +28,10 @@
"type": "url",
"strip": false
},
"oidcUserInfoEndpoint": {
"type": "url",
"strip": false
},
"oidcTokenEndpoint": {
"type": "url",
"strip": false

View File

@@ -824,6 +824,10 @@
"type": "url",
"strip": false
},
"oidcUserInfoEndpoint": {
"type": "url",
"strip": false
},
"oidcTokenEndpoint": {
"type": "url",
"strip": false