1 | <?php
|
---|
2 | /*
|
---|
3 | * Copyright 2019 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 | namespace Google\Auth;
|
---|
19 |
|
---|
20 | use DateTime;
|
---|
21 | use Firebase\JWT\ExpiredException;
|
---|
22 | use Firebase\JWT\JWT;
|
---|
23 | use Firebase\JWT\Key;
|
---|
24 | use Firebase\JWT\SignatureInvalidException;
|
---|
25 | use Google\Auth\Cache\MemoryCacheItemPool;
|
---|
26 | use Google\Auth\HttpHandler\HttpClientCache;
|
---|
27 | use Google\Auth\HttpHandler\HttpHandlerFactory;
|
---|
28 | use GuzzleHttp\Psr7\Request;
|
---|
29 | use GuzzleHttp\Psr7\Utils;
|
---|
30 | use InvalidArgumentException;
|
---|
31 | use phpseclib3\Crypt\PublicKeyLoader;
|
---|
32 | use phpseclib3\Crypt\RSA;
|
---|
33 | use phpseclib3\Math\BigInteger;
|
---|
34 | use Psr\Cache\CacheItemPoolInterface;
|
---|
35 | use RuntimeException;
|
---|
36 | use SimpleJWT\InvalidTokenException;
|
---|
37 | use SimpleJWT\JWT as SimpleJWT;
|
---|
38 | use SimpleJWT\Keys\KeyFactory;
|
---|
39 | use SimpleJWT\Keys\KeySet;
|
---|
40 | use TypeError;
|
---|
41 | use UnexpectedValueException;
|
---|
42 |
|
---|
43 | /**
|
---|
44 | * Wrapper around Google Access Tokens which provides convenience functions.
|
---|
45 | *
|
---|
46 | * @experimental
|
---|
47 | */
|
---|
48 | class AccessToken
|
---|
49 | {
|
---|
50 | const FEDERATED_SIGNON_CERT_URL = 'https://www.googleapis.com/oauth2/v3/certs';
|
---|
51 | const IAP_CERT_URL = 'https://www.gstatic.com/iap/verify/public_key-jwk';
|
---|
52 | const IAP_ISSUER = 'https://cloud.google.com/iap';
|
---|
53 | const OAUTH2_ISSUER = 'accounts.google.com';
|
---|
54 | const OAUTH2_ISSUER_HTTPS = 'https://accounts.google.com';
|
---|
55 | const OAUTH2_REVOKE_URI = 'https://oauth2.googleapis.com/revoke';
|
---|
56 |
|
---|
57 | /**
|
---|
58 | * @var callable
|
---|
59 | */
|
---|
60 | private $httpHandler;
|
---|
61 |
|
---|
62 | /**
|
---|
63 | * @var CacheItemPoolInterface
|
---|
64 | */
|
---|
65 | private $cache;
|
---|
66 |
|
---|
67 | /**
|
---|
68 | * @param callable|null $httpHandler [optional] An HTTP Handler to deliver PSR-7 requests.
|
---|
69 | * @param CacheItemPoolInterface|null $cache [optional] A PSR-6 compatible cache implementation.
|
---|
70 | */
|
---|
71 | public function __construct(
|
---|
72 | ?callable $httpHandler = null,
|
---|
73 | ?CacheItemPoolInterface $cache = null
|
---|
74 | ) {
|
---|
75 | $this->httpHandler = $httpHandler
|
---|
76 | ?: HttpHandlerFactory::build(HttpClientCache::getHttpClient());
|
---|
77 | $this->cache = $cache ?: new MemoryCacheItemPool();
|
---|
78 | }
|
---|
79 |
|
---|
80 | /**
|
---|
81 | * Verifies an id token and returns the authenticated apiLoginTicket.
|
---|
82 | * Throws an exception if the id token is not valid.
|
---|
83 | * The audience parameter can be used to control which id tokens are
|
---|
84 | * accepted. By default, the id token must have been issued to this OAuth2 client.
|
---|
85 | *
|
---|
86 | * @param string $token The JSON Web Token to be verified.
|
---|
87 | * @param array<mixed> $options [optional] {
|
---|
88 | * Configuration options.
|
---|
89 | * @type string $audience The indended recipient of the token.
|
---|
90 | * @type string $issuer The intended issuer of the token.
|
---|
91 | * @type string $cacheKey The cache key of the cached certs. Defaults to
|
---|
92 | * the sha1 of $certsLocation if provided, otherwise is set to
|
---|
93 | * "federated_signon_certs_v3".
|
---|
94 | * @type string $certsLocation The location (remote or local) from which
|
---|
95 | * to retrieve certificates, if not cached. This value should only be
|
---|
96 | * provided in limited circumstances in which you are sure of the
|
---|
97 | * behavior.
|
---|
98 | * @type bool $throwException Whether the function should throw an
|
---|
99 | * exception if the verification fails. This is useful for
|
---|
100 | * determining the reason verification failed.
|
---|
101 | * }
|
---|
102 | * @return array<mixed>|false the token payload, if successful, or false if not.
|
---|
103 | * @throws InvalidArgumentException If certs could not be retrieved from a local file.
|
---|
104 | * @throws InvalidArgumentException If received certs are in an invalid format.
|
---|
105 | * @throws InvalidArgumentException If the cert alg is not supported.
|
---|
106 | * @throws RuntimeException If certs could not be retrieved from a remote location.
|
---|
107 | * @throws UnexpectedValueException If the token issuer does not match.
|
---|
108 | * @throws UnexpectedValueException If the token audience does not match.
|
---|
109 | */
|
---|
110 | public function verify($token, array $options = [])
|
---|
111 | {
|
---|
112 | $audience = $options['audience'] ?? null;
|
---|
113 | $issuer = $options['issuer'] ?? null;
|
---|
114 | $certsLocation = $options['certsLocation'] ?? self::FEDERATED_SIGNON_CERT_URL;
|
---|
115 | $cacheKey = $options['cacheKey'] ?? $this->getCacheKeyFromCertLocation($certsLocation);
|
---|
116 | $throwException = $options['throwException'] ?? false; // for backwards compatibility
|
---|
117 |
|
---|
118 | // Check signature against each available cert.
|
---|
119 | $certs = $this->getCerts($certsLocation, $cacheKey, $options);
|
---|
120 | $alg = $this->determineAlg($certs);
|
---|
121 | if (!in_array($alg, ['RS256', 'ES256'])) {
|
---|
122 | throw new InvalidArgumentException(
|
---|
123 | 'unrecognized "alg" in certs, expected ES256 or RS256'
|
---|
124 | );
|
---|
125 | }
|
---|
126 | try {
|
---|
127 | if ($alg == 'RS256') {
|
---|
128 | return $this->verifyRs256($token, $certs, $audience, $issuer);
|
---|
129 | }
|
---|
130 | return $this->verifyEs256($token, $certs, $audience, $issuer);
|
---|
131 | } catch (ExpiredException $e) { // firebase/php-jwt 5+
|
---|
132 | } catch (SignatureInvalidException $e) { // firebase/php-jwt 5+
|
---|
133 | } catch (InvalidTokenException $e) { // simplejwt
|
---|
134 | } catch (InvalidArgumentException $e) {
|
---|
135 | } catch (UnexpectedValueException $e) {
|
---|
136 | }
|
---|
137 |
|
---|
138 | if ($throwException) {
|
---|
139 | throw $e;
|
---|
140 | }
|
---|
141 |
|
---|
142 | return false;
|
---|
143 | }
|
---|
144 |
|
---|
145 | /**
|
---|
146 | * Identifies the expected algorithm to verify by looking at the "alg" key
|
---|
147 | * of the provided certs.
|
---|
148 | *
|
---|
149 | * @param array<mixed> $certs Certificate array according to the JWK spec (see
|
---|
150 | * https://tools.ietf.org/html/rfc7517).
|
---|
151 | * @return string The expected algorithm, such as "ES256" or "RS256".
|
---|
152 | */
|
---|
153 | private function determineAlg(array $certs)
|
---|
154 | {
|
---|
155 | $alg = null;
|
---|
156 | foreach ($certs as $cert) {
|
---|
157 | if (empty($cert['alg'])) {
|
---|
158 | throw new InvalidArgumentException(
|
---|
159 | 'certs expects "alg" to be set'
|
---|
160 | );
|
---|
161 | }
|
---|
162 | $alg = $alg ?: $cert['alg'];
|
---|
163 |
|
---|
164 | if ($alg != $cert['alg']) {
|
---|
165 | throw new InvalidArgumentException(
|
---|
166 | 'More than one alg detected in certs'
|
---|
167 | );
|
---|
168 | }
|
---|
169 | }
|
---|
170 | return $alg;
|
---|
171 | }
|
---|
172 |
|
---|
173 | /**
|
---|
174 | * Verifies an ES256-signed JWT.
|
---|
175 | *
|
---|
176 | * @param string $token The JSON Web Token to be verified.
|
---|
177 | * @param array<mixed> $certs Certificate array according to the JWK spec (see
|
---|
178 | * https://tools.ietf.org/html/rfc7517).
|
---|
179 | * @param string|null $audience If set, returns false if the provided
|
---|
180 | * audience does not match the "aud" claim on the JWT.
|
---|
181 | * @param string|null $issuer If set, returns false if the provided
|
---|
182 | * issuer does not match the "iss" claim on the JWT.
|
---|
183 | * @return array<mixed> the token payload, if successful, or false if not.
|
---|
184 | */
|
---|
185 | private function verifyEs256($token, array $certs, $audience = null, $issuer = null)
|
---|
186 | {
|
---|
187 | $this->checkSimpleJwt();
|
---|
188 |
|
---|
189 | $jwkset = new KeySet();
|
---|
190 | foreach ($certs as $cert) {
|
---|
191 | $jwkset->add(KeyFactory::create($cert, 'php'));
|
---|
192 | }
|
---|
193 |
|
---|
194 | // Validate the signature using the key set and ES256 algorithm.
|
---|
195 | $jwt = $this->callSimpleJwtDecode([$token, $jwkset, 'ES256']);
|
---|
196 | $payload = $jwt->getClaims();
|
---|
197 |
|
---|
198 | if ($audience) {
|
---|
199 | if (!isset($payload['aud']) || $payload['aud'] != $audience) {
|
---|
200 | throw new UnexpectedValueException('Audience does not match');
|
---|
201 | }
|
---|
202 | }
|
---|
203 |
|
---|
204 | // @see https://cloud.google.com/iap/docs/signed-headers-howto#verifying_the_jwt_payload
|
---|
205 | $issuer = $issuer ?: self::IAP_ISSUER;
|
---|
206 | if (!isset($payload['iss']) || $payload['iss'] !== $issuer) {
|
---|
207 | throw new UnexpectedValueException('Issuer does not match');
|
---|
208 | }
|
---|
209 |
|
---|
210 | return $payload;
|
---|
211 | }
|
---|
212 |
|
---|
213 | /**
|
---|
214 | * Verifies an RS256-signed JWT.
|
---|
215 | *
|
---|
216 | * @param string $token The JSON Web Token to be verified.
|
---|
217 | * @param array<mixed> $certs Certificate array according to the JWK spec (see
|
---|
218 | * https://tools.ietf.org/html/rfc7517).
|
---|
219 | * @param string|null $audience If set, returns false if the provided
|
---|
220 | * audience does not match the "aud" claim on the JWT.
|
---|
221 | * @param string|null $issuer If set, returns false if the provided
|
---|
222 | * issuer does not match the "iss" claim on the JWT.
|
---|
223 | * @return array<mixed> the token payload, if successful, or false if not.
|
---|
224 | */
|
---|
225 | private function verifyRs256($token, array $certs, $audience = null, $issuer = null)
|
---|
226 | {
|
---|
227 | $this->checkAndInitializePhpsec();
|
---|
228 | $keys = [];
|
---|
229 | foreach ($certs as $cert) {
|
---|
230 | if (empty($cert['kid'])) {
|
---|
231 | throw new InvalidArgumentException(
|
---|
232 | 'certs expects "kid" to be set'
|
---|
233 | );
|
---|
234 | }
|
---|
235 | if (empty($cert['n']) || empty($cert['e'])) {
|
---|
236 | throw new InvalidArgumentException(
|
---|
237 | 'RSA certs expects "n" and "e" to be set'
|
---|
238 | );
|
---|
239 | }
|
---|
240 | $publicKey = $this->loadPhpsecPublicKey($cert['n'], $cert['e']);
|
---|
241 |
|
---|
242 | // create an array of key IDs to certs for the JWT library
|
---|
243 | $keys[$cert['kid']] = new Key($publicKey, 'RS256');
|
---|
244 | }
|
---|
245 |
|
---|
246 | $payload = $this->callJwtStatic('decode', [
|
---|
247 | $token,
|
---|
248 | $keys,
|
---|
249 | ]);
|
---|
250 |
|
---|
251 | if ($audience) {
|
---|
252 | if (!property_exists($payload, 'aud') || $payload->aud != $audience) {
|
---|
253 | throw new UnexpectedValueException('Audience does not match');
|
---|
254 | }
|
---|
255 | }
|
---|
256 |
|
---|
257 | // support HTTP and HTTPS issuers
|
---|
258 | // @see https://developers.google.com/identity/sign-in/web/backend-auth
|
---|
259 | $issuers = $issuer ? [$issuer] : [self::OAUTH2_ISSUER, self::OAUTH2_ISSUER_HTTPS];
|
---|
260 | if (!isset($payload->iss) || !in_array($payload->iss, $issuers)) {
|
---|
261 | throw new UnexpectedValueException('Issuer does not match');
|
---|
262 | }
|
---|
263 |
|
---|
264 | return (array) $payload;
|
---|
265 | }
|
---|
266 |
|
---|
267 | /**
|
---|
268 | * Revoke an OAuth2 access token or refresh token. This method will revoke the current access
|
---|
269 | * token, if a token isn't provided.
|
---|
270 | *
|
---|
271 | * @param string|array<mixed> $token The token (access token or a refresh token) that should be revoked.
|
---|
272 | * @param array<mixed> $options [optional] Configuration options.
|
---|
273 | * @return bool Returns True if the revocation was successful, otherwise False.
|
---|
274 | */
|
---|
275 | public function revoke($token, array $options = [])
|
---|
276 | {
|
---|
277 | if (is_array($token)) {
|
---|
278 | if (isset($token['refresh_token'])) {
|
---|
279 | $token = $token['refresh_token'];
|
---|
280 | } else {
|
---|
281 | $token = $token['access_token'];
|
---|
282 | }
|
---|
283 | }
|
---|
284 |
|
---|
285 | $body = Utils::streamFor(http_build_query(['token' => $token]));
|
---|
286 | $request = new Request('POST', self::OAUTH2_REVOKE_URI, [
|
---|
287 | 'Cache-Control' => 'no-store',
|
---|
288 | 'Content-Type' => 'application/x-www-form-urlencoded',
|
---|
289 | ], $body);
|
---|
290 |
|
---|
291 | $httpHandler = $this->httpHandler;
|
---|
292 |
|
---|
293 | $response = $httpHandler($request, $options);
|
---|
294 |
|
---|
295 | return $response->getStatusCode() == 200;
|
---|
296 | }
|
---|
297 |
|
---|
298 | /**
|
---|
299 | * Gets federated sign-on certificates to use for verifying identity tokens.
|
---|
300 | * Returns certs as array structure, where keys are key ids, and values
|
---|
301 | * are PEM encoded certificates.
|
---|
302 | *
|
---|
303 | * @param string $location The location from which to retrieve certs.
|
---|
304 | * @param string $cacheKey The key under which to cache the retrieved certs.
|
---|
305 | * @param array<mixed> $options [optional] Configuration options.
|
---|
306 | * @return array<mixed>
|
---|
307 | * @throws InvalidArgumentException If received certs are in an invalid format.
|
---|
308 | */
|
---|
309 | private function getCerts($location, $cacheKey, array $options = [])
|
---|
310 | {
|
---|
311 | $cacheItem = $this->cache->getItem($cacheKey);
|
---|
312 | $certs = $cacheItem ? $cacheItem->get() : null;
|
---|
313 |
|
---|
314 | $expireTime = null;
|
---|
315 | if (!$certs) {
|
---|
316 | list($certs, $expireTime) = $this->retrieveCertsFromLocation($location, $options);
|
---|
317 | }
|
---|
318 |
|
---|
319 | if (!isset($certs['keys'])) {
|
---|
320 | if ($location !== self::IAP_CERT_URL) {
|
---|
321 | throw new InvalidArgumentException(
|
---|
322 | 'federated sign-on certs expects "keys" to be set'
|
---|
323 | );
|
---|
324 | }
|
---|
325 | throw new InvalidArgumentException(
|
---|
326 | 'certs expects "keys" to be set'
|
---|
327 | );
|
---|
328 | }
|
---|
329 |
|
---|
330 | // Push caching off until after verifying certs are in a valid format.
|
---|
331 | // Don't want to cache bad data.
|
---|
332 | if ($expireTime) {
|
---|
333 | $cacheItem->expiresAt(new DateTime($expireTime));
|
---|
334 | $cacheItem->set($certs);
|
---|
335 | $this->cache->save($cacheItem);
|
---|
336 | }
|
---|
337 |
|
---|
338 | return $certs['keys'];
|
---|
339 | }
|
---|
340 |
|
---|
341 | /**
|
---|
342 | * Retrieve and cache a certificates file.
|
---|
343 | *
|
---|
344 | * @param string $url location
|
---|
345 | * @param array<mixed> $options [optional] Configuration options.
|
---|
346 | * @return array{array<mixed>, string}
|
---|
347 | * @throws InvalidArgumentException If certs could not be retrieved from a local file.
|
---|
348 | * @throws RuntimeException If certs could not be retrieved from a remote location.
|
---|
349 | */
|
---|
350 | private function retrieveCertsFromLocation($url, array $options = [])
|
---|
351 | {
|
---|
352 | // If we're retrieving a local file, just grab it.
|
---|
353 | $expireTime = '+1 hour';
|
---|
354 | if (strpos($url, 'http') !== 0) {
|
---|
355 | if (!file_exists($url)) {
|
---|
356 | throw new InvalidArgumentException(sprintf(
|
---|
357 | 'Failed to retrieve verification certificates from path: %s.',
|
---|
358 | $url
|
---|
359 | ));
|
---|
360 | }
|
---|
361 |
|
---|
362 | return [
|
---|
363 | json_decode((string) file_get_contents($url), true),
|
---|
364 | $expireTime
|
---|
365 | ];
|
---|
366 | }
|
---|
367 |
|
---|
368 | $httpHandler = $this->httpHandler;
|
---|
369 | $response = $httpHandler(new Request('GET', $url), $options);
|
---|
370 |
|
---|
371 | if ($response->getStatusCode() == 200) {
|
---|
372 | if ($cacheControl = $response->getHeaderLine('Cache-Control')) {
|
---|
373 | array_map(function ($value) use (&$expireTime) {
|
---|
374 | list($key, $value) = explode('=', $value) + [null, null];
|
---|
375 | if (trim($key) == 'max-age') {
|
---|
376 | $expireTime = '+' . $value . ' seconds';
|
---|
377 | }
|
---|
378 | }, explode(',', $cacheControl));
|
---|
379 | }
|
---|
380 | return [
|
---|
381 | json_decode((string) $response->getBody(), true),
|
---|
382 | $expireTime
|
---|
383 | ];
|
---|
384 | }
|
---|
385 |
|
---|
386 | throw new RuntimeException(sprintf(
|
---|
387 | 'Failed to retrieve verification certificates: "%s".',
|
---|
388 | $response->getBody()->getContents()
|
---|
389 | ), $response->getStatusCode());
|
---|
390 | }
|
---|
391 |
|
---|
392 | /**
|
---|
393 | * @return void
|
---|
394 | */
|
---|
395 | private function checkAndInitializePhpsec()
|
---|
396 | {
|
---|
397 | if (!class_exists(RSA::class)) {
|
---|
398 | throw new RuntimeException('Please require phpseclib/phpseclib v3 to use this utility.');
|
---|
399 | }
|
---|
400 | }
|
---|
401 |
|
---|
402 | /**
|
---|
403 | * @return string
|
---|
404 | * @throws TypeError If the key cannot be initialized to a string.
|
---|
405 | */
|
---|
406 | private function loadPhpsecPublicKey(string $modulus, string $exponent): string
|
---|
407 | {
|
---|
408 | $key = PublicKeyLoader::load([
|
---|
409 | 'n' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [
|
---|
410 | $modulus,
|
---|
411 | ]), 256),
|
---|
412 | 'e' => new BigInteger($this->callJwtStatic('urlsafeB64Decode', [
|
---|
413 | $exponent
|
---|
414 | ]), 256),
|
---|
415 | ]);
|
---|
416 | $formattedPublicKey = $key->toString('PKCS8');
|
---|
417 | if (!is_string($formattedPublicKey)) {
|
---|
418 | throw new TypeError('Failed to initialize the key');
|
---|
419 | }
|
---|
420 | return $formattedPublicKey;
|
---|
421 | }
|
---|
422 |
|
---|
423 | /**
|
---|
424 | * @return void
|
---|
425 | */
|
---|
426 | private function checkSimpleJwt()
|
---|
427 | {
|
---|
428 | // @codeCoverageIgnoreStart
|
---|
429 | if (!class_exists(SimpleJwt::class)) {
|
---|
430 | throw new RuntimeException('Please require kelvinmo/simplejwt ^0.2 to use this utility.');
|
---|
431 | }
|
---|
432 | // @codeCoverageIgnoreEnd
|
---|
433 | }
|
---|
434 |
|
---|
435 | /**
|
---|
436 | * Provide a hook to mock calls to the JWT static methods.
|
---|
437 | *
|
---|
438 | * @param string $method
|
---|
439 | * @param array<mixed> $args
|
---|
440 | * @return mixed
|
---|
441 | */
|
---|
442 | protected function callJwtStatic($method, array $args = [])
|
---|
443 | {
|
---|
444 | return call_user_func_array([JWT::class, $method], $args); // @phpstan-ignore-line
|
---|
445 | }
|
---|
446 |
|
---|
447 | /**
|
---|
448 | * Provide a hook to mock calls to the JWT static methods.
|
---|
449 | *
|
---|
450 | * @param array<mixed> $args
|
---|
451 | * @return mixed
|
---|
452 | */
|
---|
453 | protected function callSimpleJwtDecode(array $args = [])
|
---|
454 | {
|
---|
455 | return call_user_func_array([SimpleJwt::class, 'decode'], $args);
|
---|
456 | }
|
---|
457 |
|
---|
458 | /**
|
---|
459 | * Generate a cache key based on the cert location using sha1 with the
|
---|
460 | * exception of using "federated_signon_certs_v3" to preserve BC.
|
---|
461 | *
|
---|
462 | * @param string $certsLocation
|
---|
463 | * @return string
|
---|
464 | */
|
---|
465 | private function getCacheKeyFromCertLocation($certsLocation)
|
---|
466 | {
|
---|
467 | $key = $certsLocation === self::FEDERATED_SIGNON_CERT_URL
|
---|
468 | ? 'federated_signon_certs_v3'
|
---|
469 | : sha1($certsLocation);
|
---|
470 |
|
---|
471 | return 'google_auth_certs_cache|' . $key;
|
---|
472 | }
|
---|
473 | }
|
---|