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