From 3eb2236d7909ba812c4fcc7893b98736902bcb3c Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 15:24:06 +0100 Subject: [PATCH 01/30] chore: bump minimum PHP to 8.1 and modernize dependencies - Require PHP ^8.1, CI matrix now tests 8.1-8.5 - Upgrade phpunit/phpunit ^9.6 to ^10.5 (convert @dataProvider to #[DataProvider] attributes, static data providers, update phpunit.xml) - Upgrade aws/aws-sdk-php ^2.8 to ^3.0 (rewrite DynamoDB storage for v3 API: Marshaler, Result, lazy credential handling) - Upgrade predis/predis ^1.1 to ^2.0 (fix getPayload() compat) - Upgrade firebase/php-jwt ^6.4 to ^6.4 || ^7.0 (v7 requires 2048-bit RSA keys; all test keypairs regenerated) - Replace Cassandra storage: swap abandoned thobbs/phpcassa (Thrift) for mroosz/php-cassandra ^1.2 (CQL native protocol, PHP 8.1+) - Fix CI: bump to actions/checkout@v5, drop unmaintained retry action, fix MariaDB service config, add health checks, add Cassandra service, add pdo_pgsql extension --- .github/workflows/tests.yml | 33 +- composer.json | 20 +- phpunit.xml | 13 +- src/OAuth2/Controller/AuthorizeController.php | 2 +- src/OAuth2/Controller/TokenController.php | 10 +- .../OpenID/GrantType/AuthorizationCode.php | 2 +- src/OAuth2/Scope.php | 4 +- src/OAuth2/Server.php | 6 +- src/OAuth2/Storage/Cassandra.php | 360 +++++------------- src/OAuth2/Storage/DynamoDB.php | 86 +++-- src/OAuth2/Storage/Memory.php | 8 +- src/OAuth2/Storage/Redis.php | 6 +- .../Controller/AuthorizeControllerTest.php | 1 + .../Controller/ResourceControllerTest.php | 1 + .../OAuth2/Controller/TokenControllerTest.php | 1 + test/OAuth2/Encryption/FirebaseJwtTest.php | 50 ++- test/OAuth2/Encryption/JwtTest.php | 50 ++- test/OAuth2/GrantType/JwtBearerTest.php | 43 ++- .../OpenID/Storage/AuthorizationCodeTest.php | 5 +- test/OAuth2/OpenID/Storage/UserClaimsTest.php | 6 +- test/OAuth2/ServerTest.php | 28 +- test/OAuth2/Storage/AccessTokenTest.php | 8 +- test/OAuth2/Storage/AuthorizationCodeTest.php | 8 +- test/OAuth2/Storage/ClientCredentialsTest.php | 4 +- test/OAuth2/Storage/ClientTest.php | 12 +- test/OAuth2/Storage/DynamoDBTest.php | 22 +- test/OAuth2/Storage/JwtAccessTokenTest.php | 6 +- test/OAuth2/Storage/JwtBearerTest.php | 4 +- test/OAuth2/Storage/PdoTest.php | 5 +- test/OAuth2/Storage/PublicKeyTest.php | 7 +- test/OAuth2/Storage/RefreshTokenTest.php | 4 +- test/OAuth2/Storage/ScopeTest.php | 11 +- test/OAuth2/Storage/UserCredentialsTest.php | 4 +- test/config/storage.json | 2 +- test/lib/OAuth2/Storage/BaseTest.php | 2 +- test/lib/OAuth2/Storage/Bootstrap.php | 200 +++++----- 36 files changed, 464 insertions(+), 570 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fe13e48b8..2747e4ce0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,13 +17,14 @@ jobs: image: mongo ports: - 27017:27017 - myriadb: + options: --health-cmd="mongosh --eval 'db.runCommand(\"ping\").ok'" --health-interval=10s --health-timeout=5s --health-retries=5 + mariadb: image: mariadb env: MYSQL_ROOT_PASSWORD: root ports: - - 3808:3808 - 3306:3306 + options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=5 postgres: image: postgres env: @@ -33,41 +34,37 @@ jobs: ports: - 5432:5432 options: --health-cmd="pg_isready -h localhost" --health-interval=10s --health-timeout=5s --health-retries=5 + cassandra: + image: cassandra:4 + ports: + - 9042:9042 + options: --health-cmd="cqlsh -e 'describe cluster'" --health-interval=15s --health-timeout=10s --health-retries=10 strategy: matrix: - php: [ 7.2, 7.3, 7.4, "8.0", 8.1, 8.2 ] + php: [ 8.1, 8.2, 8.3, 8.4, 8.5 ] name: "PHP ${{ matrix.php }} Unit Test" steps: - - uses: actions/checkout@v2 - - uses: codecov/codecov-action@v1 + - uses: actions/checkout@v5 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: mongodb, mbstring, intl, redis, pdo_mysql + extensions: mongodb, mbstring, intl, redis, pdo_mysql, pdo_pgsql - name: Install composer dependencies - uses: nick-invision/retry@v1 - with: - timeout_minutes: 10 - max_attempts: 3 - command: composer install + run: composer install - name: Run PHPUnit - run: vendor/bin/phpunit -v + run: vendor/bin/phpunit phpstan: name: "PHPStan" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.1 - name: Install composer dependencies - uses: nick-invision/retry@v1 - with: - timeout_minutes: 10 - max_attempts: 3 - command: composer install + run: composer install - name: Run PHPStan run: | composer require phpstan/phpstan diff --git a/composer.json b/composer.json index 50a950774..6c35421b5 100644 --- a/composer.json +++ b/composer.json @@ -16,21 +16,21 @@ "psr-0": { "OAuth2": "src/" } }, "require":{ - "php":">=7.2" + "php":"^8.1" }, "require-dev": { - "phpunit/phpunit": "^7.5||^8.0", - "aws/aws-sdk-php": "^2.8", - "firebase/php-jwt": "^6.4", - "predis/predis": "^1.1", - "thobbs/phpcassa": "dev-master", - "yoast/phpunit-polyfills": "^1.0" + "phpunit/phpunit": "^10.5", + "aws/aws-sdk-php": "^3.0", + "firebase/php-jwt": "^6.4 || ^7.0", + "predis/predis": "^2.0", + "mroosz/php-cassandra": "^1.2", + "yoast/phpunit-polyfills": "^2.0||^3.0" }, "suggest": { "predis/predis": "Required to use Redis storage", - "thobbs/phpcassa": "Required to use Cassandra storage", - "aws/aws-sdk-php": "~2.8 is required to use DynamoDB storage", - "firebase/php-jwt": "~v6.4 is required to use JWT features", + "mroosz/php-cassandra": "^1.2 is required to use Cassandra storage", + "aws/aws-sdk-php": "^3.0 is required to use DynamoDB storage", + "firebase/php-jwt": "^6.4 || ^7.0 is required to use JWT features", "mongodb/mongodb": "^1.1 is required to use MongoDB storage" } } diff --git a/phpunit.xml b/phpunit.xml index 6e663e39d..cb63322f1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,14 +1,11 @@ @@ -16,9 +13,9 @@ - - + + ./src/OAuth2/ - - + + diff --git a/src/OAuth2/Controller/AuthorizeController.php b/src/OAuth2/Controller/AuthorizeController.php index 181f884a6..93e5b782f 100644 --- a/src/OAuth2/Controller/AuthorizeController.php +++ b/src/OAuth2/Controller/AuthorizeController.php @@ -257,7 +257,7 @@ public function validateAuthorizeRequest(RequestInterface $request, ResponseInte $response_type = $request->query('response_type', $request->request('response_type')); // for multiple-valued response types - make them alphabetical - if (false !== strpos($response_type, ' ')) { + if (false !== strpos((string) $response_type, ' ')) { $types = explode(' ', $response_type); sort($types); $response_type = ltrim(implode(' ', $types)); diff --git a/src/OAuth2/Controller/TokenController.php b/src/OAuth2/Controller/TokenController.php index 1e21b5f67..01c8798ba 100644 --- a/src/OAuth2/Controller/TokenController.php +++ b/src/OAuth2/Controller/TokenController.php @@ -118,13 +118,13 @@ public function handleTokenRequest(RequestInterface $request, ResponseInterface */ public function grantAccessToken(RequestInterface $request, ResponseInterface $response) { - if (strtolower($request->server('REQUEST_METHOD')) === 'options') { + if (strtolower((string) $request->server('REQUEST_METHOD')) === 'options') { $response->addHttpHeaders(array('Allow' => 'POST, OPTIONS')); return null; } - if (strtolower($request->server('REQUEST_METHOD')) !== 'post') { + if (strtolower((string) $request->server('REQUEST_METHOD')) !== 'post') { $response->setError(405, 'invalid_request', 'The request method must be POST when requesting an access token', '#section-3.2'); $response->addHttpHeaders(array('Allow' => 'POST, OPTIONS')); @@ -263,7 +263,7 @@ public function addGrantType(GrantTypeInterface $grantType, $identifier = null) $identifier = $grantType->getQueryStringIdentifier(); } - $this->grantTypes[$identifier] = $grantType; + $this->grantTypes[(string) $identifier] = $grantType; } /** @@ -293,13 +293,13 @@ public function handleRevokeRequest(RequestInterface $request, ResponseInterface */ public function revokeToken(RequestInterface $request, ResponseInterface $response) { - if (strtolower($request->server('REQUEST_METHOD')) === 'options') { + if (strtolower((string) $request->server('REQUEST_METHOD')) === 'options') { $response->addHttpHeaders(array('Allow' => 'POST, OPTIONS')); return null; } - if (strtolower($request->server('REQUEST_METHOD')) !== 'post') { + if (strtolower((string) $request->server('REQUEST_METHOD')) !== 'post') { $response->setError(405, 'invalid_request', 'The request method must be POST when revoking an access token', '#section-3.2'); $response->addHttpHeaders(array('Allow' => 'POST, OPTIONS')); diff --git a/src/OAuth2/OpenID/GrantType/AuthorizationCode.php b/src/OAuth2/OpenID/GrantType/AuthorizationCode.php index ee113a0e5..01e14540b 100644 --- a/src/OAuth2/OpenID/GrantType/AuthorizationCode.php +++ b/src/OAuth2/OpenID/GrantType/AuthorizationCode.php @@ -25,7 +25,7 @@ public function createAccessToken(AccessTokenInterface $accessToken, $client_id, if (isset($this->authCode['id_token'])) { // OpenID Connect requests include the refresh token only if the // offline_access scope has been requested and granted. - $scopes = explode(' ', trim($scope)); + $scopes = explode(' ', trim((string) $scope)); $includeRefreshToken = in_array('offline_access', $scopes); } diff --git a/src/OAuth2/Scope.php b/src/OAuth2/Scope.php index 3ba6e5328..b38b757a9 100644 --- a/src/OAuth2/Scope.php +++ b/src/OAuth2/Scope.php @@ -47,8 +47,8 @@ public function __construct($storage = null) */ public function checkScope($required_scope, $available_scope) { - $required_scope = explode(' ', trim($required_scope)); - $available_scope = explode(' ', trim($available_scope)); + $required_scope = explode(' ', trim((string) $required_scope)); + $available_scope = explode(' ', trim((string) $available_scope)); return (count(array_diff($required_scope, $available_scope)) == 0); } diff --git a/src/OAuth2/Server.php b/src/OAuth2/Server.php index e5716358d..6b4881ab2 100644 --- a/src/OAuth2/Server.php +++ b/src/OAuth2/Server.php @@ -453,7 +453,7 @@ public function addGrantType(GrantTypeInterface $grantType, $identifier = null) $identifier = $grantType->getQueryStringIdentifier(); } - $this->grantTypes[$identifier] = $grantType; + $this->grantTypes[(string) $identifier] = $grantType; // persist added grant type down to TokenController if (!is_null($this->tokenController)) { @@ -473,7 +473,7 @@ public function addGrantType(GrantTypeInterface $grantType, $identifier = null) public function addStorage($storage, $key = null) { // if explicitly set to a valid key, do not "magically" set below - if (isset($this->storageMap[$key])) { + if (!is_null($key) && isset($this->storageMap[$key])) { if (!is_null($storage) && !$storage instanceof $this->storageMap[$key]) { throw new \InvalidArgumentException(sprintf('storage of type "%s" must implement interface "%s"', $key, $this->storageMap[$key])); } @@ -516,7 +516,7 @@ public function addResponseType(ResponseTypeInterface $responseType, $key = null { $key = $this->normalizeResponseType($key); - if (isset($this->responseTypeMap[$key])) { + if (!is_null($key) && isset($this->responseTypeMap[$key])) { if (!$responseType instanceof $this->responseTypeMap[$key]) { throw new \InvalidArgumentException(sprintf('responseType of type "%s" must implement interface "%s"', $key, $this->responseTypeMap[$key])); } diff --git a/src/OAuth2/Storage/Cassandra.php b/src/OAuth2/Storage/Cassandra.php index 3a138bb52..fa4b136ab 100644 --- a/src/OAuth2/Storage/Cassandra.php +++ b/src/OAuth2/Storage/Cassandra.php @@ -2,9 +2,8 @@ namespace OAuth2\Storage; -use phpcassa\ColumnFamily; -use phpcassa\ColumnSlice; -use phpcassa\Connection\ConnectionPool; +use Cassandra\Connection; +use Cassandra\Connection\StreamNodeConfig; use OAuth2\OpenID\Storage\UserClaimsInterface; use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; use InvalidArgumentException; @@ -12,14 +11,17 @@ /** * Cassandra storage for all storage types * - * To use, install "thobbs/phpcassa" via composer: + * To use, install "mroosz/php-cassandra" via composer: * - * composer require thobbs/phpcassa:dev-master + * composer require mroosz/php-cassandra * * * Once this is done, instantiate the connection: * - * $cassandra = new \phpcassa\Connection\ConnectionPool('oauth2_server', array('127.0.0.1:9160')); + * $cassandra = new \Cassandra\Connection([ + * new \Cassandra\Connection\StreamNodeConfig(host: '127.0.0.1', port: 9042), + * ], keyspace: 'oauth2'); + * $cassandra->connect(); * * * Then, register the storage client: @@ -27,8 +29,6 @@ * $storage = new OAuth2\Storage\Cassandra($cassandra); * $storage->setClientDetails($client_id, $client_secret, $redirect_uri); * - * - * @see test/lib/OAuth2/Storage/Bootstrap::getCassandraStorage */ class Cassandra implements AuthorizationCodeInterface, AccessTokenInterface, @@ -41,48 +41,50 @@ class Cassandra implements AuthorizationCodeInterface, UserClaimsInterface, OpenIDAuthorizationCodeInterface { - private $cache; - /** - * @var ConnectionPool - */ - protected $cassandra; + protected Connection $connection; - /** - * @var array - */ - protected $config; + protected array $config; /** - * Cassandra Storage! uses phpCassa - * - * @param ConnectionPool|array $connection - * @param array $config + * @param Connection|array $connection A Connection instance or a configuration array + * @param array $config * * @throws InvalidArgumentException */ - public function __construct($connection = array(), array $config = array()) + public function __construct($connection, array $config = array()) { - if ($connection instanceof ConnectionPool) { - $this->cassandra = $connection; - } else { - if (!is_array($connection)) { - throw new InvalidArgumentException('First argument to OAuth2\Storage\Cassandra must be an instance of phpcassa\Connection\ConnectionPool or a configuration array'); - } + if ($connection instanceof Connection) { + $this->connection = $connection; + } elseif (is_array($connection)) { $connection = array_merge(array( + 'host' => '127.0.0.1', + 'port' => 9042, 'keyspace' => 'oauth2', - 'servers' => null, + 'username' => null, + 'password' => null, ), $connection); - $this->cassandra = new ConnectionPool($connection['keyspace'], $connection['servers']); + $nodeConfig = new StreamNodeConfig( + host: $connection['host'], + port: $connection['port'], + username: $connection['username'], + password: $connection['password'], + ); + + $this->connection = new Connection([$nodeConfig], keyspace: $connection['keyspace']); + $this->connection->connect(); + } else { + throw new InvalidArgumentException( + 'First argument to OAuth2\Storage\Cassandra must be an instance of Cassandra\Connection or a configuration array' + ); } $this->config = array_merge(array( - // cassandra config - 'column_family' => 'auth', + 'table' => 'oauth_data', - // key names + // key prefixes 'client_key' => 'oauth_clients:', 'access_token_key' => 'oauth_access_tokens:', 'refresh_token_key' => 'oauth_refresh_tokens:', @@ -90,107 +92,98 @@ public function __construct($connection = array(), array $config = array()) 'user_key' => 'oauth_users:', 'jwt_key' => 'oauth_jwt:', 'scope_key' => 'oauth_scopes:', - 'public_key_key' => 'oauth_public_keys:', + 'public_key_key' => 'oauth_public_keys:', ), $config); } - /** - * @param $key - * @return bool|mixed - */ protected function getValue($key) { if (isset($this->cache[$key])) { return $this->cache[$key]; } - $cf = new ColumnFamily($this->cassandra, $this->config['column_family']); try { - $value = $cf->get($key, new ColumnSlice("", "")); - $value = array_shift($value); - } catch (\cassandra\NotFoundException $e) { + $result = $this->connection->query( + sprintf('SELECT value FROM %s WHERE key = ?', $this->config['table']), + [$key] + )->asRowsResult(); + + foreach ($result as $row) { + return json_decode($row['value'], true); + } + } catch (\Exception $e) { return false; } - return json_decode($value, true); + return false; } - /** - * @param $key - * @param $value - * @param int $expire - * @return bool - */ protected function setValue($key, $value, $expire = 0) { $this->cache[$key] = $value; - $cf = new ColumnFamily($this->cassandra, $this->config['column_family']); - $str = json_encode($value); - if ($expire > 0) { - try { + + try { + if ($expire > 0) { $seconds = $expire - time(); - // __data key set as C* requires a field, note: max TTL can only be 630720000 seconds - $cf->insert($key, array('__data' => $str), null, $seconds); - } catch (\Exception $e) { - return false; - } - } else { - try { - // __data key set as C* requires a field - $cf->insert($key, array('__data' => $str)); - } catch (\Exception $e) { - return false; + if ($seconds < 1) { + $seconds = 1; + } + $this->connection->query( + sprintf('INSERT INTO %s (key, value) VALUES (?, ?) USING TTL %d', $this->config['table'], $seconds), + [$key, $str] + ); + } else { + $this->connection->query( + sprintf('INSERT INTO %s (key, value) VALUES (?, ?)', $this->config['table']), + [$key, $str] + ); } + } catch (\Exception $e) { + return false; } return true; } - /** - * @param $key - * @return bool - */ protected function expireValue($key) { unset($this->cache[$key]); - $cf = new ColumnFamily($this->cassandra, $this->config['column_family']); + try { + // check if the key exists before deleting + $result = $this->connection->query( + sprintf('SELECT key FROM %s WHERE key = ?', $this->config['table']), + [$key] + )->asRowsResult(); + + $found = false; + foreach ($result as $row) { + $found = true; + break; + } - if ($cf->get_count($key) > 0) { - try { - // __data key set as C* requires a field - $cf->remove($key, array('__data')); - } catch (\Exception $e) { + if (!$found) { return false; } - return true; + $this->connection->query( + sprintf('DELETE FROM %s WHERE key = ?', $this->config['table']), + [$key] + ); + } catch (\Exception $e) { + return false; } - return false; + return true; } - /** - * @param string $code - * @return bool|mixed - */ public function getAuthorizationCode($code) { return $this->getValue($this->config['code_key'] . $code); } - /** - * @param string $authorization_code - * @param mixed $client_id - * @param mixed $user_id - * @param string $redirect_uri - * @param int $expires - * @param string $scope - * @param string $id_token - * @return bool - */ public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null) { return $this->setValue( @@ -200,10 +193,6 @@ public function setAuthorizationCode($authorization_code, $client_id, $user_id, ); } - /** - * @param string $code - * @return bool - */ public function expireAuthorizationCode($code) { $key = $this->config['code_key'] . $code; @@ -212,11 +201,6 @@ public function expireAuthorizationCode($code) return $this->expireValue($key); } - /** - * @param string $username - * @param string $password - * @return bool - */ public function checkUserCredentials($username, $password) { if ($user = $this->getUser($username)) { @@ -226,56 +210,32 @@ public function checkUserCredentials($username, $password) return false; } - /** - * plaintext passwords are bad! Override this for your application - * - * @param array $user - * @param string $password - * @return bool - */ protected function checkPassword($user, $password) { return $user['password'] == $this->hashPassword($password); } - // use a secure hashing algorithm when storing passwords. Override this for your application protected function hashPassword($password) { return sha1($password); } - /** - * @param string $username - * @return array|bool|false - */ public function getUserDetails($username) { return $this->getUser($username); } - /** - * @param string $username - * @return array|bool - */ public function getUser($username) { if (!$userInfo = $this->getValue($this->config['user_key'] . $username)) { return false; } - // the default behavior is to use "username" as the user_id return array_merge(array( 'user_id' => $username, ), $userInfo); } - /** - * @param string $username - * @param string $password - * @param string $first_name - * @param string $last_name - * @return bool - */ public function setUser($username, $password, $first_name = null, $last_name = null) { $password = $this->hashPassword($password); @@ -286,11 +246,6 @@ public function setUser($username, $password, $first_name = null, $last_name = n ); } - /** - * @param mixed $client_id - * @param string $client_secret - * @return bool - */ public function checkClientCredentials($client_id, $client_secret = null) { if (!$client = $this->getClientDetails($client_id)) { @@ -301,10 +256,6 @@ public function checkClientCredentials($client_id, $client_secret = null) && $client['client_secret'] == $client_secret; } - /** - * @param $client_id - * @return bool - */ public function isPublicClient($client_id) { if (!$client = $this->getClientDetails($client_id)) { @@ -314,24 +265,11 @@ public function isPublicClient($client_id) return empty($client['client_secret']); } - /** - * @param $client_id - * @return array|bool|mixed - */ public function getClientDetails($client_id) { return $this->getValue($this->config['client_key'] . $client_id); } - /** - * @param $client_id - * @param null $client_secret - * @param null $redirect_uri - * @param null $grant_types - * @param null $scope - * @param null $user_id - * @return bool - */ public function setClientDetails($client_id, $client_secret = null, $redirect_uri = null, $grant_types = null, $scope = null, $user_id = null) { return $this->setValue( @@ -340,11 +278,6 @@ public function setClientDetails($client_id, $client_secret = null, $redirect_ur ); } - /** - * @param $client_id - * @param $grant_type - * @return bool - */ public function checkRestrictedGrantType($client_id, $grant_type) { $details = $this->getClientDetails($client_id); @@ -354,27 +287,14 @@ public function checkRestrictedGrantType($client_id, $grant_type) return in_array($grant_type, (array) $grant_types); } - // if grant_types are not defined, then none are restricted return true; } - /** - * @param $refresh_token - * @return bool|mixed - */ public function getRefreshToken($refresh_token) { return $this->getValue($this->config['refresh_token_key'] . $refresh_token); } - /** - * @param $refresh_token - * @param $client_id - * @param $user_id - * @param $expires - * @param null $scope - * @return bool - */ public function setRefreshToken($refresh_token, $client_id, $user_id, $expires, $scope = null) { return $this->setValue( @@ -384,85 +304,50 @@ public function setRefreshToken($refresh_token, $client_id, $user_id, $expires, ); } - /** - * @param $refresh_token - * @return bool - */ public function unsetRefreshToken($refresh_token) { return $this->expireValue($this->config['refresh_token_key'] . $refresh_token); } - /** - * @param string $access_token - * @return array|bool|mixed|null - */ public function getAccessToken($access_token) { - return $this->getValue($this->config['access_token_key'].$access_token); + return $this->getValue($this->config['access_token_key'] . $access_token); } - /** - * @param string $access_token - * @param mixed $client_id - * @param mixed $user_id - * @param int $expires - * @param null $scope - * @return bool - */ public function setAccessToken($access_token, $client_id, $user_id, $expires, $scope = null) { return $this->setValue( - $this->config['access_token_key'].$access_token, + $this->config['access_token_key'] . $access_token, compact('access_token', 'client_id', 'user_id', 'expires', 'scope'), $expires ); } - /** - * @param $access_token - * @return bool - */ public function unsetAccessToken($access_token) { return $this->expireValue($this->config['access_token_key'] . $access_token); } - /** - * @param $scope - * @return bool - */ public function scopeExists($scope) { $scope = explode(' ', $scope); - $result = $this->getValue($this->config['scope_key'].'supported:global'); + $result = $this->getValue($this->config['scope_key'] . 'supported:global'); $supportedScope = explode(' ', (string) $result); return (count(array_diff($scope, $supportedScope)) == 0); } - /** - * @param null $client_id - * @return bool|mixed - */ public function getDefaultScope($client_id = null) { - if (is_null($client_id) || !$result = $this->getValue($this->config['scope_key'].'default:'.$client_id)) { - $result = $this->getValue($this->config['scope_key'].'default:global'); + if (is_null($client_id) || !$result = $this->getValue($this->config['scope_key'] . 'default:' . $client_id)) { + $result = $this->getValue($this->config['scope_key'] . 'default:global'); } return $result; } - /** - * @param $scope - * @param null $client_id - * @param string $type - * @return bool - * @throws \InvalidArgumentException - */ public function setScope($scope, $client_id = null, $type = 'supported') { if (!in_array($type, array('default', 'supported'))) { @@ -470,50 +355,35 @@ public function setScope($scope, $client_id = null, $type = 'supported') } if (is_null($client_id)) { - $key = $this->config['scope_key'].$type.':global'; + $key = $this->config['scope_key'] . $type . ':global'; } else { - $key = $this->config['scope_key'].$type.':'.$client_id; + $key = $this->config['scope_key'] . $type . ':' . $client_id; } return $this->setValue($key, $scope); } - /** - * @param $client_id - * @param $subject - * @return bool|null - */ public function getClientKey($client_id, $subject) { if (!$jwt = $this->getValue($this->config['jwt_key'] . $client_id)) { return false; } - if (isset($jwt['subject']) && $jwt['subject'] == $subject ) { + if (isset($jwt['subject']) && $jwt['subject'] == $subject) { return $jwt['key']; } return null; } - /** - * @param $client_id - * @param $key - * @param null $subject - * @return bool - */ public function setClientKey($client_id, $key, $subject = null) { return $this->setValue($this->config['jwt_key'] . $client_id, array( 'key' => $key, - 'subject' => $subject + 'subject' => $subject, )); } - /** - * @param $client_id - * @return bool|null - */ public function getClientScope($client_id) { if (!$clientDetails = $this->getClientDetails($client_id)) { @@ -527,38 +397,16 @@ public function getClientScope($client_id) return null; } - /** - * @param $client_id - * @param $subject - * @param $audience - * @param $expiration - * @param $jti - * @throws \Exception - */ public function getJti($client_id, $subject, $audience, $expiration, $jti) { - //TODO: Needs cassandra implementation. throw new \Exception('getJti() for the Cassandra driver is currently unimplemented.'); } - /** - * @param $client_id - * @param $subject - * @param $audience - * @param $expiration - * @param $jti - * @throws \Exception - */ public function setJti($client_id, $subject, $audience, $expiration, $jti) { - //TODO: Needs cassandra implementation. throw new \Exception('setJti() for the Cassandra driver is currently unimplemented.'); } - /** - * @param string $client_id - * @return mixed - */ public function getPublicKey($client_id = '') { $public_key = $this->getValue($this->config['public_key_key'] . $client_id); @@ -571,10 +419,6 @@ public function getPublicKey($client_id = '') } } - /** - * @param string $client_id - * @return mixed - */ public function getPrivateKey($client_id = '') { $public_key = $this->getValue($this->config['public_key_key'] . $client_id); @@ -587,10 +431,6 @@ public function getPrivateKey($client_id = '') } } - /** - * @param null $client_id - * @return mixed|string - */ public function getEncryptionAlgorithm($client_id = null) { $public_key = $this->getValue($this->config['public_key_key'] . $client_id); @@ -605,11 +445,6 @@ public function getEncryptionAlgorithm($client_id = null) return 'RS256'; } - /** - * @param mixed $user_id - * @param string $claims - * @return array|bool - */ public function getUserClaims($user_id, $claims) { $userDetails = $this->getUserDetails($user_id); @@ -620,12 +455,10 @@ public function getUserClaims($user_id, $claims) $claims = explode(' ', trim($claims)); $userClaims = array(); - // for each requested claim, if the user has the claim, set it in the response $validClaims = explode(' ', self::VALID_CLAIMS); foreach ($validClaims as $validClaim) { if (in_array($validClaim, $claims)) { if ($validClaim == 'address') { - // address is an object with subfields $userClaims['address'] = $this->getUserClaim($validClaim, $userDetails['address'] ?: $userDetails); } else { $userClaims = array_merge($userClaims, $this->getUserClaim($validClaim, $userDetails)); @@ -636,11 +469,6 @@ public function getUserClaims($user_id, $claims) return $userClaims; } - /** - * @param $claim - * @param $userDetails - * @return array - */ protected function getUserClaim($claim, $userDetails) { $userClaims = array(); @@ -649,7 +477,7 @@ protected function getUserClaim($claim, $userDetails) foreach ($claimValues as $value) { if ($value == 'email_verified') { - $userClaims[$value] = $userDetails[$value]=='true' ? true : false; + $userClaims[$value] = $userDetails[$value] == 'true' ? true : false; } else { $userClaims[$value] = isset($userDetails[$value]) ? $userDetails[$value] : null; } @@ -657,4 +485,4 @@ protected function getUserClaim($claim, $userDetails) return $userClaims; } -} \ No newline at end of file +} diff --git a/src/OAuth2/Storage/DynamoDB.php b/src/OAuth2/Storage/DynamoDB.php index 713189d23..9387371f3 100644 --- a/src/OAuth2/Storage/DynamoDB.php +++ b/src/OAuth2/Storage/DynamoDB.php @@ -3,6 +3,7 @@ namespace OAuth2\Storage; use Aws\DynamoDb\DynamoDbClient; +use Aws\DynamoDb\Marshaler; use OAuth2\OpenID\Storage\UserClaimsInterface; use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; @@ -11,7 +12,7 @@ * * To use, install "aws/aws-sdk-php" via composer * - * composer require aws/aws-sdk-php:dev-master + * composer require aws/aws-sdk-php:^3.0 * * * Once this is done, instantiate the DynamoDB client @@ -45,25 +46,31 @@ class DynamoDB implements { protected $client; protected $config; + protected $marshaler; public function __construct($connection, $config = array()) { if (!($connection instanceof DynamoDbClient)) { if (!is_array($connection)) { - throw new \InvalidArgumentException('First argument to OAuth2\Storage\Dynamodb must be an instance a configuration array containt key, secret, region'); + throw new \InvalidArgumentException('First argument to OAuth2\Storage\Dynamodb must be an instance of DynamoDbClient or a configuration array containing key, secret, region'); } if (!array_key_exists("key",$connection) || !array_key_exists("secret",$connection) || !array_key_exists("region",$connection) ) { - throw new \InvalidArgumentException('First argument to OAuth2\Storage\Dynamodb must be an instance a configuration array containt key, secret, region'); + throw new \InvalidArgumentException('First argument to OAuth2\Storage\Dynamodb must be an instance of DynamoDbClient or a configuration array containing key, secret, region'); } - $this->client = DynamoDbClient::factory(array( - 'key' => $connection["key"], - 'secret' => $connection["secret"], - 'region' =>$connection["region"] + $this->client = new DynamoDbClient(array( + 'credentials' => array( + 'key' => $connection["key"], + 'secret' => $connection["secret"], + ), + 'region' => $connection["region"], + 'version' => 'latest', )); } else { $this->client = $connection; } + $this->marshaler = new Marshaler(); + $this->config = array_merge(array( 'client_table' => 'oauth_clients', 'access_token_table' => 'oauth_access_tokens', @@ -84,7 +91,7 @@ public function checkClientCredentials($client_id, $client_secret = null) "Key" => array('client_id' => array('S' => $client_id)) )); - return $result->count()==1 && $result["Item"]["client_secret"]["S"] == $client_secret; + return isset($result["Item"]) && $result["Item"]["client_secret"]["S"] == $client_secret; } public function isPublicClient($client_id) @@ -94,7 +101,7 @@ public function isPublicClient($client_id) "Key" => array('client_id' => array('S' => $client_id)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } @@ -108,7 +115,7 @@ public function getClientDetails($client_id) "TableName"=> $this->config['client_table'], "Key" => array('client_id' => array('S' => $client_id)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } $result = $this->dynamo2array($result); @@ -126,9 +133,9 @@ public function setClientDetails($client_id, $client_secret = null, $redirect_ur $clientData = compact('client_id', 'client_secret', 'redirect_uri', 'grant_types', 'scope', 'user_id'); $clientData = array_filter($clientData, 'self::isNotEmpty'); - $result = $this->client->putItem(array( + $this->client->putItem(array( 'TableName' => $this->config['client_table'], - 'Item' => $this->client->formatAttributes($clientData) + 'Item' => $this->marshaler->marshalItem($clientData) )); return true; @@ -154,7 +161,7 @@ public function getAccessToken($access_token) "TableName"=> $this->config['access_token_table'], "Key" => array('access_token' => array('S' => $access_token)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } $token = $this->dynamo2array($result); @@ -173,9 +180,9 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s $clientData = compact('access_token', 'client_id', 'user_id', 'expires', 'scope'); $clientData = array_filter($clientData, 'self::isNotEmpty'); - $result = $this->client->putItem(array( + $this->client->putItem(array( 'TableName' => $this->config['access_token_table'], - 'Item' => $this->client->formatAttributes($clientData) + 'Item' => $this->marshaler->marshalItem($clientData) )); return true; @@ -186,11 +193,11 @@ public function unsetAccessToken($access_token) { $result = $this->client->deleteItem(array( 'TableName' => $this->config['access_token_table'], - 'Key' => $this->client->formatAttributes(array("access_token" => $access_token)), + 'Key' => array('access_token' => array('S' => $access_token)), 'ReturnValues' => 'ALL_OLD', )); - return null !== $result->get('Attributes'); + return !empty($result['Attributes']); } /* OAuth2\Storage\AuthorizationCodeInterface */ @@ -200,7 +207,7 @@ public function getAuthorizationCode($code) "TableName"=> $this->config['code_table'], "Key" => array('authorization_code' => array('S' => $code)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } $token = $this->dynamo2array($result); @@ -221,9 +228,9 @@ public function setAuthorizationCode($authorization_code, $client_id, $user_id, $clientData = compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token', 'code_challenge', 'code_challenge_method'); $clientData = array_filter($clientData, 'self::isNotEmpty'); - $result = $this->client->putItem(array( + $this->client->putItem(array( 'TableName' => $this->config['code_table'], - 'Item' => $this->client->formatAttributes($clientData) + 'Item' => $this->marshaler->marshalItem($clientData) )); return true; @@ -232,9 +239,9 @@ public function setAuthorizationCode($authorization_code, $client_id, $user_id, public function expireAuthorizationCode($code) { - $result = $this->client->deleteItem(array( + $this->client->deleteItem(array( 'TableName' => $this->config['code_table'], - 'Key' => $this->client->formatAttributes(array("authorization_code" => $code)) + 'Key' => array('authorization_code' => array('S' => $code)) )); return true; @@ -305,7 +312,7 @@ public function getRefreshToken($refresh_token) "TableName"=> $this->config['refresh_token_table'], "Key" => array('refresh_token' => array('S' => $refresh_token)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } $token = $this->dynamo2array($result); @@ -322,9 +329,9 @@ public function setRefreshToken($refresh_token, $client_id, $user_id, $expires, $clientData = compact('refresh_token', 'client_id', 'user_id', 'expires', 'scope'); $clientData = array_filter($clientData, 'self::isNotEmpty'); - $result = $this->client->putItem(array( + $this->client->putItem(array( 'TableName' => $this->config['refresh_token_table'], - 'Item' => $this->client->formatAttributes($clientData) + 'Item' => $this->marshaler->marshalItem($clientData) )); return true; @@ -332,9 +339,9 @@ public function setRefreshToken($refresh_token, $client_id, $user_id, $expires, public function unsetRefreshToken($refresh_token) { - $result = $this->client->deleteItem(array( + $this->client->deleteItem(array( 'TableName' => $this->config['refresh_token_table'], - 'Key' => $this->client->formatAttributes(array("refresh_token" => $refresh_token)) + 'Key' => array('refresh_token' => array('S' => $refresh_token)) )); return true; @@ -358,7 +365,7 @@ public function getUser($username) "TableName"=> $this->config['user_table'], "Key" => array('username' => array('S' => $username)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } $token = $this->dynamo2array($result); @@ -375,9 +382,9 @@ public function setUser($username, $password, $first_name = null, $last_name = n $clientData = compact('username', 'password', 'first_name', 'last_name'); $clientData = array_filter($clientData, 'self::isNotEmpty'); - $result = $this->client->putItem(array( + $this->client->putItem(array( 'TableName' => $this->config['user_table'], - 'Item' => $this->client->formatAttributes($clientData) + 'Item' => $this->marshaler->marshalItem($clientData) )); return true; @@ -388,7 +395,6 @@ public function setUser($username, $password, $first_name = null, $last_name = n public function scopeExists($scope) { $scope = explode(' ', $scope); - $scope_query = array(); $count = 0; foreach ($scope as $key => $val) { $result = $this->client->query(array( @@ -422,9 +428,8 @@ public function getDefaultScope($client_id = null) ) )); $defaultScope = array(); - if ($result->count() > 0) { - $array = $result->toArray(); - foreach ($array["Items"] as $item) { + if (($result['Count'] ?? 0) > 0) { + foreach ($result["Items"] as $item) { $defaultScope[] = $item['scope']['S']; } @@ -441,7 +446,7 @@ public function getClientKey($client_id, $subject) "TableName"=> $this->config['jwt_table'], "Key" => array('client_id' => array('S' => $client_id), 'subject' => array('S' => $subject)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } $token = $this->dynamo2array($result); @@ -480,7 +485,7 @@ public function getPublicKey($client_id = '0') "TableName"=> $this->config['public_key_table'], "Key" => array('client_id' => array('S' => $client_id)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } $token = $this->dynamo2array($result); @@ -495,7 +500,7 @@ public function getPrivateKey($client_id = '0') "TableName"=> $this->config['public_key_table'], "Key" => array('client_id' => array('S' => $client_id)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return false ; } $token = $this->dynamo2array($result); @@ -509,7 +514,7 @@ public function getEncryptionAlgorithm($client_id = null) "TableName"=> $this->config['public_key_table'], "Key" => array('client_id' => array('S' => $client_id)) )); - if ($result->count()==0) { + if (!isset($result["Item"])) { return 'RS256' ; } $token = $this->dynamo2array($result); @@ -520,14 +525,13 @@ public function getEncryptionAlgorithm($client_id = null) /** * Transform dynamodb resultset to an array. * @param $dynamodbResult - * @return $array + * @return array */ private function dynamo2array($dynamodbResult) { $result = array(); foreach ($dynamodbResult["Item"] as $key => $val) { $result[$key] = $val["S"]; - $result[] = $val["S"]; } return $result; @@ -537,4 +541,4 @@ private static function isNotEmpty($value) { return null !== $value && '' !== $value; } -} \ No newline at end of file +} diff --git a/src/OAuth2/Storage/Memory.php b/src/OAuth2/Storage/Memory.php index c33bd0ebb..b059ce200 100644 --- a/src/OAuth2/Storage/Memory.php +++ b/src/OAuth2/Storage/Memory.php @@ -112,7 +112,7 @@ public function setUser($username, $password, $firstName = null, $lastName = nul public function getUserDetails($username) { - if (!isset($this->userCredentials[$username])) { + if (is_null($username) || !isset($this->userCredentials[$username])) { return false; } @@ -339,7 +339,7 @@ public function setJti($client_id, $subject, $audience, $expires, $jti) /*PublicKeyInterface */ public function getPublicKey($client_id = null) { - if (isset($this->keys[$client_id])) { + if (!is_null($client_id) && isset($this->keys[$client_id])) { return $this->keys[$client_id]['public_key']; } @@ -353,7 +353,7 @@ public function getPublicKey($client_id = null) public function getPrivateKey($client_id = null) { - if (isset($this->keys[$client_id])) { + if (!is_null($client_id) && isset($this->keys[$client_id])) { return $this->keys[$client_id]['private_key']; } @@ -367,7 +367,7 @@ public function getPrivateKey($client_id = null) public function getEncryptionAlgorithm($client_id = null) { - if (isset($this->keys[$client_id]['encryption_algorithm'])) { + if (!is_null($client_id) && isset($this->keys[$client_id]['encryption_algorithm'])) { return $this->keys[$client_id]['encryption_algorithm']; } diff --git a/src/OAuth2/Storage/Redis.php b/src/OAuth2/Storage/Redis.php index 5a41dfc22..9b74798d4 100644 --- a/src/OAuth2/Storage/Redis.php +++ b/src/OAuth2/Storage/Redis.php @@ -79,7 +79,11 @@ protected function setValue($key, $value, $expire=0) // check that the key was set properly // if this fails, an exception will usually thrown, so this step isn't strictly necessary - return is_bool($ret) ? $ret : $ret->getPayload() == 'OK'; + if (is_bool($ret)) { + return $ret; + } + + return (string) $ret === 'OK'; } protected function expireValue($key) diff --git a/test/OAuth2/Controller/AuthorizeControllerTest.php b/test/OAuth2/Controller/AuthorizeControllerTest.php index 88f0d0da0..2e7240e34 100644 --- a/test/OAuth2/Controller/AuthorizeControllerTest.php +++ b/test/OAuth2/Controller/AuthorizeControllerTest.php @@ -478,6 +478,7 @@ public function testCreateController() { $storage = Bootstrap::getInstance()->getMemoryStorage(); $controller = new AuthorizeController($storage); + $this->assertInstanceOf('OAuth2\Controller\AuthorizeController', $controller); } private function getTestServer($config = array()) diff --git a/test/OAuth2/Controller/ResourceControllerTest.php b/test/OAuth2/Controller/ResourceControllerTest.php index cd54d239a..5e362b564 100644 --- a/test/OAuth2/Controller/ResourceControllerTest.php +++ b/test/OAuth2/Controller/ResourceControllerTest.php @@ -162,6 +162,7 @@ public function testCreateController() $storage = Bootstrap::getInstance()->getMemoryStorage(); $tokenType = new \OAuth2\TokenType\Bearer(); $controller = new ResourceController($tokenType, $storage); + $this->assertInstanceOf('OAuth2\Controller\ResourceController', $controller); } private function getTestServer($config = array()) diff --git a/test/OAuth2/Controller/TokenControllerTest.php b/test/OAuth2/Controller/TokenControllerTest.php index d18eaa6d7..191e540e7 100644 --- a/test/OAuth2/Controller/TokenControllerTest.php +++ b/test/OAuth2/Controller/TokenControllerTest.php @@ -319,6 +319,7 @@ public function testCreateController() $storage = Bootstrap::getInstance()->getMemoryStorage(); $accessToken = new \OAuth2\ResponseType\AccessToken($storage); $controller = new TokenController($accessToken, $storage); + $this->assertInstanceOf('OAuth2\Controller\TokenController', $controller); } private function getTestServer() diff --git a/test/OAuth2/Encryption/FirebaseJwtTest.php b/test/OAuth2/Encryption/FirebaseJwtTest.php index 63a5d4036..0512c16dc 100644 --- a/test/OAuth2/Encryption/FirebaseJwtTest.php +++ b/test/OAuth2/Encryption/FirebaseJwtTest.php @@ -3,6 +3,7 @@ namespace OAuth2\Encryption; use OAuth2\Storage\Bootstrap; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class FirebaseJwtTest extends TestCase @@ -12,25 +13,38 @@ class FirebaseJwtTest extends TestCase public function setUp(): void { $this->privateKey = <<assertFalse($jwtUtil->decode('go.o.b')); } - /** @dataProvider provideClientCredentials */ + #[DataProvider('provideClientCredentials')] public function testInvalidJwtHeader($client_id, $client_key) { $jwtUtil = new FirebaseJwt(); @@ -90,7 +104,7 @@ public function testInvalidJwtHeader($client_id, $client_key) $this->assertFalse($payload); } - public function provideClientCredentials() + public static function provideClientCredentials() { $storage = Bootstrap::getInstance()->getMemoryStorage(); $client_id = 'Test Client ID'; diff --git a/test/OAuth2/Encryption/JwtTest.php b/test/OAuth2/Encryption/JwtTest.php index 376a922b1..60ce8def8 100644 --- a/test/OAuth2/Encryption/JwtTest.php +++ b/test/OAuth2/Encryption/JwtTest.php @@ -3,6 +3,7 @@ namespace OAuth2\Encryption; use OAuth2\Storage\Bootstrap; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class JwtTest extends TestCase @@ -12,25 +13,38 @@ class JwtTest extends TestCase public function setUp(): void { $this->privateKey = <<assertFalse($jwtUtil->decode('go.o.b')); } - /** @dataProvider provideClientCredentials */ + #[DataProvider('provideClientCredentials')] public function testInvalidJwtHeader($client_id, $client_key) { $jwtUtil = new Jwt(); @@ -90,7 +104,7 @@ public function testInvalidJwtHeader($client_id, $client_key) $this->assertFalse($payload); } - public function provideClientCredentials() + public static function provideClientCredentials() { $storage = Bootstrap::getInstance()->getMemoryStorage(); $client_id = 'Test Client ID'; diff --git a/test/OAuth2/GrantType/JwtBearerTest.php b/test/OAuth2/GrantType/JwtBearerTest.php index 4f6d67b2c..1c24a26c8 100644 --- a/test/OAuth2/GrantType/JwtBearerTest.php +++ b/test/OAuth2/GrantType/JwtBearerTest.php @@ -16,21 +16,34 @@ class JwtBearerTest extends TestCase public function setUp(): void { $this->privateKey = <<assertEquals($code['id_token'], $new_id_token); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testRemoveIdTokenFromAuthorizationCode($storage) { // add new code diff --git a/test/OAuth2/OpenID/Storage/UserClaimsTest.php b/test/OAuth2/OpenID/Storage/UserClaimsTest.php index 840f6c566..da79ac5d5 100644 --- a/test/OAuth2/OpenID/Storage/UserClaimsTest.php +++ b/test/OAuth2/OpenID/Storage/UserClaimsTest.php @@ -4,10 +4,11 @@ use OAuth2\Storage\BaseTest; use OAuth2\Storage\NullStorage; +use PHPUnit\Framework\Attributes\DataProvider; class UserClaimsTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testGetUserClaims($storage) { if ($storage instanceof NullStorage) { @@ -17,7 +18,8 @@ public function testGetUserClaims($storage) } if (!$storage instanceof UserClaimsInterface) { - // incompatible storage + $this->markTestSkipped('Incompatible storage: UserClaimsInterface required'); + return; } diff --git a/test/OAuth2/ServerTest.php b/test/OAuth2/ServerTest.php index fab526a6f..36ffd1af7 100644 --- a/test/OAuth2/ServerTest.php +++ b/test/OAuth2/ServerTest.php @@ -6,11 +6,9 @@ use OAuth2\ResponseType\AuthorizationCode; use OAuth2\Storage\Bootstrap; use PHPUnit\Framework\TestCase; -use Yoast\PHPUnitPolyfills\Polyfills\ExpectPHPException; class ServerTest extends TestCase { - use ExpectPHPException; public function testGetAuthorizeControllerWithNoClientStorageThrowsException() { @@ -108,7 +106,7 @@ public function testGetTokenControllerWithAccessTokenAndClientCredentialsStorage $server = new Server(); $server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface')); $server->addStorage($this->createMock('OAuth2\Storage\ClientCredentialsInterface')); - $server->getTokenController(); + $this->assertNotNull($server->getTokenController()); } public function testGetTokenControllerAccessTokenStorageAndClientCredentialsStorageAndGrantTypes() @@ -117,7 +115,7 @@ public function testGetTokenControllerAccessTokenStorageAndClientCredentialsStor $server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface')); $server->addStorage($this->createMock('OAuth2\Storage\ClientCredentialsInterface')); $server->addGrantType($this->createMock('OAuth2\GrantType\AuthorizationCode')); - $server->getTokenController(); + $this->assertNotNull($server->getTokenController()); } public function testGetResourceControllerWithNoAccessTokenStorageThrowsException() @@ -131,7 +129,7 @@ public function testGetResourceControllerWithAccessTokenStorage() { $server = new Server(); $server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface')); - $server->getResourceController(); + $this->assertNotNull($server->getResourceController()); } public function testAddingStorageWithInvalidClass() @@ -162,7 +160,7 @@ public function testAddingStorageWithValidKeyOnlySetsThatKey() $reflection = new \ReflectionClass($server); $prop = $reflection->getProperty('storages'); - $prop->setAccessible(true); + $storages = $prop->getValue($server); // get the private "storages" property @@ -252,11 +250,11 @@ public function testAddingResponseType() $storage ->expects($this->any()) ->method('getClientDetails') - ->will($this->returnValue(array('client_id' => 'some_client'))); + ->willReturn(array('client_id' => 'some_client')); $storage ->expects($this->any()) ->method('checkRestrictedGrantType') - ->will($this->returnValue(true)); + ->willReturn(true); // add with the "code" key explicitly set $codeType = new AuthorizationCode($storage); @@ -309,11 +307,11 @@ public function testCustomClientAssertionType() $clientAssertionType ->expects($this->once()) ->method('validateRequest') - ->will($this->returnValue(true)); + ->willReturn(true); $clientAssertionType ->expects($this->once()) ->method('getClientId') - ->will($this->returnValue('Test Client ID')); + ->willReturn('Test Client ID'); // create mock storage $storage = Bootstrap::getInstance()->getMemoryStorage(); @@ -334,7 +332,7 @@ public function testHttpBasicConfig() $reflection = new \ReflectionClass($httpBasic); $prop = $reflection->getProperty('config'); - $prop->setAccessible(true); + $config = $prop->getValue($httpBasic); // get the private "config" property @@ -357,11 +355,11 @@ public function testRefreshTokenConfig() $reflection1 = new \ReflectionClass($refreshToken1); $prop1 = $reflection1->getProperty('config'); - $prop1->setAccessible(true); + $reflection2 = new \ReflectionClass($refreshToken2); $prop2 = $reflection2->getProperty('config'); - $prop2->setAccessible(true); + // get the private "config" property $config1 = $prop1->getValue($refreshToken1); @@ -503,7 +501,7 @@ public function testUsingOpenIDConnectWithIssuerPublicKeyAndUserClaimsIsOkay() public function testUsingOpenIDConnectWithAllowImplicitWithoutTokenStorageThrowsException() { - $this->expectErrorMessage('OAuth2\ResponseType\AccessTokenInterface'); + $this->expectExceptionMessage('OAuth2\ResponseType\AccessTokenInterface'); $client = $this->createMock('OAuth2\Storage\ClientInterface'); $userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface'); $pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface'); @@ -581,7 +579,7 @@ public function testUsingOpenIDConnectWithAuthorizationCodeStorageThrowsExceptio $token = $this->createMock('OAuth2\Storage\AccessTokenInterface'); $authcode = $this->createMock('OAuth2\Storage\AuthorizationCodeInterface'); - $this->expectErrorMessage('OAuth2\OpenID\Storage\AuthorizationCodeInterface'); + $this->expectExceptionMessage('OAuth2\OpenID\Storage\AuthorizationCodeInterface'); $server = new Server(array($client, $userclaims, $pubkey, $token, $authcode), array( 'use_openid_connect' => true, 'issuer' => 'someguy' diff --git a/test/OAuth2/Storage/AccessTokenTest.php b/test/OAuth2/Storage/AccessTokenTest.php index b34e0bfc0..ae6c76d45 100644 --- a/test/OAuth2/Storage/AccessTokenTest.php +++ b/test/OAuth2/Storage/AccessTokenTest.php @@ -2,9 +2,11 @@ namespace OAuth2\Storage; +use PHPUnit\Framework\Attributes\DataProvider; + class AccessTokenTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testSetAccessToken(AccessTokenInterface $storage) { if ($storage instanceof NullStorage) { @@ -55,7 +57,7 @@ public function testSetAccessToken(AccessTokenInterface $storage) $this->assertTrue($success); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testUnsetAccessToken(AccessTokenInterface $storage) { if ($storage instanceof NullStorage || !method_exists($storage, 'unsetAccessToken')) { @@ -82,7 +84,7 @@ public function testUnsetAccessToken(AccessTokenInterface $storage) $this->assertFalse($token); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testUnsetAccessTokenReturnsFalse(AccessTokenInterface $storage) { if ($storage instanceof NullStorage || !method_exists($storage, 'unsetAccessToken')) { diff --git a/test/OAuth2/Storage/AuthorizationCodeTest.php b/test/OAuth2/Storage/AuthorizationCodeTest.php index 2d901b501..9fb015387 100644 --- a/test/OAuth2/Storage/AuthorizationCodeTest.php +++ b/test/OAuth2/Storage/AuthorizationCodeTest.php @@ -2,9 +2,11 @@ namespace OAuth2\Storage; +use PHPUnit\Framework\Attributes\DataProvider; + class AuthorizationCodeTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testGetAuthorizationCode(AuthorizationCodeInterface $storage) { if ($storage instanceof NullStorage) { @@ -22,7 +24,7 @@ public function testGetAuthorizationCode(AuthorizationCodeInterface $storage) $this->assertNotNull($details); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testSetAuthorizationCode(AuthorizationCodeInterface $storage) { if ($storage instanceof NullStorage) { @@ -77,7 +79,7 @@ public function testSetAuthorizationCode(AuthorizationCodeInterface $storage) $this->assertTrue($success); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testExpireAccessToken(AccessTokenInterface $storage) { if ($storage instanceof NullStorage) { diff --git a/test/OAuth2/Storage/ClientCredentialsTest.php b/test/OAuth2/Storage/ClientCredentialsTest.php index 15289af30..af8f973c4 100644 --- a/test/OAuth2/Storage/ClientCredentialsTest.php +++ b/test/OAuth2/Storage/ClientCredentialsTest.php @@ -2,9 +2,11 @@ namespace OAuth2\Storage; +use PHPUnit\Framework\Attributes\DataProvider; + class ClientCredentialsTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testCheckClientCredentials(ClientCredentialsInterface $storage) { if ($storage instanceof NullStorage) { diff --git a/test/OAuth2/Storage/ClientTest.php b/test/OAuth2/Storage/ClientTest.php index 6a5cc0b49..2a97ca739 100644 --- a/test/OAuth2/Storage/ClientTest.php +++ b/test/OAuth2/Storage/ClientTest.php @@ -2,9 +2,11 @@ namespace OAuth2\Storage; +use PHPUnit\Framework\Attributes\DataProvider; + class ClientTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testGetClientDetails(ClientInterface $storage) { if ($storage instanceof NullStorage) { @@ -25,7 +27,7 @@ public function testGetClientDetails(ClientInterface $storage) $this->assertArrayHasKey('redirect_uri', $details); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testCheckRestrictedGrantType(ClientInterface $storage) { if ($storage instanceof NullStorage) { @@ -43,7 +45,7 @@ public function testCheckRestrictedGrantType(ClientInterface $storage) $this->assertTrue($pass); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testGetAccessToken(ClientInterface $storage) { if ($storage instanceof NullStorage) { @@ -61,7 +63,7 @@ public function testGetAccessToken(ClientInterface $storage) $this->assertNotNull($details); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testIsPublicClient(ClientInterface $storage) { if ($storage instanceof NullStorage) { @@ -84,7 +86,7 @@ public function testIsPublicClient(ClientInterface $storage) $this->assertFalse($storage->isPublicClient($confidentialClientId)); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testSaveClient(ClientInterface $storage) { if ($storage instanceof NullStorage) { diff --git a/test/OAuth2/Storage/DynamoDBTest.php b/test/OAuth2/Storage/DynamoDBTest.php index 2147f0914..cc58345b3 100644 --- a/test/OAuth2/Storage/DynamoDBTest.php +++ b/test/OAuth2/Storage/DynamoDBTest.php @@ -2,37 +2,27 @@ namespace OAuth2\Storage; +use Aws\Result; + class DynamoDBTest extends BaseTest { public function testGetDefaultScope() { $client = $this->getMockBuilder('\Aws\DynamoDb\DynamoDbClient') ->disableOriginalConstructor() - ->setMethods(array('query')) - ->getMock(); - - $return = $this->getMockBuilder('\Guzzle\Service\Resource\Model') - ->setMethods(array('count', 'toArray')) + ->addMethods(array('query')) ->getMock(); - $data = array( + $data = new Result(array( 'Items' => array(), 'Count' => 0, 'ScannedCount'=> 0 - ); - - $return->expects($this->once()) - ->method('count') - ->will($this->returnValue(count($data))); - - $return->expects($this->once()) - ->method('toArray') - ->will($this->returnValue($data)); + )); // should return null default scope if none is set in database $client->expects($this->once()) ->method('query') - ->will($this->returnValue($return)); + ->willReturn($data); $storage = new DynamoDB($client); $this->assertNull($storage->getDefaultScope()); diff --git a/test/OAuth2/Storage/JwtAccessTokenTest.php b/test/OAuth2/Storage/JwtAccessTokenTest.php index a6acbea1f..7a3eb8737 100644 --- a/test/OAuth2/Storage/JwtAccessTokenTest.php +++ b/test/OAuth2/Storage/JwtAccessTokenTest.php @@ -3,14 +3,16 @@ namespace OAuth2\Storage; use OAuth2\Encryption\Jwt; +use PHPUnit\Framework\Attributes\DataProvider; class JwtAccessTokenTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testSetAccessToken($storage) { if (!$storage instanceof PublicKey) { - // incompatible storage + $this->markTestSkipped('Incompatible storage: PublicKey required'); + return; } diff --git a/test/OAuth2/Storage/JwtBearerTest.php b/test/OAuth2/Storage/JwtBearerTest.php index d0ab9b899..880b3b650 100644 --- a/test/OAuth2/Storage/JwtBearerTest.php +++ b/test/OAuth2/Storage/JwtBearerTest.php @@ -2,9 +2,11 @@ namespace OAuth2\Storage; +use PHPUnit\Framework\Attributes\DataProvider; + class JwtBearerTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testGetClientKey(JwtBearerInterface $storage) { if ($storage instanceof NullStorage) { diff --git a/test/OAuth2/Storage/PdoTest.php b/test/OAuth2/Storage/PdoTest.php index 9a7630423..e269bc2d0 100644 --- a/test/OAuth2/Storage/PdoTest.php +++ b/test/OAuth2/Storage/PdoTest.php @@ -2,11 +2,8 @@ namespace OAuth2\Storage; -use Yoast\PHPUnitPolyfills\Polyfills\ExpectPHPException; - class PdoTest extends BaseTest { - use ExpectPHPException; public function testCreatePdoStorageUsingPdoClass() { @@ -36,7 +33,7 @@ public function testCreatePdoStorageUsingConfig() public function testCreatePdoStorageWithoutDSNThrowsException() { - $this->expectErrorMessage('dsn'); + $this->expectExceptionMessage('dsn'); $config = array('username' => 'brent', 'password' => 'brentisaballer'); $storage = new Pdo($config); } diff --git a/test/OAuth2/Storage/PublicKeyTest.php b/test/OAuth2/Storage/PublicKeyTest.php index f85195870..db2ff9c3d 100644 --- a/test/OAuth2/Storage/PublicKeyTest.php +++ b/test/OAuth2/Storage/PublicKeyTest.php @@ -2,9 +2,11 @@ namespace OAuth2\Storage; +use PHPUnit\Framework\Attributes\DataProvider; + class PublicKeyTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testSetAccessToken($storage) { if ($storage instanceof NullStorage) { @@ -14,7 +16,8 @@ public function testSetAccessToken($storage) } if (!$storage instanceof PublicKeyInterface) { - // incompatible storage + $this->markTestSkipped('Incompatible storage: PublicKeyInterface required'); + return; } diff --git a/test/OAuth2/Storage/RefreshTokenTest.php b/test/OAuth2/Storage/RefreshTokenTest.php index 314c93195..e4172d5ff 100644 --- a/test/OAuth2/Storage/RefreshTokenTest.php +++ b/test/OAuth2/Storage/RefreshTokenTest.php @@ -2,9 +2,11 @@ namespace OAuth2\Storage; +use PHPUnit\Framework\Attributes\DataProvider; + class RefreshTokenTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testSetRefreshToken(RefreshTokenInterface $storage) { if ($storage instanceof NullStorage) { diff --git a/test/OAuth2/Storage/ScopeTest.php b/test/OAuth2/Storage/ScopeTest.php index fd1edeb93..97f1faa33 100644 --- a/test/OAuth2/Storage/ScopeTest.php +++ b/test/OAuth2/Storage/ScopeTest.php @@ -3,10 +3,11 @@ namespace OAuth2\Storage; use OAuth2\Scope; +use PHPUnit\Framework\Attributes\DataProvider; class ScopeTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testScopeExists($storage) { if ($storage instanceof NullStorage) { @@ -16,7 +17,8 @@ public function testScopeExists($storage) } if (!$storage instanceof ScopeInterface) { - // incompatible storage + $this->markTestSkipped('Incompatible storage: ScopeInterface required'); + return; } @@ -28,7 +30,7 @@ public function testScopeExists($storage) $this->assertFalse($scopeUtil->scopeExists('supportedscope1 supportedscope2 supportedscope3 fakescope')); } - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testGetDefaultScope($storage) { if ($storage instanceof NullStorage) { @@ -38,7 +40,8 @@ public function testGetDefaultScope($storage) } if (!$storage instanceof ScopeInterface) { - // incompatible storage + $this->markTestSkipped('Incompatible storage: ScopeInterface required'); + return; } diff --git a/test/OAuth2/Storage/UserCredentialsTest.php b/test/OAuth2/Storage/UserCredentialsTest.php index 65655a6b2..fad3b15f4 100644 --- a/test/OAuth2/Storage/UserCredentialsTest.php +++ b/test/OAuth2/Storage/UserCredentialsTest.php @@ -2,9 +2,11 @@ namespace OAuth2\Storage; +use PHPUnit\Framework\Attributes\DataProvider; + class UserCredentialsTest extends BaseTest { - /** @dataProvider provideStorage */ + #[DataProvider('provideStorage')] public function testCheckUserCredentials(UserCredentialsInterface $storage) { if ($storage instanceof NullStorage) { diff --git a/test/config/storage.json b/test/config/storage.json index 52d3f2399..c56118a56 100644 --- a/test/config/storage.json +++ b/test/config/storage.json @@ -140,7 +140,7 @@ }, "jwt": { "Test Client ID": { - "key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5/SxVlE8gnpFqCxgl2wjhzY7u\ncEi00s0kUg3xp7lVEvgLgYcAnHiWp+gtSjOFfH2zsvpiWm6Lz5f743j/FEzHIO1o\nwR0p4d9pOaJK07d01+RzoQLOIQAgXrr4T1CCWUesncwwPBVCyy2Mw3Nmhmr9MrF8\nUlvdRKBxriRnlP3qJQIDAQAB\n-----END PUBLIC KEY-----", + "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvryKBogPyv5P8jYRdZKb\n88Eg+gBul1FF5vkftzsVCFu8BvM5p64w5N4cAK6Rv/62MXRBi+sIhfIQ682C0rK9\nX8kbJLlu6QluvofB1r2qB5AIf8lOzJlzKlMgHN4qP4VcdB93QeyVmyL1dADPG5hI\npPgyP3gMW+a7QVr1BEfzqT26vNZm4e0n0v+9iG1W+Q9zjFIjQz1/+BM+F8yIMK74\n7Fpz+sMYLllLAJdElnTghT8E+3am6bVVDcHRKBpGeIz5f8ncVbWwHWwRqNjEVRUT\nBxXkLVQw4s4gvU+HgJHkhIhbwh+vEDpU1oY85ajO6NRuHqZbUPNc4rAccBSyJ+ze\nIQIDAQAB\n-----END PUBLIC KEY-----", "subject": "testuser@ourdomain.com" }, "Test Client ID PHP-5.2": { diff --git a/test/lib/OAuth2/Storage/BaseTest.php b/test/lib/OAuth2/Storage/BaseTest.php index e841d3ad2..773ae50bc 100755 --- a/test/lib/OAuth2/Storage/BaseTest.php +++ b/test/lib/OAuth2/Storage/BaseTest.php @@ -6,7 +6,7 @@ abstract class BaseTest extends TestCase { - public function provideStorage() + public static function provideStorage() { $memory = Bootstrap::getInstance()->getMemoryStorage(); $sqlite = Bootstrap::getInstance()->getSqlitePdo(); diff --git a/test/lib/OAuth2/Storage/Bootstrap.php b/test/lib/OAuth2/Storage/Bootstrap.php index 66c93ae5b..ad123f5c4 100755 --- a/test/lib/OAuth2/Storage/Bootstrap.php +++ b/test/lib/OAuth2/Storage/Bootstrap.php @@ -243,60 +243,40 @@ private function testCouchbaseConnection(\Couchbase $couchbase) public function getCassandraStorage() { if (!$this->cassandra) { - if (class_exists('phpcassa\ColumnFamily')) { - $cassandra = new \phpcassa\Connection\ConnectionPool('oauth2_test', array('127.0.0.1:9160')); - if ($this->testCassandraConnection($cassandra)) { - $this->removeCassandraDb(); - $this->cassandra = new Cassandra($cassandra); - $this->createCassandraDb($this->cassandra); - } else { - $this->cassandra = new NullStorage('Cassandra', 'Unable to connect to cassandra server on "127.0.0.1:9160"'); - } - } else { - $this->cassandra = new NullStorage('Cassandra', 'Missing cassandra library. Please run "composer.phar require thobbs/phpcassa:dev-master"'); - } - } + if (!class_exists('Cassandra\Connection')) { + $this->cassandra = new NullStorage('Cassandra', 'Missing cassandra library. Please run "composer require mroosz/php-cassandra"'); - return $this->cassandra; - } + return $this->cassandra; + } - private function testCassandraConnection(\phpcassa\Connection\ConnectionPool $cassandra) - { - try { - new \phpcassa\SystemManager('localhost:9160'); - } catch (\Exception $e) { - return false; + try { + $conn = new \Cassandra\Connection([ + new \Cassandra\Connection\StreamNodeConfig( + host: '127.0.0.1', + port: 9042, + ), + ]); + $conn->connect(); + + // recreate keyspace + $conn->query("DROP KEYSPACE IF EXISTS oauth2_test"); + $conn->query("CREATE KEYSPACE oauth2_test WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}"); + $conn->query("CREATE TABLE oauth2_test.oauth_data (key text PRIMARY KEY, value text)"); + + $conn->setKeyspace('oauth2_test'); + + $this->cassandra = new Cassandra($conn); + $this->createCassandraDb($this->cassandra, $conn); + } catch (\Exception $e) { + $this->cassandra = new NullStorage('Cassandra', $e->getMessage()); + } } - return true; - } - - private function removeCassandraDb() - { - $sys = new \phpcassa\SystemManager('localhost:9160'); - - try { - $sys->drop_keyspace('oauth2_test'); - } catch (\cassandra\InvalidRequestException $e) { - - } + return $this->cassandra; } - private function createCassandraDb(Cassandra $storage) + private function createCassandraDb(Cassandra $storage, \Cassandra\Connection $conn) { - // create the cassandra keyspace and column family - $sys = new \phpcassa\SystemManager('localhost:9160'); - - $sys->create_keyspace('oauth2_test', array( - "strategy_class" => \phpcassa\Schema\StrategyClass::SIMPLE_STRATEGY, - "strategy_options" => array('replication_factor' => '1') - )); - - $sys->create_column_family('oauth2_test', 'auth'); - $cassandra = new \phpcassa\Connection\ConnectionPool('oauth2_test', array('127.0.0.1:9160')); - $cf = new \phpcassa\ColumnFamily($cassandra, 'auth'); - - // populate the data $storage->setClientDetails("oauth_test_client", "testpass", "http://example.com", 'implicit password'); $storage->setAccessToken("testtoken", "Some Client", '', time() + 1000); $storage->setAuthorizationCode("testcode", "Some Client", '', '', time() + 1000); @@ -318,12 +298,24 @@ private function createCassandraDb(Cassandra $storage) $storage->setClientKey('oauth_test_client', $this->getTestPublicKey(), 'test_subject'); - $cf->insert("oauth_public_keys:ClientID_One", array('__data' => json_encode(array("public_key" => "client_1_public", "private_key" => "client_1_private", "encryption_algorithm" => "RS256")))); - $cf->insert("oauth_public_keys:ClientID_Two", array('__data' => json_encode(array("public_key" => "client_2_public", "private_key" => "client_2_private", "encryption_algorithm" => "RS256")))); - $cf->insert("oauth_public_keys:", array('__data' => json_encode(array("public_key" => $this->getTestPublicKey(), "private_key" => $this->getTestPrivateKey(), "encryption_algorithm" => "RS256")))); - - $cf->insert("oauth_users:testuser", array('__data' =>json_encode(array("password" => "password", "email" => "testuser@test.com", "email_verified" => true)))); - + // insert public keys and user directly + $table = 'oauth_data'; + $conn->query("INSERT INTO $table (key, value) VALUES (?, ?)", [ + 'oauth_public_keys:ClientID_One', + json_encode(array("public_key" => "client_1_public", "private_key" => "client_1_private", "encryption_algorithm" => "RS256")), + ]); + $conn->query("INSERT INTO $table (key, value) VALUES (?, ?)", [ + 'oauth_public_keys:ClientID_Two', + json_encode(array("public_key" => "client_2_public", "private_key" => "client_2_private", "encryption_algorithm" => "RS256")), + ]); + $conn->query("INSERT INTO $table (key, value) VALUES (?, ?)", [ + 'oauth_public_keys:', + json_encode(array("public_key" => $this->getTestPublicKey(), "private_key" => $this->getTestPrivateKey(), "encryption_algorithm" => "RS256")), + ]); + $conn->query("INSERT INTO $table (key, value) VALUES (?, ?)", [ + 'oauth_users:testuser', + json_encode(array("password" => "password", "email" => "testuser@test.com", "email_verified" => true)), + ]); } private function createSqliteDb(\PDO $pdo) @@ -352,11 +344,11 @@ private function removeMysqlDb(\PDO $pdo) private function createPostgresDb() { - if (!`PGPASSWORD=postgres psql postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='postgres'" -h localhost -U postgres`) { - `PGPASSWORD=postgres createuser -s -r postgres -h localhost -U postgres`; + if (!shell_exec('PGPASSWORD=postgres psql postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname=\'postgres\'" -h localhost -U postgres')) { + shell_exec('PGPASSWORD=postgres createuser -s -r postgres -h localhost -U postgres'); } - `PGPASSWORD=postgres createdb -O postgres oauth2_server_php -h localhost -U postgres`; + shell_exec('PGPASSWORD=postgres createdb -O postgres oauth2_server_php -h localhost -U postgres'); } private function populatePostgresDb(\PDO $pdo) @@ -366,8 +358,8 @@ private function populatePostgresDb(\PDO $pdo) private function removePostgresDb() { - if (trim(`PGPASSWORD=postgres psql -l -h localhost -U postgres | grep oauth2_server_php | wc -l`)) { - `PGPASSWORD=postgres dropdb oauth2_server_php -h localhost -U postgres`; + if (trim(shell_exec('PGPASSWORD=postgres psql -l -h localhost -U postgres | grep oauth2_server_php | wc -l') ?? '')) { + shell_exec('PGPASSWORD=postgres dropdb oauth2_server_php -h localhost -U postgres'); } } @@ -597,47 +589,58 @@ private function getTestPrivateKey() public function getDynamoDbStorage() { if (!$this->dynamodb) { - // only run once per travis build - if (true == $this->getEnvVar('TRAVIS')) { - if (self::DYNAMODB_PHP_VERSION != $this->getEnvVar('TRAVIS_PHP_VERSION')) { - $this->dynamodb = new NullStorage('DynamoDb', 'Skipping for travis.ci - only run once per build'); + try { + $this->initDynamoDbStorage(); + } catch (\Exception $e) { + $this->dynamodb = new NullStorage('DynamoDb', $e->getMessage()); + } + } - return; - } + return $this->dynamodb; + } + + private function initDynamoDbStorage() + { + // only run once per travis build + if (true == $this->getEnvVar('TRAVIS')) { + if (self::DYNAMODB_PHP_VERSION != $this->getEnvVar('TRAVIS_PHP_VERSION')) { + $this->dynamodb = new NullStorage('DynamoDb', 'Skipping for travis.ci - only run once per build'); + + return; } - if (class_exists('\Aws\DynamoDb\DynamoDbClient')) { - if ($client = $this->getDynamoDbClient()) { - // travis runs a unique set of tables per build, to avoid conflict - $prefix = ''; - if ($build_id = $this->getEnvVar('TRAVIS_JOB_NUMBER')) { - $prefix = sprintf('build_%s_', $build_id); - } else { - if (!$this->deleteDynamoDb($client, $prefix, true)) { - return $this->dynamodb = new NullStorage('DynamoDb', 'Timed out while waiting for DynamoDB deletion (30 seconds)'); - } + } + if (class_exists('\Aws\DynamoDb\DynamoDbClient')) { + if ($client = $this->getDynamoDbClient()) { + // travis runs a unique set of tables per build, to avoid conflict + $prefix = ''; + if ($build_id = $this->getEnvVar('TRAVIS_JOB_NUMBER')) { + $prefix = sprintf('build_%s_', $build_id); + } else { + if (!$this->deleteDynamoDb($client, $prefix, true)) { + $this->dynamodb = new NullStorage('DynamoDb', 'Timed out while waiting for DynamoDB deletion (30 seconds)'); + + return; } - $this->createDynamoDb($client, $prefix); - $this->populateDynamoDb($client, $prefix); - $config = array( - 'client_table' => $prefix.'oauth_clients', - 'access_token_table' => $prefix.'oauth_access_tokens', - 'refresh_token_table' => $prefix.'oauth_refresh_tokens', - 'code_table' => $prefix.'oauth_authorization_codes', - 'user_table' => $prefix.'oauth_users', - 'jwt_table' => $prefix.'oauth_jwt', - 'scope_table' => $prefix.'oauth_scopes', - 'public_key_table' => $prefix.'oauth_public_keys', - ); - $this->dynamodb = new DynamoDB($client, $config); - } elseif (!$this->dynamodb) { - $this->dynamodb = new NullStorage('DynamoDb', 'unable to connect to DynamoDB'); } - } else { - $this->dynamodb = new NullStorage('DynamoDb', 'Missing DynamoDB library. Please run "composer.phar require aws/aws-sdk-php:dev-master'); + $this->createDynamoDb($client, $prefix); + $this->populateDynamoDb($client, $prefix); + $config = array( + 'client_table' => $prefix.'oauth_clients', + 'access_token_table' => $prefix.'oauth_access_tokens', + 'refresh_token_table' => $prefix.'oauth_refresh_tokens', + 'code_table' => $prefix.'oauth_authorization_codes', + 'user_table' => $prefix.'oauth_users', + 'jwt_table' => $prefix.'oauth_jwt', + 'scope_table' => $prefix.'oauth_scopes', + 'public_key_table' => $prefix.'oauth_public_keys', + ); + $this->dynamodb = new DynamoDB($client, $config); + } elseif (!$this->dynamodb) { + $this->dynamodb = new NullStorage('DynamoDb', 'unable to connect to DynamoDB'); } + } else { + $this->dynamodb = new NullStorage('DynamoDb', 'Missing DynamoDB library. Please run "composer.phar require aws/aws-sdk-php:^3.0'); } - - return $this->dynamodb; } private function getDynamoDbClient() @@ -661,9 +664,16 @@ private function getDynamoDbClient() } // set region in AWS_REGION environment variable, defaults to "us-east-1" - $config['region'] = $this->getEnvVar('AWS_REGION', \Aws\Common\Enum\Region::US_EAST_1); + $config['region'] = $this->getEnvVar('AWS_REGION', 'us-east-1'); + $config['version'] = 'latest'; + + try { + return new \Aws\DynamoDb\DynamoDbClient($config); + } catch (\Exception $e) { + $this->dynamodb = new NullStorage('DynamoDb', $e->getMessage()); - return \Aws\DynamoDb\DynamoDbClient::factory($config); + return; + } } private function deleteDynamoDb(\Aws\DynamoDb\DynamoDbClient $client, $prefix = null, $waitForDeletion = false) From 55076daa12aff61d5e412912f27feafc43837fcc Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 15:58:41 +0100 Subject: [PATCH 02/30] chore: drop legacy Mongo, modernize Couchbase SDK 4.x, add DynamoDB Local to CI Remove Mongo.php (dead PHP 5.x MongoClient code; MongoDB.php remains). Rewrite CouchbaseDB.php for Couchbase SDK 4.x (Cluster/Collection API). Replace AWS credentials/Travis logic with DynamoDB Local in CI. --- .github/workflows/tests.yml | 7 + composer.json | 3 +- src/OAuth2/Storage/CouchbaseDB.php | 217 ++++++------ src/OAuth2/Storage/Mongo.php | 396 --------------------- test/lib/OAuth2/Storage/BaseTest.php | 2 - test/lib/OAuth2/Storage/Bootstrap.php | 485 +++++++++----------------- 6 files changed, 270 insertions(+), 840 deletions(-) delete mode 100644 src/OAuth2/Storage/Mongo.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2747e4ce0..d718d337f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,10 +39,17 @@ jobs: ports: - 9042:9042 options: --health-cmd="cqlsh -e 'describe cluster'" --health-interval=15s --health-timeout=10s --health-retries=10 + dynamodb: + image: amazon/dynamodb-local + ports: + - 8000:8000 + options: --health-cmd="curl -sf http://localhost:8000/shell/ || exit 1" --health-interval=10s --health-timeout=5s --health-retries=5 strategy: matrix: php: [ 8.1, 8.2, 8.3, 8.4, 8.5 ] name: "PHP ${{ matrix.php }} Unit Test" + env: + DYNAMODB_ENDPOINT: http://localhost:8000 steps: - uses: actions/checkout@v5 - name: Setup PHP diff --git a/composer.json b/composer.json index 6c35421b5..657da9a20 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "mroosz/php-cassandra": "^1.2 is required to use Cassandra storage", "aws/aws-sdk-php": "^3.0 is required to use DynamoDB storage", "firebase/php-jwt": "^6.4 || ^7.0 is required to use JWT features", - "mongodb/mongodb": "^1.1 is required to use MongoDB storage" + "mongodb/mongodb": "^1.1 is required to use MongoDB storage", + "ext-couchbase": "^4.0 is required to use Couchbase storage" } } diff --git a/src/OAuth2/Storage/CouchbaseDB.php b/src/OAuth2/Storage/CouchbaseDB.php index 31b0cd301..55f33e4b1 100755 --- a/src/OAuth2/Storage/CouchbaseDB.php +++ b/src/OAuth2/Storage/CouchbaseDB.php @@ -2,12 +2,17 @@ namespace OAuth2\Storage; -use Couchbase; +use Couchbase\Cluster; +use Couchbase\ClusterOptions; +use Couchbase\Collection; +use Couchbase\Exception\DocumentNotFoundException; use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface; /** * Simple Couchbase storage for all storage types * + * Uses Couchbase SDK 4.x (ext-couchbase ^4.0). + * * This class should be extended or overridden as required * * NOTE: Passwords are stored in plaintext, which is never @@ -23,59 +28,80 @@ class CouchbaseDB implements AuthorizationCodeInterface, JwtBearerInterface, OpenIDAuthorizationCodeInterface { - protected $db; - protected $config; - - public function __construct($connection, $config = array()) + protected Collection $collection; + protected array $config; + + /** + * @param Collection|array $connection A Couchbase\Collection instance or a config array + * with keys: connection_string, username, password, bucket, scope (optional), collection (optional) + * @param array $config Table name overrides + */ + public function __construct($connection, array $config = []) { - if (!class_exists(Couchbase::class)) { - throw new \RuntimeException('Missing Couchbase'); - } - - if ($connection instanceof Couchbase) { - $this->db = $connection; + if ($connection instanceof Collection) { + $this->collection = $connection; } else { - if (!is_array($connection) || !is_array($connection['servers'])) { - throw new \InvalidArgumentException('First argument to OAuth2\Storage\CouchbaseDB must be an instance of Couchbase or a configuration array containing a server array'); + if (!is_array($connection)) { + throw new \InvalidArgumentException( + 'First argument to OAuth2\Storage\CouchbaseDB must be a Couchbase\Collection or a configuration array' + ); } - $this->db = new Couchbase($connection['servers'], (!isset($connection['username'])) ? '' : $connection['username'], (!isset($connection['password'])) ? '' : $connection['password'], $connection['bucket'], false); + $options = new ClusterOptions(); + $options->credentials($connection['username'] ?? '', $connection['password'] ?? ''); + $cluster = new Cluster($connection['connection_string'], $options); + $bucket = $cluster->bucket($connection['bucket']); + $scope = $bucket->scope($connection['scope'] ?? '_default'); + $this->collection = $scope->collection($connection['collection'] ?? '_default'); } - $this->config = array_merge(array( + $this->config = array_merge([ 'client_table' => 'oauth_clients', 'access_token_table' => 'oauth_access_tokens', 'refresh_token_table' => 'oauth_refresh_tokens', 'code_table' => 'oauth_authorization_codes', 'user_table' => 'oauth_users', 'jwt_table' => 'oauth_jwt', - ), $config); + ], $config); } - // Helper function to access couchbase item by type: - protected function getObjectByType($name,$id) + /** + * Build a document key from a table name config key and an id. + */ + protected function buildKey(string $name, string $id): string { - return json_decode($this->db->get($this->config[$name].'-'.$id),true); + return $this->config[$name] . '-' . $id; } - // Helper function to set couchbase item by type: - protected function setObjectByType($name,$id,$array) + protected function getObjectByType(string $name, string $id): ?array { - $array['type'] = $name; + try { + $result = $this->collection->get($this->buildKey($name, $id)); + return $result->content(); + } catch (DocumentNotFoundException) { + return null; + } + } - return $this->db->set($this->config[$name].'-'.$id,json_encode($array)); + protected function setObjectByType(string $name, string $id, array $data): void + { + $data['type'] = $name; + $this->collection->upsert($this->buildKey($name, $id), $data); } - // Helper function to delete couchbase item by type, wait for persist to at least 1 node - protected function deleteObjectByType($name,$id) + protected function deleteObjectByType(string $name, string $id): void { - $this->db->delete($this->config[$name].'-'.$id,"",1); + try { + $this->collection->remove($this->buildKey($name, $id)); + } catch (DocumentNotFoundException) { + // already gone + } } /* ClientCredentialsInterface */ public function checkClientCredentials($client_id, $client_secret = null) { - if ($result = $this->getObjectByType('client_table',$client_id)) { + if ($result = $this->getObjectByType('client_table', $client_id)) { return $result['client_secret'] == $client_secret; } @@ -84,7 +110,7 @@ public function checkClientCredentials($client_id, $client_secret = null) public function isPublicClient($client_id) { - if (!$result = $this->getObjectByType('client_table',$client_id)) { + if (!$result = $this->getObjectByType('client_table', $client_id)) { return false; } @@ -94,33 +120,21 @@ public function isPublicClient($client_id) /* ClientInterface */ public function getClientDetails($client_id) { - $result = $this->getObjectByType('client_table',$client_id); + $result = $this->getObjectByType('client_table', $client_id); return is_null($result) ? false : $result; } public function setClientDetails($client_id, $client_secret = null, $redirect_uri = null, $grant_types = null, $scope = null, $user_id = null) { - if ($this->getClientDetails($client_id)) { - - $this->setObjectByType('client_table',$client_id, array( - 'client_id' => $client_id, - 'client_secret' => $client_secret, - 'redirect_uri' => $redirect_uri, - 'grant_types' => $grant_types, - 'scope' => $scope, - 'user_id' => $user_id, - )); - } else { - $this->setObjectByType('client_table',$client_id, array( - 'client_id' => $client_id, - 'client_secret' => $client_secret, - 'redirect_uri' => $redirect_uri, - 'grant_types' => $grant_types, - 'scope' => $scope, - 'user_id' => $user_id, - )); - } + $this->setObjectByType('client_table', $client_id, [ + 'client_id' => $client_id, + 'client_secret' => $client_secret, + 'redirect_uri' => $redirect_uri, + 'grant_types' => $grant_types, + 'scope' => $scope, + 'user_id' => $user_id, + ]); return true; } @@ -141,31 +155,20 @@ public function checkRestrictedGrantType($client_id, $grant_type) /* AccessTokenInterface */ public function getAccessToken($access_token) { - $token = $this->getObjectByType('access_token_table',$access_token); + $token = $this->getObjectByType('access_token_table', $access_token); return is_null($token) ? false : $token; } public function setAccessToken($access_token, $client_id, $user_id, $expires, $scope = null) { - // if it exists, update it. - if ($this->getAccessToken($access_token)) { - $this->setObjectByType('access_token_table',$access_token, array( - 'access_token' => $access_token, - 'client_id' => $client_id, - 'expires' => $expires, - 'user_id' => $user_id, - 'scope' => $scope - )); - } else { - $this->setObjectByType('access_token_table',$access_token, array( - 'access_token' => $access_token, - 'client_id' => $client_id, - 'expires' => $expires, - 'user_id' => $user_id, - 'scope' => $scope - )); - } + $this->setObjectByType('access_token_table', $access_token, [ + 'access_token' => $access_token, + 'client_id' => $client_id, + 'expires' => $expires, + 'user_id' => $user_id, + 'scope' => $scope, + ]); return true; } @@ -173,46 +176,31 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s /* AuthorizationCodeInterface */ public function getAuthorizationCode($code) { - $code = $this->getObjectByType('code_table',$code); + $code = $this->getObjectByType('code_table', $code); return is_null($code) ? false : $code; } public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null) { - // if it exists, update it. - if ($this->getAuthorizationCode($code)) { - $this->setObjectByType('code_table',$code, array( - 'authorization_code' => $code, - 'client_id' => $client_id, - 'user_id' => $user_id, - 'redirect_uri' => $redirect_uri, - 'expires' => $expires, - 'scope' => $scope, - 'id_token' => $id_token, - 'code_challenge' => $code_challenge, - 'code_challenge_method' => $code_challenge_method, - )); - } else { - $this->setObjectByType('code_table',$code,array( - 'authorization_code' => $code, - 'client_id' => $client_id, - 'user_id' => $user_id, - 'redirect_uri' => $redirect_uri, - 'expires' => $expires, - 'scope' => $scope, - 'id_token' => $id_token, - 'code_challenge' => $code_challenge, - 'code_challenge_method' => $code_challenge_method, - )); - } + $this->setObjectByType('code_table', $code, [ + 'authorization_code' => $code, + 'client_id' => $client_id, + 'user_id' => $user_id, + 'redirect_uri' => $redirect_uri, + 'expires' => $expires, + 'scope' => $scope, + 'id_token' => $id_token, + 'code_challenge' => $code_challenge, + 'code_challenge_method' => $code_challenge_method, + ]); return true; } public function expireAuthorizationCode($code) { - $this->deleteObjectByType('code_table',$code); + $this->deleteObjectByType('code_table', $code); return true; } @@ -239,27 +227,27 @@ public function getUserDetails($username) /* RefreshTokenInterface */ public function getRefreshToken($refresh_token) { - $token = $this->getObjectByType('refresh_token_table',$refresh_token); + $token = $this->getObjectByType('refresh_token_table', $refresh_token); return is_null($token) ? false : $token; } public function setRefreshToken($refresh_token, $client_id, $user_id, $expires, $scope = null) { - $this->setObjectByType('refresh_token_table',$refresh_token, array( + $this->setObjectByType('refresh_token_table', $refresh_token, [ 'refresh_token' => $refresh_token, 'client_id' => $client_id, 'user_id' => $user_id, 'expires' => $expires, - 'scope' => $scope - )); + 'scope' => $scope, + ]); return true; } public function unsetRefreshToken($refresh_token) { - $this->deleteObjectByType('refresh_token_table',$refresh_token); + $this->deleteObjectByType('refresh_token_table', $refresh_token); return true; } @@ -272,37 +260,26 @@ protected function checkPassword($user, $password) public function getUser($username) { - $result = $this->getObjectByType('user_table',$username); + $result = $this->getObjectByType('user_table', $username); return is_null($result) ? false : $result; } public function setUser($username, $password, $firstName = null, $lastName = null) { - if ($this->getUser($username)) { - $this->setObjectByType('user_table',$username, array( - 'username' => $username, - 'password' => $password, - 'first_name' => $firstName, - 'last_name' => $lastName - )); - - } else { - $this->setObjectByType('user_table',$username, array( - 'username' => $username, - 'password' => $password, - 'first_name' => $firstName, - 'last_name' => $lastName - )); - - } + $this->setObjectByType('user_table', $username, [ + 'username' => $username, + 'password' => $password, + 'first_name' => $firstName, + 'last_name' => $lastName, + ]); return true; } public function getClientKey($client_id, $subject) { - if (!$jwt = $this->getObjectByType('jwt_table',$client_id)) { + if (!$jwt = $this->getObjectByType('jwt_table', $client_id)) { return false; } diff --git a/src/OAuth2/Storage/Mongo.php b/src/OAuth2/Storage/Mongo.php deleted file mode 100644 index 92f93d5b2..000000000 --- a/src/OAuth2/Storage/Mongo.php +++ /dev/null @@ -1,396 +0,0 @@ - - */ -class Mongo implements AuthorizationCodeInterface, - AccessTokenInterface, - ClientCredentialsInterface, - UserCredentialsInterface, - RefreshTokenInterface, - JwtBearerInterface, - PublicKeyInterface, - OpenIDAuthorizationCodeInterface -{ - protected $db; - protected $config; - - public function __construct($connection, $config = array()) - { - if ($connection instanceof \MongoDB) { - $this->db = $connection; - } else { - if (!is_array($connection)) { - throw new \InvalidArgumentException('First argument to OAuth2\Storage\Mongo must be an instance of MongoDB or a configuration array'); - } - $server = sprintf('mongodb://%s:%d', $connection['host'], $connection['port']); - $m = new \MongoClient($server); - $this->db = $m->{$connection['database']}; - } - - $this->config = array_merge(array( - 'client_table' => 'oauth_clients', - 'access_token_table' => 'oauth_access_tokens', - 'refresh_token_table' => 'oauth_refresh_tokens', - 'code_table' => 'oauth_authorization_codes', - 'user_table' => 'oauth_users', - 'key_table' => 'oauth_keys', - 'jwt_table' => 'oauth_jwt', - ), $config); - } - - // Helper function to access a MongoDB collection by `type`: - protected function collection($name) - { - return $this->db->{$this->config[$name]}; - } - - /* ClientCredentialsInterface */ - public function checkClientCredentials($client_id, $client_secret = null) - { - if ($result = $this->collection('client_table')->findOne(array('client_id' => $client_id))) { - return $result['client_secret'] == $client_secret; - } - - return false; - } - - public function isPublicClient($client_id) - { - if (!$result = $this->collection('client_table')->findOne(array('client_id' => $client_id))) { - return false; - } - - return empty($result['client_secret']); - } - - /* ClientInterface */ - public function getClientDetails($client_id) - { - $result = $this->collection('client_table')->findOne(array('client_id' => $client_id)); - - return is_null($result) ? false : $result; - } - - public function setClientDetails($client_id, $client_secret = null, $redirect_uri = null, $grant_types = null, $scope = null, $user_id = null) - { - if ($this->getClientDetails($client_id)) { - $this->collection('client_table')->update( - array('client_id' => $client_id), - array('$set' => array( - 'client_secret' => $client_secret, - 'redirect_uri' => $redirect_uri, - 'grant_types' => $grant_types, - 'scope' => $scope, - 'user_id' => $user_id, - )) - ); - } else { - $client = array( - 'client_id' => $client_id, - 'client_secret' => $client_secret, - 'redirect_uri' => $redirect_uri, - 'grant_types' => $grant_types, - 'scope' => $scope, - 'user_id' => $user_id, - ); - $this->collection('client_table')->insert($client); - } - - return true; - } - - public function checkRestrictedGrantType($client_id, $grant_type) - { - $details = $this->getClientDetails($client_id); - if (isset($details['grant_types'])) { - $grant_types = explode(' ', $details['grant_types']); - - return in_array($grant_type, $grant_types); - } - - // if grant_types are not defined, then none are restricted - return true; - } - - /* AccessTokenInterface */ - public function getAccessToken($access_token) - { - $token = $this->collection('access_token_table')->findOne(array('access_token' => $access_token)); - - return is_null($token) ? false : $token; - } - - public function setAccessToken($access_token, $client_id, $user_id, $expires, $scope = null) - { - // if it exists, update it. - if ($this->getAccessToken($access_token)) { - $this->collection('access_token_table')->update( - array('access_token' => $access_token), - array('$set' => array( - 'client_id' => $client_id, - 'expires' => $expires, - 'user_id' => $user_id, - 'scope' => $scope - )) - ); - } else { - $token = array( - 'access_token' => $access_token, - 'client_id' => $client_id, - 'expires' => $expires, - 'user_id' => $user_id, - 'scope' => $scope - ); - $this->collection('access_token_table')->insert($token); - } - - return true; - } - - public function unsetAccessToken($access_token) - { - $result = $this->collection('access_token_table')->remove(array( - 'access_token' => $access_token - ), array('w' => 1)); - - return $result['n'] > 0; - } - - - /* AuthorizationCodeInterface */ - public function getAuthorizationCode($code) - { - $code = $this->collection('code_table')->findOne(array('authorization_code' => $code)); - - return is_null($code) ? false : $code; - } - - public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null) - { - // if it exists, update it. - if ($this->getAuthorizationCode($code)) { - $this->collection('code_table')->update( - array('authorization_code' => $code), - array('$set' => array( - 'client_id' => $client_id, - 'user_id' => $user_id, - 'redirect_uri' => $redirect_uri, - 'expires' => $expires, - 'scope' => $scope, - 'id_token' => $id_token, - 'code_challenge' => $code_challenge, - 'code_challenge_method' => $code_challenge_method, - )) - ); - } else { - $token = array( - 'authorization_code' => $code, - 'client_id' => $client_id, - 'user_id' => $user_id, - 'redirect_uri' => $redirect_uri, - 'expires' => $expires, - 'scope' => $scope, - 'id_token' => $id_token, - 'code_challenge' => $code_challenge, - 'code_challenge_method' => $code_challenge_method, - ); - $this->collection('code_table')->insert($token); - } - - return true; - } - - public function expireAuthorizationCode($code) - { - $this->collection('code_table')->remove(array('authorization_code' => $code)); - - return true; - } - - /* UserCredentialsInterface */ - public function checkUserCredentials($username, $password) - { - if ($user = $this->getUser($username)) { - return $this->checkPassword($user, $password); - } - - return false; - } - - public function getUserDetails($username) - { - if ($user = $this->getUser($username)) { - $user['user_id'] = $user['username']; - } - - return $user; - } - - /* RefreshTokenInterface */ - public function getRefreshToken($refresh_token) - { - $token = $this->collection('refresh_token_table')->findOne(array('refresh_token' => $refresh_token)); - - return is_null($token) ? false : $token; - } - - public function setRefreshToken($refresh_token, $client_id, $user_id, $expires, $scope = null) - { - $token = array( - 'refresh_token' => $refresh_token, - 'client_id' => $client_id, - 'user_id' => $user_id, - 'expires' => $expires, - 'scope' => $scope - ); - $this->collection('refresh_token_table')->insert($token); - - return true; - } - - public function unsetRefreshToken($refresh_token) - { - $result = $this->collection('refresh_token_table')->remove(array( - 'refresh_token' => $refresh_token - ), array('w' => 1)); - - return $result['n'] > 0; - } - - // plaintext passwords are bad! Override this for your application - protected function checkPassword($user, $password) - { - return $user['password'] == $password; - } - - public function getUser($username) - { - $result = $this->collection('user_table')->findOne(array('username' => $username)); - - return is_null($result) ? false : $result; - } - - public function setUser($username, $password, $firstName = null, $lastName = null) - { - if ($this->getUser($username)) { - $this->collection('user_table')->update( - array('username' => $username), - array('$set' => array( - 'password' => $password, - 'first_name' => $firstName, - 'last_name' => $lastName - )) - ); - } else { - $user = array( - 'username' => $username, - 'password' => $password, - 'first_name' => $firstName, - 'last_name' => $lastName - ); - $this->collection('user_table')->insert($user); - } - - return true; - } - - public function getClientKey($client_id, $subject) - { - $result = $this->collection('jwt_table')->findOne(array( - 'client_id' => $client_id, - 'subject' => $subject - )); - - return is_null($result) ? false : $result['key']; - } - - public function getClientScope($client_id) - { - if (!$clientDetails = $this->getClientDetails($client_id)) { - return false; - } - - if (isset($clientDetails['scope'])) { - return $clientDetails['scope']; - } - - return null; - } - - public function getJti($client_id, $subject, $audience, $expiration, $jti) - { - //TODO: Needs mongodb implementation. - throw new \Exception('getJti() for the MongoDB driver is currently unimplemented.'); - } - - public function setJti($client_id, $subject, $audience, $expiration, $jti) - { - //TODO: Needs mongodb implementation. - throw new \Exception('setJti() for the MongoDB driver is currently unimplemented.'); - } - - public function getPublicKey($client_id = null) - { - if ($client_id) { - $result = $this->collection('key_table')->findOne(array( - 'client_id' => $client_id - )); - if ($result) { - return $result['public_key']; - } - } - - $result = $this->collection('key_table')->findOne(array( - 'client_id' => null - )); - return is_null($result) ? false : $result['public_key']; - } - - public function getPrivateKey($client_id = null) - { - if ($client_id) { - $result = $this->collection('key_table')->findOne(array( - 'client_id' => $client_id - )); - if ($result) { - return $result['private_key']; - } - } - - $result = $this->collection('key_table')->findOne(array( - 'client_id' => null - )); - return is_null($result) ? false : $result['private_key']; - } - - public function getEncryptionAlgorithm($client_id = null) - { - if ($client_id) { - $result = $this->collection('key_table')->findOne(array( - 'client_id' => $client_id - )); - if ($result) { - return $result['encryption_algorithm']; - } - } - - $result = $this->collection('key_table')->findOne(array( - 'client_id' => null - )); - return is_null($result) ? 'RS256' : $result['encryption_algorithm']; - } -} diff --git a/test/lib/OAuth2/Storage/BaseTest.php b/test/lib/OAuth2/Storage/BaseTest.php index 773ae50bc..9d236c012 100755 --- a/test/lib/OAuth2/Storage/BaseTest.php +++ b/test/lib/OAuth2/Storage/BaseTest.php @@ -12,7 +12,6 @@ public static function provideStorage() $sqlite = Bootstrap::getInstance()->getSqlitePdo(); $mysql = Bootstrap::getInstance()->getMysqlPdo(); $postgres = Bootstrap::getInstance()->getPostgresPdo(); - $mongo = Bootstrap::getInstance()->getMongo(); $mongoDb = Bootstrap::getInstance()->getMongoDB(); $redis = Bootstrap::getInstance()->getRedisStorage(); $cassandra = Bootstrap::getInstance()->getCassandraStorage(); @@ -27,7 +26,6 @@ public static function provideStorage() array($sqlite), array($mysql), array($postgres), - array($mongo), array($mongoDb), array($redis), array($cassandra), diff --git a/test/lib/OAuth2/Storage/Bootstrap.php b/test/lib/OAuth2/Storage/Bootstrap.php index ad123f5c4..9aab0b966 100755 --- a/test/lib/OAuth2/Storage/Bootstrap.php +++ b/test/lib/OAuth2/Storage/Bootstrap.php @@ -4,13 +4,10 @@ class Bootstrap { - const DYNAMODB_PHP_VERSION = 'none'; - protected static $instance; private $mysql; private $sqlite; private $postgres; - private $mongo; private $mongoDb; private $redis; private $cassandra; @@ -135,28 +132,6 @@ public function getMysqlPdo() return $this->mysql; } - public function getMongo() - { - if (!$this->mongo) { - if (class_exists('MongoClient')) { - $mongo = new \MongoClient('mongodb://localhost:27017', array('connect' => false)); - if ($this->testMongoConnection($mongo)) { - $db = $mongo->oauth2_server_php_legacy; - $this->removeMongo($db); - $this->createMongo($db); - - $this->mongo = new Mongo($db); - } else { - $this->mongo = new NullStorage('Mongo', 'Unable to connect to mongo server on "localhost:27017"'); - } - } else { - $this->mongo = new NullStorage('Mongo', 'Missing mongo php extension. Please install mongo.so'); - } - } - - return $this->mongo; - } - public function getMongoDb() { if (!$this->mongoDb) { @@ -179,17 +154,6 @@ public function getMongoDb() return $this->mongoDb; } - private function testMongoConnection(\MongoClient $mongo) - { - try { - $mongo->connect(); - } catch (\MongoConnectionException $e) { - return false; - } - - return true; - } - private function testMongoDBConnection(\MongoDB\Client $mongo) { return true; @@ -200,26 +164,28 @@ public function getCouchbase() if (!$this->couchbase) { if ($this->getEnvVar('SKIP_COUCHBASE_TESTS')) { $this->couchbase = new NullStorage('Couchbase', 'Skipping Couchbase tests'); - } elseif (!class_exists('Couchbase')) { - $this->couchbase = new NullStorage('Couchbase', 'Missing Couchbase php extension. Please install couchbase.so'); + } elseif (!class_exists(\Couchbase\Cluster::class)) { + $this->couchbase = new NullStorage('Couchbase', 'Missing Couchbase PHP SDK 4.x. Please install ext-couchbase ^4.0'); } else { - // round-about way to make sure couchbase is working - // this is required because it throws a "floating point exception" otherwise - $code = "new \Couchbase(array('localhost:8091'), '', '', 'auth', false);"; - $exec = sprintf('php -r "%s"', $code); - $ret = exec($exec, $test, $var); - if ($ret != 0) { - $couchbase = new \Couchbase(array('localhost:8091'), '', '', 'auth', false); - if ($this->testCouchbaseConnection($couchbase)) { - $this->clearCouchbase($couchbase); - $this->createCouchbaseDB($couchbase); - - $this->couchbase = new CouchbaseDB($couchbase); - } else { - $this->couchbase = new NullStorage('Couchbase', 'Unable to connect to Couchbase server on "localhost:8091"'); - } - } else { - $this->couchbase = new NullStorage('Couchbase', 'Error while trying to connect to Couchbase'); + try { + $options = new \Couchbase\ClusterOptions(); + $options->credentials( + $this->getEnvVar('CB_USERNAME', 'Administrator'), + $this->getEnvVar('CB_PASSWORD', 'password') + ); + $cluster = new \Couchbase\Cluster( + $this->getEnvVar('CB_CONNECTION_STRING', 'couchbase://localhost'), + $options + ); + $bucket = $cluster->bucket($this->getEnvVar('CB_BUCKET', 'default')); + $collection = $bucket->defaultCollection(); + + $this->clearCouchbase($collection); + $this->createCouchbaseDB($collection); + + $this->couchbase = new CouchbaseDB($collection); + } catch (\Exception $e) { + $this->couchbase = new NullStorage('Couchbase', 'Unable to connect to Couchbase: ' . $e->getMessage()); } } } @@ -227,19 +193,6 @@ public function getCouchbase() return $this->couchbase; } - private function testCouchbaseConnection(\Couchbase $couchbase) - { - try { - if (count($couchbase->getServers()) > 0) { - return true; - } - } catch (\CouchbaseException $e) { - return false; - } - - return true; - } - public function getCassandraStorage() { if (!$this->cassandra) { @@ -420,90 +373,54 @@ public function getConfigDir() return $this->configDir; } - private function createCouchbaseDB(\Couchbase $db) + private function createCouchbaseDB(\Couchbase\Collection $collection) { - $db->set('oauth_clients-oauth_test_client',json_encode(array( - 'client_id' => "oauth_test_client", - 'client_secret' => "testpass", - 'redirect_uri' => "http://example.com", - 'grant_types' => 'implicit password' - ))); - - $db->set('oauth_access_tokens-testtoken',json_encode(array( - 'access_token' => "testtoken", - 'client_id' => "Some Client" - ))); - - $db->set('oauth_authorization_codes-testcode',json_encode(array( - 'access_token' => "testcode", - 'client_id' => "Some Client" - ))); - - $db->set('oauth_users-testuser',json_encode(array( - 'username' => 'testuser', - 'password' => 'password', - 'email' => 'testuser@test.com', - 'email_verified' => true, - ))); - - $db->set('oauth_jwt-oauth_test_client',json_encode(array( + $collection->upsert('oauth_clients-oauth_test_client', [ 'client_id' => 'oauth_test_client', - 'key' => $this->getTestPublicKey(), - 'subject' => 'test_subject', - ))); - } - - private function clearCouchbase(\Couchbase $cb) - { - $cb->delete('oauth_authorization_codes-new-openid-code'); - $cb->delete('oauth_access_tokens-newtoken'); - $cb->delete('oauth_authorization_codes-newcode'); - $cb->delete('oauth_refresh_tokens-refreshtoken'); - } - - private function createMongo(\MongoDB $db) - { - $db->oauth_clients->insert(array( - 'client_id' => "oauth_test_client", - 'client_secret' => "testpass", - 'redirect_uri' => "http://example.com", - 'grant_types' => 'implicit password' - )); + 'client_secret' => 'testpass', + 'redirect_uri' => 'http://example.com', + 'grant_types' => 'implicit password', + ]); - $db->oauth_access_tokens->insert(array( - 'access_token' => "testtoken", - 'client_id' => "Some Client" - )); + $collection->upsert('oauth_access_tokens-testtoken', [ + 'access_token' => 'testtoken', + 'client_id' => 'Some Client', + ]); - $db->oauth_authorization_codes->insert(array( - 'authorization_code' => "testcode", - 'client_id' => "Some Client" - )); + $collection->upsert('oauth_authorization_codes-testcode', [ + 'access_token' => 'testcode', + 'client_id' => 'Some Client', + ]); - $db->oauth_users->insert(array( + $collection->upsert('oauth_users-testuser', [ 'username' => 'testuser', 'password' => 'password', 'email' => 'testuser@test.com', 'email_verified' => true, - )); - - $db->oauth_keys->insert(array( - 'client_id' => null, - 'public_key' => $this->getTestPublicKey(), - 'private_key' => $this->getTestPrivateKey(), - 'encryption_algorithm' => 'RS256' - )); + ]); - $db->oauth_jwt->insert(array( + $collection->upsert('oauth_jwt-oauth_test_client', [ 'client_id' => 'oauth_test_client', 'key' => $this->getTestPublicKey(), - 'subject' => 'test_subject', - )); + 'subject' => 'test_subject', + ]); } - public function removeMongo(\MongoDB $db) + private function clearCouchbase(\Couchbase\Collection $collection) { - $db->drop(); + $keys = [ + 'oauth_authorization_codes-new-openid-code', + 'oauth_access_tokens-newtoken', + 'oauth_authorization_codes-newcode', + 'oauth_refresh_tokens-refreshtoken', + ]; + foreach ($keys as $key) { + try { + $collection->remove($key); + } catch (\Couchbase\Exception\DocumentNotFoundException) { + // ignore + } + } } private function createMongoDB(\MongoDB\Database $db) @@ -601,229 +518,155 @@ public function getDynamoDbStorage() private function initDynamoDbStorage() { - // only run once per travis build - if (true == $this->getEnvVar('TRAVIS')) { - if (self::DYNAMODB_PHP_VERSION != $this->getEnvVar('TRAVIS_PHP_VERSION')) { - $this->dynamodb = new NullStorage('DynamoDb', 'Skipping for travis.ci - only run once per build'); - - return; - } - } - if (class_exists('\Aws\DynamoDb\DynamoDbClient')) { - if ($client = $this->getDynamoDbClient()) { - // travis runs a unique set of tables per build, to avoid conflict - $prefix = ''; - if ($build_id = $this->getEnvVar('TRAVIS_JOB_NUMBER')) { - $prefix = sprintf('build_%s_', $build_id); - } else { - if (!$this->deleteDynamoDb($client, $prefix, true)) { - $this->dynamodb = new NullStorage('DynamoDb', 'Timed out while waiting for DynamoDB deletion (30 seconds)'); - - return; - } - } - $this->createDynamoDb($client, $prefix); - $this->populateDynamoDb($client, $prefix); - $config = array( - 'client_table' => $prefix.'oauth_clients', - 'access_token_table' => $prefix.'oauth_access_tokens', - 'refresh_token_table' => $prefix.'oauth_refresh_tokens', - 'code_table' => $prefix.'oauth_authorization_codes', - 'user_table' => $prefix.'oauth_users', - 'jwt_table' => $prefix.'oauth_jwt', - 'scope_table' => $prefix.'oauth_scopes', - 'public_key_table' => $prefix.'oauth_public_keys', - ); - $this->dynamodb = new DynamoDB($client, $config); - } elseif (!$this->dynamodb) { - $this->dynamodb = new NullStorage('DynamoDb', 'unable to connect to DynamoDB'); - } - } else { - $this->dynamodb = new NullStorage('DynamoDb', 'Missing DynamoDB library. Please run "composer.phar require aws/aws-sdk-php:^3.0'); - } - } - - private function getDynamoDbClient() - { - $config = array(); - // check for environment variables - if (($key = $this->getEnvVar('AWS_ACCESS_KEY_ID')) && ($secret = $this->getEnvVar('AWS_SECRET_KEY'))) { - $config['key'] = $key; - $config['secret'] = $secret; - } else { - // fall back on ~/.aws/credentials file - // @see http://docs.aws.amazon.com/aws-sdk-php/guide/latest/credentials.html#credential-profiles - if (!file_exists($this->getEnvVar('HOME') . '/.aws/credentials')) { - $this->dynamodb = new NullStorage('DynamoDb', 'No aws credentials file found, and no AWS_ACCESS_KEY_ID or AWS_SECRET_KEY environment variable set'); - - return; - } + if (!class_exists('\Aws\DynamoDb\DynamoDbClient')) { + $this->dynamodb = new NullStorage('DynamoDb', 'Missing DynamoDB library. Please run "composer require aws/aws-sdk-php:^3.0"'); - // set profile in AWS_PROFILE environment variable, defaults to "default" - $config['profile'] = $this->getEnvVar('AWS_PROFILE', 'default'); + return; } - // set region in AWS_REGION environment variable, defaults to "us-east-1" - $config['region'] = $this->getEnvVar('AWS_REGION', 'us-east-1'); - $config['version'] = 'latest'; + $endpoint = $this->getEnvVar('DYNAMODB_ENDPOINT', 'http://localhost:8000'); try { - return new \Aws\DynamoDb\DynamoDbClient($config); + $client = new \Aws\DynamoDb\DynamoDbClient([ + 'region' => 'us-east-1', + 'version' => 'latest', + 'endpoint' => $endpoint, + 'credentials' => [ + 'key' => 'fake', + 'secret' => 'fake', + ], + ]); + + // verify DynamoDB Local is reachable + $client->listTables(); } catch (\Exception $e) { - $this->dynamodb = new NullStorage('DynamoDb', $e->getMessage()); + $this->dynamodb = new NullStorage('DynamoDb', 'Unable to connect to DynamoDB Local at ' . $endpoint . ': ' . $e->getMessage()); return; } + + $prefix = 'test_'; + $this->deleteDynamoDb($client, $prefix); + $this->createDynamoDb($client, $prefix); + $this->populateDynamoDb($client, $prefix); + + $config = [ + 'client_table' => $prefix.'oauth_clients', + 'access_token_table' => $prefix.'oauth_access_tokens', + 'refresh_token_table' => $prefix.'oauth_refresh_tokens', + 'code_table' => $prefix.'oauth_authorization_codes', + 'user_table' => $prefix.'oauth_users', + 'jwt_table' => $prefix.'oauth_jwt', + 'scope_table' => $prefix.'oauth_scopes', + 'public_key_table' => $prefix.'oauth_public_keys', + ]; + $this->dynamodb = new DynamoDB($client, $config); } - private function deleteDynamoDb(\Aws\DynamoDb\DynamoDbClient $client, $prefix = null, $waitForDeletion = false) + private function deleteDynamoDb(\Aws\DynamoDb\DynamoDbClient $client, $prefix = null) { $tablesList = explode(' ', 'oauth_access_tokens oauth_authorization_codes oauth_clients oauth_jwt oauth_public_keys oauth_refresh_tokens oauth_scopes oauth_users'); - $nbTables = count($tablesList); - // Delete all table. - foreach ($tablesList as $key => $table) { + foreach ($tablesList as $table) { try { - $client->deleteTable(array('TableName' => $prefix.$table)); + $client->deleteTable(['TableName' => $prefix.$table]); + $client->waitUntil('TableNotExists', ['TableName' => $prefix.$table]); } catch (\Aws\DynamoDb\Exception\DynamoDbException $e) { - // Table does not exist : nothing to do - } - } - - // Wait for deleting - if ($waitForDeletion) { - $retries = 5; - $nbTableDeleted = 0; - while ($nbTableDeleted != $nbTables) { - $nbTableDeleted = 0; - foreach ($tablesList as $key => $table) { - try { - $result = $client->describeTable(array('TableName' => $prefix.$table)); - } catch (\Aws\DynamoDb\Exception\DynamoDbException $e) { - // Table does not exist : nothing to do - $nbTableDeleted++; - } - } - if ($nbTableDeleted != $nbTables) { - if ($retries < 0) { - // we are tired of waiting - return false; - } - sleep(5); - echo "Sleeping 5 seconds for DynamoDB ($retries more retries)...\n"; - $retries--; - } + // Table does not exist } } - - return true; } private function createDynamoDb(\Aws\DynamoDb\DynamoDbClient $client, $prefix = null) { - $tablesList = explode(' ', 'oauth_access_tokens oauth_authorization_codes oauth_clients oauth_jwt oauth_public_keys oauth_refresh_tokens oauth_scopes oauth_users'); - $nbTables = count($tablesList); - $client->createTable(array( + $client->createTable([ 'TableName' => $prefix.'oauth_access_tokens', - 'AttributeDefinitions' => array( - array('AttributeName' => 'access_token','AttributeType' => 'S') - ), - 'KeySchema' => array(array('AttributeName' => 'access_token','KeyType' => 'HASH')), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - )); + 'AttributeDefinitions' => [ + ['AttributeName' => 'access_token', 'AttributeType' => 'S'], + ], + 'KeySchema' => [['AttributeName' => 'access_token', 'KeyType' => 'HASH']], + 'BillingMode' => 'PAY_PER_REQUEST', + ]); - $client->createTable(array( + $client->createTable([ 'TableName' => $prefix.'oauth_authorization_codes', - 'AttributeDefinitions' => array( - array('AttributeName' => 'authorization_code','AttributeType' => 'S') - ), - 'KeySchema' => array(array('AttributeName' => 'authorization_code','KeyType' => 'HASH')), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - )); + 'AttributeDefinitions' => [ + ['AttributeName' => 'authorization_code', 'AttributeType' => 'S'], + ], + 'KeySchema' => [['AttributeName' => 'authorization_code', 'KeyType' => 'HASH']], + 'BillingMode' => 'PAY_PER_REQUEST', + ]); - $client->createTable(array( + $client->createTable([ 'TableName' => $prefix.'oauth_clients', - 'AttributeDefinitions' => array( - array('AttributeName' => 'client_id','AttributeType' => 'S') - ), - 'KeySchema' => array(array('AttributeName' => 'client_id','KeyType' => 'HASH')), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - )); + 'AttributeDefinitions' => [ + ['AttributeName' => 'client_id', 'AttributeType' => 'S'], + ], + 'KeySchema' => [['AttributeName' => 'client_id', 'KeyType' => 'HASH']], + 'BillingMode' => 'PAY_PER_REQUEST', + ]); - $client->createTable(array( + $client->createTable([ 'TableName' => $prefix.'oauth_jwt', - 'AttributeDefinitions' => array( - array('AttributeName' => 'client_id','AttributeType' => 'S'), - array('AttributeName' => 'subject','AttributeType' => 'S') - ), - 'KeySchema' => array( - array('AttributeName' => 'client_id','KeyType' => 'HASH'), - array('AttributeName' => 'subject','KeyType' => 'RANGE') - ), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - )); + 'AttributeDefinitions' => [ + ['AttributeName' => 'client_id', 'AttributeType' => 'S'], + ['AttributeName' => 'subject', 'AttributeType' => 'S'], + ], + 'KeySchema' => [ + ['AttributeName' => 'client_id', 'KeyType' => 'HASH'], + ['AttributeName' => 'subject', 'KeyType' => 'RANGE'], + ], + 'BillingMode' => 'PAY_PER_REQUEST', + ]); - $client->createTable(array( + $client->createTable([ 'TableName' => $prefix.'oauth_public_keys', - 'AttributeDefinitions' => array( - array('AttributeName' => 'client_id','AttributeType' => 'S') - ), - 'KeySchema' => array(array('AttributeName' => 'client_id','KeyType' => 'HASH')), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - )); + 'AttributeDefinitions' => [ + ['AttributeName' => 'client_id', 'AttributeType' => 'S'], + ], + 'KeySchema' => [['AttributeName' => 'client_id', 'KeyType' => 'HASH']], + 'BillingMode' => 'PAY_PER_REQUEST', + ]); - $client->createTable(array( + $client->createTable([ 'TableName' => $prefix.'oauth_refresh_tokens', - 'AttributeDefinitions' => array( - array('AttributeName' => 'refresh_token','AttributeType' => 'S') - ), - 'KeySchema' => array(array('AttributeName' => 'refresh_token','KeyType' => 'HASH')), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - )); + 'AttributeDefinitions' => [ + ['AttributeName' => 'refresh_token', 'AttributeType' => 'S'], + ], + 'KeySchema' => [['AttributeName' => 'refresh_token', 'KeyType' => 'HASH']], + 'BillingMode' => 'PAY_PER_REQUEST', + ]); - $client->createTable(array( + $client->createTable([ 'TableName' => $prefix.'oauth_scopes', - 'AttributeDefinitions' => array( - array('AttributeName' => 'scope','AttributeType' => 'S'), - array('AttributeName' => 'is_default','AttributeType' => 'S') - ), - 'KeySchema' => array(array('AttributeName' => 'scope','KeyType' => 'HASH')), - 'GlobalSecondaryIndexes' => array( - array( + 'AttributeDefinitions' => [ + ['AttributeName' => 'scope', 'AttributeType' => 'S'], + ['AttributeName' => 'is_default', 'AttributeType' => 'S'], + ], + 'KeySchema' => [['AttributeName' => 'scope', 'KeyType' => 'HASH']], + 'GlobalSecondaryIndexes' => [ + [ 'IndexName' => 'is_default-index', - 'KeySchema' => array(array('AttributeName' => 'is_default', 'KeyType' => 'HASH')), - 'Projection' => array('ProjectionType' => 'ALL'), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - ), - ), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - )); + 'KeySchema' => [['AttributeName' => 'is_default', 'KeyType' => 'HASH']], + 'Projection' => ['ProjectionType' => 'ALL'], + ], + ], + 'BillingMode' => 'PAY_PER_REQUEST', + ]); - $client->createTable(array( + $client->createTable([ 'TableName' => $prefix.'oauth_users', - 'AttributeDefinitions' => array(array('AttributeName' => 'username','AttributeType' => 'S')), - 'KeySchema' => array(array('AttributeName' => 'username','KeyType' => 'HASH')), - 'ProvisionedThroughput' => array('ReadCapacityUnits' => 1,'WriteCapacityUnits' => 1) - )); + 'AttributeDefinitions' => [ + ['AttributeName' => 'username', 'AttributeType' => 'S'], + ], + 'KeySchema' => [['AttributeName' => 'username', 'KeyType' => 'HASH']], + 'BillingMode' => 'PAY_PER_REQUEST', + ]); - // Wait for creation - $nbTableCreated = 0; - while ($nbTableCreated != $nbTables) { - $nbTableCreated = 0; - foreach ($tablesList as $key => $table) { - try { - $result = $client->describeTable(array('TableName' => $prefix.$table)); - if ($result['Table']['TableStatus'] == 'ACTIVE') { - $nbTableCreated++; - } - } catch (\Aws\DynamoDb\Exception\DynamoDbException $e) { - // Table does not exist : nothing to do - $nbTableCreated++; - } - } - if ($nbTableCreated != $nbTables) { - sleep(1); - } + // Wait for all tables to become active + $tablesList = explode(' ', 'oauth_access_tokens oauth_authorization_codes oauth_clients oauth_jwt oauth_public_keys oauth_refresh_tokens oauth_scopes oauth_users'); + foreach ($tablesList as $table) { + $client->waitUntil('TableExists', ['TableName' => $prefix.$table]); } } From 2ed7b46091e4932ea2b07cc63e427edb6aa3d341 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:01:41 +0100 Subject: [PATCH 03/30] chore: add Couchbase stub for PHPStan and phpstan.neon config --- .github/workflows/tests.yml | 2 +- phpstan.neon | 6 +++++ stubs/couchbase.stub | 47 +++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 phpstan.neon create mode 100644 stubs/couchbase.stub diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d718d337f..273aff313 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -75,4 +75,4 @@ jobs: - name: Run PHPStan run: | composer require phpstan/phpstan - vendor/bin/phpstan analyse --level=0 src/ + vendor/bin/phpstan analyse diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..d0a164b61 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 0 + paths: + - src/ + scanFiles: + - stubs/couchbase.stub diff --git a/stubs/couchbase.stub b/stubs/couchbase.stub new file mode 100644 index 000000000..afb2034e1 --- /dev/null +++ b/stubs/couchbase.stub @@ -0,0 +1,47 @@ + Date: Mon, 16 Mar 2026 16:03:33 +0100 Subject: [PATCH 04/30] fix: use wget for DynamoDB Local health check (image has no curl) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 273aff313..d9ea177bc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,7 +43,7 @@ jobs: image: amazon/dynamodb-local ports: - 8000:8000 - options: --health-cmd="curl -sf http://localhost:8000/shell/ || exit 1" --health-interval=10s --health-timeout=5s --health-retries=5 + options: --health-cmd="wget -q -O /dev/null http://localhost:8000 || exit 1" --health-interval=10s --health-timeout=5s --health-retries=5 strategy: matrix: php: [ 8.1, 8.2, 8.3, 8.4, 8.5 ] From 060617555f2c742cae735de795c2722c3cb0f58c Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:08:39 +0100 Subject: [PATCH 05/30] chore: require firebase/php-jwt ^7.0, drop yoast/phpunit-polyfills, fix DynamoDB health check Use bash /dev/tcp for DynamoDB Local health check since the image has no curl or wget. Narrow php-jwt to ^7.0 and remove the polyfills package which is unnecessary on PHPUnit 10. --- .github/workflows/tests.yml | 2 +- composer.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d9ea177bc..9f6d0fbd6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,7 +43,7 @@ jobs: image: amazon/dynamodb-local ports: - 8000:8000 - options: --health-cmd="wget -q -O /dev/null http://localhost:8000 || exit 1" --health-interval=10s --health-timeout=5s --health-retries=5 + options: --health-cmd="bash -c 'echo > /dev/tcp/localhost/8000'" --health-interval=10s --health-timeout=5s --health-retries=5 strategy: matrix: php: [ 8.1, 8.2, 8.3, 8.4, 8.5 ] diff --git a/composer.json b/composer.json index 657da9a20..04bfab7a7 100644 --- a/composer.json +++ b/composer.json @@ -21,16 +21,16 @@ "require-dev": { "phpunit/phpunit": "^10.5", "aws/aws-sdk-php": "^3.0", - "firebase/php-jwt": "^6.4 || ^7.0", + "firebase/php-jwt": "^7.0", "predis/predis": "^2.0", "mroosz/php-cassandra": "^1.2", - "yoast/phpunit-polyfills": "^2.0||^3.0" + "phpstan/phpstan": "^2.1" }, "suggest": { "predis/predis": "Required to use Redis storage", "mroosz/php-cassandra": "^1.2 is required to use Cassandra storage", "aws/aws-sdk-php": "^3.0 is required to use DynamoDB storage", - "firebase/php-jwt": "^6.4 || ^7.0 is required to use JWT features", + "firebase/php-jwt": "^7.0 is required to use JWT features", "mongodb/mongodb": "^1.1 is required to use MongoDB storage", "ext-couchbase": "^4.0 is required to use Couchbase storage" } From 7275aece338c9f422f6745e294454f5c5b356fd2 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:14:33 +0100 Subject: [PATCH 06/30] chore: add composer scripts for test, coverage, and static analysis Add composer test, test:coverage, and analyze scripts. Enable verbose PHPUnit output for deprecations, warnings, and skip reasons. CI now uses the composer scripts. --- .github/workflows/tests.yml | 6 ++---- composer.json | 5 +++++ phpunit.xml | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9f6d0fbd6..14d213104 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,7 +60,7 @@ jobs: - name: Install composer dependencies run: composer install - name: Run PHPUnit - run: vendor/bin/phpunit + run: composer test phpstan: name: "PHPStan" runs-on: ubuntu-latest @@ -73,6 +73,4 @@ jobs: - name: Install composer dependencies run: composer install - name: Run PHPStan - run: | - composer require phpstan/phpstan - vendor/bin/phpstan analyse + run: composer analyze diff --git a/composer.json b/composer.json index 04bfab7a7..464863725 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,11 @@ "mroosz/php-cassandra": "^1.2", "phpstan/phpstan": "^2.1" }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-text", + "analyze": "phpstan analyse --memory-limit=512M" + }, "suggest": { "predis/predis": "Required to use Redis storage", "mroosz/php-cassandra": "^1.2 is required to use Cassandra storage", diff --git a/phpunit.xml b/phpunit.xml index cb63322f1..66b040c02 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,6 +6,9 @@ stopOnFailure="false" bootstrap="test/bootstrap.php" cacheDirectory=".phpunit.cache" + displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnSkippedTests="true" > From fc460b4cbb12169c0e3573964e59123019871fdb Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:16:50 +0100 Subject: [PATCH 07/30] perf: start Cassandra and DynamoDB in background to speed up CI Move Cassandra and DynamoDB out of services block so they start in parallel with PHP setup and composer install instead of blocking the entire job. Cassandra takes ~70s to boot; this now overlaps with the ~20s of checkout + PHP setup + composer install. --- .github/workflows/tests.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 14d213104..aac59e361 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,16 +34,6 @@ jobs: ports: - 5432:5432 options: --health-cmd="pg_isready -h localhost" --health-interval=10s --health-timeout=5s --health-retries=5 - cassandra: - image: cassandra:4 - ports: - - 9042:9042 - options: --health-cmd="cqlsh -e 'describe cluster'" --health-interval=15s --health-timeout=10s --health-retries=10 - dynamodb: - image: amazon/dynamodb-local - ports: - - 8000:8000 - options: --health-cmd="bash -c 'echo > /dev/tcp/localhost/8000'" --health-interval=10s --health-timeout=5s --health-retries=5 strategy: matrix: php: [ 8.1, 8.2, 8.3, 8.4, 8.5 ] @@ -52,6 +42,10 @@ jobs: DYNAMODB_ENDPOINT: http://localhost:8000 steps: - uses: actions/checkout@v5 + - name: Start slow containers in background + run: | + docker run -d --name cassandra -p 9042:9042 cassandra:4 + docker run -d --name dynamodb -p 8000:8000 amazon/dynamodb-local - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -59,6 +53,14 @@ jobs: extensions: mongodb, mbstring, intl, redis, pdo_mysql, pdo_pgsql - name: Install composer dependencies run: composer install + - name: Wait for Cassandra and DynamoDB + run: | + echo "Waiting for DynamoDB..." + timeout 30 bash -c 'until docker exec dynamodb bash -c "echo > /dev/tcp/localhost/8000" 2>/dev/null; do sleep 1; done' + echo "DynamoDB ready" + echo "Waiting for Cassandra..." + timeout 120 bash -c 'until docker exec cassandra cqlsh -e "describe cluster" 2>/dev/null; do sleep 5; done' + echo "Cassandra ready" - name: Run PHPUnit run: composer test phpstan: From f213c5a2779f008c524729686691524f8899a613 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:18:08 +0100 Subject: [PATCH 08/30] fix: use PDO for Postgres setup instead of shell commands Eliminates psql client/server version mismatch errors (daticulocale) and createdb failures when the database already exists from the Docker service POSTGRES_DB env var. --- test/lib/OAuth2/Storage/Bootstrap.php | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/test/lib/OAuth2/Storage/Bootstrap.php b/test/lib/OAuth2/Storage/Bootstrap.php index 9aab0b966..64feb24a8 100755 --- a/test/lib/OAuth2/Storage/Bootstrap.php +++ b/test/lib/OAuth2/Storage/Bootstrap.php @@ -297,11 +297,16 @@ private function removeMysqlDb(\PDO $pdo) private function createPostgresDb() { - if (!shell_exec('PGPASSWORD=postgres psql postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname=\'postgres\'" -h localhost -U postgres')) { - shell_exec('PGPASSWORD=postgres createuser -s -r postgres -h localhost -U postgres'); + try { + $pdo = new \PDO('pgsql:host=localhost', 'postgres', 'postgres'); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $exists = $pdo->query("SELECT 1 FROM pg_database WHERE datname = 'oauth2_server_php'")->fetchColumn(); + if (!$exists) { + $pdo->exec('CREATE DATABASE oauth2_server_php'); + } + } catch (\PDOException $e) { + // connection failed — will be caught later in getPostgresPdo } - - shell_exec('PGPASSWORD=postgres createdb -O postgres oauth2_server_php -h localhost -U postgres'); } private function populatePostgresDb(\PDO $pdo) @@ -311,8 +316,14 @@ private function populatePostgresDb(\PDO $pdo) private function removePostgresDb() { - if (trim(shell_exec('PGPASSWORD=postgres psql -l -h localhost -U postgres | grep oauth2_server_php | wc -l') ?? '')) { - shell_exec('PGPASSWORD=postgres dropdb oauth2_server_php -h localhost -U postgres'); + try { + $pdo = new \PDO('pgsql:host=localhost', 'postgres', 'postgres'); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + // terminate existing connections before dropping + $pdo->exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'oauth2_server_php' AND pid <> pg_backend_pid()"); + $pdo->exec('DROP DATABASE IF EXISTS oauth2_server_php'); + } catch (\PDOException $e) { + // connection failed — will be caught later } } From 4979b6525ab106a11c622c6e11f86bf171471947 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:23:33 +0100 Subject: [PATCH 09/30] fix: resolve CI test deprecations and skipped storage backends - Replace deprecated 'self::isNotEmpty' callable strings with first-class callable syntax in DynamoDB storage - Use 127.0.0.1 instead of localhost for MySQL to force TCP connection to Docker service (localhost uses Unix socket on Linux) - Add mongodb/mongodb to require-dev so MongoDB tests run in CI - Check extension_loaded('mongodb') before class_exists to avoid fatal errors when library is present but extension is not --- composer.json | 1 + src/OAuth2/Storage/DynamoDB.php | 10 +++++----- test/lib/OAuth2/Storage/Bootstrap.php | 6 +++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 464863725..757d1b3e6 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "aws/aws-sdk-php": "^3.0", "firebase/php-jwt": "^7.0", "predis/predis": "^2.0", + "mongodb/mongodb": "^1.1", "mroosz/php-cassandra": "^1.2", "phpstan/phpstan": "^2.1" }, diff --git a/src/OAuth2/Storage/DynamoDB.php b/src/OAuth2/Storage/DynamoDB.php index 9387371f3..c4939736a 100644 --- a/src/OAuth2/Storage/DynamoDB.php +++ b/src/OAuth2/Storage/DynamoDB.php @@ -131,7 +131,7 @@ public function getClientDetails($client_id) public function setClientDetails($client_id, $client_secret = null, $redirect_uri = null, $grant_types = null, $scope = null, $user_id = null) { $clientData = compact('client_id', 'client_secret', 'redirect_uri', 'grant_types', 'scope', 'user_id'); - $clientData = array_filter($clientData, 'self::isNotEmpty'); + $clientData = array_filter($clientData, self::isNotEmpty(...)); $this->client->putItem(array( 'TableName' => $this->config['client_table'], @@ -178,7 +178,7 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s $expires = date('Y-m-d H:i:s', $expires); $clientData = compact('access_token', 'client_id', 'user_id', 'expires', 'scope'); - $clientData = array_filter($clientData, 'self::isNotEmpty'); + $clientData = array_filter($clientData, self::isNotEmpty(...)); $this->client->putItem(array( 'TableName' => $this->config['access_token_table'], @@ -226,7 +226,7 @@ public function setAuthorizationCode($authorization_code, $client_id, $user_id, $expires = date('Y-m-d H:i:s', $expires); $clientData = compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token', 'code_challenge', 'code_challenge_method'); - $clientData = array_filter($clientData, 'self::isNotEmpty'); + $clientData = array_filter($clientData, self::isNotEmpty(...)); $this->client->putItem(array( 'TableName' => $this->config['code_table'], @@ -327,7 +327,7 @@ public function setRefreshToken($refresh_token, $client_id, $user_id, $expires, $expires = date('Y-m-d H:i:s', $expires); $clientData = compact('refresh_token', 'client_id', 'user_id', 'expires', 'scope'); - $clientData = array_filter($clientData, 'self::isNotEmpty'); + $clientData = array_filter($clientData, self::isNotEmpty(...)); $this->client->putItem(array( 'TableName' => $this->config['refresh_token_table'], @@ -380,7 +380,7 @@ public function setUser($username, $password, $first_name = null, $last_name = n $password = $this->hashPassword($password); $clientData = compact('username', 'password', 'first_name', 'last_name'); - $clientData = array_filter($clientData, 'self::isNotEmpty'); + $clientData = array_filter($clientData, self::isNotEmpty(...)); $this->client->putItem(array( 'TableName' => $this->config['user_table'], diff --git a/test/lib/OAuth2/Storage/Bootstrap.php b/test/lib/OAuth2/Storage/Bootstrap.php index 64feb24a8..a4156590d 100755 --- a/test/lib/OAuth2/Storage/Bootstrap.php +++ b/test/lib/OAuth2/Storage/Bootstrap.php @@ -115,9 +115,9 @@ public function getMysqlPdo() if (!$this->mysql) { $pdo = null; try { - $pdo = new \PDO('mysql:host=localhost;', 'root', 'root'); + $pdo = new \PDO('mysql:host=127.0.0.1;', 'root', 'root'); } catch (\PDOException $e) { - $this->mysql = new NullStorage('MySQL', 'Unable to connect to MySQL on root@localhost'); + $this->mysql = new NullStorage('MySQL', 'Unable to connect to MySQL on root@127.0.0.1'); } if ($pdo) { @@ -135,7 +135,7 @@ public function getMysqlPdo() public function getMongoDb() { if (!$this->mongoDb) { - if (class_exists('MongoDB\Client')) { + if (extension_loaded('mongodb') && class_exists('MongoDB\Client')) { $mongoDb = new \MongoDB\Client('mongodb://localhost:27017'); if ($this->testMongoDBConnection($mongoDb)) { $db = $mongoDb->oauth2_server_php; From 6c22c2c8e78aec96ff3e7ddc4dded02b81952bec Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:27:15 +0100 Subject: [PATCH 10/30] fix: require mongodb/mongodb ^1.20 || ^2.0 for ext-mongodb 2.x compat --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 757d1b3e6..aaf6ac4fe 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "aws/aws-sdk-php": "^3.0", "firebase/php-jwt": "^7.0", "predis/predis": "^2.0", - "mongodb/mongodb": "^1.1", + "mongodb/mongodb": "^1.20 || ^2.0", "mroosz/php-cassandra": "^1.2", "phpstan/phpstan": "^2.1" }, @@ -37,7 +37,7 @@ "mroosz/php-cassandra": "^1.2 is required to use Cassandra storage", "aws/aws-sdk-php": "^3.0 is required to use DynamoDB storage", "firebase/php-jwt": "^7.0 is required to use JWT features", - "mongodb/mongodb": "^1.1 is required to use MongoDB storage", + "mongodb/mongodb": "^1.20 || ^2.0 is required to use MongoDB storage", "ext-couchbase": "^4.0 is required to use Couchbase storage" } } From bc5e0edee46d2d2737ce4b19aaacbd9071440bfc Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:32:30 +0100 Subject: [PATCH 11/30] feat: add Couchbase Server to CI Start couchbase:community-7.6.5 as a background container, initialize the cluster with kv service, create the oauth2test bucket, and install ext-couchbase via shivammathur/setup-php. This eliminates the last remaining test skips (Couchbase). --- .github/workflows/tests.yml | 40 +++++++++++++++++++++++++-- test/lib/OAuth2/Storage/Bootstrap.php | 2 +- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aac59e361..f9e20405e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,24 +40,60 @@ jobs: name: "PHP ${{ matrix.php }} Unit Test" env: DYNAMODB_ENDPOINT: http://localhost:8000 + CB_CONNECTION_STRING: couchbase://127.0.0.1 + CB_USERNAME: Administrator + CB_PASSWORD: password + CB_BUCKET: oauth2test steps: - uses: actions/checkout@v5 - name: Start slow containers in background run: | docker run -d --name cassandra -p 9042:9042 cassandra:4 docker run -d --name dynamodb -p 8000:8000 amazon/dynamodb-local + docker run -d --name couchbase -p 8091-8096:8091-8096 -p 11210:11210 couchbase:community-7.6.5 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: mongodb, mbstring, intl, redis, pdo_mysql, pdo_pgsql + extensions: mongodb, mbstring, intl, redis, pdo_mysql, pdo_pgsql, couchbase - name: Install composer dependencies run: composer install - - name: Wait for Cassandra and DynamoDB + - name: Wait for slow containers run: | echo "Waiting for DynamoDB..." timeout 30 bash -c 'until docker exec dynamodb bash -c "echo > /dev/tcp/localhost/8000" 2>/dev/null; do sleep 1; done' echo "DynamoDB ready" + + echo "Waiting for Couchbase..." + timeout 90 bash -c 'until curl -sf http://127.0.0.1:8091/ui/index.html > /dev/null 2>&1; do sleep 2; done' + echo "Couchbase responding" + + # Initialize cluster + curl -sf -X POST http://127.0.0.1:8091/clusterInit \ + -d clusterName=ci \ + -d services=kv \ + -d memoryQuota=256 \ + -d afamily=ipv4 \ + -d afamilyOnly=false \ + -d nodeEncryption=off \ + -d username=Administrator \ + -d password=password \ + -d port=SAME \ + -d sendStats=false + echo "Couchbase cluster initialized" + + # Create bucket + curl -sf -X POST http://127.0.0.1:8091/pools/default/buckets \ + -u Administrator:password \ + -d name=oauth2test \ + -d ramQuota=128 \ + -d bucketType=couchbase + echo "Couchbase bucket created" + + # Wait for bucket to be ready + timeout 30 bash -c 'until curl -sf http://127.0.0.1:8091/pools/default/buckets/oauth2test -u Administrator:password | grep -q '"'"'"status":"healthy"'"'"'; do sleep 2; done' + echo "Couchbase ready" + echo "Waiting for Cassandra..." timeout 120 bash -c 'until docker exec cassandra cqlsh -e "describe cluster" 2>/dev/null; do sleep 5; done' echo "Cassandra ready" diff --git a/test/lib/OAuth2/Storage/Bootstrap.php b/test/lib/OAuth2/Storage/Bootstrap.php index a4156590d..89ab995c0 100755 --- a/test/lib/OAuth2/Storage/Bootstrap.php +++ b/test/lib/OAuth2/Storage/Bootstrap.php @@ -164,7 +164,7 @@ public function getCouchbase() if (!$this->couchbase) { if ($this->getEnvVar('SKIP_COUCHBASE_TESTS')) { $this->couchbase = new NullStorage('Couchbase', 'Skipping Couchbase tests'); - } elseif (!class_exists(\Couchbase\Cluster::class)) { + } elseif (!extension_loaded('couchbase') || !class_exists(\Couchbase\Cluster::class)) { $this->couchbase = new NullStorage('Couchbase', 'Missing Couchbase PHP SDK 4.x. Please install ext-couchbase ^4.0'); } else { try { From c0f201a7de1e0884b73892000a9d9c85275ff539 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:37:46 +0100 Subject: [PATCH 12/30] fix: JwtAccessTokenTest was checking wrong type (PublicKey vs PublicKeyInterface) The instanceof check used the non-existent PublicKey class instead of PublicKeyInterface, causing all storage backends to skip. Also add NullStorage guard to prevent errors from unavailable backends. --- test/OAuth2/Storage/JwtAccessTokenTest.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/OAuth2/Storage/JwtAccessTokenTest.php b/test/OAuth2/Storage/JwtAccessTokenTest.php index 7a3eb8737..afa460c3f 100644 --- a/test/OAuth2/Storage/JwtAccessTokenTest.php +++ b/test/OAuth2/Storage/JwtAccessTokenTest.php @@ -10,8 +10,14 @@ class JwtAccessTokenTest extends BaseTest #[DataProvider('provideStorage')] public function testSetAccessToken($storage) { - if (!$storage instanceof PublicKey) { - $this->markTestSkipped('Incompatible storage: PublicKey required'); + if ($storage instanceof NullStorage) { + $this->markTestSkipped("Skipped Storage: {$storage}"); + + return; + } + + if (!$storage instanceof PublicKeyInterface) { + $this->markTestSkipped('Incompatible storage: PublicKeyInterface required'); return; } From 4e1b7026a35bf399312b55bbf92319d2cfe3647b Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:41:31 +0100 Subject: [PATCH 13/30] fix: use correct Couchbase Docker image (couchbase/server:community-7.6.2) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f9e20405e..9a4bbbeac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,7 +50,7 @@ jobs: run: | docker run -d --name cassandra -p 9042:9042 cassandra:4 docker run -d --name dynamodb -p 8000:8000 amazon/dynamodb-local - docker run -d --name couchbase -p 8091-8096:8091-8096 -p 11210:11210 couchbase:community-7.6.5 + docker run -d --name couchbase -p 8091-8096:8091-8096 -p 11210:11210 couchbase/server:community-7.6.2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: From c6c74354748dfdf28d53308ac704a10627a3afff Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 16:56:02 +0100 Subject: [PATCH 14/30] revert: remove Couchbase from CI (ext-couchbase PECL build hangs) The couchbase extension has no pre-built binary in setup-php and compiling from PECL hangs indefinitely. Remove the Couchbase container, cluster setup, and extension from CI. Couchbase tests remain as skips. --- .github/workflows/tests.yml | 37 +------------------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a4bbbeac..a334ab40f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,22 +40,17 @@ jobs: name: "PHP ${{ matrix.php }} Unit Test" env: DYNAMODB_ENDPOINT: http://localhost:8000 - CB_CONNECTION_STRING: couchbase://127.0.0.1 - CB_USERNAME: Administrator - CB_PASSWORD: password - CB_BUCKET: oauth2test steps: - uses: actions/checkout@v5 - name: Start slow containers in background run: | docker run -d --name cassandra -p 9042:9042 cassandra:4 docker run -d --name dynamodb -p 8000:8000 amazon/dynamodb-local - docker run -d --name couchbase -p 8091-8096:8091-8096 -p 11210:11210 couchbase/server:community-7.6.2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: mongodb, mbstring, intl, redis, pdo_mysql, pdo_pgsql, couchbase + extensions: mongodb, mbstring, intl, redis, pdo_mysql, pdo_pgsql - name: Install composer dependencies run: composer install - name: Wait for slow containers @@ -64,36 +59,6 @@ jobs: timeout 30 bash -c 'until docker exec dynamodb bash -c "echo > /dev/tcp/localhost/8000" 2>/dev/null; do sleep 1; done' echo "DynamoDB ready" - echo "Waiting for Couchbase..." - timeout 90 bash -c 'until curl -sf http://127.0.0.1:8091/ui/index.html > /dev/null 2>&1; do sleep 2; done' - echo "Couchbase responding" - - # Initialize cluster - curl -sf -X POST http://127.0.0.1:8091/clusterInit \ - -d clusterName=ci \ - -d services=kv \ - -d memoryQuota=256 \ - -d afamily=ipv4 \ - -d afamilyOnly=false \ - -d nodeEncryption=off \ - -d username=Administrator \ - -d password=password \ - -d port=SAME \ - -d sendStats=false - echo "Couchbase cluster initialized" - - # Create bucket - curl -sf -X POST http://127.0.0.1:8091/pools/default/buckets \ - -u Administrator:password \ - -d name=oauth2test \ - -d ramQuota=128 \ - -d bucketType=couchbase - echo "Couchbase bucket created" - - # Wait for bucket to be ready - timeout 30 bash -c 'until curl -sf http://127.0.0.1:8091/pools/default/buckets/oauth2test -u Administrator:password | grep -q '"'"'"status":"healthy"'"'"'; do sleep 2; done' - echo "Couchbase ready" - echo "Waiting for Cassandra..." timeout 120 bash -c 'until docker exec cassandra cqlsh -e "describe cluster" 2>/dev/null; do sleep 5; done' echo "Cassandra ready" From f7e36a4a39b2045e2387fb41670d434dda91a61c Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 17:00:50 +0100 Subject: [PATCH 15/30] fix: DynamoDB public key methods crash on null client_id DynamoDB rejects empty AttributeValue strings. The getEncryptionAlgorithm method defaulted client_id to null, causing 'S' => null which is invalid. Coalesce null to '0' (the sentinel key used for global keys) in all three public key methods. --- src/OAuth2/Storage/DynamoDB.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/OAuth2/Storage/DynamoDB.php b/src/OAuth2/Storage/DynamoDB.php index c4939736a..eddbf7d72 100644 --- a/src/OAuth2/Storage/DynamoDB.php +++ b/src/OAuth2/Storage/DynamoDB.php @@ -480,7 +480,7 @@ public function setJti($client_id, $subject, $audience, $expires, $jti) /* PublicKeyInterface */ public function getPublicKey($client_id = '0') { - + $client_id = $client_id ?? '0'; $result = $this->client->getItem(array( "TableName"=> $this->config['public_key_table'], "Key" => array('client_id' => array('S' => $client_id)) @@ -496,6 +496,7 @@ public function getPublicKey($client_id = '0') public function getPrivateKey($client_id = '0') { + $client_id = $client_id ?? '0'; $result = $this->client->getItem(array( "TableName"=> $this->config['public_key_table'], "Key" => array('client_id' => array('S' => $client_id)) @@ -510,6 +511,7 @@ public function getPrivateKey($client_id = '0') public function getEncryptionAlgorithm($client_id = null) { + $client_id = $client_id ?? '0'; $result = $this->client->getItem(array( "TableName"=> $this->config['public_key_table'], "Key" => array('client_id' => array('S' => $client_id)) From 97d0c06b100f30f2f16193185b7768a78d283735 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 21:25:50 +0100 Subject: [PATCH 16/30] feat: add custom Docker CI images with ext-couchbase pre-installed - Add .docker/Dockerfile based on php:*-cli-alpine with mongodb, redis, couchbase, pdo_mysql, pdo_pgsql, intl extensions pre-compiled - Add docker.yml workflow to build and push images to GHCR for PHP 8.1-8.4, triggered on Dockerfile changes and weekly schedule - Update tests.yml to use container images for PHP 8.1-8.4 (with all services including Couchbase), PHP 8.5 remains on setup-php - Make all service hostnames configurable via env vars in Bootstrap (MYSQL_HOST, POSTGRES_HOST, REDIS_HOST, MONGODB_HOST, CASSANDRA_HOST) --- .docker/Dockerfile | 29 +++++++++ .github/workflows/docker.yml | 45 ++++++++++++++ .github/workflows/tests.yml | 89 +++++++++++++++++++++++---- test/lib/OAuth2/Storage/Bootstrap.php | 23 ++++--- 4 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 .docker/Dockerfile create mode 100644 .github/workflows/docker.yml diff --git a/.docker/Dockerfile b/.docker/Dockerfile new file mode 100644 index 000000000..6fb1d32b9 --- /dev/null +++ b/.docker/Dockerfile @@ -0,0 +1,29 @@ +ARG PHP_VERSION=8.3 +FROM php:${PHP_VERSION}-cli-alpine + +RUN apk add --no-cache --virtual .build-deps \ + $PHPIZE_DEPS \ + cmake \ + linux-headers \ + openssl-dev \ + zlib-dev \ + libpq-dev \ + icu-dev \ + && apk add --no-cache \ + git \ + unzip \ + libpq \ + libstdc++ \ + icu-libs \ + && docker-php-ext-install -j$(nproc) \ + pdo_mysql \ + pdo_pgsql \ + intl \ + && pecl install mongodb redis couchbase \ + && docker-php-ext-enable mongodb redis couchbase \ + && apk del .build-deps \ + && rm -rf /tmp/* /var/cache/apk/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..f10534acd --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,45 @@ +name: Build CI Images +on: + workflow_dispatch: + schedule: + # Weekly rebuild to pick up PHP patch updates + - cron: '0 6 * * 1' + push: + branches: + - main + - php85 + paths: + - '.docker/Dockerfile' + - '.github/workflows/docker.yml' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/oauth2-php-ci + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + php: [ '8.1', '8.2', '8.3', '8.4' ] + name: "Build PHP ${{ matrix.php }}" + steps: + - uses: actions/checkout@v5 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: .docker + build-args: PHP_VERSION=${{ matrix.php }} + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.php }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a334ab40f..19a7b9a6a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,75 @@ on: pull_request: jobs: test: + runs-on: ubuntu-latest + container: + image: ghcr.io/${{ github.repository_owner }}/oauth2-php-ci:${{ matrix.php }} + services: + redis: + image: redis + options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 + mongodb: + image: mongo + options: --health-cmd="mongosh --eval 'db.runCommand(\"ping\").ok'" --health-interval=10s --health-timeout=5s --health-retries=5 + mariadb: + image: mariadb + env: + MYSQL_ROOT_PASSWORD: root + options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=5 + postgres: + image: postgres + env: + POSTGRES_DB: oauth2_server_php + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: --health-cmd="pg_isready -h localhost" --health-interval=10s --health-timeout=5s --health-retries=5 + cassandra: + image: cassandra:4 + options: --health-cmd="cqlsh -e 'describe cluster'" --health-interval=15s --health-timeout=10s --health-retries=10 + dynamodb: + image: amazon/dynamodb-local + couchbase: + image: couchbase/server:community-7.6.2 + strategy: + matrix: + php: [ '8.1', '8.2', '8.3', '8.4' ] + name: "PHP ${{ matrix.php }} Unit Test" + env: + MYSQL_HOST: mariadb + POSTGRES_HOST: postgres + REDIS_HOST: redis + MONGODB_HOST: mongodb + CASSANDRA_HOST: cassandra + DYNAMODB_ENDPOINT: http://dynamodb:8000 + CB_CONNECTION_STRING: couchbase://couchbase + CB_USERNAME: Administrator + CB_PASSWORD: password + CB_BUCKET: oauth2test + steps: + - uses: actions/checkout@v5 + - name: Install composer dependencies + run: composer install --no-interaction + - name: Setup Couchbase + run: | + echo "Waiting for Couchbase..." + timeout 90 sh -c 'until wget -qO- http://couchbase:8091/ui/index.html > /dev/null 2>&1; do sleep 2; done' + echo "Couchbase responding" + + wget -qO- --post-data 'clusterName=ci&services=kv&memoryQuota=256&afamily=ipv4&afamilyOnly=false&nodeEncryption=off&username=Administrator&password=password&port=SAME&sendStats=false' \ + http://couchbase:8091/clusterInit + echo "Couchbase cluster initialized" + + wget -qO- --post-data 'name=oauth2test&ramQuota=128&bucketType=couchbase' \ + --header="Authorization: Basic $(echo -n Administrator:password | base64)" \ + http://couchbase:8091/pools/default/buckets + echo "Couchbase bucket created" + + timeout 30 sh -c 'until wget -qO- --header="Authorization: Basic $(echo -n Administrator:password | base64)" http://couchbase:8091/pools/default/buckets/oauth2test 2>/dev/null | grep -q "\"status\":\"healthy\""; do sleep 2; done' + echo "Couchbase ready" + - name: Run PHPUnit + run: composer test + + test-php85: runs-on: ubuntu-latest services: redis: @@ -34,10 +103,7 @@ jobs: ports: - 5432:5432 options: --health-cmd="pg_isready -h localhost" --health-interval=10s --health-timeout=5s --health-retries=5 - strategy: - matrix: - php: [ 8.1, 8.2, 8.3, 8.4, 8.5 ] - name: "PHP ${{ matrix.php }} Unit Test" + name: "PHP 8.5 Unit Test" env: DYNAMODB_ENDPOINT: http://localhost:8000 steps: @@ -49,31 +115,28 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: '8.5' extensions: mongodb, mbstring, intl, redis, pdo_mysql, pdo_pgsql - name: Install composer dependencies - run: composer install + run: composer install --no-interaction - name: Wait for slow containers run: | echo "Waiting for DynamoDB..." timeout 30 bash -c 'until docker exec dynamodb bash -c "echo > /dev/tcp/localhost/8000" 2>/dev/null; do sleep 1; done' echo "DynamoDB ready" - echo "Waiting for Cassandra..." timeout 120 bash -c 'until docker exec cassandra cqlsh -e "describe cluster" 2>/dev/null; do sleep 5; done' echo "Cassandra ready" - name: Run PHPUnit run: composer test + phpstan: - name: "PHPStan" runs-on: ubuntu-latest + container: + image: ghcr.io/${{ github.repository_owner }}/oauth2-php-ci:8.3 steps: - uses: actions/checkout@v5 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.1 - name: Install composer dependencies - run: composer install + run: composer install --no-interaction - name: Run PHPStan run: composer analyze diff --git a/test/lib/OAuth2/Storage/Bootstrap.php b/test/lib/OAuth2/Storage/Bootstrap.php index 89ab995c0..661ab909a 100755 --- a/test/lib/OAuth2/Storage/Bootstrap.php +++ b/test/lib/OAuth2/Storage/Bootstrap.php @@ -65,7 +65,8 @@ public function getPostgresPdo() public function getPostgresDriver() { try { - $pdo = new \PDO('pgsql:host=localhost;dbname=oauth2_server_php', 'postgres', 'postgres'); + $pgHost = $this->getEnvVar('POSTGRES_HOST', 'localhost'); + $pdo = new \PDO("pgsql:host={$pgHost};dbname=oauth2_server_php", 'postgres', 'postgres'); return $pdo; } catch (\PDOException $e) { @@ -82,7 +83,8 @@ public function getRedisStorage() { if (!$this->redis) { if (class_exists('Predis\Client')) { - $redis = new \Predis\Client(); + $redisHost = $this->getEnvVar('REDIS_HOST', '127.0.0.1'); + $redis = new \Predis\Client(['host' => $redisHost]); if ($this->testRedisConnection($redis)) { $redis->flushdb(); $this->redis = new Redis($redis); @@ -115,9 +117,10 @@ public function getMysqlPdo() if (!$this->mysql) { $pdo = null; try { - $pdo = new \PDO('mysql:host=127.0.0.1;', 'root', 'root'); + $mysqlHost = $this->getEnvVar('MYSQL_HOST', '127.0.0.1'); + $pdo = new \PDO("mysql:host={$mysqlHost};", 'root', 'root'); } catch (\PDOException $e) { - $this->mysql = new NullStorage('MySQL', 'Unable to connect to MySQL on root@127.0.0.1'); + $this->mysql = new NullStorage('MySQL', "Unable to connect to MySQL on root@{$mysqlHost}"); } if ($pdo) { @@ -136,7 +139,8 @@ public function getMongoDb() { if (!$this->mongoDb) { if (extension_loaded('mongodb') && class_exists('MongoDB\Client')) { - $mongoDb = new \MongoDB\Client('mongodb://localhost:27017'); + $mongoHost = $this->getEnvVar('MONGODB_HOST', 'localhost'); + $mongoDb = new \MongoDB\Client("mongodb://{$mongoHost}:27017"); if ($this->testMongoDBConnection($mongoDb)) { $db = $mongoDb->oauth2_server_php; $this->removeMongoDb($db); @@ -203,9 +207,10 @@ public function getCassandraStorage() } try { + $cassandraHost = $this->getEnvVar('CASSANDRA_HOST', '127.0.0.1'); $conn = new \Cassandra\Connection([ new \Cassandra\Connection\StreamNodeConfig( - host: '127.0.0.1', + host: $cassandraHost, port: 9042, ), ]); @@ -298,7 +303,8 @@ private function removeMysqlDb(\PDO $pdo) private function createPostgresDb() { try { - $pdo = new \PDO('pgsql:host=localhost', 'postgres', 'postgres'); + $pgHost = $this->getEnvVar('POSTGRES_HOST', 'localhost'); + $pdo = new \PDO("pgsql:host={$pgHost}", 'postgres', 'postgres'); $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); $exists = $pdo->query("SELECT 1 FROM pg_database WHERE datname = 'oauth2_server_php'")->fetchColumn(); if (!$exists) { @@ -317,7 +323,8 @@ private function populatePostgresDb(\PDO $pdo) private function removePostgresDb() { try { - $pdo = new \PDO('pgsql:host=localhost', 'postgres', 'postgres'); + $pgHost = $this->getEnvVar('POSTGRES_HOST', 'localhost'); + $pdo = new \PDO("pgsql:host={$pgHost}", 'postgres', 'postgres'); $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); // terminate existing connections before dropping $pdo->exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'oauth2_server_php' AND pid <> pg_backend_pid()"); From 7f16b47671648afbe899240a1cae4dab80e946ef Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 21:27:02 +0100 Subject: [PATCH 17/30] feat: add PHP 8.5 to Docker CI images, remove separate test-php85 job php:8.5-cli-alpine exists, so all 5 PHP versions now use the same custom container image path. No more special-cased setup-php job. --- .github/workflows/docker.yml | 2 +- .github/workflows/tests.yml | 58 +----------------------------------- 2 files changed, 2 insertions(+), 58 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f10534acd..2abaf1e08 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,7 +24,7 @@ jobs: packages: write strategy: matrix: - php: [ '8.1', '8.2', '8.3', '8.4' ] + php: [ '8.1', '8.2', '8.3', '8.4', '8.5' ] name: "Build PHP ${{ matrix.php }}" steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 19a7b9a6a..0e25a78bd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,7 +37,7 @@ jobs: image: couchbase/server:community-7.6.2 strategy: matrix: - php: [ '8.1', '8.2', '8.3', '8.4' ] + php: [ '8.1', '8.2', '8.3', '8.4', '8.5' ] name: "PHP ${{ matrix.php }} Unit Test" env: MYSQL_HOST: mariadb @@ -74,62 +74,6 @@ jobs: - name: Run PHPUnit run: composer test - test-php85: - runs-on: ubuntu-latest - services: - redis: - image: redis - ports: - - 6379:6379 - options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 - mongodb: - image: mongo - ports: - - 27017:27017 - options: --health-cmd="mongosh --eval 'db.runCommand(\"ping\").ok'" --health-interval=10s --health-timeout=5s --health-retries=5 - mariadb: - image: mariadb - env: - MYSQL_ROOT_PASSWORD: root - ports: - - 3306:3306 - options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=5 - postgres: - image: postgres - env: - POSTGRES_DB: oauth2_server_php - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: --health-cmd="pg_isready -h localhost" --health-interval=10s --health-timeout=5s --health-retries=5 - name: "PHP 8.5 Unit Test" - env: - DYNAMODB_ENDPOINT: http://localhost:8000 - steps: - - uses: actions/checkout@v5 - - name: Start slow containers in background - run: | - docker run -d --name cassandra -p 9042:9042 cassandra:4 - docker run -d --name dynamodb -p 8000:8000 amazon/dynamodb-local - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.5' - extensions: mongodb, mbstring, intl, redis, pdo_mysql, pdo_pgsql - - name: Install composer dependencies - run: composer install --no-interaction - - name: Wait for slow containers - run: | - echo "Waiting for DynamoDB..." - timeout 30 bash -c 'until docker exec dynamodb bash -c "echo > /dev/tcp/localhost/8000" 2>/dev/null; do sleep 1; done' - echo "DynamoDB ready" - echo "Waiting for Cassandra..." - timeout 120 bash -c 'until docker exec cassandra cqlsh -e "describe cluster" 2>/dev/null; do sleep 5; done' - echo "Cassandra ready" - - name: Run PHPUnit - run: composer test - phpstan: runs-on: ubuntu-latest container: From bafba2f128f42b1bba53e1228430b53d5efae4ca Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 21:38:45 +0100 Subject: [PATCH 18/30] chore: remove unused intl extension from CI Docker image --- .docker/Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 6fb1d32b9..4fe7f7440 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -8,17 +8,14 @@ RUN apk add --no-cache --virtual .build-deps \ openssl-dev \ zlib-dev \ libpq-dev \ - icu-dev \ && apk add --no-cache \ git \ unzip \ libpq \ libstdc++ \ - icu-libs \ && docker-php-ext-install -j$(nproc) \ pdo_mysql \ pdo_pgsql \ - intl \ && pecl install mongodb redis couchbase \ && docker-php-ext-enable mongodb redis couchbase \ && apk del .build-deps \ From c09475cc7cb82d9962216ab499934f97f33ab732 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 21:42:28 +0100 Subject: [PATCH 19/30] perf: add GHA layer caching to Docker CI image builds Uses GitHub Actions cache backend (type=gha) with per-PHP-version scopes so subsequent rebuilds only recompile changed layers. --- .docker/Dockerfile | 2 ++ .github/workflows/docker.yml | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 4fe7f7440..cec98beb5 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -13,6 +13,8 @@ RUN apk add --no-cache --virtual .build-deps \ unzip \ libpq \ libstdc++ \ + openssl \ + zlib \ && docker-php-ext-install -j$(nproc) \ pdo_mysql \ pdo_pgsql \ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2abaf1e08..c3c1202c8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -36,6 +36,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push uses: docker/build-push-action@v6 with: @@ -43,3 +46,5 @@ jobs: build-args: PHP_VERSION=${{ matrix.php }} push: true tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.php }} + cache-from: type=gha,scope=php-${{ matrix.php }} + cache-to: type=gha,mode=max,scope=php-${{ matrix.php }} From 571181c1320e57d093e05631eef455c09185ebe3 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 16 Mar 2026 23:31:51 +0100 Subject: [PATCH 20/30] debug: verify couchbase extension loads in Docker image after cleanup --- .docker/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index cec98beb5..06c147f54 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -21,7 +21,8 @@ RUN apk add --no-cache --virtual .build-deps \ && pecl install mongodb redis couchbase \ && docker-php-ext-enable mongodb redis couchbase \ && apk del .build-deps \ - && rm -rf /tmp/* /var/cache/apk/* + && rm -rf /tmp/* /var/cache/apk/* \ + && php -m | grep couchbase COPY --from=composer:2 /usr/bin/composer /usr/bin/composer From 91805d961bb74397b396e25f450b702345cecf08 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Tue, 17 Mar 2026 00:10:24 +0100 Subject: [PATCH 21/30] ci: trigger test run with updated Docker images From 78de386f97deff2b47a132c36e8bc72a2b5bf762 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Tue, 17 Mar 2026 06:56:50 +0100 Subject: [PATCH 22/30] debug: add extension diagnostics to CI --- .docker/Dockerfile | 4 +++- .github/workflows/tests.yml | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 06c147f54..6ceaece26 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -22,7 +22,9 @@ RUN apk add --no-cache --virtual .build-deps \ && docker-php-ext-enable mongodb redis couchbase \ && apk del .build-deps \ && rm -rf /tmp/* /var/cache/apk/* \ - && php -m | grep couchbase + && php -m | grep couchbase \ + && php -r "new \Couchbase\ClusterOptions();" \ + && ldd $(php -r "echo ini_get('extension_dir');")/couchbase.so COPY --from=composer:2 /usr/bin/composer /usr/bin/composer diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0e25a78bd..b5443faaf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,6 +52,10 @@ jobs: CB_BUCKET: oauth2test steps: - uses: actions/checkout@v5 + - name: Verify PHP extensions + run: | + php -m + php -r "var_dump(extension_loaded('couchbase'), class_exists('Couchbase\Cluster'));" - name: Install composer dependencies run: composer install --no-interaction - name: Setup Couchbase From 356544e10c174f47632f0f4820d9f96e49f035d6 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Tue, 17 Mar 2026 08:09:30 +0100 Subject: [PATCH 23/30] fix: use scanelf to detect and preserve runtime deps for PHP extensions The couchbase extension's classes weren't registering because apk del removed shared libraries needed at runtime. Use scanelf to discover all shared lib dependencies of compiled extensions and install them before removing build deps. --- .docker/Dockerfile | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 6ceaece26..afb2d3df7 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -20,11 +20,17 @@ RUN apk add --no-cache --virtual .build-deps \ pdo_pgsql \ && pecl install mongodb redis couchbase \ && docker-php-ext-enable mongodb redis couchbase \ + # Capture runtime deps before removing build deps + && runDeps="$( \ + scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \ + | tr ',' '\n' | sort -u \ + | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \ + )" \ + && apk add --no-cache $runDeps \ && apk del .build-deps \ && rm -rf /tmp/* /var/cache/apk/* \ - && php -m | grep couchbase \ - && php -r "new \Couchbase\ClusterOptions();" \ - && ldd $(php -r "echo ini_get('extension_dir');")/couchbase.so + && php -r "new \Couchbase\ClusterOptions(); echo 'couchbase OK\n';" \ + && php -r "new \MongoDB\Driver\Manager(); echo 'mongodb OK\n';" COPY --from=composer:2 /usr/bin/composer /usr/bin/composer From 91479b683daeb50eb16295975f0d57d790abfb0a Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Tue, 17 Mar 2026 08:37:35 +0100 Subject: [PATCH 24/30] fix: couchbase 4.x classes come from composer package, not extension The ext-couchbase 4.x C extension only provides low-level functions (Couchbase\Extension\*) and exception classes. The high-level classes (Cluster, ClusterOptions, Collection) come from the couchbase/couchbase composer package. - Install couchbase/couchbase:^4.4 in CI after composer install - Update Bootstrap check to look for ClusterOptions class - Add scanelf to Dockerfile to auto-detect runtime shared lib deps - Keep couchbase/couchbase out of require-dev since it needs ext-couchbase --- .docker/Dockerfile | 7 ++----- .github/workflows/tests.yml | 8 +++----- composer.json | 2 +- test/lib/OAuth2/Storage/Bootstrap.php | 4 ++-- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index afb2d3df7..4c8a13028 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -13,14 +13,11 @@ RUN apk add --no-cache --virtual .build-deps \ unzip \ libpq \ libstdc++ \ - openssl \ - zlib \ && docker-php-ext-install -j$(nproc) \ pdo_mysql \ pdo_pgsql \ && pecl install mongodb redis couchbase \ && docker-php-ext-enable mongodb redis couchbase \ - # Capture runtime deps before removing build deps && runDeps="$( \ scanelf --needed --nobanner --format '%n#p' --recursive /usr/local/lib/php/extensions \ | tr ',' '\n' | sort -u \ @@ -29,8 +26,8 @@ RUN apk add --no-cache --virtual .build-deps \ && apk add --no-cache $runDeps \ && apk del .build-deps \ && rm -rf /tmp/* /var/cache/apk/* \ - && php -r "new \Couchbase\ClusterOptions(); echo 'couchbase OK\n';" \ - && php -r "new \MongoDB\Driver\Manager(); echo 'mongodb OK\n';" + && php -m | grep -q couchbase \ + && php -m | grep -q mongodb COPY --from=composer:2 /usr/bin/composer /usr/bin/composer diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b5443faaf..6bddcc797 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,12 +52,10 @@ jobs: CB_BUCKET: oauth2test steps: - uses: actions/checkout@v5 - - name: Verify PHP extensions - run: | - php -m - php -r "var_dump(extension_loaded('couchbase'), class_exists('Couchbase\Cluster'));" - name: Install composer dependencies - run: composer install --no-interaction + run: | + composer install --no-interaction + composer require couchbase/couchbase:^4.4 --no-interaction - name: Setup Couchbase run: | echo "Waiting for Couchbase..." diff --git a/composer.json b/composer.json index aaf6ac4fe..7fdcea18b 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,6 @@ "aws/aws-sdk-php": "^3.0 is required to use DynamoDB storage", "firebase/php-jwt": "^7.0 is required to use JWT features", "mongodb/mongodb": "^1.20 || ^2.0 is required to use MongoDB storage", - "ext-couchbase": "^4.0 is required to use Couchbase storage" + "couchbase/couchbase": "^4.4 is required to use Couchbase storage (requires ext-couchbase)" } } diff --git a/test/lib/OAuth2/Storage/Bootstrap.php b/test/lib/OAuth2/Storage/Bootstrap.php index 661ab909a..724b1dbef 100755 --- a/test/lib/OAuth2/Storage/Bootstrap.php +++ b/test/lib/OAuth2/Storage/Bootstrap.php @@ -168,8 +168,8 @@ public function getCouchbase() if (!$this->couchbase) { if ($this->getEnvVar('SKIP_COUCHBASE_TESTS')) { $this->couchbase = new NullStorage('Couchbase', 'Skipping Couchbase tests'); - } elseif (!extension_loaded('couchbase') || !class_exists(\Couchbase\Cluster::class)) { - $this->couchbase = new NullStorage('Couchbase', 'Missing Couchbase PHP SDK 4.x. Please install ext-couchbase ^4.0'); + } elseif (!extension_loaded('couchbase') || !class_exists(\Couchbase\ClusterOptions::class)) { + $this->couchbase = new NullStorage('Couchbase', 'Missing Couchbase SDK. Install ext-couchbase and couchbase/couchbase ^4.4'); } else { try { $options = new \Couchbase\ClusterOptions(); From bf89eb423662146075ec81c6c456fc5c5781d5b6 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Tue, 17 Mar 2026 09:24:53 +0100 Subject: [PATCH 25/30] fix: add missing unsetAccessToken to CouchbaseDB, fix test getMessage bug CouchbaseDB was the only storage missing unsetAccessToken. Also fix AccessTokenTest calling getMessage() on non-NullStorage objects when the method check fails. --- src/OAuth2/Storage/CouchbaseDB.php | 7 +++++++ test/OAuth2/Storage/AccessTokenTest.php | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/OAuth2/Storage/CouchbaseDB.php b/src/OAuth2/Storage/CouchbaseDB.php index 55f33e4b1..09de37213 100755 --- a/src/OAuth2/Storage/CouchbaseDB.php +++ b/src/OAuth2/Storage/CouchbaseDB.php @@ -173,6 +173,13 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s return true; } + public function unsetAccessToken($access_token) + { + $this->deleteObjectByType('access_token_table', $access_token); + + return true; + } + /* AuthorizationCodeInterface */ public function getAuthorizationCode($code) { diff --git a/test/OAuth2/Storage/AccessTokenTest.php b/test/OAuth2/Storage/AccessTokenTest.php index ae6c76d45..9fccee844 100644 --- a/test/OAuth2/Storage/AccessTokenTest.php +++ b/test/OAuth2/Storage/AccessTokenTest.php @@ -60,12 +60,18 @@ public function testSetAccessToken(AccessTokenInterface $storage) #[DataProvider('provideStorage')] public function testUnsetAccessToken(AccessTokenInterface $storage) { - if ($storage instanceof NullStorage || !method_exists($storage, 'unsetAccessToken')) { + if ($storage instanceof NullStorage) { $this->markTestSkipped('Skipped Storage: ' . $storage->getMessage()); return; } + if (!method_exists($storage, 'unsetAccessToken')) { + $this->markTestSkipped('Skipped Storage: unsetAccessToken not implemented'); + + return; + } + // assert token we are about to unset does not exist $token = $storage->getAccessToken('revokabletoken'); $this->assertFalse($token); @@ -87,12 +93,18 @@ public function testUnsetAccessToken(AccessTokenInterface $storage) #[DataProvider('provideStorage')] public function testUnsetAccessTokenReturnsFalse(AccessTokenInterface $storage) { - if ($storage instanceof NullStorage || !method_exists($storage, 'unsetAccessToken')) { + if ($storage instanceof NullStorage) { $this->markTestSkipped('Skipped Storage: ' . $storage->getMessage()); return; } + if (!method_exists($storage, 'unsetAccessToken')) { + $this->markTestSkipped('Skipped Storage: unsetAccessToken not implemented'); + + return; + } + // assert token we are about to unset does not exist $token = $storage->getAccessToken('nonexistanttoken'); $this->assertFalse($token); From 7f92652f52df0b421ae0c33f9ba6774641574590 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Tue, 17 Mar 2026 09:29:11 +0100 Subject: [PATCH 26/30] fix: CouchbaseDB unsetAccessToken should return false for missing tokens --- src/OAuth2/Storage/CouchbaseDB.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/OAuth2/Storage/CouchbaseDB.php b/src/OAuth2/Storage/CouchbaseDB.php index 09de37213..c59a104ac 100755 --- a/src/OAuth2/Storage/CouchbaseDB.php +++ b/src/OAuth2/Storage/CouchbaseDB.php @@ -175,9 +175,13 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s public function unsetAccessToken($access_token) { - $this->deleteObjectByType('access_token_table', $access_token); + try { + $this->collection->remove($this->buildKey('access_token_table', $access_token)); - return true; + return true; + } catch (DocumentNotFoundException) { + return false; + } } /* AuthorizationCodeInterface */ From aa7580c58f0f20af076b401c1a433202339f6014 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Tue, 17 Mar 2026 10:03:37 +0100 Subject: [PATCH 27/30] chore: move mongodb/mongodb from require-dev to suggest --- .github/workflows/tests.yml | 2 +- composer.json | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6bddcc797..7808e35ce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,7 +55,7 @@ jobs: - name: Install composer dependencies run: | composer install --no-interaction - composer require couchbase/couchbase:^4.4 --no-interaction + composer require mongodb/mongodb:'^1.20 || ^2.0' couchbase/couchbase:^4.4 --no-interaction - name: Setup Couchbase run: | echo "Waiting for Couchbase..." diff --git a/composer.json b/composer.json index 7fdcea18b..66cc9cbd2 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,6 @@ "aws/aws-sdk-php": "^3.0", "firebase/php-jwt": "^7.0", "predis/predis": "^2.0", - "mongodb/mongodb": "^1.20 || ^2.0", "mroosz/php-cassandra": "^1.2", "phpstan/phpstan": "^2.1" }, From 91747b48b38a26f67849e27aa7fc5b9fb2a77e79 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Tue, 17 Mar 2026 10:28:26 +0100 Subject: [PATCH 28/30] ci: hardcode GHCR image namespace for cross-repo compatibility --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7808e35ce..a7bfadddd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: test: runs-on: ubuntu-latest container: - image: ghcr.io/${{ github.repository_owner }}/oauth2-php-ci:${{ matrix.php }} + image: ghcr.io/maksimovic/oauth2-php-ci:${{ matrix.php }} services: redis: image: redis @@ -79,7 +79,7 @@ jobs: phpstan: runs-on: ubuntu-latest container: - image: ghcr.io/${{ github.repository_owner }}/oauth2-php-ci:8.3 + image: ghcr.io/maksimovic/oauth2-php-ci:8.3 steps: - uses: actions/checkout@v5 - name: Install composer dependencies From 5605a1699dd977bf0ae0f326b5d267b23cd75613 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 20 Apr 2026 16:31:26 +0200 Subject: [PATCH 29/30] chore: rename package to maksimovic/oauth2-server-php Republish as our own package so we can ship the PHP 8.5 work without waiting on bshaffer/oauth2-server-php (upstream PR #1088 has been open for a month with no response). The "replace" block keeps this a drop-in for any consumer still requiring bshaffer/oauth2-server-php. --- composer.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 66cc9cbd2..ecdabb0cf 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "bshaffer/oauth2-server-php", + "name": "maksimovic/oauth2-server-php", "description":"OAuth2 Server for PHP", "keywords":["oauth","oauth2","auth"], "type":"library", @@ -11,7 +11,10 @@ "homepage":"http://brentertainment.com" } ], - "homepage": "http://github.com/bshaffer/oauth2-server-php", + "homepage": "http://github.com/maksimovic/oauth2-server-php", + "replace": { + "bshaffer/oauth2-server-php": "*" + }, "autoload": { "psr-0": { "OAuth2": "src/" } }, From d1220061dcc1096e01a1b6966760dc371ae07fe4 Mon Sep 17 00:00:00 2001 From: Oliver Maksimovic Date: Mon, 20 Apr 2026 16:44:54 +0200 Subject: [PATCH 30/30] ci: pin couchbase/couchbase to ~4.4.0 to match ext-couchbase in CI image The Docker CI image bakes a specific ext-couchbase build. Using ^4.4 in the on-the-fly composer require lets the resolver pull any newer minor (e.g. 4.5.0), which expects native functions the older baked extension doesn't export ("Call to undefined function Couchbase\\Extension\\clusterLabels()"). Pin to the 4.4.x line so the PHP wrapper and C extension stay compatible. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a7bfadddd..e754f8d8a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,7 +55,7 @@ jobs: - name: Install composer dependencies run: | composer install --no-interaction - composer require mongodb/mongodb:'^1.20 || ^2.0' couchbase/couchbase:^4.4 --no-interaction + composer require mongodb/mongodb:'^1.20 || ^2.0' couchbase/couchbase:'~4.4.0' --no-interaction - name: Setup Couchbase run: | echo "Waiting for Couchbase..."