From 6ab312ee884c172fcca7fe9d8c0086eb9601378d Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Tue, 21 Jan 2025 10:36:59 +0100 Subject: [PATCH 01/17] Add cache to client repository --- config-templates/module_oidc.php | 56 ++++++++-------- src/ModuleConfig.php | 16 +++++ .../AbstractDatabaseRepository.php | 7 ++ src/Repositories/ClientRepository.php | 65 ++++++++++++++++++- src/Repositories/UserRepository.php | 5 -- 5 files changed, 116 insertions(+), 33 deletions(-) diff --git a/config-templates/module_oidc.php b/config-templates/module_oidc.php index c4f8d03b..9344ab8b 100644 --- a/config-templates/module_oidc.php +++ b/config-templates/module_oidc.php @@ -258,42 +258,46 @@ // also give proper adapter arguments for its instantiation below. // @see https://symfony.com/doc/current/components/cache.html#available-cache-adapters ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => null, - //ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\FilesystemAdapter::class, - //ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\MemcachedAdapter::class, +// ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\FilesystemAdapter::class, +// ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\MemcachedAdapter::class, - // Federation cache adapter arguments used for adapter instantiation. Refer to documentation for particular + // Protocol cache adapter arguments used for adapter instantiation. Refer to documentation for particular // adapter on which arguments are needed to create its instance, in the order of constructor arguments. // See examples below. ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ // Adapter arguments here... ], // Example for FileSystemAdapter: - //ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS => [ - // 'openidFederation', // Namespace, subdirectory of main cache directory - // 60 * 60 * 6, // Default lifetime in seconds (used when particular cache item doesn't define its own lifetime) - // '/path/to/main/cache/directory' // Must be writable. Can be set to null to use system temporary directory. - //], - // Example for MemcachedAdapter: - //ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS => [ - // // First argument is a connection instance, so we can use the helper method to create it. In this example a - // // single server is used. Refer to documentation on how to use multiple servers, and / or to provide other - // // options. - // \Symfony\Component\Cache\Adapter\MemcachedAdapter::createConnection( - // 'memcached://localhost' - // // the DSN can include config options (pass them as a query string): - // // 'memcached://localhost:11222?retry_timeout=10' - // // 'memcached://localhost:11222?socket_recv_size=1&socket_send_size=2' - // ), - // 'openidFederation', // Namespace, key prefix. - // 60 * 60 * 6, // Default lifetime in seconds (used when particular cache item doesn't define its own lifetime) - //], +// ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ +// 'openidFederation', // Namespace, subdirectory of main cache directory +// 60 * 60 * 6, // Default lifetime in seconds (used when particular cache item doesn't define its own lifetime) +// '/path/to/main/cache/directory' // Must be writable. Can be set to null to use system temporary directory. +// ], +// Example for MemcachedAdapter: +// ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [ +// // First argument is a connection instance, so we can use the helper method to create it. In this example a +// // single server is used. Refer to documentation on how to use multiple servers, and / or to provide other +// // options. +// \Symfony\Component\Cache\Adapter\MemcachedAdapter::createConnection( +// 'memcached://localhost' +// // the DSN can include config options (pass them as a query string): +// // 'memcached://localhost:11222?retry_timeout=10' +// // 'memcached://localhost:11222?socket_recv_size=1&socket_send_size=2' +// ), +// 'openidProtocol', // Namespace, key prefix. +// 60 * 60 * 6, // Default lifetime in seconds (used when particular cache item doesn't define its own lifetime) +// ], + /** + * Protocol cache duration for particular entities. This is only relevant if protocol cache adapter is set up. + * For duration format info, check https://www.php.net/manual/en/dateinterval.construct.php. + */ // Cache duration for user entities (authenticated users data). If not set, cache duration will be the same as - // session duration. This is used to avoid fetching user data from database on every authentication event. - // This is only relevant if protocol cache adapter is set up. For duration format info, check - // https://www.php.net/manual/en/dateinterval.construct.php. + // session duration. // ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => 'PT1H', // 1 hour - ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, // fallback to session duration + ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, // Fallback to session duration + // Cache duration for client entities, with given default. + ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => 'PT10M', // 10 minutes /** * Cron related options. diff --git a/src/ModuleConfig.php b/src/ModuleConfig.php index 6c905eb3..dcb2de51 100644 --- a/src/ModuleConfig.php +++ b/src/ModuleConfig.php @@ -80,6 +80,7 @@ class ModuleConfig final public const OPTION_PROTOCOL_CACHE_ADAPTER = 'protocol_cache_adapter'; final public const OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS = 'protocol_cache_adapter_arguments'; final public const OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION = 'protocol_user_entity_cache_duration'; + final public const OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION = 'protocol_client_entity_cache_duration'; final public const OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS = 'federation_participation_limit_by_trust_marks'; @@ -464,6 +465,21 @@ public function getProtocolUserEntityCacheDuration(): DateInterval ); } + /** + * Get cache duration for client entities (user data), with given default + * + * @throws \Exception + */ + public function getProtocolClientEntityCacheDuration(): DateInterval + { + return new DateInterval( + $this->config()->getOptionalString( + self::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION, + null, + ) ?? 'PT10M', + ); + } + /***************************************************************************************************************** * OpenID Federation related config. diff --git a/src/Repositories/AbstractDatabaseRepository.php b/src/Repositories/AbstractDatabaseRepository.php index 61d8b416..9434eafb 100644 --- a/src/Repositories/AbstractDatabaseRepository.php +++ b/src/Repositories/AbstractDatabaseRepository.php @@ -32,5 +32,12 @@ public function __construct( ) { } + public function getCacheKey(string $identifier): string + { + return is_string($tableName = $this->getTableName()) ? + $tableName . '_' . $identifier : + $identifier; + } + abstract public function getTableName(): ?string; } diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index 97a62766..6e056c7a 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -92,6 +92,13 @@ public function validateClient($clientIdentifier, $clientSecret, $grantType): bo */ public function findById(string $clientIdentifier, ?string $owner = null): ?ClientEntityInterface { + /** @var ?array $cachedState */ + $cachedState = $this->protocolCache?->get(null, $this->getCacheKey($clientIdentifier)); + + if (is_array($cachedState)) { + return $this->clientEntityFactory->fromState($cachedState); + } + /** * @var string $query * @var array $params @@ -116,11 +123,26 @@ public function findById(string $clientIdentifier, ?string $owner = null): ?Clie return null; } - return $this->clientEntityFactory->fromState($row); + $clientEntity = $this->clientEntityFactory->fromState($row); + + $this->protocolCache?->set( + $clientEntity->getState(), + $this->moduleConfig->getProtocolClientEntityCacheDuration(), + $this->getCacheKey($clientEntity->getIdentifier()), + ); + + return $clientEntity; } public function findByEntityIdentifier(string $entityIdentifier, ?string $owner = null): ?ClientEntityInterface { + /** @var ?array $cachedState */ + $cachedState = $this->protocolCache?->get(null, $this->getCacheKey($entityIdentifier)); + + if (is_array($cachedState)) { + return $this->clientEntityFactory->fromState($cachedState); + } + /** * @var string $query * @var array $params @@ -153,7 +175,15 @@ public function findByEntityIdentifier(string $entityIdentifier, ?string $owner return null; } - return $this->clientEntityFactory->fromState($row); + $clientEntity = $this->clientEntityFactory->fromState($row); + + $this->protocolCache?->set( + $clientEntity->getState(), + $this->moduleConfig->getProtocolClientEntityCacheDuration(), + $this->getCacheKey($entityIdentifier), + ); + + return $clientEntity; } private function addOwnerWhereClause(string $query, array $params, ?string $owner = null): array @@ -302,6 +332,19 @@ public function add(ClientEntityInterface $client): void $stmt, $client->getState(), ); + + $this->protocolCache?->set( + $client->getState(), + $this->moduleConfig->getProtocolClientEntityCacheDuration(), + $this->getCacheKey($client->getIdentifier()), + ); + if (($entityIdentifier = $client->getEntityIdentifier()) !== null) { + $this->protocolCache?->set( + $client->getState(), + $this->moduleConfig->getProtocolClientEntityCacheDuration(), + $this->getCacheKey($entityIdentifier), + ); + } } public function delete(ClientEntityInterface $client, ?string $owner = null): void @@ -318,6 +361,11 @@ public function delete(ClientEntityInterface $client, ?string $owner = null): vo $owner, ); $this->database->write($sqlQuery, $params); + + $this->protocolCache?->delete($this->getCacheKey($client->getIdentifier())); + if (($entityIdentifier = $client->getEntityIdentifier()) !== null) { + $this->protocolCache?->delete($this->getCacheKey($entityIdentifier)); + } } public function update(ClientEntityInterface $client, ?string $owner = null): void @@ -366,6 +414,19 @@ public function update(ClientEntityInterface $client, ?string $owner = null): vo $sqlQuery, $params, ); + + $this->protocolCache?->set( + $client->getState(), + $this->moduleConfig->getProtocolClientEntityCacheDuration(), + $this->getCacheKey($client->getIdentifier()), + ); + if (($entityIdentifier = $client->getEntityIdentifier()) !== null) { + $this->protocolCache?->set( + $client->getState(), + $this->moduleConfig->getProtocolClientEntityCacheDuration(), + $this->getCacheKey($entityIdentifier), + ); + } } private function count(string $query, ?string $owner): int diff --git a/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php index 420e02f5..b98aa88b 100644 --- a/src/Repositories/UserRepository.php +++ b/src/Repositories/UserRepository.php @@ -48,11 +48,6 @@ public function getTableName(): string return $this->database->applyPrefix(self::TABLE_NAME); } - public function getCacheKey(string $identifier): string - { - return $this->getTableName() . '_' . $identifier; - } - /** * @param string $identifier * From 1e39abcf2cd402a98557e0ded3bcba63e859ef25 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Tue, 21 Jan 2025 15:05:54 +0100 Subject: [PATCH 02/17] Add cache to access token repository --- config-templates/module_oidc.php | 2 ++ src/Repositories/AccessTokenRepository.php | 37 ++++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/config-templates/module_oidc.php b/config-templates/module_oidc.php index 9344ab8b..a48b6169 100644 --- a/config-templates/module_oidc.php +++ b/config-templates/module_oidc.php @@ -298,6 +298,8 @@ ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, // Fallback to session duration // Cache duration for client entities, with given default. ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => 'PT10M', // 10 minutes + // Cache duration for Authorization Code, Access Token, and Refresh Token will fall back to their TTL. + /** * Cron related options. diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 4298211e..4a9bcfd3 100644 --- a/src/Repositories/AccessTokenRepository.php +++ b/src/Repositories/AccessTokenRepository.php @@ -98,7 +98,7 @@ public function getNewToken( */ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTokenEntity): void { - if (!$accessTokenEntity instanceof AccessTokenEntity) { + if (!($accessTokenEntity instanceof AccessTokenEntity)) { throw new Error('Invalid AccessTokenEntity'); } @@ -112,6 +112,14 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo $stmt, $accessTokenEntity->getState(), ); + + $this->protocolCache?->set( + $accessTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $accessTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$accessTokenEntity->getIdentifier()), + ); } /** @@ -121,6 +129,13 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo */ public function findById(string $tokenId): ?AccessTokenEntity { + /** @var ?array $cachedState */ + $cachedState = $this->protocolCache?->get(null, $this->getCacheKey($tokenId)); + + if (is_array($cachedState)) { + return $this->accessTokenEntityFactory->fromState($cachedState); + } + $stmt = $this->database->read( "SELECT * FROM {$this->getTableName()} WHERE id = :id", [ @@ -136,7 +151,17 @@ public function findById(string $tokenId): ?AccessTokenEntity $data = current($rows); $data['client'] = $this->clientRepository->findById((string)$data['client_id']); - return $this->accessTokenEntityFactory->fromState($data); + $accessTokenEntity = $this->accessTokenEntityFactory->fromState($data); + + $this->protocolCache?->set( + $accessTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $accessTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$accessTokenEntity->getIdentifier()), + ); + + return $accessTokenEntity; } /** @@ -209,5 +234,13 @@ private function update(AccessTokenEntity $accessTokenEntity): void $stmt, $accessTokenEntity->getState(), ); + + $this->protocolCache?->set( + $accessTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $accessTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$accessTokenEntity->getIdentifier()), + ); } } From 691b2758d653d21f25b4f892d854fe538cb73389 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 09:44:30 +0100 Subject: [PATCH 03/17] Include client instance for access token data --- src/Repositories/AccessTokenRepository.php | 35 +++++++++++----------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 4a9bcfd3..47ba940e 100644 --- a/src/Repositories/AccessTokenRepository.php +++ b/src/Repositories/AccessTokenRepository.php @@ -129,26 +129,25 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo */ public function findById(string $tokenId): ?AccessTokenEntity { - /** @var ?array $cachedState */ - $cachedState = $this->protocolCache?->get(null, $this->getCacheKey($tokenId)); - - if (is_array($cachedState)) { - return $this->accessTokenEntityFactory->fromState($cachedState); - } - - $stmt = $this->database->read( - "SELECT * FROM {$this->getTableName()} WHERE id = :id", - [ - 'id' => $tokenId, - ], - ); - - if (empty($rows = $stmt->fetchAll())) { - return null; + /** @var ?array $data */ + $data = $this->protocolCache?->get(null, $this->getCacheKey($tokenId)); + + if (!is_array($data)) { + $stmt = $this->database->read( + "SELECT * FROM {$this->getTableName()} WHERE id = :id", + [ + 'id' => $tokenId, + ], + ); + + if (empty($rows = $stmt->fetchAll())) { + return null; + } + + /** @var array $data */ + $data = current($rows); } - /** @var array $data */ - $data = current($rows); $data['client'] = $this->clientRepository->findById((string)$data['client_id']); $accessTokenEntity = $this->accessTokenEntityFactory->fromState($data); From f63818a732a830afd26f3ca217f596d053b92a51 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 14:30:23 +0100 Subject: [PATCH 04/17] Move PDO logic from entities to repositories --- src/Entities/AccessTokenEntity.php | 3 +- src/Entities/AuthCodeEntity.php | 3 +- src/Entities/ClientEntity.php | 7 ++-- src/Entities/RefreshTokenEntity.php | 3 +- src/Repositories/AccessTokenRepository.php | 32 ++++++++++++++++--- src/Repositories/AuthCodeRepository.php | 14 ++++++-- src/Repositories/ClientRepository.php | 17 ++++++++-- src/Repositories/RefreshTokenRepository.php | 32 ++++++++++++++++--- .../unit/src/Entities/AuthCodeEntityTest.php | 3 +- tests/unit/src/Entities/ClientEntityTest.php | 7 ++-- .../src/Entities/RefreshTokenEntityTest.php | 3 +- 11 files changed, 92 insertions(+), 32 deletions(-) diff --git a/src/Entities/AccessTokenEntity.php b/src/Entities/AccessTokenEntity.php index 67630acd..b98fe7cf 100644 --- a/src/Entities/AccessTokenEntity.php +++ b/src/Entities/AccessTokenEntity.php @@ -24,7 +24,6 @@ use League\OAuth2\Server\Entities\Traits\AccessTokenTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; -use PDO; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\EntityStringRepresentationInterface; use SimpleSAML\Module\oidc\Entities\Traits\AssociateWithAuthCodeTrait; @@ -112,7 +111,7 @@ public function getState(): array 'expires_at' => $this->getExpiryDateTime()->format('Y-m-d H:i:s'), 'user_id' => $this->getUserIdentifier(), 'client_id' => $this->getClient()->getIdentifier(), - 'is_revoked' => [$this->isRevoked(), PDO::PARAM_BOOL], + 'is_revoked' => $this->isRevoked(), 'auth_code_id' => $this->getAuthCodeId(), 'requested_claims' => json_encode($this->requestedClaims, JSON_THROW_ON_ERROR), ]; diff --git a/src/Entities/AuthCodeEntity.php b/src/Entities/AuthCodeEntity.php index c0bf7c0a..d98fe347 100644 --- a/src/Entities/AuthCodeEntity.php +++ b/src/Entities/AuthCodeEntity.php @@ -19,7 +19,6 @@ use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; -use PDO; use SimpleSAML\Module\oidc\Entities\Interfaces\AuthCodeEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\MementoInterface; use SimpleSAML\Module\oidc\Entities\Traits\OidcAuthCodeTrait; @@ -66,7 +65,7 @@ public function getState(): array 'expires_at' => $this->getExpiryDateTime()->format('Y-m-d H:i:s'), 'user_id' => $this->getUserIdentifier(), 'client_id' => $this->client->getIdentifier(), - 'is_revoked' => [$this->isRevoked(), PDO::PARAM_BOOL], + 'is_revoked' => $this->isRevoked(), 'redirect_uri' => $this->getRedirectUri(), 'nonce' => $this->getNonce(), ]; diff --git a/src/Entities/ClientEntity.php b/src/Entities/ClientEntity.php index 3aa7267c..d834d41e 100644 --- a/src/Entities/ClientEntity.php +++ b/src/Entities/ClientEntity.php @@ -19,7 +19,6 @@ use DateTimeImmutable; use League\OAuth2\Server\Entities\Traits\ClientTrait; use League\OAuth2\Server\Entities\Traits\EntityTrait; -use PDO; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\OpenID\Codebooks\ClientRegistrationTypesEnum; @@ -167,8 +166,8 @@ public function getState(): array self::KEY_AUTH_SOURCE => $this->getAuthSourceId(), self::KEY_REDIRECT_URI => json_encode($this->getRedirectUri(), JSON_THROW_ON_ERROR), self::KEY_SCOPES => json_encode($this->getScopes(), JSON_THROW_ON_ERROR), - self::KEY_IS_ENABLED => [$this->isEnabled(), PDO::PARAM_BOOL], - self::KEY_IS_CONFIDENTIAL => [$this->isConfidential(), PDO::PARAM_BOOL], + self::KEY_IS_ENABLED => $this->isEnabled(), + self::KEY_IS_CONFIDENTIAL => $this->isConfidential(), self::KEY_OWNER => $this->getOwner(), self::KEY_POST_LOGOUT_REDIRECT_URI => json_encode($this->getPostLogoutRedirectUri(), JSON_THROW_ON_ERROR), self::KEY_BACKCHANNEL_LOGOUT_URI => $this->getBackChannelLogoutUri(), @@ -188,7 +187,7 @@ public function getState(): array self::KEY_UPDATED_AT => $this->getUpdatedAt()?->format('Y-m-d H:i:s'), self::KEY_CREATED_AT => $this->getCreatedAt()?->format('Y-m-d H:i:s'), self::KEY_EXPIRES_AT => $this->getExpiresAt()?->format('Y-m-d H:i:s'), - self::KEY_IS_FEDERATED => [$this->isFederated(), PDO::PARAM_BOOL], + self::KEY_IS_FEDERATED => $this->isFederated(), ]; } diff --git a/src/Entities/RefreshTokenEntity.php b/src/Entities/RefreshTokenEntity.php index c2094c12..de12e766 100644 --- a/src/Entities/RefreshTokenEntity.php +++ b/src/Entities/RefreshTokenEntity.php @@ -19,7 +19,6 @@ use DateTimeImmutable; use League\OAuth2\Server\Entities\Traits\EntityTrait; use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; -use PDO; use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Interfaces\RefreshTokenEntityInterface; use SimpleSAML\Module\oidc\Entities\Traits\AssociateWithAuthCodeTrait; @@ -52,7 +51,7 @@ public function getState(): array 'id' => $this->getIdentifier(), 'expires_at' => $this->getExpiryDateTime()->format('Y-m-d H:i:s'), 'access_token_id' => $this->getAccessToken()->getIdentifier(), - 'is_revoked' => [$this->isRevoked(), PDO::PARAM_BOOL], + 'is_revoked' => $this->isRevoked(), 'auth_code_id' => $this->getAuthCodeId(), ]; } diff --git a/src/Repositories/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 47ba940e..6c7d16e5 100644 --- a/src/Repositories/AccessTokenRepository.php +++ b/src/Repositories/AccessTokenRepository.php @@ -19,6 +19,7 @@ use DateTimeImmutable; use League\OAuth2\Server\Entities\AccessTokenEntityInterface as OAuth2AccessTokenEntityInterface; use League\OAuth2\Server\Entities\ClientEntityInterface as OAuth2ClientEntityInterface; +use PDO; use RuntimeException; use SimpleSAML\Database; use SimpleSAML\Error\Error; @@ -29,14 +30,11 @@ use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\Interfaces\AccessTokenRepositoryInterface; -use SimpleSAML\Module\oidc\Repositories\Traits\RevokeTokenByAuthCodeIdTrait; use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Utils\ProtocolCache; class AccessTokenRepository extends AbstractDatabaseRepository implements AccessTokenRepositoryInterface { - use RevokeTokenByAuthCodeIdTrait; - final public const TABLE_NAME = 'oidc_access_token'; public function __construct( @@ -110,7 +108,7 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo $this->database->write( $stmt, - $accessTokenEntity->getState(), + $this->preparePdoState($accessTokenEntity->getState()), ); $this->protocolCache?->set( @@ -180,6 +178,22 @@ public function revokeAccessToken($tokenId): void $this->update($accessToken); } + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + * @throws \JsonException + */ + public function revokeByAuthCodeId(string $authCodeId): void + { + $stmt = $this->database->read( + "SELECT id FROM {$this->getTableName()} WHERE auth_code_id = :auth_code_id", + ['auth_code_id' => $authCodeId], + ); + + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN, 0) as $id) { + $this->revokeAccessToken((string)$id); + } + } + /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -231,7 +245,7 @@ private function update(AccessTokenEntity $accessTokenEntity): void $this->database->write( $stmt, - $accessTokenEntity->getState(), + $this->preparePdoState($accessTokenEntity->getState()), ); $this->protocolCache?->set( @@ -242,4 +256,12 @@ private function update(AccessTokenEntity $accessTokenEntity): void $this->getCacheKey((string)$accessTokenEntity->getIdentifier()), ); } + + protected function preparePdoState(array $state): array + { + $isRevoked = (bool)($state['is_revoked'] ?? true); + $state['is_revoked'] = [$isRevoked, PDO::PARAM_BOOL]; + + return $state; + } } diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index f1b95fba..d0cf5644 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -17,6 +17,7 @@ namespace SimpleSAML\Module\oidc\Repositories; use League\OAuth2\Server\Entities\AuthCodeEntityInterface as OAuth2AuthCodeEntityInterface; +use PDO; use RuntimeException; use SimpleSAML\Database; use SimpleSAML\Error\Error; @@ -76,7 +77,7 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity $this->database->write( $stmt, - $authCodeEntity->getState(), + $this->preparePdoState($authCodeEntity->getState()), ); } @@ -174,7 +175,16 @@ private function update(AuthCodeEntity $authCodeEntity): void $this->database->write( $stmt, - $authCodeEntity->getState(), + $this->preparePdoState($authCodeEntity->getState()), ); } + + protected function preparePdoState(array $state): array + { + $isRevoked = (bool)($state['is_revoked'] ?? true); + + $state['is_revoked'] = [$isRevoked, PDO::PARAM_BOOL]; + + return $state; + } } diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index 6e056c7a..a8b83457 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -330,7 +330,7 @@ public function add(ClientEntityInterface $client): void ); $this->database->write( $stmt, - $client->getState(), + $this->preparePdoState($client->getState()), ); $this->protocolCache?->set( @@ -407,7 +407,7 @@ public function update(ClientEntityInterface $client, ?string $owner = null): vo */ [$sqlQuery, $params] = $this->addOwnerWhereClause( $stmt, - $client->getState(), + $this->preparePdoState($client->getState()), $owner, ); $this->database->write( @@ -482,4 +482,17 @@ private function calculateOffset(int $page, int $limit): float|int { return ($page - 1) * $limit; } + + protected function preparePdoState(array $state): array + { + $isEnabled = (bool)($state[ClientEntity::KEY_IS_ENABLED] ?? false); + $isConfidential = (bool)($state[ClientEntity::KEY_IS_CONFIDENTIAL] ?? false); + $isFederated = (bool)($state[ClientEntity::KEY_IS_FEDERATED] ?? false); + + $state[ClientEntity::KEY_IS_ENABLED] = [$isEnabled, PDO::PARAM_BOOL]; + $state[ClientEntity::KEY_IS_CONFIDENTIAL] = [$isConfidential, PDO::PARAM_BOOL]; + $state[ClientEntity::KEY_IS_FEDERATED] = [$isFederated, PDO::PARAM_BOOL]; + + return $state; + } } diff --git a/src/Repositories/RefreshTokenRepository.php b/src/Repositories/RefreshTokenRepository.php index e20bb23c..554a3b8d 100644 --- a/src/Repositories/RefreshTokenRepository.php +++ b/src/Repositories/RefreshTokenRepository.php @@ -18,6 +18,7 @@ use League\OAuth2\Server\Entities\RefreshTokenEntityInterface as OAuth2RefreshTokenEntityInterface; use League\OAuth2\Server\Exception\OAuthServerException; +use PDO; use RuntimeException; use SimpleSAML\Database; use SimpleSAML\Module\oidc\Codebooks\DateFormatsEnum; @@ -27,13 +28,10 @@ use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\Interfaces\RefreshTokenRepositoryInterface; -use SimpleSAML\Module\oidc\Repositories\Traits\RevokeTokenByAuthCodeIdTrait; use SimpleSAML\Module\oidc\Utils\ProtocolCache; class RefreshTokenRepository extends AbstractDatabaseRepository implements RefreshTokenRepositoryInterface { - use RevokeTokenByAuthCodeIdTrait; - final public const TABLE_NAME = 'oidc_refresh_token'; public function __construct( @@ -81,7 +79,7 @@ public function persistNewRefreshToken(OAuth2RefreshTokenEntityInterface $refres $this->database->write( $stmt, - $refreshTokenEntity->getState(), + $this->preparePdoState($refreshTokenEntity->getState()), ); } @@ -126,6 +124,21 @@ public function revokeRefreshToken($tokenId): void $this->update($refreshToken); } + /** + * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException + */ + public function revokeByAuthCodeId(string $authCodeId): void + { + $stmt = $this->database->read( + "SELECT id FROM {$this->getTableName()} WHERE auth_code_id = :auth_code_id", + ['auth_code_id' => $authCodeId], + ); + + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN, 0) as $id) { + $this->revokeRefreshToken((string)$id); + } + } + /** * {@inheritdoc} * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException @@ -165,7 +178,16 @@ private function update(RefreshTokenEntityInterface $refreshTokenEntity): void $this->database->write( $stmt, - $refreshTokenEntity->getState(), + $this->preparePdoState($refreshTokenEntity->getState()), ); } + + protected function preparePdoState(array $state): array + { + $isRevoked = (bool)($state['is_revoked'] ?? true); + + $state['is_revoked'] = [$isRevoked, PDO::PARAM_BOOL]; + + return $state; + } } diff --git a/tests/unit/src/Entities/AuthCodeEntityTest.php b/tests/unit/src/Entities/AuthCodeEntityTest.php index 5a494bcb..b9cc457e 100644 --- a/tests/unit/src/Entities/AuthCodeEntityTest.php +++ b/tests/unit/src/Entities/AuthCodeEntityTest.php @@ -6,7 +6,6 @@ use DateTimeImmutable; use DateTimeZone; -use PDO; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; @@ -96,7 +95,7 @@ public function testCanGetState(): void 'expires_at' => '1970-01-01 00:00:00', 'user_id' => 'user_id', 'client_id' => 'client_id', - 'is_revoked' => [false, PDO::PARAM_BOOL], + 'is_revoked' => false, 'redirect_uri' => 'https://localhost/redirect', 'nonce' => 'nonce', ], diff --git a/tests/unit/src/Entities/ClientEntityTest.php b/tests/unit/src/Entities/ClientEntityTest.php index 45d28284..49a709cc 100644 --- a/tests/unit/src/Entities/ClientEntityTest.php +++ b/tests/unit/src/Entities/ClientEntityTest.php @@ -5,7 +5,6 @@ namespace SimpleSAML\Test\Module\oidc\unit\Entities; use DateTimeImmutable; -use PDO; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Codebooks\RegistrationTypeEnum; use SimpleSAML\Module\oidc\Entities\ClientEntity; @@ -168,8 +167,8 @@ public function testCanGetState(): void 'auth_source' => 'auth_source', 'redirect_uri' => json_encode(['https://localhost/redirect']), 'scopes' => json_encode([]), - 'is_enabled' => [$this->state['is_enabled'], PDO::PARAM_BOOL], - 'is_confidential' => [$this->state['is_confidential'], PDO::PARAM_BOOL], + 'is_enabled' => $this->state['is_enabled'], + 'is_confidential' => $this->state['is_confidential'], 'owner' => 'user@test.com', 'post_logout_redirect_uri' => json_encode([]), 'backchannel_logout_uri' => null, @@ -183,7 +182,7 @@ public function testCanGetState(): void 'updated_at' => null, 'created_at' => null, 'expires_at' => null, - 'is_federated' => [$this->state['is_federated'], PDO::PARAM_BOOL], + 'is_federated' => $this->state['is_federated'], ], ); } diff --git a/tests/unit/src/Entities/RefreshTokenEntityTest.php b/tests/unit/src/Entities/RefreshTokenEntityTest.php index 7abed99f..31c32bc8 100644 --- a/tests/unit/src/Entities/RefreshTokenEntityTest.php +++ b/tests/unit/src/Entities/RefreshTokenEntityTest.php @@ -6,7 +6,6 @@ use DateTimeImmutable; use DateTimeZone; -use PDO; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; @@ -73,7 +72,7 @@ public function testCanGetState(): void 'id' => $this->id, 'expires_at' => '1970-01-01 00:00:00', 'access_token_id' => $this->accessTokenEntityMock->getIdentifier(), - 'is_revoked' => [$this->isRevoked, PDO::PARAM_BOOL], + 'is_revoked' => $this->isRevoked, 'auth_code_id' => $this->authCodeId, ], ); From 1e98c2da41e94b67afdce757fb11cdf7e84994df Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 14:46:13 +0100 Subject: [PATCH 05/17] Get rid off RevokeTokenByAuthCodeIdTrait --- .../Traits/RevokeTokenByAuthCodeIdTrait.php | 39 ------------------- ...Test.php => AccessTokenRepositoryTest.php} | 37 ++---------------- 2 files changed, 4 insertions(+), 72 deletions(-) delete mode 100644 src/Repositories/Traits/RevokeTokenByAuthCodeIdTrait.php rename tests/integration/src/Repositories/{Traits/RevokeTokenByAuthCodeIdTraitTest.php => AccessTokenRepositoryTest.php} (91%) diff --git a/src/Repositories/Traits/RevokeTokenByAuthCodeIdTrait.php b/src/Repositories/Traits/RevokeTokenByAuthCodeIdTrait.php deleted file mode 100644 index 8830c8af..00000000 --- a/src/Repositories/Traits/RevokeTokenByAuthCodeIdTrait.php +++ /dev/null @@ -1,39 +0,0 @@ -generateQuery($authCodeId, $revokedParam); - $this->database->write((string)$query, (array)$bindParam); - } - - /** - * @param string $authCodeId - * @param array $revokedParam - * - * @return array - */ - protected function generateQuery(string $authCodeId, array $revokedParam): array - { - $query = sprintf( - 'UPDATE %s SET is_revoked = :is_revoked WHERE auth_code_id = :auth_code_id', - $this->getTableName(), - ); - $bindParam = ['auth_code_id' => $authCodeId, 'is_revoked' => $revokedParam]; - - return [$query, $bindParam]; - } -} diff --git a/tests/integration/src/Repositories/Traits/RevokeTokenByAuthCodeIdTraitTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php similarity index 91% rename from tests/integration/src/Repositories/Traits/RevokeTokenByAuthCodeIdTraitTest.php rename to tests/integration/src/Repositories/AccessTokenRepositoryTest.php index c0354d28..8b132435 100644 --- a/tests/integration/src/Repositories/Traits/RevokeTokenByAuthCodeIdTraitTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -2,10 +2,11 @@ declare(strict_types=1); -namespace SimpleSAML\Test\Module\oidc\integration\Repositories\Traits; +namespace SimpleSAML\Test\Module\oidc\integration\Repositories; use League\OAuth2\Server\CryptKey; use PDO; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -25,7 +26,6 @@ use SimpleSAML\Module\oidc\Repositories\AbstractDatabaseRepository; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; -use SimpleSAML\Module\oidc\Repositories\Traits\RevokeTokenByAuthCodeIdTrait; use SimpleSAML\Module\oidc\Repositories\UserRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService; @@ -34,10 +34,8 @@ use Testcontainers\Wait\WaitForHealthCheck; use Testcontainers\Wait\WaitForLog; -/** - * @covers \SimpleSAML\Module\oidc\Repositories\Traits\RevokeTokenByAuthCodeIdTrait - */ -class RevokeTokenByAuthCodeIdTraitTest extends TestCase +#[CoversClass(AccessTokenRepository::class)] +class AccessTokenRepositoryTest extends TestCase { protected array $state; protected array $scopes; @@ -139,18 +137,11 @@ public function useDatabase($config): void $moduleConfig = new ModuleConfig(); $this->mock = new class ($moduleConfig, $database, null) extends AbstractDatabaseRepository { - use RevokeTokenByAuthCodeIdTrait; - public function getTableName(): ?string { return $this->database->applyPrefix('oidc_access_token'); } - public function generateQueryWrapper(string $authCodeId, array $revokedParam): array - { - return $this->generateQuery($authCodeId, $revokedParam); - } - public function getDatabase(): Database { return $this->database; @@ -291,26 +282,6 @@ public static function databaseToTest(): array ]; } - #[DataProvider('databaseToTest')] - public function testItGenerateQuery(string $database): void - { - $this->useDatabase(self::$$database); - - $revokedParam = [self::IS_REVOKED, PDO::PARAM_BOOL]; - $expected = [ - 'UPDATE phpunit_oidc_access_token SET is_revoked = :is_revoked WHERE auth_code_id = :auth_code_id', - [ - 'auth_code_id' => self::AUTH_CODE_ID, - 'is_revoked' => $revokedParam, - ], - ]; - - $this->assertEquals( - $expected, - $this->mock->generateQueryWrapper(self::AUTH_CODE_ID, $revokedParam), - ); - } - /** * @throws \JsonException * @throws \League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException From 1d61528b15c0df9158b71f558df49af26976f73e Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 14:54:46 +0100 Subject: [PATCH 06/17] Fix certFolder path in tests --- .../integration/src/Repositories/AccessTokenRepositoryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 8b132435..059e152e 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -116,7 +116,7 @@ public function setUp(): void $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); $this->accessTokenEntityFactoryMock = $this->createMock(AccessTokenEntityFactory::class); - $certFolder = dirname(__DIR__, 5) . '/docker/ssp/'; + $certFolder = dirname(__DIR__, 4) . '/docker/ssp/'; $privateKeyPath = $certFolder . ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME; $this->privateKey = new CryptKey($privateKeyPath); $this->accessTokenEntityFactory = new AccessTokenEntityFactory( From 5de015ee853ada41fa8927c6f97d3c2a86c0e286 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 15:00:55 +0100 Subject: [PATCH 07/17] Fix configDir path in tests --- .../integration/src/Repositories/AccessTokenRepositoryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 059e152e..077c9c0d 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -83,7 +83,7 @@ public static function setUpBeforeClass(): void self::$mysqlPort ??= "3306"; self::$postgresPort ??= "5432"; } - Configuration::setConfigDir(__DIR__ . '/../../../../../config-templates'); + Configuration::setConfigDir(__DIR__ . '/../../../../config-templates'); self::$pgConfig = self::loadPGDatabase(); self::$mysqlConfig = self::loadMySqlDatabase(); self::$sqliteConfig = self::loadSqliteDatabase(); From 08f55a0b14eb500eb1aea1a6e950cbf613652c15 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 15:09:30 +0100 Subject: [PATCH 08/17] Fix access token state in test --- .../integration/src/Repositories/AccessTokenRepositoryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index 077c9c0d..b51f9cb8 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -109,7 +109,7 @@ public function setUp(): void 'expires_at' => date('Y-m-d H:i:s', time() - 60), // expired... 'user_id' => self::USER_ID, 'client_id' => self::CLIENT_ID, - 'is_revoked' => [false, PDO::PARAM_BOOL], + 'is_revoked' => false, 'auth_code_id' => self::AUTH_CODE_ID, 'requested_claims' => '[]', ]; From c87204a5d19e5c46bfafc3433118e623ae226a69 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 15:19:19 +0100 Subject: [PATCH 09/17] Use accessTokenRepository directly in tests --- .../integration/src/Repositories/AccessTokenRepositoryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index b51f9cb8..ba0c0603 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -302,7 +302,7 @@ public function testRevokeByAuthCodeId(string $database): void $this->assertFalse($isRevoked); // Revoke the access token - $this->mock->revokeByAuthCodeId(self::AUTH_CODE_ID); + $this->accessTokenRepository->revokeByAuthCodeId(self::AUTH_CODE_ID); $isRevoked = $this->accessTokenRepository->isAccessTokenRevoked(self::ACCESS_TOKEN_ID); $this->assertTrue($isRevoked); From 7eef08a02acaeee7cb64257c6a8e77f2bc7a3dbd Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 16:06:11 +0100 Subject: [PATCH 10/17] Add cache to AllowedOriginRepository --- src/Repositories/AllowedOriginRepository.php | 26 +++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Repositories/AllowedOriginRepository.php b/src/Repositories/AllowedOriginRepository.php index 4dcc2628..30299dcb 100644 --- a/src/Repositories/AllowedOriginRepository.php +++ b/src/Repositories/AllowedOriginRepository.php @@ -21,6 +21,7 @@ public function getTableName(): string public function set(string $clientId, array $origins): void { $this->delete($clientId); + $this->clearCache($origins); $origins = array_unique(array_filter(array_values($origins))); @@ -65,11 +66,34 @@ public function get(string $clientId): array public function has(string $origin): bool { + // We only cache this method since it is used in authentication flow. + $has = $this->protocolCache?->get(null, $this->getCacheKey($origin)); + + if ($has !== null) { + return (bool) $has; + } + $stmt = $this->database->read( "SELECT origin FROM {$this->getTableName()} WHERE origin = :origin LIMIT 1", ['origin' => $origin], ); - return (bool) count($stmt->fetchAll(PDO::FETCH_COLUMN, 0)); + $has = (bool) count($stmt->fetchAll(PDO::FETCH_COLUMN, 0)); + + $this->protocolCache?->set( + $has, + $this->moduleConfig->getProtocolClientEntityCacheDuration(), + $this->getCacheKey($origin), + ); + + return $has; + } + + protected function clearCache(array $origins): void + { + /** @var string $origin */ + foreach ($origins as $origin) { + $this->protocolCache?->delete($this->getCacheKey($origin)); + } } } From 77c0e94578e7c5c22507ee3b3ca5fddd6b85fbc3 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 16:25:15 +0100 Subject: [PATCH 11/17] Add cache to AuthCodeRepository --- src/Repositories/AuthCodeRepository.php | 60 +++++++++++++++++++------ 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index d0cf5644..46d46832 100644 --- a/src/Repositories/AuthCodeRepository.php +++ b/src/Repositories/AuthCodeRepository.php @@ -32,6 +32,8 @@ class AuthCodeRepository extends AbstractDatabaseRepository implements AuthCodeRepositoryInterface { + final public const TABLE_NAME = 'oidc_auth_code'; + public function __construct( ModuleConfig $moduleConfig, Database $database, @@ -43,8 +45,6 @@ public function __construct( parent::__construct($moduleConfig, $database, $protocolCache); } - final public const TABLE_NAME = 'oidc_auth_code'; - public function getTableName(): string { return $this->database->applyPrefix(self::TABLE_NAME); @@ -79,6 +79,14 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity $stmt, $this->preparePdoState($authCodeEntity->getState()), ); + + $this->protocolCache?->set( + $authCodeEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $authCodeEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$authCodeEntity->getIdentifier()), + ); } /** @@ -87,22 +95,38 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity */ public function findById(string $codeId): ?AuthCodeEntityInterface { - $stmt = $this->database->read( - "SELECT * FROM {$this->getTableName()} WHERE id = :id", - [ - 'id' => $codeId, - ], - ); - - if (empty($rows = $stmt->fetchAll())) { - return null; + /** @var ?array $data */ + $data = $this->protocolCache?->get(null, $this->getCacheKey($codeId)); + + if (!is_array($data)) { + $stmt = $this->database->read( + "SELECT * FROM {$this->getTableName()} WHERE id = :id", + [ + 'id' => $codeId, + ], + ); + + if (empty($rows = $stmt->fetchAll())) { + return null; + } + + /** @var array $data */ + $data = current($rows); } - /** @var array $data */ - $data = current($rows); $data['client'] = $this->clientRepository->findById((string)$data['client_id']); - return $this->authCodeEntityFactory->fromState($data); + $authCodeEntity = $this->authCodeEntityFactory->fromState($data); + + $this->protocolCache?->set( + $authCodeEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $authCodeEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$authCodeEntity->getIdentifier()), + ); + + return $authCodeEntity; } /** @@ -177,6 +201,14 @@ private function update(AuthCodeEntity $authCodeEntity): void $stmt, $this->preparePdoState($authCodeEntity->getState()), ); + + $this->protocolCache?->set( + $authCodeEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $authCodeEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$authCodeEntity->getIdentifier()), + ); } protected function preparePdoState(array $state): array From 4961aaa45d7ebb7bb2f3b1a4f67694117bfc00a9 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Wed, 22 Jan 2025 16:42:49 +0100 Subject: [PATCH 12/17] Add cache to RefreshTokenRepository --- src/Repositories/RefreshTokenRepository.php | 56 ++++++++++++++++----- 1 file changed, 44 insertions(+), 12 deletions(-) diff --git a/src/Repositories/RefreshTokenRepository.php b/src/Repositories/RefreshTokenRepository.php index 554a3b8d..0d1ed120 100644 --- a/src/Repositories/RefreshTokenRepository.php +++ b/src/Repositories/RefreshTokenRepository.php @@ -81,6 +81,14 @@ public function persistNewRefreshToken(OAuth2RefreshTokenEntityInterface $refres $stmt, $this->preparePdoState($refreshTokenEntity->getState()), ); + + $this->protocolCache?->set( + $refreshTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $refreshTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$refreshTokenEntity->getIdentifier()), + ); } /** @@ -90,22 +98,38 @@ public function persistNewRefreshToken(OAuth2RefreshTokenEntityInterface $refres */ public function findById(string $tokenId): ?RefreshTokenEntityInterface { - $stmt = $this->database->read( - "SELECT * FROM {$this->getTableName()} WHERE id = :id", - [ - 'id' => $tokenId, - ], - ); - - if (empty($rows = $stmt->fetchAll())) { - return null; + /** @var ?array $data */ + $data = $this->protocolCache?->get(null, $this->getCacheKey($tokenId)); + + if (!is_array($data)) { + $stmt = $this->database->read( + "SELECT * FROM {$this->getTableName()} WHERE id = :id", + [ + 'id' => $tokenId, + ], + ); + + if (empty($rows = $stmt->fetchAll())) { + return null; + } + + /** @var array $data */ + $data = current($rows); } - /** @var array $data */ - $data = current($rows); $data['access_token'] = $this->accessTokenRepository->findById((string)$data['access_token_id']); - return $this->refreshTokenEntityFactory->fromState($data); + $refreshTokenEntity = $this->refreshTokenEntityFactory->fromState($data); + + $this->protocolCache?->set( + $refreshTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $refreshTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$refreshTokenEntity->getIdentifier()), + ); + + return $refreshTokenEntity; } /** @@ -180,6 +204,14 @@ private function update(RefreshTokenEntityInterface $refreshTokenEntity): void $stmt, $this->preparePdoState($refreshTokenEntity->getState()), ); + + $this->protocolCache?->set( + $refreshTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $refreshTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey($refreshTokenEntity->getIdentifier()), + ); } protected function preparePdoState(array $state): array From df556da356569ec83deb739fd9031f60220a40bc Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Thu, 23 Jan 2025 17:00:46 +0100 Subject: [PATCH 13/17] Add coverage --- UPGRADE.md | 8 +- src/Helpers/Client.php | 2 +- src/Helpers/Random.php | 2 + src/Repositories/ClientRepository.php | 4 + tests/config/module_oidc.php | 65 ++++++- tests/unit/src/Helpers/ArrTest.php | 8 + tests/unit/src/Helpers/ClientTest.php | 78 ++++++++ tests/unit/src/Helpers/DateTimeTest.php | 48 +++++ tests/unit/src/Helpers/HttpTest.php | 87 +++++++++ tests/unit/src/Helpers/RandomTest.php | 33 ++++ tests/unit/src/Helpers/StrTest.php | 34 ++++ tests/unit/src/HelpersTest.php | 35 ++++ tests/unit/src/ModuleConfigTest.php | 166 ++++++++++++++---- .../AbstractDatabaseRepositoryTest.php | 51 ++++++ .../AccessTokenRepositoryTest.php | 163 ++++++++++++++--- .../AllowedOriginRepositoryTest.php | 23 ++- .../Repositories/AuthCodeRepositoryTest.php | 29 ++- .../src/Repositories/ClientRepositoryTest.php | 94 ++++++++-- 18 files changed, 840 insertions(+), 90 deletions(-) create mode 100644 tests/unit/src/Helpers/ClientTest.php create mode 100644 tests/unit/src/Helpers/DateTimeTest.php create mode 100644 tests/unit/src/Helpers/HttpTest.php create mode 100644 tests/unit/src/Helpers/RandomTest.php create mode 100644 tests/unit/src/Helpers/StrTest.php create mode 100644 tests/unit/src/HelpersTest.php create mode 100644 tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php diff --git a/UPGRADE.md b/UPGRADE.md index 9a766943..5c166c34 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -12,7 +12,11 @@ # Version 5 to 6 ## New features - +- Caching support for OIDC protocol artifacts like Access Tokens, Authorization Codes, Refresh Tokens, but also + client and user data. The cache layer stands in front of the database store, so it can improve performance, especially + in cases of sudden surge of users trying to authenticate. Implementation is based on Symfony Cache component, so any + compatible Symfony cache adapter can be used. Check the module config file for more information on how to set the + protocol cache. - OpenID capabilities - New federation endpoints: - endpoint for issuing configuration entity statement (statement about itself) @@ -40,7 +44,7 @@ https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication - (optional) Issuer - you can now override the issuer (OP identifier). If not set, it falls back to current scheme, host and optionally a port (as in all previous module versions). -- protocol caching adapter and its arguments +- (optional) Protocol caching adapter and its arguments - (optional) OpenID Federation related options (needed if federation capabilities are to be used): - enabled or disabled federation capabilities - valid trust anchors diff --git a/src/Helpers/Client.php b/src/Helpers/Client.php index 45d98d48..8928154f 100644 --- a/src/Helpers/Client.php +++ b/src/Helpers/Client.php @@ -30,7 +30,7 @@ public function getFromRequest( $clientId = empty($params['client_id']) ? null : (string)$params['client_id']; if (!is_string($clientId)) { - throw new BadRequest('Client id is missing.'); + throw new BadRequest('Client ID is missing.'); } $client = $clientRepository->findById($clientId); diff --git a/src/Helpers/Random.php b/src/Helpers/Random.php index f6c0b68d..16c617a8 100644 --- a/src/Helpers/Random.php +++ b/src/Helpers/Random.php @@ -20,8 +20,10 @@ public function getIdentifier(int $length = 40): string try { return bin2hex(random_bytes($length)); + // @codeCoverageIgnoreStart } catch (Throwable $e) { throw OidcServerException::serverError('Could not generate a random string', $e); } + // @codeCoverageIgnoreEnd } } diff --git a/src/Repositories/ClientRepository.php b/src/Repositories/ClientRepository.php index a8b83457..49654731 100644 --- a/src/Repositories/ClientRepository.php +++ b/src/Repositories/ClientRepository.php @@ -119,9 +119,11 @@ public function findById(string $clientIdentifier, ?string $owner = null): ?Clie $row = current($rows); + // @codeCoverageIgnoreStart if (!is_array($row)) { return null; } + // @codeCoverageIgnoreEnd $clientEntity = $this->clientEntityFactory->fromState($row); @@ -171,9 +173,11 @@ public function findByEntityIdentifier(string $entityIdentifier, ?string $owner $row = current($rows); + // @codeCoverageIgnoreStart if (!is_array($row)) { return null; } + // @codeCoverageIgnoreEnd $clientEntity = $this->clientEntityFactory->fromState($row); diff --git a/tests/config/module_oidc.php b/tests/config/module_oidc.php index 31c5eb16..8efd6ee2 100644 --- a/tests/config/module_oidc.php +++ b/tests/config/module_oidc.php @@ -23,8 +23,6 @@ ModuleConfig::OPTION_TOKEN_REFRESH_TOKEN_TTL => 'P1M', ModuleConfig::OPTION_TOKEN_ACCESS_TOKEN_TTL => 'PT1H', - ModuleConfig::OPTION_CRON_TAG => 'hourly', - ModuleConfig::OPTION_TOKEN_SIGNER => Sha256::class, ModuleConfig::OPTION_AUTH_SOURCE => 'default-sp', @@ -44,15 +42,70 @@ ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION => null, - ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => Sha256::class, + ModuleConfig::OPTION_AUTH_PROCESSING_FILTERS => [ + ], + + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\ArrayAdapter::class, + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [], + ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, + ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => 'PT10M', + + ModuleConfig::OPTION_CRON_TAG => 'hourly', + + ModuleConfig::OPTION_ADMIN_UI_PERMISSIONS => [ + 'attribute' => 'eduPersonEntitlement', + 'client' => ['urn:example:oidc:manage:client'], + ], + + ModuleConfig::OPTION_ADMIN_UI_PAGINATION_ITEMS_PER_PAGE => 20, + + ModuleConfig::OPTION_FEDERATION_ENABLED => false, + + ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS => [ + // phpcs:ignore + 'https://ta.example.org/' => '{"keys":[{"kty": "RSA","alg": "RS256","use": "sig","kid": "Nzb...9Xs","e": "AQAB","n": "pnXB...ub9J"}]}', + 'https://ta2.example.org/' => null, + ], + + ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [ + 'https://intermediate.example.org/', + ], + + ModuleConfig::OPTION_FEDERATION_TRUST_MARK_TOKENS => [ + 'eyJ...GHg', + ], + + ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS => [ + // We are limiting federation participation using Trust Marks for 'https://ta.example.org/'. + 'https://ta.example.org/' => [ + // Entities must have (at least) one Trust Mark from the list below. + \SimpleSAML\Module\oidc\Codebooks\LimitsEnum::OneOf->value => [ + 'trust-mark-id', + 'trust-mark-id-2', + ], + // Entities must have all Trust Marks from the list below. + \SimpleSAML\Module\oidc\Codebooks\LimitsEnum::AllOf->value => [ + 'trust-mark-id-3', + 'trust-mark-id-4', + ], + ], + ], + + ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\ArrayAdapter::class, + ModuleConfig::OPTION_FEDERATION_CACHE_ADAPTER_ARGUMENTS => [], + ModuleConfig::OPTION_FEDERATION_ENTITY_STATEMENT_DURATION => 'P1D', + ModuleConfig::OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED => 'PT2M', + + ModuleConfig::OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED => 'PT6H', + ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, ModuleConfig::OPTION_PKI_FEDERATION_PRIVATE_KEY_PASSPHRASE => 'abc123', ModuleConfig::OPTION_PKI_FEDERATION_CERTIFICATE_FILENAME => ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, - ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [ - 'abc123', - ], + + ModuleConfig::OPTION_FEDERATION_TOKEN_SIGNER => Sha256::class, + ModuleConfig::OPTION_ORGANIZATION_NAME => 'Foo corp', ModuleConfig::OPTION_CONTACTS => [ 'John Doe jdoe@example.org', diff --git a/tests/unit/src/Helpers/ArrTest.php b/tests/unit/src/Helpers/ArrTest.php index a6fdd7e1..e3555dd9 100644 --- a/tests/unit/src/Helpers/ArrTest.php +++ b/tests/unit/src/Helpers/ArrTest.php @@ -16,6 +16,14 @@ protected function sut(): Arr return new Arr(); } + public function testEnsureStringValues(): void + { + $this->assertSame( + ['1', '2'], + $this->sut()->ensureStringValues([1, 2]), + ); + } + public function testIsValueOneOf(): void { $this->assertTrue($this->sut()->isValueOneOf('a', ['a'])); diff --git a/tests/unit/src/Helpers/ClientTest.php b/tests/unit/src/Helpers/ClientTest.php new file mode 100644 index 00000000..7916e678 --- /dev/null +++ b/tests/unit/src/Helpers/ClientTest.php @@ -0,0 +1,78 @@ +httpMock; + + return new Client($http); + } + + protected function setUp(): void + { + $this->httpMock = $this->createMock(Http::class); + $this->requestMock = $this->createMock(ServerRequestInterface::class); + $this->clientRepositoryMock = $this->createMock(ClientRepository::class); + $this->clientEntityMock = $this->createMock(ClientEntity::class); + } + + public function testCanGetFromRequest(): void + { + $this->httpMock->expects($this->once())->method('getAllRequestParams') + ->willReturn(['client_id' => 'clientId']); + + $this->clientRepositoryMock->expects($this->once())->method('findById') + ->with('clientId') + ->willReturn($this->clientEntityMock); + + $this->assertInstanceOf( + ClientEntity::class, + $this->sut()->getFromRequest($this->requestMock, $this->clientRepositoryMock), + ); + } + + public function testGetFromRequestThrowsIfNoClientId(): void + { + $this->expectException(BadRequest::class); + $this->expectExceptionMessage('Client ID'); + + $this->sut()->getFromRequest($this->requestMock, $this->clientRepositoryMock); + } + + public function testGetFromRequestThrowsIfClientNotFound(): void + { + $this->expectException(NotFound::class); + $this->expectExceptionMessage('Client not found'); + + $this->httpMock->expects($this->once())->method('getAllRequestParams') + ->willReturn(['client_id' => 'clientId']); + $this->clientRepositoryMock->expects($this->once())->method('findById') + ->with('clientId') + ->willReturn(null); + + $this->sut()->getFromRequest($this->requestMock, $this->clientRepositoryMock); + } +} diff --git a/tests/unit/src/Helpers/DateTimeTest.php b/tests/unit/src/Helpers/DateTimeTest.php new file mode 100644 index 00000000..f5d673f6 --- /dev/null +++ b/tests/unit/src/Helpers/DateTimeTest.php @@ -0,0 +1,48 @@ +assertInstanceOf(\DateTimeImmutable::class, $this->sut()->getUtc()); + $this->assertSame( + 'UTC', + $this->sut()->getUtc()->getTimezone()->getName(), + ); + } + + public function testCanGetFromTimestamp(): void + { + $timestamp = (new DateTimeImmutable())->getTimestamp(); + + $this->assertSame( + $timestamp, + $this->sut()->getFromTimestamp($timestamp)->getTimestamp(), + ); + } + + public function testCanGetSecondsToExpirationTime(): void + { + $expirationTime = (new DateTimeImmutable())->getTimestamp() + 60; + + $this->assertSame( + 60, + $this->sut()->getSecondsToExpirationTime($expirationTime), + ); + } +} diff --git a/tests/unit/src/Helpers/HttpTest.php b/tests/unit/src/Helpers/HttpTest.php new file mode 100644 index 00000000..2e4143e5 --- /dev/null +++ b/tests/unit/src/Helpers/HttpTest.php @@ -0,0 +1,87 @@ +serverRequestMock = $this->createMock(ServerRequestInterface::class); + } + + protected function sut(): Http + { + return new Http(); + } + + public function testCanGetAllRequestParams(): void + { + $this->serverRequestMock->expects($this->once())->method('getQueryParams') + ->willReturn(['a' => 'b']); + + $this->serverRequestMock->expects($this->once())->method('getParsedBody') + ->willReturn(['c' => 'd']); + + $this->assertSame( + ['a' => 'b', 'c' => 'd'], + $this->sut()->getAllRequestParams($this->serverRequestMock), + ); + } + + public function testCanGetAllRequestParamsBasedOnAllowedMethodsForGet(): void + { + $this->serverRequestMock->expects($this->once())->method('getMethod') + ->willReturn(HttpMethodsEnum::GET->value); + + $this->serverRequestMock->expects($this->once())->method('getQueryParams') + ->willReturn(['a' => 'b']); + + $this->assertSame( + ['a' => 'b'], + $this->sut()->getAllRequestParamsBasedOnAllowedMethods( + $this->serverRequestMock, + [HttpMethodsEnum::GET, HttpMethodsEnum::POST], + ), + ); + } + + public function testCanGetAllRequestParamsBasedOnAllowedMethodsForPost(): void + { + $this->serverRequestMock->expects($this->once())->method('getMethod') + ->willReturn(HttpMethodsEnum::POST->value); + + $this->serverRequestMock->expects($this->once())->method('getParsedBody') + ->willReturn(['c' => 'd']); + + $this->assertSame( + ['c' => 'd'], + $this->sut()->getAllRequestParamsBasedOnAllowedMethods( + $this->serverRequestMock, + [HttpMethodsEnum::GET, HttpMethodsEnum::POST], + ), + ); + } + + public function testGerAllRequestParamsBasedOnAllowedMethodsReturnsNullForNonAllowedMethod(): void + { + $this->serverRequestMock->expects($this->once())->method('getMethod') + ->willReturn(HttpMethodsEnum::POST->value); + + $this->assertNull( + $this->sut()->getAllRequestParamsBasedOnAllowedMethods( + $this->serverRequestMock, + [HttpMethodsEnum::GET], + ), + ); + } +} diff --git a/tests/unit/src/Helpers/RandomTest.php b/tests/unit/src/Helpers/RandomTest.php new file mode 100644 index 00000000..0960bc5c --- /dev/null +++ b/tests/unit/src/Helpers/RandomTest.php @@ -0,0 +1,33 @@ +assertNotEmpty( + $this->sut()->getIdentifier(), + ); + } + + public function testGetIdentifierThrowsOnInvalidLength(): void + { + $this->expectException(OidcServerException::class); + $this->expectExceptionMessage('Random'); + + $this->sut()->getIdentifier(0); + } +} diff --git a/tests/unit/src/Helpers/StrTest.php b/tests/unit/src/Helpers/StrTest.php new file mode 100644 index 00000000..f3397a9d --- /dev/null +++ b/tests/unit/src/Helpers/StrTest.php @@ -0,0 +1,34 @@ +assertSame( + ['a', 'b'], + $this->sut()->convertScopesStringToArray('a b'), + ); + } + + public function testCanConvertTextToArray(): void + { + $this->assertSame( + ['a', 'b', 'c', 'd'], + $this->sut()->convertTextToArray("a\tb\nc\rd"), + ); + } +} diff --git a/tests/unit/src/HelpersTest.php b/tests/unit/src/HelpersTest.php new file mode 100644 index 00000000..643ad853 --- /dev/null +++ b/tests/unit/src/HelpersTest.php @@ -0,0 +1,35 @@ +assertInstanceOf(Helpers\Http::class, $this->sut()->http()); + $this->assertInstanceOf(Helpers\Client::class, $this->sut()->client()); + $this->assertInstanceOf(Helpers\DateTime::class, $this->sut()->dateTime()); + $this->assertInstanceOf(Helpers\Str::class, $this->sut()->str()); + $this->assertInstanceOf(Helpers\Arr::class, $this->sut()->arr()); + $this->assertInstanceOf(Helpers\Random::class, $this->sut()->random()); + } +} diff --git a/tests/unit/src/ModuleConfigTest.php b/tests/unit/src/ModuleConfigTest.php index 4c6e0a81..f13c2d41 100644 --- a/tests/unit/src/ModuleConfigTest.php +++ b/tests/unit/src/ModuleConfigTest.php @@ -4,6 +4,7 @@ namespace SimpleSAML\Test\Module\oidc\unit; +use DateInterval; use Lcobucci\JWT\Signer; use Lcobucci\JWT\Signer\Rsa\Sha256; use PHPUnit\Framework\Attributes\CoversClass; @@ -62,6 +63,11 @@ class ModuleConfigTest extends TestCase ModuleConfig::OPTION_FEDERATION_AUTHORITY_HINTS => [ 'abc123', ], + + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER => \Symfony\Component\Cache\Adapter\ArrayAdapter::class, + ModuleConfig::OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS => [], + ModuleConfig::OPTION_PROTOCOL_USER_ENTITY_CACHE_DURATION => null, + ModuleConfig::OPTION_PROTOCOL_CLIENT_ENTITY_CACHE_DURATION => null, ]; private MockObject $sspBridgeMock; private MockObject $sspBridgeUtilsMock; @@ -95,9 +101,37 @@ protected function setUp(): void $this->sspBridgeUtilsMock->method('config')->willReturn($this->sspBridgeUtilsConfigMock); } - protected function mock(): ModuleConfig + protected function sut( + ?string $fileName = null, + ?array $overrides = null, + ?Configuration $sspConfig = null, + ?SspBridge $sspBridge = null, + ): ModuleConfig { + $fileName ??= $this->fileName; + $overrides ??= $this->overrides; + $sspConfig ??= $this->sspConfigMock; + $sspBridge ??= $this->sspBridgeMock; + + return new ModuleConfig( + $fileName, + $overrides, + $sspConfig, + $sspBridge, + ); + } + + public function testCanGetCommonOptions(): void { - return new ModuleConfig($this->fileName, $this->overrides, $this->sspConfigMock, $this->sspBridgeMock); + $this->assertSame(ModuleConfig::MODULE_NAME, $this->sut()->moduleName()); + + $this->assertInstanceOf(DateInterval::class, $this->sut()->getAuthCodeDuration()); + $this->assertInstanceOf(DateInterval::class, $this->sut()->getAccessTokenDuration()); + $this->assertInstanceOf(DateInterval::class, $this->sut()->getRefreshTokenDuration()); + + $this->assertSame( + $this->moduleConfig[ModuleConfig::OPTION_AUTH_SOURCE], + $this->sut()->getDefaultAuthSourceId(), + ); } /** @@ -108,54 +142,54 @@ public function testSigningKeyNameCanBeCustomized(): void // Test default cert and pem $this->assertStringContainsString( ModuleConfig::DEFAULT_PKI_CERTIFICATE_FILENAME, - $this->mock()->getProtocolCertPath(), + $this->sut()->getProtocolCertPath(), ); $this->assertStringContainsString( ModuleConfig::DEFAULT_PKI_PRIVATE_KEY_FILENAME, - $this->mock()->getProtocolPrivateKeyPath(), + $this->sut()->getProtocolPrivateKeyPath(), ); // Set customized $this->overrides[ModuleConfig::OPTION_PKI_PRIVATE_KEY_FILENAME] = 'myPrivateKey.key'; $this->overrides[ModuleConfig::OPTION_PKI_CERTIFICATE_FILENAME] = 'myCertificate.crt'; - $this->assertStringContainsString('myCertificate.crt', $this->mock()->getProtocolCertPath()); - $this->assertStringContainsString('myPrivateKey.key', $this->mock()->getProtocolPrivateKeyPath()); + $this->assertStringContainsString('myCertificate.crt', $this->sut()->getProtocolCertPath()); + $this->assertStringContainsString('myPrivateKey.key', $this->sut()->getProtocolPrivateKeyPath()); } public function testCanGetSspConfig(): void { - $this->assertInstanceOf(Configuration::class, $this->mock()->sspConfig()); + $this->assertInstanceOf(Configuration::class, $this->sut()->sspConfig()); } public function testCanGetModuleUrl(): void { - $this->assertStringContainsString(ModuleConfig::MODULE_NAME, $this->mock()->getModuleUrl('test')); + $this->assertStringContainsString(ModuleConfig::MODULE_NAME, $this->sut()->getModuleUrl('test')); } public function testCanGetOpenIdScopes(): void { - $this->assertNotEmpty($this->mock()->getScopes()); + $this->assertNotEmpty($this->sut()->getScopes()); } public function testCanGetProtocolSigner(): void { - $this->assertInstanceOf(Signer::class, $this->mock()->getProtocolSigner()); + $this->assertInstanceOf(Signer::class, $this->sut()->getProtocolSigner()); } public function testCanGetProtocolPrivateKeyPassphrase(): void { $this->overrides[ModuleConfig::OPTION_PKI_PRIVATE_KEY_PASSPHRASE] = 'test'; - $this->assertNotEmpty($this->mock()->getProtocolPrivateKeyPassPhrase()); + $this->assertNotEmpty($this->sut()->getProtocolPrivateKeyPassPhrase()); } public function testCanGetAuthProcFilters(): void { - $this->assertIsArray($this->mock()->getAuthProcFilters()); + $this->assertIsArray($this->sut()->getAuthProcFilters()); } public function testCanGetIssuer(): void { - $this->assertNotEmpty($this->mock()->getIssuer()); + $this->assertNotEmpty($this->sut()->getIssuer()); } public function testGetsCurrentHostIfIssuerNotSetInConfig(): void @@ -163,7 +197,7 @@ public function testGetsCurrentHostIfIssuerNotSetInConfig(): void $this->sspBridgeUtilsHttpMock->expects($this->once())->method('getSelfURLHost') ->willReturn('sample'); $this->overrides[ModuleConfig::OPTION_ISSUER] = null; - $this->mock()->getIssuer(); + $this->sut()->getIssuer(); } public function testThrowsOnEmptyIssuer(): void @@ -171,41 +205,82 @@ public function testThrowsOnEmptyIssuer(): void $this->overrides[ModuleConfig::OPTION_ISSUER] = ''; $this->expectException(OidcServerException::class); - $this->mock()->getIssuer(); + $this->sut()->getIssuer(); } public function testCanGetForcedAcrValueForCookieAuthentication(): void { $this->overrides[ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION] = '1a'; $this->overrides[ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED] = ['1a']; - $this->assertEquals('1a', $this->mock()->getForcedAcrValueForCookieAuthentication()); + $this->assertEquals('1a', $this->sut()->getForcedAcrValueForCookieAuthentication()); } public function testCanGetUserIdentifierAttribute(): void { $this->overrides[ModuleConfig::OPTION_AUTH_USER_IDENTIFIER_ATTRIBUTE] = 'sample'; - $this->assertEquals('sample', $this->mock()->getUserIdentifierAttribute()); + $this->assertEquals('sample', $this->sut()->getUserIdentifierAttribute()); } public function testCanGetCommonFederationOptions(): void { - $this->assertInstanceOf(Signer::class, $this->mock()->getFederationSigner()); + $this->assertFalse($this->sut()->getFederationEnabled()); + $this->assertInstanceOf(Signer::class, $this->sut()->getFederationSigner()); $this->assertStringContainsString( ModuleConfig::DEFAULT_PKI_FEDERATION_PRIVATE_KEY_FILENAME, - $this->mock()->getFederationPrivateKeyPath(), + $this->sut()->getFederationPrivateKeyPath(), ); - $this->assertNotEmpty($this->mock()->getFederationPrivateKeyPassPhrase()); + $this->assertNotEmpty($this->sut()->getFederationPrivateKeyPassPhrase()); $this->assertStringContainsString( ModuleConfig::DEFAULT_PKI_FEDERATION_CERTIFICATE_FILENAME, - $this->mock()->getFederationCertPath(), + $this->sut()->getFederationCertPath(), ); - $this->assertNotEmpty($this->mock()->getFederationEntityStatementDuration()); - $this->assertNotEmpty($this->mock()->getFederationAuthorityHints()); - $this->assertNotEmpty($this->mock()->getOrganizationName()); - $this->assertNotEmpty($this->mock()->getContacts()); - $this->assertNotEmpty($this->mock()->getLogoUri()); - $this->assertNotEmpty($this->mock()->getPolicyUri()); - $this->assertNotEmpty($this->mock()->getHomepageUri()); + $this->assertNotEmpty($this->sut()->getFederationEntityStatementDuration()); + $this->assertNotEmpty($this->sut()->getFederationEntityStatementCacheDurationForProduced()); + $this->assertNotEmpty($this->sut()->getFederationAuthorityHints()); + $this->assertNotEmpty($this->sut()->getFederationTrustMarkTokens()); + $this->assertNotEmpty($this->sut()->getOrganizationName()); + $this->assertNotEmpty($this->sut()->getContacts()); + $this->assertNotEmpty($this->sut()->getLogoUri()); + $this->assertNotEmpty($this->sut()->getPolicyUri()); + $this->assertNotEmpty($this->sut()->getHomepageUri()); + $this->assertNotEmpty($this->sut()->getFederationCacheAdapterClass()); + $this->assertIsArray($this->sut()->getFederationCacheAdapterArguments()); + $this->assertNotEmpty($this->sut()->getFederationCacheMaxDurationForFetched()); + $this->assertNotEmpty($this->sut()->getFederationTrustAnchors()); + $this->assertNotEmpty($this->sut()->getFederationTrustAnchorIds()); + } + + public function testGetFederationTrustAnchorsThrowsOnEmptyIfFederationEnabled(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('No Trust Anchors'); + + $this->sut( + overrides: [ + ModuleConfig::OPTION_FEDERATION_ENABLED => true, + ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS => [], + ], + )->getFederationTrustAnchors(); + } + + + + public function testCanGetTrustAnchorJwksJson(): void + { + $this->assertNotEmpty($this->sut()->getTrustAnchorJwksJson('https://ta.example.org/')); + $this->assertEmpty($this->sut()->getTrustAnchorJwksJson('invalid')); + } + + public function testGetTrustAnchorJwksJsonThrowsOnInvalidData(): void + { + $this->expectException(ConfigurationError::class); + $this->expectExceptionMessage('format'); + + $this->sut( + overrides: [ + ModuleConfig::OPTION_FEDERATION_TRUST_ANCHORS => ['ta' => 123], + ], + )->getTrustAnchorJwksJson('ta'); } public function testThrowsIfTryingToOverrideProtectedScopes(): void @@ -217,7 +292,7 @@ public function testThrowsIfTryingToOverrideProtectedScopes(): void ]; $this->expectException(ConfigurationError::class); - $this->mock(); + $this->sut(); } public function testThrowsIfCustomScopeDoesNotHaveDescription(): void @@ -227,7 +302,7 @@ public function testThrowsIfCustomScopeDoesNotHaveDescription(): void ]; $this->expectException(ConfigurationError::class); - $this->mock(); + $this->sut(); } public function testThrowsIfAcrIsNotString(): void @@ -235,35 +310,35 @@ public function testThrowsIfAcrIsNotString(): void $this->overrides[ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED] = [123]; $this->expectException(ConfigurationError::class); - $this->mock(); + $this->sut(); } public function testThrowsIfAuthSourceNotString(): void { $this->overrides[ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP] = [123 => []]; $this->expectException(ConfigurationError::class); - $this->mock(); + $this->sut(); } public function testThrowsIfAuthSourceToAcrMapAcrNotArray(): void { $this->overrides[ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP] = ['abc' => 123]; $this->expectException(ConfigurationError::class); - $this->mock(); + $this->sut(); } public function testThrowsIfAuthSourceToAcrMapAcrNotString(): void { $this->overrides[ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP] = ['abc' => [123]]; $this->expectException(ConfigurationError::class); - $this->mock(); + $this->sut(); } public function testThrowsIfAuthSourceToAcrMapAcrNotAllowed(): void { $this->overrides[ModuleConfig::OPTION_AUTH_SOURCES_TO_ACR_VALUES_MAP] = ['abc' => ['acr']]; $this->expectException(ConfigurationError::class); - $this->mock(); + $this->sut(); } public function testThrowsIForcedAcrValueForCookieAuthenticationNotAllowed(): void @@ -271,13 +346,30 @@ public function testThrowsIForcedAcrValueForCookieAuthenticationNotAllowed(): vo $this->overrides[ModuleConfig::OPTION_AUTH_ACR_VALUES_SUPPORTED] = ['abc']; $this->overrides[ModuleConfig::OPTION_AUTH_FORCED_ACR_VALUE_FOR_COOKIE_AUTHENTICATION] = 'cba'; $this->expectException(ConfigurationError::class); - $this->mock(); + $this->sut(); } public function testThrowsIfInvalidSignerProvided(): void { $this->overrides[ModuleConfig::OPTION_TOKEN_SIGNER] = stdClass::class; $this->expectException(ConfigurationError::class); - $this->mock()->getProtocolSigner(); + $this->sut()->getProtocolSigner(); + } + + public function testCanGetEncryptionKey(): void + { + $this->sspBridgeUtilsConfigMock->expects($this->once())->method('getSecretSalt') + ->willReturn('secretSalt'); + + $this->assertSame('secretSalt', $this->sut()->getEncryptionKey()); + } + + public function testCanGetProtocolCacheConfiguration(): void + { + $this->assertNotEmpty($this->sut()->getProtocolCacheAdapterClass()); + $this->assertIsArray($this->sut()->getProtocolCacheAdapterArguments()); + + $this->assertInstanceOf(DateInterval::class, $this->sut()->getProtocolUserEntityCacheDuration()); + $this->assertInstanceOf(DateInterval::class, $this->sut()->getProtocolClientEntityCacheDuration()); } } diff --git a/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php b/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php new file mode 100644 index 00000000..c54f34a0 --- /dev/null +++ b/tests/unit/src/Repositories/AbstractDatabaseRepositoryTest.php @@ -0,0 +1,51 @@ +moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->databaseMock = $this->createMock(Database::class); + $this->protocolCacheMock = $this->createMock(ProtocolCache::class); + } + + protected function sut( + ?ModuleConfig $moduleConfig = null, + ?Database $database = null, + ?ProtocolCache $protocolCache = null, + ): AbstractDatabaseRepository { + $moduleConfig ??= $this->moduleConfigMock; + $database ??= $this->databaseMock; + $protocolCache ??= $this->protocolCacheMock; + + return new class ($moduleConfig, $database, $protocolCache) extends AbstractDatabaseRepository + { + public function getTableName(): ?string + { + return 'sut'; + } + }; + } + + public function testCanGetCacheKey(): void + { + $this->assertSame('sut_something', $this->sut()->getCacheKey('something')); + } +} diff --git a/tests/unit/src/Repositories/AccessTokenRepositoryTest.php b/tests/unit/src/Repositories/AccessTokenRepositoryTest.php index 7da2cf77..efe6fe32 100644 --- a/tests/unit/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/unit/src/Repositories/AccessTokenRepositoryTest.php @@ -17,31 +17,33 @@ use DateTimeImmutable; use Exception; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Database; +use SimpleSAML\Error\Error; use SimpleSAML\Module\oidc\Codebooks\DateFormatsEnum; use SimpleSAML\Module\oidc\Entities\AccessTokenEntity; -use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; +use SimpleSAML\Module\oidc\Entities\ClientEntity; +use SimpleSAML\Module\oidc\Entities\Interfaces\AccessTokenEntityInterface; use SimpleSAML\Module\oidc\Factories\Entities\AccessTokenEntityFactory; use SimpleSAML\Module\oidc\Factories\Entities\ClientEntityFactory; use SimpleSAML\Module\oidc\Helpers; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AccessTokenRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; +use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException; use SimpleSAML\Module\oidc\Services\DatabaseMigration; +use SimpleSAML\Module\oidc\Utils\ProtocolCache; -/** - * @covers \SimpleSAML\Module\oidc\Repositories\AccessTokenRepository - */ +#[CoversClass(AccessTokenRepository::class)] class AccessTokenRepositoryTest extends TestCase { final public const CLIENT_ID = 'access_token_client_id'; final public const USER_ID = 'access_token_user_id'; final public const ACCESS_TOKEN_ID = 'access_token_id'; - - protected AccessTokenRepository $repository; + final public const AUTH_CODE_ID = 'auth_code_id'; protected MockObject $moduleConfigMock; protected MockObject $clientRepositoryMock; @@ -52,8 +54,10 @@ class AccessTokenRepositoryTest extends TestCase protected MockObject $dateTimeHelperMock; protected static bool $dbSeeded = false; - protected ClientEntityInterface $clientEntity; + protected MockObject $clientEntityMock; protected array $accessTokenState; + protected Database $database; + protected MockObject $protocolCacheMock; /** * @throws \Exception @@ -79,10 +83,10 @@ protected function setUp(): void $this->clientEntityFactoryMock = $this->createMock(ClientEntityFactory::class); $this->clientRepositoryMock = $this->createMock(ClientRepository::class); - $this->clientEntity = ClientRepositoryTest::getClient(self::CLIENT_ID); - $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntity); + $this->clientEntityMock = $this->createMock(ClientEntity::class); + $this->clientRepositoryMock->method('findById')->willReturn($this->clientEntityMock); - $this->clientEntityFactoryMock->method('fromState')->willReturn($this->clientEntity); + $this->clientEntityFactoryMock->method('fromState')->willReturn($this->clientEntityMock); $this->accessTokenEntityMock = $this->createMock(AccessTokenEntity::class); $this->accessTokenEntityFactoryMock = $this->createMock(AccessTokenEntityFactory::class); @@ -96,28 +100,45 @@ protected function setUp(): void 'user_id' => 'user123', 'client_id' => self::CLIENT_ID, 'is_revoked' => false, - 'auth_code_id' => 'authCode123', + 'auth_code_id' => self::AUTH_CODE_ID, ]; $this->helpersMock = $this->createMock(Helpers::class); $this->dateTimeHelperMock = $this->createMock(Helpers\DateTime::class); $this->helpersMock->method('dateTime')->willReturn($this->dateTimeHelperMock); - $database = Database::getInstance(); + $this->database = Database::getInstance(); + $this->protocolCacheMock = $this->createMock(ProtocolCache::class); + } - $this->repository = new AccessTokenRepository( - $this->moduleConfigMock, + protected function sut( + ?ModuleConfig $moduleConfig = null, + ?Database $database = null, + ?ProtocolCache $protocolCache = null, + ?ClientRepository $clientRepository = null, + ?AccessTokenEntityFactory $accessTokenEntityFactory = null, + ?Helpers $helpers = null, + ): AccessTokenRepository { + $moduleConfig ??= $this->moduleConfigMock; + $database ??= $this->database; + $protocolCache ??= $this->protocolCacheMock; + $clientRepository ??= $this->clientRepositoryMock; + $accessTokenEntityFactory ??= $this->accessTokenEntityFactoryMock; + $helpers ??= $this->helpersMock; + + return new AccessTokenRepository( + $moduleConfig, $database, - null, - $this->clientRepositoryMock, - $this->accessTokenEntityFactoryMock, - $this->helpersMock, + $protocolCache, + $clientRepository, + $accessTokenEntityFactory, + $helpers, ); } public function testGetTableName(): void { - $this->assertSame('phpunit_oidc_access_token', $this->repository->getTableName()); + $this->assertSame('phpunit_oidc_access_token', $this->sut()->getTableName()); } /** @@ -130,20 +151,33 @@ public function testGetTableName(): void public function testAddAndFound(): void { $this->accessTokenEntityMock->method('getState')->willReturn($this->accessTokenState); + $this->accessTokenEntityMock->method('getExpiryDateTime') + ->willReturn(new DateTimeImmutable()); - $this->repository->persistNewAccessToken($this->accessTokenEntityMock); + $sut = $this->sut(); + $sut->persistNewAccessToken($this->accessTokenEntityMock); - $foundAccessToken = $this->repository->findById(self::ACCESS_TOKEN_ID); + $foundAccessToken = $sut->findById(self::ACCESS_TOKEN_ID); $this->assertEquals($this->accessTokenEntityMock, $foundAccessToken); } + public function testPersistNewAccessTokenThrowsIfNotAccessTokenEntity(): void + { + $oAuthAccessTokenEntity = $this->createMock(\League\OAuth2\Server\Entities\AccessTokenEntityInterface::class); + + $this->expectException(Error::class); + $this->expectExceptionMessage('Invalid'); + + $this->sut()->persistNewAccessToken($oAuthAccessTokenEntity); + } + /** * @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException */ public function testAddAndNotFound(): void { - $notFoundAccessToken = $this->repository->findById('notoken'); + $notFoundAccessToken = $this->sut()->findById('notoken'); $this->assertNull($notFoundAccessToken); } @@ -155,13 +189,17 @@ public function testAddAndNotFound(): void public function testRevokeToken(): void { $this->accessTokenEntityMock->expects($this->once())->method('revoke'); + $this->accessTokenEntityMock->method('getExpiryDateTime') + ->willReturn(new DateTimeImmutable()); + $state = $this->accessTokenState; $state['is_revoked'] = true; $this->accessTokenEntityMock->method('getState')->willReturn($state); $this->accessTokenEntityMock->method('isRevoked')->willReturn(true); - $this->repository->revokeAccessToken(self::ACCESS_TOKEN_ID); - $isRevoked = $this->repository->isAccessTokenRevoked(self::ACCESS_TOKEN_ID); + $sut = $this->sut(); + $sut->revokeAccessToken(self::ACCESS_TOKEN_ID); + $isRevoked = $sut->isAccessTokenRevoked(self::ACCESS_TOKEN_ID); $this->assertTrue($isRevoked); } @@ -174,7 +212,7 @@ public function testErrorRevokeInvalidToken(): void { $this->expectException(Exception::class); - $this->repository->revokeAccessToken('notoken'); + $this->sut()->revokeAccessToken('notoken'); } /** @@ -184,7 +222,7 @@ public function testErrorCheckIsRevokedInvalidToken(): void { $this->expectException(Exception::class); - $this->repository->isAccessTokenRevoked('notoken'); + $this->sut()->isAccessTokenRevoked('notoken'); } /** @@ -199,9 +237,78 @@ public function testRemoveExpired(): void $this->dateTimeHelperMock->expects($this->once())->method('getUtc') ->willReturn($dateTimeMock); - $this->repository->removeExpired(); - $notFoundAccessToken = $this->repository->findById(self::ACCESS_TOKEN_ID); + $sut = $this->sut(); + $sut->removeExpired(); + $notFoundAccessToken = $sut->findById(self::ACCESS_TOKEN_ID); $this->assertNull($notFoundAccessToken); } + + public function testCanGetNewToken() + { + $this->accessTokenEntityFactoryMock->expects($this->once())->method('fromData') + ->willReturn($this->accessTokenEntityMock); + + $this->assertInstanceOf( + AccessTokenEntityInterface::class, + $this->sut()->getNewToken( + $this->clientEntityMock, + [], + 'userId', + 'authCodeId', + [], + 'id', + new DateTimeImmutable(), + ), + ); + } + + public function testCanGetNewTokenForEmptyUserId(): void + { + $this->accessTokenEntityFactoryMock->expects($this->once())->method('fromData') + ->willReturn($this->accessTokenEntityMock); + + $this->assertInstanceOf( + AccessTokenEntityInterface::class, + $this->sut()->getNewToken( + $this->clientEntityMock, + [], + '', + 'authCodeId', + [], + 'id', + new DateTimeImmutable(), + ), + ); + } + + public function testCanGetNewTokenThrowsForEmptyId(): void + { + $this->expectException(OidcServerException::class); + $this->expectExceptionMessage('Invalid'); + + $this->sut()->getNewToken( + $this->clientEntityMock, + [], + '', + 'authCodeId', + [], + null, + new DateTimeImmutable(), + ); + } + + public function testCanRevokeByAuthCodeId(): void + { + $this->accessTokenEntityMock->method('getState')->willReturn($this->accessTokenState); + $this->accessTokenEntityMock->method('getExpiryDateTime') + ->willReturn(new DateTimeImmutable()); + + $this->accessTokenEntityMock->expects($this->once())->method('revoke'); + + $sut = $this->sut(); + $sut->persistNewAccessToken($this->accessTokenEntityMock); + + $sut->revokeByAuthCodeId(self::AUTH_CODE_ID); + } } diff --git a/tests/unit/src/Repositories/AllowedOriginRepositoryTest.php b/tests/unit/src/Repositories/AllowedOriginRepositoryTest.php index 2c6f438c..47b3cc8c 100644 --- a/tests/unit/src/Repositories/AllowedOriginRepositoryTest.php +++ b/tests/unit/src/Repositories/AllowedOriginRepositoryTest.php @@ -4,12 +4,14 @@ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Database; use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; +use SimpleSAML\Module\oidc\Utils\ProtocolCache; /** * @covers \SimpleSAML\Module\oidc\Repositories\AllowedOriginRepository @@ -18,6 +20,10 @@ class AllowedOriginRepositoryTest extends TestCase { final public const CLIENT_ID = 'some_client_id'; + protected MockObject $moduleConfigMock; + protected MockObject $protocolCacheMock; + + final public const ORIGINS = [ 'https://example.org', 'https://sample.com', @@ -45,12 +51,15 @@ public static function setUpBeforeClass(): void protected function setUp(): void { - $moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->protocolCacheMock = $this->createMock(ProtocolCache::class); + $database = Database::getInstance(); + $this->repository = new AllowedOriginRepository( - $moduleConfigMock, + $this->moduleConfigMock, $database, - null, + $this->protocolCacheMock, ); } @@ -79,4 +88,12 @@ public function testSetGetHasDelete(): void $this->assertFalse($this->repository->has(self::ORIGINS[0])); $this->assertFalse($this->repository->has(self::ORIGINS[1])); } + + public function testHasCanReturnFromCache(): void + { + $this->protocolCacheMock->expects($this->once())->method('get') + ->willReturn(true); + + $this->assertTrue($this->repository->has('origin')); + } } diff --git a/tests/unit/src/Repositories/AuthCodeRepositoryTest.php b/tests/unit/src/Repositories/AuthCodeRepositoryTest.php index 99cce538..966ed188 100644 --- a/tests/unit/src/Repositories/AuthCodeRepositoryTest.php +++ b/tests/unit/src/Repositories/AuthCodeRepositoryTest.php @@ -18,10 +18,12 @@ use DateTimeImmutable; use DateTimeZone; use Exception; +use League\OAuth2\Server\Entities\AuthCodeEntityInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Database; +use SimpleSAML\Error\Error; use SimpleSAML\Module\oidc\Codebooks\DateFormatsEnum; use SimpleSAML\Module\oidc\Entities\AuthCodeEntity; use SimpleSAML\Module\oidc\Entities\ClientEntity; @@ -32,6 +34,7 @@ use SimpleSAML\Module\oidc\Repositories\AuthCodeRepository; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; +use SimpleSAML\Module\oidc\Utils\ProtocolCache; /** * @covers \SimpleSAML\Module\oidc\Repositories\AuthCodeRepository @@ -48,6 +51,8 @@ class AuthCodeRepositoryTest extends TestCase protected MockObject $clientRepositoryMock; protected MockObject $authCodeEntityFactoryMock; protected MockObject $helpersMock; + protected MockObject $moduleConfigMock; + protected MockObject $protocolCacheMock; protected MockObject $dateTimeHelperMock; /** @var \League\OAuth2\Server\Entities\ScopeEntityInterface[] */ protected array $scopes; @@ -72,6 +77,9 @@ public static function setUpBeforeClass(): void protected function setUp(): void { + $this->moduleConfigMock = $this->createMock(ModuleConfig::class); + $this->protocolCacheMock = $this->createMock(ProtocolCache::class); + $this->clientEntityMock = $this->createMock(ClientEntity::class); $this->clientEntityMock->method('getIdentifier')->willReturn(self::CLIENT_ID); $this->clientRepositoryMock = $this->createMock(ClientRepository::class); @@ -88,9 +96,9 @@ protected function setUp(): void $database = Database::getInstance(); $this->repository = new AuthCodeRepository( - $this->createMock(ModuleConfig::class), + $this->moduleConfigMock, $database, - null, + $this->protocolCacheMock, $this->clientRepositoryMock, $this->authCodeEntityFactoryMock, $this->helpersMock, @@ -216,4 +224,21 @@ public function testRemoveExpired(): void $this->assertNull($notFoundAuthCode); } + + public function testGetNewAuthCodeThrows(): void + { + $this->expectException(\RuntimeException::class); + + $this->repository->getNewAuthCode(); + } + + public function testPersistNewAuthCodeThrowsIfNotAuthCodeEntity(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessage('Invalid'); + + $this->repository->persistNewAuthCode( + $this->createMock(AuthCodeEntityInterface::class), + ); + } } diff --git a/tests/unit/src/Repositories/ClientRepositoryTest.php b/tests/unit/src/Repositories/ClientRepositoryTest.php index 2d18cd8e..7360b681 100644 --- a/tests/unit/src/Repositories/ClientRepositoryTest.php +++ b/tests/unit/src/Repositories/ClientRepositoryTest.php @@ -25,6 +25,7 @@ use SimpleSAML\Module\oidc\ModuleConfig; use SimpleSAML\Module\oidc\Repositories\ClientRepository; use SimpleSAML\Module\oidc\Services\DatabaseMigration; +use SimpleSAML\Module\oidc\Utils\ProtocolCache; /** * @covers \SimpleSAML\Module\oidc\Repositories\ClientRepository @@ -35,7 +36,6 @@ class ClientRepositoryTest extends TestCase protected MockObject $clientEntityMock; protected MockObject $clientEntityFactoryMock; - /** * @throws \Exception */ @@ -121,6 +121,20 @@ public function testGetClientEntity(): void $this->assertNotNull($client); } + public function testGetClientEntityReturnsNullForExpiredClient(): void + { + $this->clientEntityMock->expects($this->once())->method('isExpired')->willReturn(true); + + $this->clientEntityFactoryMock->expects($this->once())->method('fromState') + ->willReturn($this->clientEntityMock); + + // Just so we have a client with this ID in repo. + $client = self::getClient('clientid'); + $this->repository->add($client); + + $this->assertNull($this->repository->getClientEntity('clientid')); + } + /** * @throws \JsonException */ @@ -354,23 +368,81 @@ public function testCrudWithOwner(): void $this->assertNotNull($foundClient); } + public function testCanFindByIdFromCache(): void + { + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->expects($this->once())->method('get')->willReturn(['state']); + + + $this->clientEntityFactoryMock->expects($this->once())->method('fromState') + ->with(['state']) + ->willReturn($this->clientEntityMock); + + $sut = new ClientRepository( + new ModuleConfig(), + Database::getInstance(), + $protocolCacheMock, + $this->clientEntityFactoryMock, + ); + + $this->assertInstanceOf(ClientEntityInterface::class, $sut->findById('clientid')); + } + + public function testCanFindByEntityIdentifier(): void + { + $client = self::getClient(id: 'clientId', entityId: 'entityId', isFederated: true); + $this->repository->add($client); + + $this->clientEntityFactoryMock->expects($this->once())->method('fromState')->willReturn($client); + + $this->assertSame( + $client, + $this->repository->findByEntityIdentifier('entityId'), + ); + + $this->assertNull($this->repository->findByEntityIdentifier('nonExistingEntityId')); + } + + public function testCanFindByEntityIdFromCache(): void + { + $protocolCacheMock = $this->createMock(ProtocolCache::class); + $protocolCacheMock->expects($this->once())->method('get')->willReturn(['state']); + + $this->clientEntityFactoryMock->expects($this->once())->method('fromState') + ->with(['state']) + ->willReturn($this->clientEntityMock); + + $sut = new ClientRepository( + new ModuleConfig(), + Database::getInstance(), + $protocolCacheMock, + $this->clientEntityFactoryMock, + ); + + $this->assertInstanceOf(ClientEntityInterface::class, $sut->findByEntityIdentifier('entityId')); + } + public static function getClient( string $id, bool $enabled = true, bool $confidential = false, ?string $owner = null, + ?string $entityId = null, + bool $isFederated = false, ): ClientEntityInterface { return new ClientEntity( - $id, - 'clientsecret', - 'Client', - 'Description', - ['http://localhost/redirect'], - ['openid'], - $enabled, - $confidential, - 'admin', - $owner, + identifier: $id, + secret: 'clientsecret', + name: 'Client', + description: 'Description', + redirectUri: ['http://localhost/redirect'], + scopes: ['openid'], + isEnabled: $enabled, + isConfidential: $confidential, + authSource: 'admin', + owner: $owner, + entityIdentifier: $entityId, + isFederated: $isFederated, ); } } From 9a7f463ec60dd67525e5f2c3d9c4dd144f88925d Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Fri, 24 Jan 2025 09:22:16 +0100 Subject: [PATCH 14/17] Test GHA int with Linux OS family --- .../src/Repositories/AccessTokenRepositoryTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php index ba0c0603..4b3a19b9 100644 --- a/tests/integration/src/Repositories/AccessTokenRepositoryTest.php +++ b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php @@ -70,11 +70,15 @@ class AccessTokenRepositoryTest extends TestCase public static function setUpBeforeClass(): void { + self::$containerAddress = getenv('HOSTADDRESS') ?: null; self::$mysqlPort = getenv('HOSTPORT_MY') ?: null; self::$postgresPort = getenv('HOSTPORT_PG') ?: null; // Mac docker seems to require connecting to localhost and mapped port to access containers - if (PHP_OS_FAMILY === 'Darwin' && getenv('HOSTADDRESS') === false) { + if ( + in_array(PHP_OS_FAMILY, ['Darwin', 'Linux']) && + getenv('HOSTADDRESS') === false + ) { //phpcs:ignore Generic.Files.LineLength.TooLong echo "Defaulting docker host address to 127.0.0.1. Disable this behavior by setting HOSTADDRESS to a blank.\n\tHOSTADDRESS= ./vendor/bin/phpunit"; self::$containerAddress = "127.0.0.1"; From 775aef7ad6ab7f6bf8a8eaf4a18222366d7a7f4e Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Fri, 24 Jan 2025 10:27:01 +0100 Subject: [PATCH 15/17] Add coverage --- src/Repositories/UserRepository.php | 2 + .../src/Repositories/ClientRepositoryTest.php | 29 ++++++------ .../CodeChallengeVerifiersRepositoryTest.php | 46 +++++++++++++++++++ .../RefreshTokenRepositoryTest.php | 44 ++++++++++++++++++ .../src/Repositories/ScopeRepositoryTest.php | 14 ++++++ .../src/Repositories/UserRepositoryTest.php | 14 ++++++ 6 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 tests/unit/src/Repositories/CodeChallengeVerifiersRepositoryTest.php diff --git a/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php index b98aa88b..db644bdb 100644 --- a/src/Repositories/UserRepository.php +++ b/src/Repositories/UserRepository.php @@ -76,9 +76,11 @@ public function getUserEntityByIdentifier(string $identifier): ?UserEntity $row = current($rows); + // @codeCoverageIgnoreStart if (!is_array($row)) { return null; } + // @codeCoverageIgnoreEnd $userEntity = $this->userEntityFactory->fromState($row); diff --git a/tests/unit/src/Repositories/ClientRepositoryTest.php b/tests/unit/src/Repositories/ClientRepositoryTest.php index 7360b681..d8a493bb 100644 --- a/tests/unit/src/Repositories/ClientRepositoryTest.php +++ b/tests/unit/src/Repositories/ClientRepositoryTest.php @@ -277,24 +277,25 @@ public function testFindPaginationWithEmptyList() */ public function testUpdate(): void { - $client = self::getClient('clientid'); + $client = self::getClient(id: 'clientId', entityId: 'entityId'); $this->repository->add($client); $client = new ClientEntity( - 'clientid', - 'newclientsecret', - 'Client', - 'Description', - ['http://localhost/redirect'], - ['openid'], - true, - false, - 'admin', + identifier: 'clientId', + secret: 'newclientsecret', + name: 'Client', + description: 'Description', + redirectUri: ['http://localhost/redirect'], + scopes: ['openid'], + isEnabled: true, + isConfidential: false, + authSource: 'admin', + entityIdentifier: 'newEntityId', ); $this->repository->update($client); $this->clientEntityFactoryMock->expects($this->once())->method('fromState')->willReturn($client); - $foundClient = $this->repository->findById('clientid'); + $foundClient = $this->repository->findById('clientId'); $this->assertEquals($client, $foundClient); } @@ -305,13 +306,13 @@ public function testUpdate(): void */ public function testDelete(): void { - $client = self::getClient('clientid'); + $client = self::getClient(id: 'clientId', entityId: 'entityId'); $this->repository->add($client); $this->clientEntityFactoryMock->expects($this->once())->method('fromState')->willReturn($client); - $client = $this->repository->findById('clientid'); + $client = $this->repository->findById('clientId'); $this->repository->delete($client); - $foundClient = $this->repository->findById('clientid'); + $foundClient = $this->repository->findById('clientId'); $this->assertNull($foundClient); } diff --git a/tests/unit/src/Repositories/CodeChallengeVerifiersRepositoryTest.php b/tests/unit/src/Repositories/CodeChallengeVerifiersRepositoryTest.php new file mode 100644 index 00000000..06b34126 --- /dev/null +++ b/tests/unit/src/Repositories/CodeChallengeVerifiersRepositoryTest.php @@ -0,0 +1,46 @@ +assertInstanceOf(CodeChallengeVerifiersRepository::class, $this->sut()); + } + + public function testCanGetCodeChallengeVerifier(): void + { + $this->assertInstanceOf( + CodeChallengeVerifierInterface::class, + $this->sut()->get('S256'), + ); + $this->assertTrue($this->sut()->has('S256')); + + $this->assertInstanceOf( + CodeChallengeVerifierInterface::class, + $this->sut()->get('plain'), + ); + $this->assertTrue($this->sut()->has('plain')); + + $this->assertNotEmpty($this->sut()->getAll()); + } + + public function testReturnsNullForUnsuportedVerifier(): void + { + $this->assertNull($this->sut()->get('unsuported')); + } +} diff --git a/tests/unit/src/Repositories/RefreshTokenRepositoryTest.php b/tests/unit/src/Repositories/RefreshTokenRepositoryTest.php index ce04ccbd..6c706c84 100644 --- a/tests/unit/src/Repositories/RefreshTokenRepositoryTest.php +++ b/tests/unit/src/Repositories/RefreshTokenRepositoryTest.php @@ -17,6 +17,8 @@ use DateTimeImmutable; use DateTimeZone; +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -40,6 +42,7 @@ class RefreshTokenRepositoryTest extends TestCase final public const USER_ID = 'refresh_token_user_id'; final public const ACCESS_TOKEN_ID = 'refresh_token_access_token_id'; final public const REFRESH_TOKEN_ID = 'refresh_token_id'; + final public const AUTH_CODE_ID = 'auth_code_id'; protected RefreshTokenRepository $repository; protected MockObject $accessTokenMock; @@ -184,4 +187,45 @@ public function testRemoveExpired(): void $this->assertNull($notFoundRefreshToken); } + + public function testGetNewRefreshTokenThrows(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Not implemented'); + + $this->repository->getNewRefreshToken(); + } + + public function testPersistNewRefreshTokenThrowsIfNotRefreshTokenEntity(): void + { + $this->expectException(OAuthServerException::class); + + $oAuthRefreshTokenEntity = $this->createMock(RefreshTokenEntityInterface::class); + + $this->repository->persistNewRefreshToken($oAuthRefreshTokenEntity); + } + + public function testCanRevokeByAuthCodeId(): void + { + $refreshToken = new RefreshTokenEntity( + self::REFRESH_TOKEN_ID, + new DateTimeImmutable('tomorrow', new DateTimeZone('UTC')), + $this->accessTokenMock, + self::AUTH_CODE_ID, + ); + + $this->repository->persistNewRefreshToken($refreshToken); + + $this->refreshTokenEntityFactoryMock->expects($this->once()) + ->method('fromState') + ->with($this->callback(function (array $state): bool { + return $state['id'] === self::REFRESH_TOKEN_ID; + }))->willReturn($this->refreshTokenEntityMock); + + $this->accessTokenRepositoryMock->method('findById')->willReturn($this->accessTokenMock); + + $this->refreshTokenEntityMock->expects($this->once())->method('revoke'); + + $this->repository->revokeByAuthCodeId(self::AUTH_CODE_ID); + } } diff --git a/tests/unit/src/Repositories/ScopeRepositoryTest.php b/tests/unit/src/Repositories/ScopeRepositoryTest.php index 4615fdff..308c32df 100644 --- a/tests/unit/src/Repositories/ScopeRepositoryTest.php +++ b/tests/unit/src/Repositories/ScopeRepositoryTest.php @@ -15,6 +15,7 @@ */ namespace SimpleSAML\Test\Module\oidc\unit\Repositories; +use League\OAuth2\Server\Entities\ClientEntityInterface; use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Module\oidc\Entities\ScopeEntity; @@ -90,4 +91,17 @@ public function testFinalizeScopes(): void ]; $this->assertEquals($expectedScopes, $finalizedScopes); } + + public function testFinalizeScopesReturnsEmptyIfNotClientEntity(): void + { + $scopeRepository = new ScopeRepository(new ModuleConfig(), new ScopeEntityFactory()); + $scopes = [ + new ScopeEntity('openid'), + new ScopeEntity('basic'), + ]; + + $clientMock = $this->createMock(ClientEntityInterface::class); + + $this->assertEmpty($scopeRepository->finalizeScopes($scopes, 'any', $clientMock)); + } } diff --git a/tests/unit/src/Repositories/UserRepositoryTest.php b/tests/unit/src/Repositories/UserRepositoryTest.php index ec2189b0..9daed4eb 100644 --- a/tests/unit/src/Repositories/UserRepositoryTest.php +++ b/tests/unit/src/Repositories/UserRepositoryTest.php @@ -22,6 +22,7 @@ use PHPUnit\Framework\TestCase; use SimpleSAML\Configuration; use SimpleSAML\Database; +use SimpleSAML\Module\oidc\Entities\Interfaces\ClientEntityInterface; use SimpleSAML\Module\oidc\Entities\UserEntity; use SimpleSAML\Module\oidc\Factories\Entities\UserEntityFactory; use SimpleSAML\Module\oidc\Helpers; @@ -307,4 +308,17 @@ public function testWillDeleteFromDatabaseAndCache(): void protocolCache: $this->protocolCacheMock, )->delete($this->userEntityMock); } + + public function testGetUserEntityByUserCredentialsThrows(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Not supported'); + + $this->mock()->getUserEntityByUserCredentials( + 'username', + 'password', + 'grantType', + $this->createMock(ClientEntityInterface::class), + ); + } } From 4690e8d553511ee5eb5576a81665470d7fd9cef1 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Fri, 24 Jan 2025 11:03:00 +0100 Subject: [PATCH 16/17] Update readme --- README.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6b3c0e94..600478c5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Currently supported flows are: [![Build Status](https://github.com/simplesamlphp/simplesamlphp-module-oidc/actions/workflows/test.yaml/badge.svg)](https://github.com/simplesamlphp/simplesamlphp-module-oidc/actions/workflows/test.yaml) [![Coverage Status](https://codecov.io/gh/simplesamlphp/simplesamlphp-module-oidc/branch/master/graph/badge.svg)](https://app.codecov.io/gh/simplesamlphp/simplesamlphp-module-oidc) -[![SimpleSAMLphp](https://img.shields.io/badge/simplesamlphp-2.1-brightgreen)](https://simplesamlphp.org/) +[![SimpleSAMLphp](https://img.shields.io/badge/simplesamlphp-2.3-brightgreen)](https://simplesamlphp.org/) ![Main screen capture](docs/oidc.png) @@ -112,7 +112,7 @@ Once the module is enabled, the database migrations must be run. ### Run database migrations The module comes with some default SQL migrations which set up needed tables in the configured database. To run them, -go to `OIDC` > `Database Migrations`, and press the available button. +in the SimpleSAMLphp administration area go to `OIDC` > `Database Migrations`, and press the available button. Alternatively, in case of automatic / scripted deployments, you can run the 'install.php' script from the command line: @@ -122,7 +122,8 @@ Alternatively, in case of automatic / scripted deployments, you can run the 'ins The module lets you manage (create, read, update and delete) approved RPs from the module user interface itself. -Once the database schema has been created, you can go to `OIDC` > `Client Registry`. +Once the database schema has been created, in the SimpleSAMLphp administration area go to `OIDC` > +`Client Registry`. Note that clients can be marked as confidential or public. If the client is not marked as confidential (it is public), and is using Authorization Code flow, it will have to provide PKCE parameters during the flow. @@ -136,12 +137,9 @@ to be enabled and configured. ### Endpoint locations -Once you deployed the module, you will need the exact endpoint urls the module provides to configure the relying parties. -You can visit the discovery endpoint to learn this information: - -`/module.php/oidc/.well-known/openid-configuration` - -This endpoint can be used to set up a `.well-known` URL (see below). +Once you deploy the module, in the SimpleSAMLphp administration area go to `OIDC` and then select the +Protocol / Federation Settings page to see the available discovery URLs. These URLs can then be used to set up a +`.well-known` URLs (see below). ### Note when using Apache web server @@ -161,6 +159,20 @@ SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 ``` Choose the one which works for you. If you don't set it, you'll get a warnings about this situation in your logs. +### Note on OpenID Federation (OIDF) support + +OpenID Federation support is in "draft" phase, as is the +[specification](https://openid.net/specs/openid-federation-1_0) itself. This means that you can expect braking changes +in future releases related to OIDF capabilities. You can enable / disable OIDF support at any time in module +configuration. + +Currently, the following OIDF features are supported: +* endpoint for issuing configuration entity statement (statement about itself) +* fetch endpoint for issuing statements about subordinates (registered clients) +* automatic client registration using a Request Object + +OIDF support is implemented using the underlying [SimpleSAMLphp OpenID library](https://github.com/simplesamlphp/openid). + ## Additional considerations ### Private scopes @@ -343,7 +355,7 @@ You may view the OIDC configuration endpoint at `https://localhost/.well-known/o To test local changes against another DB, such as Postgres, we need to: * Create a docker network layer -* Run a DB container ( and create a DB if one doesn't exist) +* Run a DB container (and create a DB if one doesn't exist) * Run SSP and use the DB container ``` From 6d7fe6724eb58b97f59de09096db3d430184055f Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Fri, 24 Jan 2025 11:15:17 +0100 Subject: [PATCH 17/17] Add note about artifacts caching --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 600478c5..6489b569 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,15 @@ Alternatively, in case of automatic / scripted deployments, you can run the 'ins php modules/oidc/bin/install.php +### Protocol Artifacts Caching + +The configured database serves as the primary storage for protocol artifacts, such as access tokens, authorization +codes, refresh tokens, clients, and user data. In production environments, it is recommended to also set up caching +for these artifacts. The cache layer operates in front of the database, improving performance, particularly during +sudden surges of users attempting to authenticate. The implementation leverages the Symfony Cache component, allowing +the use of any compatible Symfony cache adapter. For more details on configuring the protocol cache, refer to the +module configuration file. + ### Relying Party (RP) Administration The module lets you manage (create, read, update and delete) approved RPs from the module user interface itself.