Skip to content

Commit e07a6d4

Browse files
committed
Verify Google ID token
1 parent acb6d01 commit e07a6d4

File tree

10 files changed

+136
-90
lines changed

10 files changed

+136
-90
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ gcloud tasks queues create [QUEUE_ID]
4343
'location' => env('STACKKIT_CLOUD_TASKS_LOCATION', ''),
4444
'handler' => env('STACKKIT_CLOUD_TASKS_HANDLER', ''),
4545
'queue' => env('STACKKIT_CLOUD_TASKS_QUEUE', 'default'),
46+
'service_account_email' => env('STACKKIT_CLOUD_TASKS_SERVICE_EMAIL', ''),
4647
],
4748
```
4849

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
],
1010
"require": {
1111
"ext-json": "*",
12-
"google/cloud-tasks": "^1.6"
12+
"google/cloud-tasks": "^1.6",
13+
"firebase/php-jwt": "^5.2",
14+
"phpseclib/phpseclib": "~2.0"
1315
},
1416
"require-dev": {
1517
"mockery/mockery": "^1.2",

src/CloudTasksQueue.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace Stackkit\LaravelGoogleCloudTasksQueue;
44

5-
use Carbon\Carbon;
65
use Google\Cloud\Tasks\V2\CloudTasksClient;
76
use Google\Cloud\Tasks\V2\HttpMethod;
87
use Google\Cloud\Tasks\V2\HttpRequest;
@@ -63,11 +62,11 @@ protected function pushToCloudTasks($queue, $payload, $delay = 0, $attempts = 0)
6362
$httpRequest->setBody($payload);
6463

6564
$task = $this->createTask();
66-
$randomString = Str::random();
67-
$task->setName($queueName . '/tasks/' . $randomString);
6865
$task->setHttpRequest($httpRequest);
6966

70-
$httpRequest->setHeaders(['X-Stackkit-Auth-Token' => encrypt($randomString)]);
67+
$token = new OidcToken;
68+
$token->setServiceAccountEmail(Config::serviceAccountEmail());
69+
$httpRequest->setOidcToken($token);
7170

7271
if ($availableAt > time()) {
7372
$task->setScheduleTime(new Timestamp(['seconds' => $availableAt]));

src/Config.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ public static function handler()
2626
return config('queue.connections.cloudtasks.handler');
2727
}
2828

29+
public static function serviceAccountEmail()
30+
{
31+
return config('queue.connections.cloudtasks.service_account_email');
32+
}
33+
2934
public static function validate(array $config)
3035
{
3136
if (empty($config['credentials'])) {
@@ -47,5 +52,9 @@ public static function validate(array $config)
4752
if (empty($config['handler'])) {
4853
throw new Error(Errors::invalidHandler());
4954
}
55+
56+
if (empty($config['service_account_email'])) {
57+
throw new Error(Errors::invalidServiceAccountEmail());
58+
}
5059
}
5160
}

src/Errors.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,9 @@ public static function invalidHandler()
2828
{
2929
return 'Google Cloud Tasks handler not provided. To fix this, set the STACKKIT_CLOUD_TASKS_HANDLER environment variable';
3030
}
31+
32+
public static function invalidServiceAccountEmail()
33+
{
34+
return 'Google Service Account email address not provided. This is needed to secure the handler so it is only accessible by Google. To fix this, set the STACKKIT_CLOUD_TASKS_SERVICE_EMAIL environment variable';
35+
}
3136
}

src/TaskHandler.php

Lines changed: 84 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,30 @@
22

33
namespace Stackkit\LaravelGoogleCloudTasksQueue;
44

5-
use Ahc\Jwt\JWT;
65
use Google\Cloud\Tasks\V2\CloudTasksClient;
6+
use GuzzleHttp\Client;
77
use Illuminate\Http\Request;
88
use Illuminate\Queue\Worker;
99
use Illuminate\Queue\WorkerOptions;
10+
use Illuminate\Support\Arr;
11+
use phpseclib\Crypt\RSA;
12+
use phpseclib\Math\BigInteger;
1013
use Throwable;
14+
use Firebase\JWT\JWT;
1115

1216
class TaskHandler
1317
{
1418
private $client;
1519
private $request;
20+
private $guzzle;
21+
private $jwt;
1622

17-
public function __construct(CloudTasksClient $client, Request $request)
23+
public function __construct(CloudTasksClient $client, Request $request, Client $guzzle, JWT $jwt)
1824
{
1925
$this->client = $client;
2026
$this->request = $request;
27+
$this->guzzle = $guzzle;
28+
$this->jwt = $jwt;
2129
}
2230

2331
/**
@@ -36,39 +44,88 @@ public function handle($task = null)
3644
/**
3745
* @throws CloudTasksException
3846
*/
39-
private function authorizeRequest()
47+
public function authorizeRequest()
4048
{
41-
$this->checkForRequiredHeaders();
49+
if (!$this->request->hasHeader('Authorization')) {
50+
throw new CloudTasksException('Missing [Authorization] header');
51+
}
4252

43-
$taskName = $this->request->header('X-Cloudtasks-Taskname');
44-
$queueName = $this->request->header('X-Cloudtasks-Queuename');
45-
$authToken = $this->request->header('X-Stackkit-Auth-Token');
53+
// @todo - kill this check with a Mock
54+
if (app()->environment('testing')) {
55+
return;
56+
}
4657

47-
$fullQueueName = $this->client->queueName(Config::project(), Config::location(), $queueName);
58+
$openIdToken = $this->request->bearerToken();
59+
$pubKey = $this->getGooglePublicKey();
4860

49-
try {
50-
$this->client->getTask($fullQueueName . '/tasks/' . $taskName);
51-
} catch (Throwable $e) {
52-
throw new CloudTasksException('Could not find task');
53-
}
61+
$decodedToken = $this->jwt->decode($openIdToken, $pubKey, ['RS256']);
5462

55-
if (decrypt($authToken) != $taskName) {
56-
throw new CloudTasksException('Auth token is not valid');
57-
}
63+
$this->validateToken($decodedToken);
64+
}
65+
66+
private function getGooglePublicKey()
67+
{
68+
$jwksUri = $this->getJwksUri();
69+
70+
$keys = $this->getCertificateKeys($jwksUri);
71+
72+
$firstKey = $keys[1];
73+
74+
$modulus = $firstKey['n'];
75+
$exponent = $firstKey['e'];
76+
77+
$rsa = new RSA();
78+
79+
$modulus = new BigInteger(JWT::urlsafeB64Decode($modulus), 256);
80+
$exponent = new BigInteger(JWT::urlsafeB64Decode($exponent), 256);
81+
82+
$rsa->loadKey([
83+
'n' => $modulus,
84+
'e' => $exponent
85+
]);
86+
$rsa->setPublicKey();
87+
88+
return $rsa->getPublicKey();
89+
}
90+
91+
private function getJwksUri()
92+
{
93+
$discoveryEndpoint = 'https://accounts.google.com/.well-known/openid-configuration';
94+
95+
$configurationJson = $this->guzzle->get($discoveryEndpoint);
96+
97+
$configurations = json_decode($configurationJson->getBody(), true);
98+
99+
return Arr::get($configurations, 'jwks_uri');
100+
}
101+
102+
private function getCertificateKeys($jwksUri)
103+
{
104+
$json = $this->guzzle->get($jwksUri);
105+
106+
$certificates = json_decode($json->getBody(), true);
107+
108+
return Arr::get($certificates, 'keys');
58109
}
59110

60-
private function checkForRequiredHeaders()
111+
/**
112+
* https://developers.google.com/identity/protocols/oauth2/openid-connect#validatinganidtoken
113+
*
114+
* @param $openIdToken
115+
* @throws CloudTasksException
116+
*/
117+
protected function validateToken($openIdToken)
61118
{
62-
$headers = [
63-
'X-Cloudtasks-Taskname',
64-
'X-Cloudtasks-Queuename',
65-
'X-Stackkit-Auth-Token',
66-
];
67-
68-
foreach ($headers as $header) {
69-
if (!$this->request->hasHeader($header)) {
70-
throw new CloudTasksException('Missing [' . $header . '] header');
71-
}
119+
if (!in_array($openIdToken->iss, ['https://accounts.google.com', 'accounts.google.com'])) {
120+
throw new CloudTasksException('The given OpenID token is not valid');
121+
}
122+
123+
if ($openIdToken->aud != Config::handler()) {
124+
throw new CloudTasksException('The given OpenID token is not valid');
125+
}
126+
127+
if ($openIdToken->exp < time()) {
128+
throw new CloudTasksException('The given OpenID token has expired');
72129
}
73130
}
74131

tests/ConfigTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,15 @@ public function handler_is_required()
6262

6363
SimpleJob::dispatch();
6464
}
65+
66+
/** @test */
67+
public function service_email_is_required()
68+
{
69+
$this->setConfigValue('service_account_email', '');
70+
71+
$this->expectException(Error::class);
72+
$this->expectExceptionMessage(Errors::invalidServiceAccountEmail());
73+
74+
SimpleJob::dispatch();
75+
}
6576
}

tests/QueueTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ protected function setUp(): void
2222
parent::setUp();
2323

2424
$this->client = Mockery::mock(CloudTasksClient::class)->makePartial();
25-
$this->http = Mockery::mock(HttpRequest::class)->makePartial();
25+
$this->http = Mockery::mock(new HttpRequest)->makePartial();
2626
$this->task = Mockery::mock(new Task);
2727

2828
$this->app->instance(CloudTasksClient::class, $this->client);

tests/TaskHandlerTest.php

Lines changed: 18 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace Tests;
44

5+
use Firebase\JWT\JWT;
56
use Google\Cloud\Tasks\V2\CloudTasksClient;
7+
use GuzzleHttp\Client;
68
use Illuminate\Support\Facades\Mail;
79
use Stackkit\LaravelGoogleCloudTasksQueue\CloudTasksException;
810
use Stackkit\LaravelGoogleCloudTasksQueue\TaskHandler;
@@ -17,88 +19,47 @@ class TaskHandlerTest extends TestCase
1719

1820
private $client;
1921

22+
private $jwt;
23+
2024
protected function setUp(): void
2125
{
2226
parent::setUp();
2327

2428
$this->client = \Mockery::mock(CloudTasksClient::class)->makePartial();
25-
$this->handler = new TaskHandler(
26-
$this->client,
27-
request()
28-
);
29-
}
30-
31-
/** @test */
32-
public function it_needs_a_task_name_header()
33-
{
34-
$this->expectException(CloudTasksException::class);
35-
$this->expectExceptionMessage('Missing [X-Cloudtasks-Taskname] header');
36-
37-
$this->handler->handle();
38-
}
3929

40-
/** @test */
41-
public function it_needs_a_queue_name_header()
42-
{
43-
$this->expectException(CloudTasksException::class);
44-
$this->expectExceptionMessage('Missing [X-Cloudtasks-Queuename] header');
30+
$this->jwt = \Mockery::mock(JWT::class)->makePartial();
4531

46-
request()->headers->add(['X-Cloudtasks-Taskname' => 'test']);
47-
$this->handler->handle();
32+
$this->handler = \Mockery::mock(new TaskHandler(
33+
$this->client,
34+
request(),
35+
new Client(),
36+
$this->jwt
37+
))->shouldAllowMockingProtectedMethods();
38+
$this->app->instance(TaskHandler::class, $this->handler);
39+
40+
$this->jwt->shouldReceive('decode')->andReturnNull();
41+
$this->handler->shouldReceive('authorizeRequest')->andReturnNull();
4842
}
4943

5044
/** @test */
51-
public function it_needs_a_stackkit_auth_token_header()
45+
public function it_needs_an_authorization_header()
5246
{
5347
$this->expectException(CloudTasksException::class);
54-
$this->expectExceptionMessage('Missing [X-Stackkit-Auth-Token] header');
48+
$this->expectExceptionMessage('Missing [Authorization] header');
5549

5650
request()->headers->add(['X-Cloudtasks-Taskname' => 'test']);
5751
request()->headers->add(['X-Cloudtasks-Queuename' => 'test']);
5852
$this->handler->handle();
5953
}
6054

61-
/** @test */
62-
public function it_will_check_if_the_incoming_task_exists()
63-
{
64-
request()->headers->add(['X-Cloudtasks-Taskname' => 'test']);
65-
request()->headers->add(['X-Cloudtasks-Queuename' => 'test']);
66-
request()->headers->add(['X-Stackkit-Auth-Token' => encrypt('test')]);
67-
68-
Mail::fake();
69-
70-
$this->client
71-
->shouldReceive('getTask')
72-
->once()
73-
->with('projects/test-project/locations/europe-west6/queues/test/tasks/test')
74-
->andReturnNull();
75-
76-
$this->handler->handle(json_decode(file_get_contents(__DIR__ . '/Support/test-job-payload.json'), true));
77-
}
78-
79-
/** @test */
80-
public function it_will_check_the_auth_token()
81-
{
82-
request()->headers->add(['X-Cloudtasks-Taskname' => 'test']);
83-
request()->headers->add(['X-Cloudtasks-Queuename' => 'test']);
84-
request()->headers->add(['X-Stackkit-Auth-Token' => encrypt('does not match the task name')]);
85-
86-
$this->client->shouldReceive('getTask')->andReturnNull();
87-
88-
$this->expectException(CloudTasksException::class);
89-
$this->expectExceptionMessage('Auth token is not valid');
90-
91-
$this->handler->handle(json_decode(file_get_contents(__DIR__ . '/Support/test-job-payload.json'), true));
92-
}
93-
9455
/** @test */
9556
public function it_runs_the_incoming_job()
9657
{
9758
Mail::fake();
9859

9960
request()->headers->add(['X-Cloudtasks-Taskname' => 'test']);
10061
request()->headers->add(['X-Cloudtasks-Queuename' => 'test']);
101-
request()->headers->add(['X-Stackkit-Auth-Token' => encrypt('test')]);
62+
request()->headers->add(['Authorization' => 'Bearer 123']);
10263

10364
$this->client->shouldReceive('getTask')->andReturnNull();
10465

tests/TestCase.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ protected function getEnvironmentSetUp($app)
3737
'project' => 'test-project',
3838
'location' => 'europe-west6',
3939
'handler' => 'https://localhost/my-handler',
40+
'service_account_email' => 'info@stackkit.io',
4041
]);
4142
}
4243

0 commit comments

Comments
 (0)