Skip to content

Commit ecc069d

Browse files
committed
adds jwt abstraction
1 parent f1e7f2b commit ecc069d

File tree

10 files changed

+252
-414
lines changed

10 files changed

+252
-414
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ composer.lock
44
.cache
55
.docs
66
.gitmodules
7+
.phpunit.result.cache
78

89
# IntelliJ
910
.idea

UPGRADING.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,27 @@ $httpClient = new Google\Http\Client\GuzzleClient($guzzle);
6464
$auth = new GoogleAuth(['httpClient' => $httpClient]);
6565
```
6666

67+
#### Improved JWT handling
68+
69+
* Provides an abstraction from `firebase/jwt`, `phpseclib/phpseclib`, and `kelvinmo/simplejwt`
70+
* Using the composer "[replace](https://stackoverflow.com/questions/18882201/how-does-the-replace-property-work-with-composer)" keyword, users can ignore sub-dependencies such as Firebase JWT in favor of a separate JWT library
71+
* **TODO**: Provide documentation on how to use a different library
72+
* Adds `JwtClientInterface` and `FirebaseJwtClient`
73+
74+
**Example**
75+
76+
```php
77+
$jwt = new class implements Google\Auth\Jwt\JwtClientInterface {
78+
public function encode($payload, $signingKey, $signingAlg, $keyId) {
79+
// encode method
80+
}
81+
82+
// ... other JWT hander interface methods go here ...
83+
};
84+
$googleAuth = new GoogleAuth(['jwtClient' => $jwt]);
85+
$googleAuth->verify($someJwt);
86+
```
87+
6788
#### New `GoogleAuth` class
6889

6990
`GoogleAuth` replaces `ApplicationDefaultCredentials`, and provides a

src/Auth/GoogleAuth.php

Lines changed: 83 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,22 @@
2020
namespace Google\Auth;
2121

2222
use DomainException;
23+
use Firebase\JWT\JWT;
24+
use Firebase\JWT\JWK;
2325
use Google\Auth\Credentials\ComputeCredentials;
2426
use Google\Auth\Credentials\ServiceAccountCredentials;
2527
use Google\Auth\Credentials\ServiceAccountJwtAccessCredentials;
2628
use Google\Auth\Credentials\CredentialsInterface;
2729
use Google\Auth\Credentials\UserRefreshCredentials;
2830
use Google\Auth\Http\ClientFactory;
31+
use Google\Auth\Jwt\FirebaseJwtClient;
32+
use Google\Auth\Jwt\JwtClientInterface;
2933
use Google\Cache\MemoryCacheItemPool;
3034
use GuzzleHttp\Psr7\Request;
3135
use InvalidArgumentException;
3236
use Psr\Cache\CacheItemPoolInterface;
3337
use RuntimeException;
38+
use UnexpectedValueException;
3439

3540
/**
3641
* GoogleAuth obtains the default credentials for
@@ -73,20 +78,19 @@ class GoogleAuth
7378
{
7479
private const TOKEN_REVOKE_URI = 'https://oauth2.googleapis.com/revoke';
7580
private const OIDC_CERT_URI = 'https://www.googleapis.com/oauth2/v3/certs';
76-
private const OIDC_ISSUERS = ['accounts.google.com', 'https://accounts.google.com'];
81+
private const OIDC_ISSUERS = ['http://accounts.google.com', 'https://accounts.google.com'];
7782
private const IAP_JWK_URI = 'https://www.gstatic.com/iap/verify/public_key-jwk';
7883
private const IAP_ISSUERS = ['https://cloud.google.com/iap'];
7984

8085
private const ENV_VAR = 'GOOGLE_APPLICATION_CREDENTIALS';
8186
private const WELL_KNOWN_PATH = 'gcloud/application_default_credentials.json';
8287
private const NON_WINDOWS_WELL_KNOWN_PATH_BASE = '.config';
8388

84-
private const ON_COMPUTE_CACHE_KEY = 'google_auth_on_gce_cache';
85-
86-
private $httpClient;
8789
private $cache;
8890
private $cacheLifetime;
8991
private $cachePrefix;
92+
private $httpClient;
93+
private $jwtClient;
9094

9195
/**
9296
* Obtains an AuthTokenMiddleware which will fetch an access token to use in
@@ -98,6 +102,7 @@ class GoogleAuth
98102
*
99103
* @param array $options {
100104
* @type ClientInterface $httpClient client which delivers psr7 request
105+
* @type JwtClientInterface $jwtClient
101106
* @type CacheItemPoolInterface $cache A cache implementation, may be
102107
* provided if you have one already available for use.
103108
* @type int $cacheLifetime
@@ -107,15 +112,18 @@ class GoogleAuth
107112
public function __construct(array $options = [])
108113
{
109114
$options += [
110-
'httpClient' => null,
111115
'cache' => null,
112116
'cacheLifetime' => 1500,
113117
'cachePrefix' => '',
118+
'httpClient' => null,
119+
'jwtClient' => null,
114120
];
115-
$this->httpClient = $options['httpClient'] ?: ClientFactory::build();
116121
$this->cache = $options['cache'] ?: new MemoryCacheItemPool();
117122
$this->cacheLifetime = $options['cacheLifetime'];
118123
$this->cachePrefix = $options['cachePrefix'];
124+
$this->httpClient = $options['httpClient'] ?: ClientFactory::build();
125+
$this->jwtClient = $options['jwtClient']
126+
?: new FirebaseJwtClient(new JWT(), new JWK());
119127
}
120128

121129
/**
@@ -233,13 +241,15 @@ public function makeCredentials(array $options = []): CredentialsInterface
233241
* Determines if this a GCE instance, by accessing the expected metadata
234242
* host.
235243
*
244+
* @param array $options [optional] Configuration options.
245+
* @param string $options.cacheKey cache key used for caching the result
246+
*
236247
* @return bool
237248
*/
238-
public function onCompute(): bool
249+
public function onCompute(?array $options): bool
239250
{
240-
$cacheItem = $this->cache->getItem(
241-
$this->cachePrefix . self::ON_COMPUTE_CACHE_KEY
242-
);
251+
$cacheKey = $options['cacheKey'] ?? 'google_auth_on_gce_cache';
252+
$cacheItem = $this->cache->getItem($this->cachePrefix . $cacheKey);
243253

244254
if ($cacheItem->isHit()) {
245255
return $cacheItem->get();
@@ -257,23 +267,34 @@ public function onCompute(): bool
257267
* @param string $token The JSON Web Token to be verified.
258268
* @param array $options [optional] Configuration options.
259269
* @param string $options.audience The indended recipient of the token.
260-
* @param string $options.issuer The intended issuer of the token.
261-
* @param string $certsLocation URI for JSON certificate array conforming to
270+
* @param array $options.issuers The intended issuers of the token.
271+
* @param string $options.cacheKey cache key used for caching certs
272+
* @param string $options.certsLocation URI for JSON certificate array conforming to
262273
* the JWK spec (see https://tools.ietf.org/html/rfc7517).
263274
*/
264-
public function verify(string $token, array $options = []): array
275+
public function verify(string $token, ?array $options): bool
265276
{
266-
$location = isset($options['certsLocation'])
267-
? $options['certsLocation']
268-
: self::OIDC_CERT_URI;
277+
$location = $options['certsLocation'] ?? self::OIDC_CERT_URI;
278+
$cacheKey = $options['cacheKey'] ??
279+
sprintf('google_auth_certs_cache|%s', sha1($location));
280+
$certs = $this->getCerts($location, $cacheKey);
281+
$alg = $this->determineAlg($certs);
282+
$issuers = $options['issuers'] ??
283+
['RS256' => self::OIDC_ISSUERS, 'ES256' => self::IAP_ISSUERS][$alg];
284+
$aud = $options['audience'] ?? null;
269285

270-
$cacheKey = isset($options['cacheKey'])
271-
? $options['cacheKey']
272-
: $this->getCacheKeyFromCertLocation($location);
286+
$keys = $this->jwtClient->parseKeySet($certs);
287+
$payload = $this->jwtClient->decode($token, $keys, [$alg]);
273288

274-
$certs = $this->getCerts($location, $cacheKey);
275-
$oauth = new OAuth2();
276-
return $oauth->verify($token, $certs, $options);
289+
if (empty($payload['iss']) || !in_array($payload['iss'], $issuers)) {
290+
throw new UnexpectedValueException('Issuer does not match');
291+
}
292+
293+
if ($aud && isset($payload['aud']) && $payload['aud'] != $aud) {
294+
throw new UnexpectedValueException('Audience does not match');
295+
}
296+
297+
return true;
277298
}
278299

279300
/**
@@ -286,8 +307,9 @@ public function verify(string $token, array $options = []): array
286307
* @return array
287308
* @throws InvalidArgumentException If received certs are in an invalid format.
288309
*/
289-
private function getCerts(string $location, string $cacheKey): array {
290-
$cacheItem = $this->cache->getItem($cacheKey);
310+
private function getCerts(string $location, string $cacheKey): array
311+
{
312+
$cacheItem = $this->cache->getItem($this->cachePrefix . $cacheKey);
291313
$certs = $cacheItem ? $cacheItem->get() : null;
292314

293315
$gotNewCerts = false;
@@ -298,11 +320,6 @@ private function getCerts(string $location, string $cacheKey): array {
298320
}
299321

300322
if (!isset($certs['keys'])) {
301-
if ($location !== self::IAP_JWK_URI) {
302-
throw new InvalidArgumentException(
303-
'federated sign-on certs expects "keys" to be set'
304-
);
305-
}
306323
throw new InvalidArgumentException(
307324
'certs expects "keys" to be set'
308325
);
@@ -316,7 +333,40 @@ private function getCerts(string $location, string $cacheKey): array {
316333
$this->cache->save($cacheItem);
317334
}
318335

319-
return $certs['keys'];
336+
return $certs;
337+
}
338+
339+
/**
340+
* Identifies the expected algorithm to verify by looking at the "alg" key
341+
* of the provided certs.
342+
*
343+
* @param array $certs Certificate array according to the JWK spec (see
344+
* https://tools.ietf.org/html/rfc7517).
345+
* @return string The expected algorithm, such as "ES256" or "RS256".
346+
*/
347+
private function determineAlg(array $certs): string
348+
{
349+
$alg = null;
350+
foreach ($certs['keys'] as $cert) {
351+
if (empty($cert['alg'])) {
352+
throw new InvalidArgumentException(
353+
'certs expects "alg" to be set'
354+
);
355+
}
356+
$alg = $alg ?: $cert['alg'];
357+
358+
if ($alg != $cert['alg']) {
359+
throw new InvalidArgumentException(
360+
'More than one alg detected in certs'
361+
);
362+
}
363+
}
364+
if (!in_array($alg, ['RS256', 'ES256'])) {
365+
throw new InvalidArgumentException(
366+
'unrecognized "alg" in certs, expected ES256 or RS256'
367+
);
368+
}
369+
return $alg;
320370
}
321371

322372
/**
@@ -353,22 +403,6 @@ private function retrieveCertsFromLocation(string $url): array
353403
), $response->getStatusCode());
354404
}
355405

356-
/**
357-
* Generate a cache key based on the cert location using sha1 with the
358-
* exception of using "federated_signon_certs_v3" to preserve BC.
359-
*
360-
* @param string $certsLocation
361-
* @return string
362-
*/
363-
private function getCacheKeyFromCertLocation($certsLocation)
364-
{
365-
$key = $certsLocation === self::OIDC_CERT_URI
366-
? 'federated_signon_certs_v3'
367-
: sha1($certsLocation);
368-
369-
return 'google_auth_certs_cache|' . $key;
370-
}
371-
372406
/**
373407
* Revoke an OAuth2 access token or refresh token. This method will revoke the current access
374408
* token, if a token isn't provided.
@@ -378,11 +412,11 @@ private function getCacheKeyFromCertLocation($certsLocation)
378412
*/
379413
public function revoke($token): bool
380414
{
381-
$oauth = new OAuth2([
415+
$oauth2 = new OAuth2([
382416
'tokenRevokeUri' => self::TOKEN_REVOKE_URI,
417+
'httpClient' => $this->httpClient,
383418
]);
384-
385-
return $oauth->revoke($token);
419+
return $oauth2->revoke($token);
386420
}
387421

388422
/**

src/Auth/Jwt/FirebaseJwtClient.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
/*
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace Google\Auth\Jwt;
21+
22+
use Firebase\JWT\JWT;
23+
use Firebase\JWT\JWK;
24+
25+
class FirebaseJwtClient implements JwtClientInterface
26+
{
27+
private $jwt;
28+
private $jwk;
29+
30+
public function __construct(JWT $jwt, JWK $jwk)
31+
{
32+
$this->jwt = $jwt;
33+
$this->jwk = $jwk;
34+
}
35+
36+
public function encode(
37+
array $payload,
38+
string $signingKey,
39+
string $signingAlg,
40+
string $keyId
41+
): string {
42+
return $this->jwt->encode($payload, $signingKey, $signingAlg, $keyId);
43+
}
44+
45+
public function decode(string $jwt, array $keys, array $allowedAlgs): array
46+
{
47+
return (array) $this->jwt->decode($jwt, $keys, $allowedAlgs);
48+
}
49+
50+
public function parseKeySet(array $keySet): array
51+
{
52+
return $this->jwk->parseKeySet($keySet);
53+
}
54+
}
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
11
<?php
2-
/**
2+
/*
33
* Copyright 2020 Google LLC
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
77
* You may obtain a copy of the License at
88
*
9-
* http://www.apache.org/licenses/LICENSE-2.0
9+
* http://www.apache.org/licenses/LICENSE-2.0
1010
*
1111
* Unless required by applicable law or agreed to in writing, software
1212
* distributed under the License is distributed on an "AS IS" BASIS,
1313
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1414
* See the License for the specific language governing permissions and
1515
* limitations under the License.
1616
*/
17-
namespace Google\Auth\HttpHandler;
1817

19-
class Guzzle7HttpHandler extends Guzzle6HttpHandler
18+
declare(strict_types=1);
19+
20+
namespace Google\Auth\Jwt;
21+
22+
interface JwtClientInterface
2023
{
21-
}
24+
public function encode(
25+
array $payload,
26+
string $signingKey,
27+
string $signingAlg,
28+
string $keyId
29+
): string;
30+
31+
public function decode(string $jwt, array $keys, array $allowedAlgs): array;
32+
33+
public function parseKeySet(array $keySey): array;
34+
}

0 commit comments

Comments
 (0)