diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5cdd0c6..f6d4a68 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -10,57 +10,22 @@ on: jobs: php-tests: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest strategy: matrix: - php: [8.1, 8.0, 7.4, 7.3] - laravel: [9.*, 8.*, 7.*, 6.*, 5.8.*, 5.7.*, 5.6.*] - os: [ubuntu-latest] - include: - - laravel: 9.* - testbench: 7.* - - laravel: 8.* - testbench: 6.* - - laravel: 7.* - testbench: 5.* - - laravel: 6.* - testbench: 4.* - - laravel: 5.8.* - testbench: 3.8.* - - laravel: 5.7.* - testbench: 3.7.* - - laravel: 5.6.* - testbench: 3.6.* - exclude: - - laravel: 9.* - php: 7.3 - - laravel: 9.* - php: 7.4 - - laravel: 5.7.* - php: 7.4 - - laravel: 5.6.* - php: 7.4 - - laravel: 5.5.* - php: 7.4 - - laravel: 5.8.* - php: 8.0 - - laravel: 5.7.* - php: 8.0 - - laravel: 5.6.* - php: 8.0 - - laravel: 5.6.* - php: 8.1 - - laravel: 5.7.* - php: 8.1 - - laravel: 5.8.* - php: 8.1 - - laravel: 6.* - php: 8.1 - - laravel: 7.* - php: 8.1 + payload: + - { queue: 'github-actions-laravel9-php81', laravel: '9.*', php: '8.1', 'testbench': '7.*'} + - { queue: 'github-actions-laravel9-php80', laravel: '9.*', php: '8.0', 'testbench': '7.*'} + - { queue: 'github-actions-laravel8-php81', laravel: '8.*', php: '8.1', 'testbench': '6.*'} + - { queue: 'github-actions-laravel8-php80', laravel: '8.*', php: '8.0', 'testbench': '6.*'} + - { queue: 'github-actions-laravel8-php74', laravel: '8.*', php: '7.4', 'testbench': '6.*'} + - { queue: 'github-actions-laravel7-php80', laravel: '7.*', php: '8.0', 'testbench': '5.*' } + - { queue: 'github-actions-laravel7-php74', laravel: '7.*', php: '7.4', 'testbench': '5.*' } + - { queue: 'github-actions-laravel6-php80', laravel: '6.*', php: '8.0', 'testbench': '4.*' } + - { queue: 'github-actions-laravel6-php74', laravel: '6.*', php: '7.4', 'testbench': '4.*' } - name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + name: PHP ${{ matrix.payload.php }} - Laravel ${{ matrix.payload.laravel }} steps: - name: Checkout code @@ -69,13 +34,13 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: ${{ matrix.payload.php }} extensions: mbstring, dom, fileinfo coverage: none - name: Install dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --prefer-stable --prefer-dist --no-interaction --ignore-platform-reqs + composer require "laravel/framework:${{ matrix.payload.laravel }}" "orchestra/testbench:${{ matrix.payload.testbench }}" --no-interaction --no-update + composer update --prefer-stable --prefer-dist --no-interaction - name: Execute tests run: vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index d579ace..be19f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +# 2.0.0 - 2022-04-23 + +**Changed** + +- Dropped older PHP and Laravel support +- Bumped dependencies + ## 1.1.0 - 2022-02-09 **Changed** diff --git a/README.md b/README.md index 19fc6d8..2b0edc8 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,15 @@ All these features are supported. This package scans your console kernel (`app/C # Requirements -This package requires Laravel 5.6 or higher. +This package requires Laravel 6 or higher. Please check the table below for supported Laravel and PHP versions: |Laravel Version| PHP Version | |---|---| -| 5.6 | 7.3 -| 5.7 | 7.3 -| 5.8 | 7.3 or 7.4 -| 6.x | 7.3 or 7.4 or 8.0 -| 7.x | 7.3 or 7.4 or 8.0 -| 8.x | 7.3 or 7.4 or 8.0 +| 6.x | 7.4 or 8.0 +| 7.x | 7.4 or 8.0 +| 8.x | 7.4 or 8.0 | 9.x | 8.0 or 8.1 # Installation diff --git a/composer.json b/composer.json index d7c96cc..efd4a64 100644 --- a/composer.json +++ b/composer.json @@ -9,15 +9,12 @@ ], "require": { "ext-json": "*", - "google/cloud-scheduler": "^1.5", - "firebase/php-jwt": "^5.5", + "google/cloud-scheduler": "^1.6", "phpseclib/phpseclib": "~2.0" }, "require-dev": { - "mockery/mockery": "^1.3", - "orchestra/testbench": "^3.5 || ^3.6 || ^3.7 || ^3.8 || ^4.0 || ^5.0", - "psr/log": "^1.1", - "spatie/macroable": "^1.0" + "mockery/mockery": "^1.5", + "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0" }, "autoload": { "psr-4": { diff --git a/src/CloudSchedulerServiceProvider.php b/src/CloudSchedulerServiceProvider.php index 14c9e15..5c9b7f8 100644 --- a/src/CloudSchedulerServiceProvider.php +++ b/src/CloudSchedulerServiceProvider.php @@ -10,6 +10,7 @@ class CloudSchedulerServiceProvider extends LaravelServiceProvider public function boot(Router $router) { $this->registerRoutes($router); + $this->registerClient(); } public function register() @@ -21,4 +22,9 @@ private function registerRoutes(Router $router) { $router->post('cloud-scheduler-job', [TaskHandler::class, 'handle']); } + + private function registerClient() + { + $this->app->bind('open-id-verificator', OpenIdVerificatorConcrete::class); + } } diff --git a/src/OpenIdVerificator.php b/src/OpenIdVerificator.php index d9f8191..69677e1 100644 --- a/src/OpenIdVerificator.php +++ b/src/OpenIdVerificator.php @@ -2,148 +2,17 @@ namespace Stackkit\LaravelGoogleCloudScheduler; -use Carbon\Carbon; -use Firebase\JWT\JWT; -use Firebase\JWT\SignatureInvalidException; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\ServerException; -use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Cache; -use phpseclib\Crypt\RSA; -use phpseclib\Math\BigInteger; -use Throwable; +use Illuminate\Support\Facades\Facade; -class OpenIdVerificator +class OpenIdVerificator extends Facade { - private const V3_CERTS = 'GOOGLE_V3_CERTS'; - private const URL_OPENID_CONFIG = 'https://accounts.google.com/.well-known/openid-configuration'; - private const URL_TOKEN_INFO = 'https://www.googleapis.com/oauth2/v3/tokeninfo'; - - private $guzzle; - private $rsa; - private $jwt; - private $maxAge = []; - - public function __construct(Client $guzzle, RSA $rsa, JWT $jwt) - { - $this->guzzle = $guzzle; - $this->rsa = $rsa; - $this->jwt = $jwt; - } - - public function guardAgainstInvalidOpenIdToken($decodedToken) - { - /** - * https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken - */ - if (!in_array($decodedToken->iss, ['https://accounts.google.com', 'accounts.google.com'])) { - throw new CloudSchedulerException('The given OpenID token is not valid'); - } - - if ($decodedToken->exp < time()) { - throw new CloudSchedulerException('The given OpenID token has expired'); - } - - if ($decodedToken->aud !== config('laravel-google-cloud-scheduler.app_url')) { - throw new CloudSchedulerException('The given OpenID token is not valid'); - } - } - - public function decodeOpenIdToken($openIdToken, $kid, $cache = true) - { - if (!$cache) { - $this->forgetFromCache(); - } - - $publicKey = $this->getPublicKey($kid); - - try { - return $this->jwt->decode($openIdToken, $publicKey, ['RS256']); - } catch (SignatureInvalidException $e) { - if (!$cache) { - throw $e; - } - - return $this->decodeOpenIdToken($openIdToken, $kid, false); - } - } - - public function getPublicKey($kid = null) - { - if (Cache::has(self::V3_CERTS)) { - $v3Certs = Cache::get(self::V3_CERTS); - } else { - $v3Certs = $this->getFreshCertificates(); - Cache::put(self::V3_CERTS, $v3Certs, Carbon::now()->addSeconds($this->maxAge[self::URL_OPENID_CONFIG])); - } - - $cert = $kid ? collect($v3Certs)->firstWhere('kid', '=', $kid) : $v3Certs[0]; - - return $this->extractPublicKeyFromCertificate($cert); - } - - private function getFreshCertificates() - { - $jwksUri = $this->callApiAndReturnValue(self::URL_OPENID_CONFIG, 'jwks_uri'); - - return $this->callApiAndReturnValue($jwksUri, 'keys'); - } - - private function extractPublicKeyFromCertificate($certificate) - { - $modulus = new BigInteger(JWT::urlsafeB64Decode($certificate['n']), 256); - $exponent = new BigInteger(JWT::urlsafeB64Decode($certificate['e']), 256); - - $this->rsa->loadKey(compact('modulus', 'exponent')); - - return $this->rsa->getPublicKey(); - } - - public function getKidFromOpenIdToken($openIdToken) - { - return $this->callApiAndReturnValue(self::URL_TOKEN_INFO . '?id_token=' . $openIdToken, 'kid'); - } - - private function callApiAndReturnValue($url, $value) - { - $attempts = 0; - - while (true) { - try { - $response = $this->guzzle->get($url); - - break; - } catch (ServerException $e) { - $attempts++; - - if ($attempts >= 3) { - throw $e; - } - - sleep(1); - } - } - - $data = json_decode($response->getBody(), true); - - $maxAge = 0; - foreach ($response->getHeader('Cache-Control') as $line) { - preg_match('/max-age=(\d+)/', $line, $matches); - $maxAge = isset($matches[1]) ? (int) $matches[1] : 0; - } - - $this->maxAge[$url] = $maxAge; - - return Arr::get($data, $value); - } - - public function isCached() + protected static function getFacadeAccessor() { - return Cache::has(self::V3_CERTS); + return 'open-id-verificator'; } - public function forgetFromCache() + public static function fake(): void { - Cache::forget(self::V3_CERTS); + self::swap(new OpenIdVerificatorFake()); } } diff --git a/src/OpenIdVerificatorConcrete.php b/src/OpenIdVerificatorConcrete.php new file mode 100644 index 0000000..7b6abe4 --- /dev/null +++ b/src/OpenIdVerificatorConcrete.php @@ -0,0 +1,24 @@ +verify( + $token, + [ + 'audience' => config('laravel-google-cloud-scheduler.app_url'), + 'throwException' => true, + ] + ); + } +} diff --git a/src/OpenIdVerificatorFake.php b/src/OpenIdVerificatorFake.php new file mode 100644 index 0000000..1df7cb1 --- /dev/null +++ b/src/OpenIdVerificatorFake.php @@ -0,0 +1,11 @@ +command = $command; - $this->request = $request; - $this->openId = $openId; - $this->kernel = $kernel; $this->schedule = $schedule; $this->container = $container; } @@ -38,7 +27,7 @@ public function __construct( */ public function handle() { - $this->authorizeRequest(); + OpenIdVerificator::verify(request()->bearerToken(), []); set_time_limit(0); @@ -47,24 +36,6 @@ public function handle() return $this->cleanOutput($output); } - /** - * @throws CloudSchedulerException - */ - private function authorizeRequest() - { - if (!$this->request->hasHeader('Authorization')) { - throw new CloudSchedulerException('Unauthorized'); - } - - $openIdToken = $this->request->bearerToken(); - - $kid = $this->openId->getKidFromOpenIdToken($openIdToken); - - $decodedToken = $this->openId->decodeOpenIdToken($openIdToken, $kid); - - $this->openId->guardAgainstInvalidOpenIdToken($decodedToken); - } - private function runCommand($command) { if ($this->isScheduledCommand($command)) { diff --git a/tests/GooglePublicKeyTest.php b/tests/GooglePublicKeyTest.php deleted file mode 100644 index 42f694f..0000000 --- a/tests/GooglePublicKeyTest.php +++ /dev/null @@ -1,89 +0,0 @@ -guzzle = Mockery::mock(new Client()); - - $this->publicKey = new OpenIdVerificator($this->guzzle, new RSA(), new JWT()); - } - - /** @test */ - public function it_fetches_the_gcloud_public_key() - { - $this->assertStringContainsString('-----BEGIN PUBLIC KEY-----', $this->publicKey->getPublicKey()); - } - - /** @test */ - public function it_caches_the_gcloud_public_key() - { - $this->assertFalse($this->publicKey->isCached()); - - $this->publicKey->getPublicKey(); - - $this->assertTrue($this->publicKey->isCached()); - } - - /** @test */ - public function it_will_return_the_cached_gcloud_public_key() - { - Event::fake(); - - $this->publicKey->getPublicKey(); - - Event::assertDispatched(CacheMissed::class); - Event::assertDispatched(KeyWritten::class); - - $this->publicKey->getPublicKey(); - - Event::assertDispatched(CacheHit::class); - - $this->guzzle->shouldHaveReceived('get')->twice(); - } - - /** @test */ - public function public_key_is_cached_according_to_cache_control_headers() - { - Event::fake(); - - $this->publicKey->getPublicKey(); - - $this->publicKey->getPublicKey(); - - Carbon::setTestNow(Carbon::now()->addSeconds(3600)); - $this->publicKey->getPublicKey(); - - Carbon::setTestNow(Carbon::now()->addSeconds(5)); - $this->publicKey->getPublicKey(); - - Event::assertDispatched(CacheMissed::class, 2); - Event::assertDispatched(KeyWritten::class, 2); - - } -} diff --git a/tests/TaskHandlerTest.php b/tests/TaskHandlerTest.php index 44b7d1d..27c0160 100644 --- a/tests/TaskHandlerTest.php +++ b/tests/TaskHandlerTest.php @@ -2,33 +2,21 @@ namespace Tests; -use Firebase\JWT\JWT; -use Firebase\JWT\SignatureInvalidException; -use GuzzleHttp\Client; -use Illuminate\Cache\Events\CacheHit; -use Illuminate\Cache\Events\KeyWritten; use Illuminate\Console\Application as ConsoleApplication; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Container\Container; -use Illuminate\Contracts\Console\Kernel; -use Illuminate\Http\Request; use Illuminate\Log\LogManager; -use Illuminate\Support\Facades\Event; use Mockery; -use phpseclib\Crypt\RSA; use Stackkit\LaravelGoogleCloudScheduler\CloudSchedulerException; use Stackkit\LaravelGoogleCloudScheduler\Command; use Stackkit\LaravelGoogleCloudScheduler\OpenIdVerificator; use Stackkit\LaravelGoogleCloudScheduler\TaskHandler; -use Throwable; +use UnexpectedValueException; class TaskHandlerTest extends TestCase { private $taskHandler; private $fakeCommand; - private $openId; - private $request; - private $jwt; /** * @var LogManager @@ -48,30 +36,10 @@ public function setUp(): void config()->set('laravel-google-cloud-scheduler.app_url', 'my-application.com'); - // We don't have a valid token to test with, so for now act as if its always valid - $this->app->instance(JWT::class, ($this->jwt = Mockery::mock(new JWT())->byDefault()->makePartial())); - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'aud' => 'my-application.com', - 'exp' => time() + 10 - ])->byDefault(); - - // ensure we don't call Google services to validate the token - $this->openId = Mockery::mock(new OpenIdVerificator( - new Client(), - new RSA(), - $this->jwt - ))->makePartial(); - - - $this->request = new Request(); - $this->request->headers->add(['Authorization' => 'test']); + request()->headers->add(['Authorization' => 'Bearer test']); $this->taskHandler = new TaskHandler( $this->fakeCommand, - $this->request, - $this->openId, - app(Kernel::class), app(Schedule::class), Container::getInstance() ); @@ -100,10 +68,9 @@ private function registerLogFake() /** @test */ public function it_executes_the_incoming_command() { + OpenIdVerificator::fake(); + $this->fakeCommand->shouldReceive('capture')->andReturn('env'); - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('guardAgainstInvalidOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('decodeOpenIdToken')->andReturnNull(); $output = $this->taskHandler->handle(); @@ -114,10 +81,8 @@ public function it_executes_the_incoming_command() public function it_requires_a_jwt() { $this->fakeCommand->shouldReceive('capture')->andReturn('env'); - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('guardAgainstInvalidOpenIdToken')->andReturnNull(); - $this->request->headers->remove('Authorization'); + request()->headers->remove('Authorization'); $this->expectException(CloudSchedulerException::class); @@ -127,74 +92,9 @@ public function it_requires_a_jwt() /** @test */ public function it_requires_a_jwt_signed_by_google() { - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->fakeCommand->shouldReceive('capture')->andReturn('env'); - - $this->jwt->shouldReceive('decode')->andReturn((object) [ - 'iss' => 'accounts.dfdfdf.com', - 'aud' => 'my-application.com', - 'exp' => time() + 10 - ]); - - $this->expectException(CloudSchedulerException::class); - $this->expectExceptionMessage('The given OpenID token is not valid'); - - $this->taskHandler->handle(); - } - - /** @test */ - public function the_issue_identifier_should_be_google() - { - $this->expectExceptionMessage('The given OpenID token is not valid'); - - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('decodeOpenIdToken')->andReturn((object) [ - 'iss' => 'accounts.not-google.com', - ]); - - $this->taskHandler->handle(); - } - - /** @test */ - public function the_token_must_not_be_expired() - { - $this->expectExceptionMessage('The given OpenID token has expired'); - - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('decodeOpenIdToken')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'exp' => time() - 10, - ]); - - $this->taskHandler->handle(); - } - - /** @test */ - public function the_aud_claim_must_be_the_same_as_the_app_id() - { - config()->set('laravel-google-cloud-scheduler.app_url', 'my-application.com'); $this->fakeCommand->shouldReceive('capture')->andReturn('env'); - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('decodeOpenIdToken')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'exp' => time() + 10, - 'aud' => 'my-application.com', - ])->byDefault(); - - try { - $this->taskHandler->handle(); - } catch (Throwable $e) { - $this->fail('The command should not have thrown an exception'); - } - $this->openId->shouldReceive('decodeOpenIdToken')->andReturn((object) [ - 'iss' => 'accounts.google.com', - 'exp' => time() + 10, - 'aud' => 'my-other-application.com', - ]); - - $this->expectException(CloudSchedulerException::class); - $this->expectExceptionMessage('The given OpenID token is not valid'); + $this->expectException(UnexpectedValueException::class); $this->taskHandler->handle(); } @@ -202,10 +102,9 @@ public function the_aud_claim_must_be_the_same_as_the_app_id() /** @test */ public function it_prevents_overlapping_if_the_command_is_scheduled_without_overlapping() { + OpenIdVerificator::fake(); + $this->fakeCommand->shouldReceive('capture')->andReturn('test:command'); - $this->openId->shouldReceive('guardAgainstInvalidOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('decodeOpenIdToken')->andReturnNull(); cache()->clear(); @@ -236,10 +135,9 @@ public function it_prevents_overlapping_if_the_command_is_scheduled_without_over /** @test */ public function it_runs_the_before_and_after_callbacks() { + OpenIdVerificator::fake(); + $this->fakeCommand->shouldReceive('capture')->andReturn('test:command2'); - $this->openId->shouldReceive('guardAgainstInvalidOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('decodeOpenIdToken')->andReturnNull(); $this->taskHandler->handle(); @@ -248,29 +146,12 @@ public function it_runs_the_before_and_after_callbacks() $this->log->shouldHaveReceived()->debug('did something testy')->once(); } - /** @test */ - public function in_case_of_signature_verification_failure_it_will_retry() - { - Event::fake(); - - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->jwt->shouldReceive('decode')->andThrow(SignatureInvalidException::class); - - $this->expectException(SignatureInvalidException::class); - - $this->taskHandler->handle(); - - Event::assertDispatched(CacheHit::class); - Event::assertDispatched(KeyWritten::class); - } - /** @test */ public function it_can_run_the_schedule_run_command() { + OpenIdVerificator::fake(); + $this->fakeCommand->shouldReceive('capture')->andReturn('schedule:run'); - $this->openId->shouldReceive('guardAgainstInvalidOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('getKidFromOpenIdToken')->andReturnNull(); - $this->openId->shouldReceive('decodeOpenIdToken')->andReturnNull(); $this->taskHandler->handle(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 312984d..e036010 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,7 @@ namespace Tests; -use Illuminate\Console\Scheduling\Schedule; use Tests\Support\Kernel; -use Tests\Support\TestCommand; class TestCase extends \Orchestra\Testbench\TestCase {