diff --git a/README.md b/README.md index 9fbf651c..915a7f01 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,17 +112,27 @@ 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: 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. -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 +146,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 +168,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 +364,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 ``` 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/config-templates/module_oidc.php b/config-templates/module_oidc.php index c4f8d03b..a48b6169 100644 --- a/config-templates/module_oidc.php +++ b/config-templates/module_oidc.php @@ -258,42 +258,48 @@ // 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 + // Cache duration for Authorization Code, Access Token, and Refresh Token will fall back to their TTL. + /** * Cron related options. 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/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/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/AccessTokenRepository.php b/src/Repositories/AccessTokenRepository.php index 4298211e..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( @@ -98,7 +96,7 @@ public function getNewToken( */ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTokenEntity): void { - if (!$accessTokenEntity instanceof AccessTokenEntity) { + if (!($accessTokenEntity instanceof AccessTokenEntity)) { throw new Error('Invalid AccessTokenEntity'); } @@ -110,7 +108,15 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo $this->database->write( $stmt, + $this->preparePdoState($accessTokenEntity->getState()), + ); + + $this->protocolCache?->set( $accessTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $accessTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$accessTokenEntity->getIdentifier()), ); } @@ -121,22 +127,38 @@ public function persistNewAccessToken(OAuth2AccessTokenEntityInterface $accessTo */ public function findById(string $tokenId): ?AccessTokenEntity { - $stmt = $this->database->read( - "SELECT * FROM {$this->getTableName()} WHERE id = :id", - [ - 'id' => $tokenId, - ], - ); + /** @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; + 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->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; } /** @@ -156,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 @@ -207,7 +245,23 @@ private function update(AccessTokenEntity $accessTokenEntity): void $this->database->write( $stmt, + $this->preparePdoState($accessTokenEntity->getState()), + ); + + $this->protocolCache?->set( $accessTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $accessTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $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/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)); + } } } diff --git a/src/Repositories/AuthCodeRepository.php b/src/Repositories/AuthCodeRepository.php index f1b95fba..46d46832 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; @@ -31,6 +32,8 @@ class AuthCodeRepository extends AbstractDatabaseRepository implements AuthCodeRepositoryInterface { + final public const TABLE_NAME = 'oidc_auth_code'; + public function __construct( ModuleConfig $moduleConfig, Database $database, @@ -42,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); @@ -76,7 +77,15 @@ public function persistNewAuthCode(OAuth2AuthCodeEntityInterface $authCodeEntity $this->database->write( $stmt, + $this->preparePdoState($authCodeEntity->getState()), + ); + + $this->protocolCache?->set( $authCodeEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $authCodeEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$authCodeEntity->getIdentifier()), ); } @@ -86,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; } /** @@ -174,7 +199,24 @@ private function update(AuthCodeEntity $authCodeEntity): void $this->database->write( $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 + { + $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 97a62766..49654731 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 @@ -112,15 +119,32 @@ 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); - return $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 @@ -149,11 +173,21 @@ public function findByEntityIdentifier(string $entityIdentifier, ?string $owner $row = current($rows); + // @codeCoverageIgnoreStart if (!is_array($row)) { return null; } + // @codeCoverageIgnoreEnd - 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 @@ -300,8 +334,21 @@ public function add(ClientEntityInterface $client): void ); $this->database->write( $stmt, + $this->preparePdoState($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 +365,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 @@ -359,13 +411,26 @@ public function update(ClientEntityInterface $client, ?string $owner = null): vo */ [$sqlQuery, $params] = $this->addOwnerWhereClause( $stmt, - $client->getState(), + $this->preparePdoState($client->getState()), $owner, ); $this->database->write( $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 @@ -421,4 +486,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..0d1ed120 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,15 @@ public function persistNewRefreshToken(OAuth2RefreshTokenEntityInterface $refres $this->database->write( $stmt, + $this->preparePdoState($refreshTokenEntity->getState()), + ); + + $this->protocolCache?->set( $refreshTokenEntity->getState(), + $this->helpers->dateTime()->getSecondsToExpirationTime( + $refreshTokenEntity->getExpiryDateTime()->getTimestamp(), + ), + $this->getCacheKey((string)$refreshTokenEntity->getIdentifier()), ); } @@ -92,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; } /** @@ -126,6 +148,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 +202,24 @@ private function update(RefreshTokenEntityInterface $refreshTokenEntity): void $this->database->write( $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 + { + $isRevoked = (bool)($state['is_revoked'] ?? true); + + $state['is_revoked'] = [$isRevoked, PDO::PARAM_BOOL]; + + return $state; + } } 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/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php index 420e02f5..db644bdb 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 * @@ -81,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/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/integration/src/Repositories/Traits/RevokeTokenByAuthCodeIdTraitTest.php b/tests/integration/src/Repositories/AccessTokenRepositoryTest.php similarity index 89% rename from tests/integration/src/Repositories/Traits/RevokeTokenByAuthCodeIdTraitTest.php rename to tests/integration/src/Repositories/AccessTokenRepositoryTest.php index c0354d28..4b3a19b9 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; @@ -72,11 +70,15 @@ class RevokeTokenByAuthCodeIdTraitTest 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"; @@ -85,7 +87,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(); @@ -111,14 +113,14 @@ 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' => '[]', ]; $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( @@ -139,18 +141,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 +286,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 @@ -331,7 +306,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); 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, ], ); 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..d8a493bb 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 */ @@ -263,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); } @@ -291,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); } @@ -354,23 +369,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, ); } } 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), + ); + } }