diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index e04c197d..30938d14 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -27,20 +27,14 @@ jobs: lint: name: "Lint" - needs: ["coding-standards", "coverage"] runs-on: "ubuntu-latest" - strategy: - fail-fast: true - matrix: - php-version: [ '8.1', '8.2', '8.3', '8.4' ] steps: - uses: "actions/checkout@v4" - uses: "shivammathur/setup-php@v2" with: - php-version: "${{ matrix.php-version }}" + php-version: "latest" coverage: "none" - ini-values: "memory_limit=-1, zend.assertions=1, error_reporting=-1, display_errors=On" - tools: "composer:v2" + tools: "composer" - uses: "ramsey/composer-install@v3" - name: "Lint the PHP source code" run: "./vendor/bin/parallel-lint src test" @@ -60,19 +54,33 @@ jobs: - name: "Check coding standards" run: "./vendor/bin/phpcs" + static-analysis: + name: "Static Analysis" + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v4" + - uses: "shivammathur/setup-php@v2" + with: + php-version: "latest" + coverage: "none" + ini-values: "memory_limit=-1" + tools: "composer" + - uses: "ramsey/composer-install@v3" + - name: "Analyze code for errors" + run: "./vendor/bin/phpstan" + coverage: name: "Coverage" + needs: ["coding-standards", "lint", "static-analysis"] runs-on: "ubuntu-latest" steps: - uses: "actions/checkout@v4" - uses: "shivammathur/setup-php@v2" with: php-version: "latest" - coverage: "pcov" + coverage: "xdebug" ini-values: "memory_limit=-1, zend.assertions=1, error_reporting=-1, display_errors=On" tools: "composer" - - name: "Prepare for tests" - run: "mkdir -p build/logs" - uses: "ramsey/composer-install@v3" - name: "Run unit tests" run: "./vendor/bin/phpunit --colors=always --coverage-clover build/logs/clover.xml --coverage-text" @@ -81,7 +89,7 @@ jobs: unit-tests: name: "Unit Tests" - needs: ["lint"] + needs: ["coverage"] runs-on: "ubuntu-latest" strategy: fail-fast: true @@ -95,8 +103,6 @@ jobs: coverage: "none" ini-values: "memory_limit=-1, zend.assertions=1, error_reporting=-1, display_errors=On" tools: "composer" - - name: "Prepare for tests" - run: "mkdir -p build/logs" - uses: "ramsey/composer-install@v3" - name: "Run unit tests" run: "./vendor/bin/phpunit --colors=always --no-coverage" diff --git a/composer.json b/composer.json index 177c929b..08a60255 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,10 @@ "guzzlehttp/guzzle": "^7.4.5", "mockery/mockery": "^1.6", "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^10.5 || ^11.5", "ramsey/coding-standard": "^2.2", "squizlabs/php_codesniffer": "^3.11" @@ -54,17 +58,20 @@ "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true, - "ergebnis/composer-normalize": true + "ergebnis/composer-normalize": true, + "phpstan/extension-installer": true }, "sort-packages": true }, "scripts": { + "analyze": "phpstan analyze --memory-limit=1G", "cs": "phpcs", "cs-fix": "phpcbf", "lint": "parallel-lint src test", "test": [ "@lint", "@cs", + "@analyze", "phpunit --no-coverage" ] }, diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000..224dfbcb --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: max + treatPhpDocTypesAsCertain: false + paths: + - src + - test diff --git a/src/Grant/GrantFactory.php b/src/Grant/GrantFactory.php index 314bce26..7c0cb07d 100644 --- a/src/Grant/GrantFactory.php +++ b/src/Grant/GrantFactory.php @@ -19,7 +19,10 @@ use League\OAuth2\Client\Grant\Exception\InvalidGrantException; +use function gettype; use function is_object; +use function is_scalar; +use function is_string; use function is_subclass_of; use function sprintf; use function str_replace; @@ -83,9 +86,15 @@ protected function registerDefaultGrant(string $name) * Determines if a variable is a valid grant. * * @return bool + * + * @phpstan-assert-if-true class-string | AbstractGrant $class */ public function isGrant(mixed $class) { + if (!is_string($class) && !is_object($class)) { + return false; + } + return is_subclass_of($class, AbstractGrant::class); } @@ -95,14 +104,21 @@ public function isGrant(mixed $class) * @return void * * @throws InvalidGrantException + * + * @phpstan-assert class-string | AbstractGrant $class */ public function checkGrant(mixed $class) { if (!$this->isGrant($class)) { - throw new InvalidGrantException(sprintf( - 'Grant "%s" must extend AbstractGrant', - is_object($class) ? $class::class : $class, - )); + if (is_object($class)) { + $type = $class::class; + } elseif (is_scalar($class)) { + $type = $class; + } else { + $type = gettype($class); + } + + throw new InvalidGrantException(sprintf('Grant "%s" must extend AbstractGrant', $type)); } } } diff --git a/src/OptionProvider/HttpBasicAuthOptionProvider.php b/src/OptionProvider/HttpBasicAuthOptionProvider.php index e4837b37..efe5c1e4 100644 --- a/src/OptionProvider/HttpBasicAuthOptionProvider.php +++ b/src/OptionProvider/HttpBasicAuthOptionProvider.php @@ -20,6 +20,7 @@ use InvalidArgumentException; use function base64_encode; +use function is_string; use function sprintf; /** @@ -30,14 +31,20 @@ class HttpBasicAuthOptionProvider extends PostAuthOptionProvider { /** + * @return array{headers: array{content-type: string, Authorization: string}, body?: string} + * * @inheritdoc */ - public function getAccessTokenOptions($method, array $params) + public function getAccessTokenOptions(string $method, array $params): array { if (!isset($params['client_id']) || !isset($params['client_secret'])) { throw new InvalidArgumentException('clientId and clientSecret are required for http basic auth'); } + if (!is_string($params['client_id']) || !is_string($params['client_secret'])) { + throw new InvalidArgumentException('clientId and clientSecret must be string values'); + } + $encodedCredentials = base64_encode(sprintf('%s:%s', $params['client_id'], $params['client_secret'])); unset($params['client_id'], $params['client_secret']); diff --git a/src/OptionProvider/PostAuthOptionProvider.php b/src/OptionProvider/PostAuthOptionProvider.php index 3368dbe1..2afe6f67 100644 --- a/src/OptionProvider/PostAuthOptionProvider.php +++ b/src/OptionProvider/PostAuthOptionProvider.php @@ -28,9 +28,11 @@ class PostAuthOptionProvider implements OptionProviderInterface use QueryBuilderTrait; /** + * @return array{headers: array{content-type: string}, body?: string} + * * @inheritdoc */ - public function getAccessTokenOptions($method, array $params) + public function getAccessTokenOptions(string $method, array $params): array { $options = ['headers' => ['content-type' => 'application/x-www-form-urlencoded']]; diff --git a/src/Provider/AbstractProvider.php b/src/Provider/AbstractProvider.php index 62eb41df..9abf7707 100644 --- a/src/Provider/AbstractProvider.php +++ b/src/Provider/AbstractProvider.php @@ -39,11 +39,13 @@ use function array_merge; use function array_merge_recursive; +use function assert; use function base64_encode; use function bin2hex; use function hash; use function header; use function implode; +use function intdiv; use function is_array; use function is_string; use function json_decode; @@ -118,10 +120,15 @@ abstract class AbstractProvider * @param array $options An array of options to set on this * provider. Options include `clientId`, `clientSecret`, `redirectUri`, * and `state`. Individual providers may introduce more options, as needed. - * @param array $collaborators An array of collaborators that - * may be used to override this provider's default behavior. Collaborators - * include `grantFactory`, `requestFactory`, and `httpClient`. Individual - * providers may introduce more collaborators, as needed. + * @param array{ + * grantFactory?: GrantFactory, + * requestFactory?: RequestFactoryInterface, + * streamFactory?: StreamFactoryInterface, + * httpClient?: ClientInterface, + * optionProvider?: OptionProviderInterface, + * } $collaborators An array of collaborators that may be used to override + * this provider's default behavior. Individual providers may introduce + * more collaborators, as needed. */ public function __construct(array $options = [], array $collaborators = []) { @@ -355,7 +362,7 @@ abstract public function getResourceOwnerDetailsUrl(AccessToken $token); * Returns a new random string to use as the state parameter in an * authorization flow. * - * @param int $length Length of the random string to be generated. + * @param int<1, max> $length Length of the random string to be generated. * * @return string */ @@ -363,7 +370,10 @@ protected function getRandomState(int $length = 32) { // Converting bytes to hex will always double length. Hence, we can reduce // the amount of bytes by half to produce the correct length. - return bin2hex(random_bytes($length / 2)); + $length = intdiv($length, 2); + assert($length >= 1); + + return bin2hex(random_bytes($length)); } /** @@ -371,20 +381,14 @@ protected function getRandomState(int $length = 32) * hashed as code_challenge parameters in an authorization flow. * Must be between 43 and 128 characters long. * - * @param int $length Length of the random string to be generated. + * @param int<1, max> $length Length of the random string to be generated. * @return string */ protected function getRandomPkceCode(int $length = 64) { - return substr( - strtr( - base64_encode(random_bytes($length)), - '+/', - '-_', - ), - 0, - $length, - ); + assert($length >= 1); + + return substr(strtr(base64_encode(random_bytes($length)), '+/', '-_'), 0, $length); } /** @@ -446,6 +450,7 @@ protected function getAuthorizationParameters(array $options) } // Store the state as it may need to be accessed later on. + assert(is_string($options['state'])); $this->state = $options['state']; $pkceMethod = $this->getPkceMethod(); @@ -570,6 +575,7 @@ protected function getAccessTokenMethod() */ protected function getAccessTokenResourceOwnerId() { + /** @var string | null */ return static::ACCESS_TOKEN_RESOURCE_OWNER_ID; } @@ -625,7 +631,11 @@ protected function getAccessTokenUrl(array $params) /** * Returns a prepared request for requesting an access token. * - * @param array $params Query string parameters + * @param array{ + * headers?: array, + * version?: string, + * body?: string, + * } $params Any of "headers", "body", and "version". * * @return RequestInterface */ @@ -633,6 +643,14 @@ protected function getAccessTokenRequest(array $params) { $method = $this->getAccessTokenMethod(); $url = $this->getAccessTokenUrl($params); + + /** + * @var array{ + * headers?: array, + * version?: string, + * body?: string, + * } $options + */ $options = $this->optionProvider->getAccessTokenOptions($this->getAccessTokenMethod(), $params); return $this->getRequest($method, $url, $options); @@ -672,14 +690,25 @@ public function getAccessToken(mixed $grant, array $options = []) $params['code_verifier'] = $this->pkceCode; } + /** + * @var array{ + * headers?: array, + * version?: string, + * body?: string, + * } $params + */ $params = $grant->prepareRequestParameters($params, $options); + $request = $this->getAccessTokenRequest($params); + + /** @var array $response */ $response = $this->getParsedResponse($request); if (is_array($response) === false) { throw new UnexpectedValueException( 'Invalid response received from Authorization Server. Expected JSON.', ); } + $prepared = $this->prepareAccessTokenResponse($response); return $this->createAccessToken($prepared, $grant); @@ -688,7 +717,11 @@ public function getAccessToken(mixed $grant, array $options = []) /** * Returns a PSR-7 request instance that is not authenticated. * - * @param array $options + * @param array{ + * headers?: array, + * version?: string, + * body?: string, + * } $options Any of "headers", "body", and "version". * * @return RequestInterface */ @@ -700,7 +733,11 @@ public function getRequest(string $method, string $url, array $options = []) /** * Returns an authenticated PSR-7 request instance. * - * @param array $options Any of "headers", "body", and "protocolVersion". + * @param array{ + * headers?: array, + * version?: string, + * body?: string, + * } $options Any of "headers", "body", and "version". * * @return RequestInterface */ @@ -716,7 +753,11 @@ public function getAuthenticatedRequest( /** * Creates a PSR-7 request instance. * - * @param array $options + * @param array{ + * headers?: array, + * version?: string, + * body?: string, + * } $options * * @return RequestInterface */ @@ -730,6 +771,13 @@ protected function createRequest( 'headers' => $this->getHeaders($token), ]; + /** + * @var array{ + * headers: array, + * version?: string, + * body?: string, + * } $options + */ $options = array_merge_recursive($defaults, $options); $requestFactory = $this->getRequestFactory(); $streamFactory = $this->getStreamFactory(); @@ -780,6 +828,7 @@ public function getParsedResponse(RequestInterface $request) $response = $e->getResponse(); } + /** @var array $parsed */ $parsed = $this->parseResponse($response); $this->checkResponse($response, $parsed); @@ -807,6 +856,7 @@ protected function parseJson(string $content) )); } + /** @var array */ return $content; } @@ -937,7 +987,7 @@ public function getResourceOwner(AccessToken $token) /** * Requests resource owner details. * - * @return mixed + * @return array * * @throws ClientExceptionInterface * @throws IdentityProviderException @@ -957,6 +1007,7 @@ protected function fetchResourceOwnerDetails(AccessToken $token) ); } + /** @var array */ return $response; } @@ -981,7 +1032,7 @@ protected function getDefaultHeaders() * No default is provided, providers must overload this method to activate * authorization headers. * - * @param mixed $token Either a string or an access token instance + * @param AccessTokenInterface | string | null $token Either a string or an access token instance * * @return array */ @@ -995,11 +1046,11 @@ protected function getAuthorizationHeaders(AccessTokenInterface | string | null * * The request will be authenticated if an access token is provided. * - * @param mixed $token object or string + * @param AccessTokenInterface | string | null $token object or string * * @return array */ - public function getHeaders(mixed $token = null) + public function getHeaders(AccessTokenInterface | string | null $token = null) { if ($token) { return array_merge( diff --git a/src/Provider/GenericProvider.php b/src/Provider/GenericProvider.php index 7c5da40f..dbf6cdab 100644 --- a/src/Provider/GenericProvider.php +++ b/src/Provider/GenericProvider.php @@ -18,10 +18,15 @@ namespace League\OAuth2\Client\Provider; use InvalidArgumentException; +use League\OAuth2\Client\Grant\GrantFactory; +use League\OAuth2\Client\OptionProvider\OptionProviderInterface; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Token\AccessToken; use League\OAuth2\Client\Tool\BearerAuthorizationTrait; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamFactoryInterface; use function array_diff_key; use function array_flip; @@ -50,6 +55,7 @@ class GenericProvider extends AbstractProvider /** * @var list | null + * @phpstan-ignore property.unusedType */ private ?array $scopes = null; @@ -57,11 +63,21 @@ class GenericProvider extends AbstractProvider private string $responseError = 'error'; private string $responseCode; private string $responseResourceOwnerId = 'id'; + + /** + * @phpstan-ignore property.unusedType + */ private ?string $pkceMethod = null; /** * @param array $options - * @param array $collaborators + * @param array{ + * grantFactory?: GrantFactory, + * requestFactory?: RequestFactoryInterface, + * streamFactory?: StreamFactoryInterface, + * httpClient?: ClientInterface, + * optionProvider?: OptionProviderInterface, + * } $collaborators */ public function __construct(array $options = [], array $collaborators = []) { @@ -162,7 +178,7 @@ public function getResourceOwnerDetailsUrl(AccessToken $token) */ public function getDefaultScopes() { - return $this->scopes; + return $this->scopes ?? []; } /** @@ -207,7 +223,10 @@ protected function checkResponse(ResponseInterface $response, $data) if (!is_string($error)) { $error = var_export($error, true); } + + /** @var int | string $code */ $code = isset($this->responseCode) && isset($data[$this->responseCode]) ? $data[$this->responseCode] : 0; + if (!is_int($code)) { $code = intval($code); } diff --git a/src/Token/AccessToken.php b/src/Token/AccessToken.php index 627c09ad..a706a422 100644 --- a/src/Token/AccessToken.php +++ b/src/Token/AccessToken.php @@ -18,11 +18,15 @@ namespace League\OAuth2\Client\Token; use InvalidArgumentException; +use ReturnTypeWillChange; use RuntimeException; use function array_diff_key; use function array_flip; +use function is_int; use function is_numeric; +use function is_scalar; +use function is_string; use function time; /** @@ -83,17 +87,20 @@ public function getTimeNow() */ public function __construct(array $options = []) { - if (!isset($options['access_token'])) { + if (!isset($options['access_token']) || !is_string($options['access_token'])) { throw new InvalidArgumentException('Required option not passed: "access_token"'); } $this->accessToken = $options['access_token']; - if (isset($options['resource_owner_id'])) { + if ( + isset($options['resource_owner_id']) + && (is_string($options['resource_owner_id']) || is_int($options['resource_owner_id'])) + ) { $this->resourceOwnerId = $options['resource_owner_id']; } - if (isset($options['refresh_token'])) { + if (isset($options['refresh_token']) && is_string($options['refresh_token'])) { $this->refreshToken = $options['refresh_token']; } @@ -105,11 +112,11 @@ public function __construct(array $options = []) throw new InvalidArgumentException('expires_in value must be an integer'); } - $this->expires = $options['expires_in'] !== 0 ? $this->getTimeNow() + $options['expires_in'] : 0; + $this->expires = $options['expires_in'] !== 0 ? $this->getTimeNow() + (int) $options['expires_in'] : 0; } elseif (isset($options['expires'])) { // Some providers supply the seconds until expiration rather than // the exact timestamp. Take a best guess at which we received. - $expires = (int) $options['expires']; + $expires = is_scalar($options['expires']) ? (int) $options['expires'] : 0; if (!$this->isExpirationTimestamp($expires)) { $expires += $this->getTimeNow(); @@ -163,7 +170,7 @@ public function getRefreshToken() /** * @inheritdoc */ - public function setRefreshToken($refreshToken) + public function setRefreshToken(string $refreshToken) { $this->refreshToken = $refreshToken; } @@ -217,6 +224,7 @@ public function __toString() /** * @inheritdoc */ + #[ReturnTypeWillChange] public function jsonSerialize() { $parameters = $this->values; diff --git a/src/Token/ResourceOwnerAccessTokenInterface.php b/src/Token/ResourceOwnerAccessTokenInterface.php index 83324b22..5d217aae 100644 --- a/src/Token/ResourceOwnerAccessTokenInterface.php +++ b/src/Token/ResourceOwnerAccessTokenInterface.php @@ -22,7 +22,7 @@ interface ResourceOwnerAccessTokenInterface extends AccessTokenInterface /** * Returns the resource owner identifier, if defined. * - * @return string | null + * @return int | string | null */ public function getResourceOwnerId(); } diff --git a/src/Tool/ArrayAccessorTrait.php b/src/Tool/ArrayAccessorTrait.php index f3b7c712..46836624 100644 --- a/src/Tool/ArrayAccessorTrait.php +++ b/src/Tool/ArrayAccessorTrait.php @@ -38,7 +38,7 @@ trait ArrayAccessorTrait */ private function getValueByKey(array $data, mixed $key, mixed $default = null) { - if (!is_string($key) || !isset($key) || !count($data)) { + if (!is_string($key) || !count($data)) { return $default; } diff --git a/src/Tool/MacAuthorizationTrait.php b/src/Tool/MacAuthorizationTrait.php index f2c8f375..14c293c7 100644 --- a/src/Tool/MacAuthorizationTrait.php +++ b/src/Tool/MacAuthorizationTrait.php @@ -17,7 +17,6 @@ namespace League\OAuth2\Client\Tool; -use League\OAuth2\Client\Token\AccessToken; use League\OAuth2\Client\Token\AccessTokenInterface; use function compact; @@ -37,7 +36,7 @@ trait MacAuthorizationTrait * * @return string */ - abstract protected function getTokenId(AccessToken $token); + abstract protected function getTokenId(AccessTokenInterface | string | null $token); /** * Returns the MAC signature for the current request. diff --git a/src/Tool/ProviderRedirectTrait.php b/src/Tool/ProviderRedirectTrait.php index 0e10f6c6..0eea8145 100644 --- a/src/Tool/ProviderRedirectTrait.php +++ b/src/Tool/ProviderRedirectTrait.php @@ -24,6 +24,7 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use RuntimeException; trait ProviderRedirectTrait { @@ -36,10 +37,10 @@ trait ProviderRedirectTrait * Retrieves a response for a given request and retrieves subsequent * responses, with authorization headers, if a redirect is detected. * - * @return ResponseInterface + * @return ResponseInterface | null * - * @throws BadResponseException * @throws ClientExceptionInterface + * @throws RuntimeException */ protected function followRequestRedirects(RequestInterface $request) { @@ -48,9 +49,7 @@ protected function followRequestRedirects(RequestInterface $request) while ($attempts < $this->redirectLimit) { $attempts++; - $response = $this->getHttpClient()->send($request, [ - 'allow_redirects' => false, - ]); + $response = $this->getHttpClient()->sendRequest($request); if ($this->isRedirect($response)) { $redirectUrl = new Uri($response->getHeader('Location')[0]); @@ -98,7 +97,7 @@ protected function isRedirect(ResponseInterface $response) * WARNING: This method does not attempt to catch exceptions caused by HTTP * errors! It is recommended to wrap this method in a try/catch block. * - * @return ResponseInterface + * @return ResponseInterface | null * * @throws ClientExceptionInterface */ diff --git a/src/Tool/RequiredParameterTrait.php b/src/Tool/RequiredParameterTrait.php index 7ef96661..0c9746fb 100644 --- a/src/Tool/RequiredParameterTrait.php +++ b/src/Tool/RequiredParameterTrait.php @@ -18,7 +18,6 @@ namespace League\OAuth2\Client\Tool; use BadMethodCallException; -use InvalidArgumentException; use function sprintf; @@ -54,7 +53,7 @@ private function checkRequiredParameter(string $name, array $params) * * @return void * - * @throws InvalidArgumentException + * @throws BadMethodCallException */ private function checkRequiredParameters(array $names, array $params) { diff --git a/test/src/Grant/AuthorizationCodeTest.php b/test/src/Grant/AuthorizationCodeTest.php index 1fd5e0a4..99bf3889 100644 --- a/test/src/Grant/AuthorizationCodeTest.php +++ b/test/src/Grant/AuthorizationCodeTest.php @@ -22,7 +22,7 @@ public static function providerGetAccessToken(): array protected function getParamExpectation(): Closure { - return fn ($body) => isset($body['grant_type']) + return fn (array $body) => isset($body['grant_type']) && $body['grant_type'] === 'authorization_code' && isset($body['code']); } diff --git a/test/src/Grant/ClientCredentialsTest.php b/test/src/Grant/ClientCredentialsTest.php index 7cc65399..552b84f8 100644 --- a/test/src/Grant/ClientCredentialsTest.php +++ b/test/src/Grant/ClientCredentialsTest.php @@ -21,7 +21,7 @@ public static function providerGetAccessToken(): array protected function getParamExpectation(): Closure { - return fn ($body) => isset($body['grant_type']) + return fn (array $body) => isset($body['grant_type']) && $body['grant_type'] === 'client_credentials'; } diff --git a/test/src/Grant/GrantFactoryTest.php b/test/src/Grant/GrantFactoryTest.php index c959fc43..344a8ae4 100644 --- a/test/src/Grant/GrantFactoryTest.php +++ b/test/src/Grant/GrantFactoryTest.php @@ -72,6 +72,8 @@ public function testIsGrant(): void $grant = $factory->getGrant('password'); $this->assertTrue($factory->isGrant($grant)); + + /** @phpstan-ignore method.impossibleType */ $this->assertFalse($factory->isGrant('stdClass')); } @@ -89,6 +91,8 @@ public function testCheckGrantInvalidFails(): void $this->expectException(InvalidGrantException::class); $factory = new GrantFactory(); + + /** @phpstan-ignore method.impossibleType */ $factory->checkGrant('stdClass'); } } diff --git a/test/src/Grant/GrantTestCase.php b/test/src/Grant/GrantTestCase.php index 25b771d7..42d389ab 100644 --- a/test/src/Grant/GrantTestCase.php +++ b/test/src/Grant/GrantTestCase.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; @@ -87,7 +88,7 @@ public function testGetAccessToken(string $grant, array $params = []): void $client ->shouldReceive('sendRequest') ->once() - ->withArgs(function ($request) { + ->withArgs(function (RequestInterface $request) { parse_str((string) $request->getBody(), $body); return call_user_func($this->getParamExpectation(), $body); diff --git a/test/src/Grant/PasswordTest.php b/test/src/Grant/PasswordTest.php index ee49be4f..93630115 100644 --- a/test/src/Grant/PasswordTest.php +++ b/test/src/Grant/PasswordTest.php @@ -22,7 +22,7 @@ public static function providerGetAccessToken(): array protected function getParamExpectation(): Closure { - return fn ($body) => isset($body['grant_type']) + return fn (array $body) => isset($body['grant_type']) && $body['grant_type'] === 'password' && isset($body['username']) && isset($body['password']) diff --git a/test/src/Grant/RefreshTokenTest.php b/test/src/Grant/RefreshTokenTest.php index d4d54b59..eaccbeed 100644 --- a/test/src/Grant/RefreshTokenTest.php +++ b/test/src/Grant/RefreshTokenTest.php @@ -22,7 +22,7 @@ public static function providerGetAccessToken(): array protected function getParamExpectation(): Closure { - return fn ($body) => isset($body['grant_type']) + return fn (array $body) => isset($body['grant_type']) && $body['grant_type'] === 'refresh_token'; } diff --git a/test/src/OptionProvider/PostAuthOptionProviderTest.php b/test/src/OptionProvider/PostAuthOptionProviderTest.php index 8eb5857e..799bed99 100644 --- a/test/src/OptionProvider/PostAuthOptionProviderTest.php +++ b/test/src/OptionProvider/PostAuthOptionProviderTest.php @@ -24,6 +24,6 @@ public function testGetAccessTokenOptions(): void ]); $this->assertArrayHasKey('headers', $options); - $this->assertEquals('client_id=test&client_secret=test', $options['body']); + $this->assertEquals('client_id=test&client_secret=test', $options['body'] ?? null); } } diff --git a/test/src/Provider/AbstractProviderTest.php b/test/src/Provider/AbstractProviderTest.php index d191459e..5ea69b01 100644 --- a/test/src/Provider/AbstractProviderTest.php +++ b/test/src/Provider/AbstractProviderTest.php @@ -16,8 +16,9 @@ use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Test\Provider\Fake as MockProvider; use League\OAuth2\Client\Token\AccessToken; -use League\OAuth2\Client\Token\AccessTokenInterface; +use League\OAuth2\Client\Token\ResourceOwnerAccessTokenInterface; use Mockery; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -45,6 +46,8 @@ class AbstractProviderTest extends TestCase { + use MockeryPHPUnitIntegration; + protected function getMockProvider(): MockProvider { return new MockProvider([ @@ -215,7 +218,7 @@ public function testSetRedirectHandler(): void $testFunction = false; $state = false; - $callback = function ($url, $provider) use (&$testFunction, &$state) { + $callback = function (string $url, AbstractProvider $provider) use (&$testFunction, &$state) { $testFunction = $url; $state = $provider->getState(); }; @@ -267,6 +270,8 @@ public function testGetUserProperties(?string $name = null, ?string $email = nul ]); $provider->setHttpClient($client); + + /** @var MockProvider\User $user */ $user = $provider->getResourceOwner($token); $url = $provider->getResourceOwnerDetailsUrl($token); @@ -280,7 +285,7 @@ public function testGetUserProperties(?string $name = null, ?string $email = nul $client ->shouldHaveReceived('sendRequest') ->once() - ->withArgs(fn ($request) => $request->getMethod() === 'GET' + ->withArgs(fn (RequestInterface $request) => $request->getMethod() === 'GET' && $request->hasHeader('Authorization') && (string) $request->getUri() === $url); } @@ -324,7 +329,7 @@ public function testGetUserPropertiesThrowsExceptionWhenNonJsonResponseIsReceive } /** - * @return array + * @return array */ public static function userPropertyProvider(): array { @@ -352,14 +357,14 @@ public function testScopesOverloadedDuringAuthorize(): void $url = $provider->getAuthorizationUrl(); - parse_str(parse_url($url, PHP_URL_QUERY), $qs); + parse_str((string) parse_url($url, PHP_URL_QUERY), $qs); $this->assertArrayHasKey('scope', $qs); $this->assertSame('test', $qs['scope']); $url = $provider->getAuthorizationUrl(['scope' => ['foo', 'bar']]); - parse_str(parse_url($url, PHP_URL_QUERY), $qs); + parse_str((string) parse_url($url, PHP_URL_QUERY), $qs); $this->assertArrayHasKey('scope', $qs); $this->assertSame('foo,bar', $qs['scope']); @@ -374,8 +379,8 @@ public function testAuthorizationStateIsRandom(): void // Repeat the test multiple times to verify state changes $url = $provider->getAuthorizationUrl(); - parse_str(parse_url($url, PHP_URL_QUERY), $qs); - + parse_str((string) parse_url($url, PHP_URL_QUERY), $qs); + $this->assertIsString($qs['state'] ?? null); $this->assertTrue(preg_match('/^[a-zA-Z0-9\/+]{32}$/', $qs['state']) === 1); $this->assertNotSame($qs['state'], $last); @@ -402,7 +407,7 @@ public function testPkceMethod(string $pkceMethod, string $pkceCode, string $exp $url = $provider->getAuthorizationUrl(); $this->assertSame($pkceCode, $provider->getPkceCode()); - parse_str(parse_url($url, PHP_URL_QUERY), $qs); + parse_str((string) parse_url($url, PHP_URL_QUERY), $qs); $this->assertArrayHasKey('code_challenge', $qs); $this->assertArrayHasKey('code_challenge_method', $qs); $this->assertSame($pkceMethod, $qs['code_challenge_method']); @@ -442,7 +447,7 @@ public function testPkceMethod(string $pkceMethod, string $pkceCode, string $exp $client ->shouldHaveReceived('sendRequest') ->once() - ->withArgs(function ($request) use ($pkceCode) { + ->withArgs(function (RequestInterface $request) use ($pkceCode) { parse_str((string) $request->getBody(), $body); return $body['code_verifier'] === $pkceCode; @@ -487,7 +492,8 @@ public function testPkceCodeIsRandom(): void // Repeat the test multiple times to verify code_challenge changes $url = $provider->getAuthorizationUrl(); - parse_str(parse_url($url, PHP_URL_QUERY), $qs); + parse_str((string) parse_url($url, PHP_URL_QUERY), $qs); + $this->assertIsString($qs['code_challenge'] ?? null); $this->assertTrue(preg_match('/^[a-zA-Z0-9-_]{43}$/', $qs['code_challenge']) === 1); $this->assertNotSame($qs['code_challenge'], $last); $last = $qs['code_challenge']; @@ -497,6 +503,8 @@ public function testPkceCodeIsRandom(): void public function testPkceMethodIsDisabledByDefault(): void { $provider = $this->getAbstractProviderMock(); + $provider->shouldAllowMockingProtectedMethods(); + /** @phpstan-ignore method.protected */ $pkceMethod = $provider->getPkceMethod(); $this->assertNull($pkceMethod); } @@ -561,7 +569,7 @@ public function testErrorResponsesCanBeCustomizedAtTheProvider(): void $client ->shouldHaveReceived('sendRequest') ->once() - ->withArgs(fn ($request) => $request->getMethod() === $method + ->withArgs(fn (RequestInterface $request) => $request->getMethod() === $method && (string) $request->getUri() === $url); } @@ -728,7 +736,7 @@ public function testGetAccessToken(string $method): void $provider->setHttpClient($client); $token = $provider->getAccessToken($grant, ['code' => 'mock_authorization_code']); - $this->assertInstanceOf(AccessTokenInterface::class, $token); + $this->assertInstanceOf(ResourceOwnerAccessTokenInterface::class, $token); $this->assertSame($rawResponse['resource_owner_id'], $token->getResourceOwnerId()); $this->assertSame($rawResponse['access_token'], $token->getToken()); @@ -737,7 +745,7 @@ public function testGetAccessToken(string $method): void $client ->shouldHaveReceived('sendRequest') ->once() - ->withArgs(fn ($request) => $request->getMethod() === $provider->getAccessTokenMethod() + ->withArgs(fn (RequestInterface $request) => $request->getMethod() === $provider->getAccessTokenMethod() && (string) $request->getUri() === $provider->getBaseAccessTokenUrl([])); } @@ -868,7 +876,9 @@ protected function getAbstractProviderMock(): AbstractProvider & MockInterface public function testDefaultAccessTokenMethod(): void { $provider = $this->getAbstractProviderMock(); + $provider->shouldAllowMockingProtectedMethods(); + /** @phpstan-ignore method.protected */ $method = $provider->getAccessTokenMethod(); $expectedMethod = 'POST'; @@ -878,8 +888,11 @@ public function testDefaultAccessTokenMethod(): void public function testDefaultPrepareAccessTokenResponse(): void { $provider = Mockery::mock(Fake\ProviderWithAccessTokenResourceOwnerId::class)->makePartial(); + $provider->shouldAllowMockingProtectedMethods(); $result = ['user_id' => uniqid()]; + + /** @phpstan-ignore method.protected */ $newResult = $provider->prepareAccessTokenResponse($result); $this->assertArrayHasKey('resource_owner_id', $newResult); @@ -922,6 +935,8 @@ public function testPrepareAccessTokenResponseWithDotNotation(): void ->andReturn('user.id'); $result = ['user' => ['id' => uniqid()]]; + + /** @phpstan-ignore method.protected */ $newResult = $provider->prepareAccessTokenResponse($result); $this->assertArrayHasKey('resource_owner_id', $newResult); @@ -937,6 +952,8 @@ public function testPrepareAccessTokenResponseWithInvalidKeyType(): void ->andReturn(new stdClass()); $result = ['user_id' => uniqid()]; + + /** @phpstan-ignore method.protected */ $newResult = $provider->prepareAccessTokenResponse($result); $this->assertFalse(isset($newResult['resource_owner_id'])); @@ -951,6 +968,8 @@ public function testPrepareAccessTokenResponseWithInvalidKeyPath(): void ->andReturn('user.name'); $result = ['user' => ['id' => uniqid()]]; + + /** @phpstan-ignore method.protected */ $newResult = $provider->prepareAccessTokenResponse($result); $this->assertFalse(isset($newResult['resource_owner_id'])); @@ -959,7 +978,9 @@ public function testPrepareAccessTokenResponseWithInvalidKeyPath(): void public function testDefaultAuthorizationHeaders(): void { $provider = $this->getAbstractProviderMock(); + $provider->shouldAllowMockingProtectedMethods(); + /** @phpstan-ignore method.protected */ $headers = $provider->getAuthorizationHeaders(); $this->assertEquals([], $headers); diff --git a/test/src/Provider/Exception/IdentityProviderExceptionTest.php b/test/src/Provider/Exception/IdentityProviderExceptionTest.php index 900b3aa6..53308118 100644 --- a/test/src/Provider/Exception/IdentityProviderExceptionTest.php +++ b/test/src/Provider/Exception/IdentityProviderExceptionTest.php @@ -9,7 +9,7 @@ class IdentityProviderExceptionTest extends TestCase { - public function testIdentityProviderException() + public function testIdentityProviderException(): void { $result = [ 'error' => 'message', diff --git a/test/src/Provider/Fake.php b/test/src/Provider/Fake.php index 6c0ffde2..75c9f54a 100644 --- a/test/src/Provider/Fake.php +++ b/test/src/Provider/Fake.php @@ -10,6 +10,10 @@ use League\OAuth2\Client\Tool\BearerAuthorizationTrait; use Psr\Http\Message\ResponseInterface; +use function assert; +use function is_int; +use function is_string; + class Fake extends AbstractProvider { use BearerAuthorizationTrait; @@ -105,6 +109,8 @@ protected function getRandomPkceCode($length = 64) } /** + * @param array{id?: mixed, email?: string, name?: string} $response + * * @inheritDoc */ protected function createResourceOwner(array $response, AccessToken $token) @@ -118,6 +124,9 @@ protected function createResourceOwner(array $response, AccessToken $token) protected function checkResponse(ResponseInterface $response, $data) { if (isset($data['error'])) { + assert(is_string($data['error'])); + assert(is_int($data['code'])); + throw new IdentityProviderException($data['error'], $data['code'], $data); } } diff --git a/test/src/Provider/Fake/ProviderWithAccessTokenHints.php b/test/src/Provider/Fake/ProviderWithAccessTokenHints.php index a826d039..297981a1 100644 --- a/test/src/Provider/Fake/ProviderWithAccessTokenHints.php +++ b/test/src/Provider/Fake/ProviderWithAccessTokenHints.php @@ -7,6 +7,7 @@ use League\OAuth2\Client\Provider\GenericProvider; use League\OAuth2\Client\Provider\GenericResourceOwner; use League\OAuth2\Client\Token\AccessToken; +use League\OAuth2\Client\Token\AccessTokenInterface; use League\OAuth2\Client\Tool\MacAuthorizationTrait; class ProviderWithAccessTokenHints extends GenericProvider @@ -26,7 +27,7 @@ public function getResourceOwnerDetailsUrl(AccessToken $token) */ protected function createResourceOwner(array $response, AccessToken $token) { - return new GenericResourceOwner($response, $token->getResourceOwnerId()); + return new GenericResourceOwner($response, (string) $token->getResourceOwnerId()); } /** @@ -45,18 +46,12 @@ protected function fetchResourceOwnerDetails(AccessToken $token) return []; } - /** - * @inheritDoc - */ - protected function getTokenId(AccessToken $token) + protected function getTokenId(AccessTokenInterface | string | null $token): string { return 'fake_token_id'; } - /** - * @inheritDoc - */ - protected function getMacSignature($id, $ts, $nonce) + protected function getMacSignature(string $id, int $ts, string $nonce): string { return 'fake_mac_signature'; } diff --git a/test/src/Provider/Fake/User.php b/test/src/Provider/Fake/User.php index 23060961..c86dcf59 100644 --- a/test/src/Provider/Fake/User.php +++ b/test/src/Provider/Fake/User.php @@ -9,12 +9,12 @@ class User implements ResourceOwnerInterface { /** - * @var array + * @var array{id?: mixed, email?: string, name?: string} */ protected array $response; /** - * @param array $response + * @param array{id?: mixed, email?: string, name?: string} $response */ public function __construct(array $response) { @@ -26,23 +26,23 @@ public function __construct(array $response) */ public function getId() { - return $this->response['id']; + return $this->response['id'] ?? null; } public function getUserEmail(): ?string { - return $this->response['email']; + return $this->response['email'] ?? null; } public function getUserScreenName(): ?string { - return $this->response['name']; + return $this->response['name'] ?? null; } /** * @inheritDoc */ - public function toArray() + public function toArray(): array { return $this->response; } diff --git a/test/src/Provider/Generic.php b/test/src/Provider/Generic.php index 378a364d..2bd6f0f3 100644 --- a/test/src/Provider/Generic.php +++ b/test/src/Provider/Generic.php @@ -4,14 +4,25 @@ namespace League\OAuth2\Client\Test\Provider; +use League\OAuth2\Client\Grant\GrantFactory; +use League\OAuth2\Client\OptionProvider\OptionProviderInterface; use League\OAuth2\Client\Provider\GenericProvider; use League\OAuth2\Client\Token\AccessToken; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; class Generic extends GenericProvider { /** * @param array $options - * @param array $collaborators + * @param array{ + * grantFactory?: GrantFactory, + * requestFactory?: RequestFactoryInterface, + * streamFactory?: StreamFactoryInterface, + * httpClient?: ClientInterface, + * optionProvider?: OptionProviderInterface, + * } $collaborators */ public function __construct(array $options = [], array $collaborators = []) { diff --git a/test/src/Provider/GenericProviderTest.php b/test/src/Provider/GenericProviderTest.php index 99fc5d84..658813a0 100644 --- a/test/src/Provider/GenericProviderTest.php +++ b/test/src/Provider/GenericProviderTest.php @@ -163,7 +163,7 @@ public function testCheckResponse(): void * @param array $extraOptions Any extra options to configure the generic provider with. */ #[DataProvider('checkResponseThrowsExceptionProvider')] - public function testCheckResponseThrowsException(array $error, array $extraOptions = []) + public function testCheckResponseThrowsException(array $error, array $extraOptions = []): void { $response = Mockery::mock(ResponseInterface::class); diff --git a/test/src/Token/AccessTokenTest.php b/test/src/Token/AccessTokenTest.php index aa5ced5a..1126db80 100644 --- a/test/src/Token/AccessTokenTest.php +++ b/test/src/Token/AccessTokenTest.php @@ -271,7 +271,7 @@ public function testJsonSerializable(): void $token = $this->getAccessToken($options); $jsonToken = json_encode($token); - $this->assertEquals($options, json_decode($jsonToken, true)); + $this->assertEquals($options, json_decode((string) $jsonToken, true)); self::tearDownForBackwardsCompatibility(); } diff --git a/test/src/Tool/ProviderRedirectTraitTest.php b/test/src/Tool/ProviderRedirectTraitTest.php index c35e6302..227627a2 100644 --- a/test/src/Tool/ProviderRedirectTraitTest.php +++ b/test/src/Tool/ProviderRedirectTraitTest.php @@ -89,7 +89,7 @@ public function testClientLimitsRedirectResponse(): void $client = Mockery::mock(ClientInterface::class); $client - ->shouldReceive('send') + ->shouldReceive('sendRequest') ->times($redirectLimit) ->andReturn($response); @@ -119,7 +119,7 @@ public function testClientLimitsRedirectLoopWhenRedirectNotDetected(): void $client = Mockery::mock(ClientInterface::class); $client - ->shouldReceive('send') + ->shouldReceive('sendRequest') ->once() ->andReturn($response); @@ -146,7 +146,7 @@ public function testClientErrorReturnsResponse(): void $client = Mockery::mock(ClientInterface::class); $client - ->shouldReceive('send') + ->shouldReceive('sendRequest') ->andThrow($exception); $this->setHttpClient($client); diff --git a/test/src/Tool/QueryBuilderTraitTest.php b/test/src/Tool/QueryBuilderTraitTest.php index d7e0394c..e30f3d7b 100644 --- a/test/src/Tool/QueryBuilderTraitTest.php +++ b/test/src/Tool/QueryBuilderTraitTest.php @@ -13,7 +13,7 @@ class QueryBuilderTraitTest extends TestCase { use QueryBuilderTrait; - public function testBuildQueryString() + public function testBuildQueryString(): void { ini_set('arg_separator.output', '&');