1<?php
2
3/*
4 * Copyright 2008 Google Inc.
5 *
6 * Licensed under the Apache License, Version 2.0 (the "License");
7 * you may not use this file except in compliance with the License.
8 * You may obtain a copy of the License at
9 *
10 *     http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing, software
13 * distributed under the License is distributed on an "AS IS" BASIS,
14 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 * See the License for the specific language governing permissions and
16 * limitations under the License.
17 */
18
19namespace Google\AccessToken;
20
21use Firebase\JWT\ExpiredException as ExpiredExceptionV3;
22use Firebase\JWT\SignatureInvalidException;
23use Firebase\JWT\Key;
24use GuzzleHttp\Client;
25use GuzzleHttp\ClientInterface;
26use InvalidArgumentException;
27use phpseclib3\Crypt\PublicKeyLoader;
28use phpseclib3\Crypt\RSA\PublicKey;
29use Psr\Cache\CacheItemPoolInterface;
30use Google\Auth\Cache\MemoryCacheItemPool;
31use Google\Exception as GoogleException;
32use DateTime;
33use DomainException;
34use Exception;
35use ExpiredException; // Firebase v2
36use LogicException;
37
38/**
39 * Wrapper around Google Access Tokens which provides convenience functions
40 *
41 */
42class Verify
43{
44  const FEDERATED_SIGNON_CERT_URL = 'https://www.googleapis.com/oauth2/v3/certs';
45  const OAUTH2_ISSUER = 'accounts.google.com';
46  const OAUTH2_ISSUER_HTTPS = 'https://accounts.google.com';
47
48  /**
49   * @var ClientInterface The http client
50   */
51  private $http;
52
53  /**
54   * @var CacheItemPoolInterface cache class
55   */
56  private $cache;
57
58  /**
59   * Instantiates the class, but does not initiate the login flow, leaving it
60   * to the discretion of the caller.
61   */
62  public function __construct(
63      ClientInterface $http = null,
64      CacheItemPoolInterface $cache = null,
65      $jwt = null
66  ) {
67    if (null === $http) {
68      $http = new Client();
69    }
70
71    if (null === $cache) {
72      $cache = new MemoryCacheItemPool;
73    }
74
75    $this->http = $http;
76    $this->cache = $cache;
77    $this->jwt = $jwt ?: $this->getJwtService();
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 $idToken the ID token in JWT format
87   * @param string $audience Optional. The audience to verify against JWt "aud"
88   * @return array the token payload, if successful
89   */
90  public function verifyIdToken($idToken, $audience = null)
91  {
92    if (empty($idToken)) {
93      throw new LogicException('id_token cannot be null');
94    }
95
96    // set phpseclib constants if applicable
97    $this->setPhpsecConstants();
98
99    // Check signature
100    $certs = $this->getFederatedSignOnCerts();
101    foreach ($certs as $cert) {
102      try {
103        $args = [$idToken];
104        $publicKey = $this->getPublicKey($cert);
105        if (class_exists(Key::class)) {
106            $args[] = new Key($publicKey, 'RS256');
107        } else {
108            $args[] = $publicKey;
109            $args[] = ['RS256'];
110        }
111        $payload = \call_user_func_array([$this->jwt, 'decode'], $args);
112
113        if (property_exists($payload, 'aud')) {
114          if ($audience && $payload->aud != $audience) {
115            return false;
116          }
117        }
118
119        // support HTTP and HTTPS issuers
120        // @see https://developers.google.com/identity/sign-in/web/backend-auth
121        $issuers = array(self::OAUTH2_ISSUER, self::OAUTH2_ISSUER_HTTPS);
122        if (!isset($payload->iss) || !in_array($payload->iss, $issuers)) {
123          return false;
124        }
125
126        return (array) $payload;
127      } catch (ExpiredException $e) {
128        return false;
129      } catch (ExpiredExceptionV3 $e) {
130        return false;
131      } catch (SignatureInvalidException $e) {
132        // continue
133      } catch (DomainException $e) {
134        // continue
135      }
136    }
137
138    return false;
139  }
140
141  private function getCache()
142  {
143    return $this->cache;
144  }
145
146  /**
147   * Retrieve and cache a certificates file.
148   *
149   * @param $url string location
150   * @throws \Google\Exception
151   * @return array certificates
152   */
153  private function retrieveCertsFromLocation($url)
154  {
155    // If we're retrieving a local file, just grab it.
156    if (0 !== strpos($url, 'http')) {
157      if (!$file = file_get_contents($url)) {
158        throw new GoogleException(
159            "Failed to retrieve verification certificates: '" .
160            $url . "'."
161        );
162      }
163
164      return json_decode($file, true);
165    }
166
167    $response = $this->http->get($url);
168
169    if ($response->getStatusCode() == 200) {
170      return json_decode((string) $response->getBody(), true);
171    }
172    throw new GoogleException(
173        sprintf(
174            'Failed to retrieve verification certificates: "%s".',
175            $response->getBody()->getContents()
176        ),
177        $response->getStatusCode()
178    );
179  }
180
181  // Gets federated sign-on certificates to use for verifying identity tokens.
182  // Returns certs as array structure, where keys are key ids, and values
183  // are PEM encoded certificates.
184  private function getFederatedSignOnCerts()
185  {
186    $certs = null;
187    if ($cache = $this->getCache()) {
188      $cacheItem = $cache->getItem('federated_signon_certs_v3');
189      $certs = $cacheItem->get();
190    }
191
192
193    if (!$certs) {
194      $certs = $this->retrieveCertsFromLocation(
195          self::FEDERATED_SIGNON_CERT_URL
196      );
197
198      if ($cache) {
199        $cacheItem->expiresAt(new DateTime('+1 hour'));
200        $cacheItem->set($certs);
201        $cache->save($cacheItem);
202      }
203    }
204
205    if (!isset($certs['keys'])) {
206      throw new InvalidArgumentException(
207          'federated sign-on certs expects "keys" to be set'
208      );
209    }
210
211    return $certs['keys'];
212  }
213
214  private function getJwtService()
215  {
216    $jwtClass = 'JWT';
217    if (class_exists('\Firebase\JWT\JWT')) {
218      $jwtClass = 'Firebase\JWT\JWT';
219    }
220
221    if (property_exists($jwtClass, 'leeway') && $jwtClass::$leeway < 1) {
222      // Ensures JWT leeway is at least 1
223      // @see https://github.com/google/google-api-php-client/issues/827
224      $jwtClass::$leeway = 1;
225    }
226
227    return new $jwtClass;
228  }
229
230  private function getPublicKey($cert)
231  {
232    $bigIntClass = $this->getBigIntClass();
233    $modulus = new $bigIntClass($this->jwt->urlsafeB64Decode($cert['n']), 256);
234    $exponent = new $bigIntClass($this->jwt->urlsafeB64Decode($cert['e']), 256);
235    $component = array('n' => $modulus, 'e' => $exponent);
236
237    if (class_exists('phpseclib3\Crypt\RSA\PublicKey')) {
238      /** @var PublicKey $loader */
239      $loader = PublicKeyLoader::load($component);
240
241      return $loader->toString('PKCS8');
242    }
243
244    $rsaClass = $this->getRsaClass();
245    $rsa = new $rsaClass();
246    $rsa->loadKey($component);
247
248    return $rsa->getPublicKey();
249  }
250
251  private function getRsaClass()
252  {
253    if (class_exists('phpseclib3\Crypt\RSA')) {
254      return 'phpseclib3\Crypt\RSA';
255    }
256
257    if (class_exists('phpseclib\Crypt\RSA')) {
258      return 'phpseclib\Crypt\RSA';
259    }
260
261    return 'Crypt_RSA';
262  }
263
264  private function getBigIntClass()
265  {
266    if (class_exists('phpseclib3\Math\BigInteger')) {
267      return 'phpseclib3\Math\BigInteger';
268    }
269
270    if (class_exists('phpseclib\Math\BigInteger')) {
271      return 'phpseclib\Math\BigInteger';
272    }
273
274    return 'Math_BigInteger';
275  }
276
277  private function getOpenSslConstant()
278  {
279    if (class_exists('phpseclib3\Crypt\AES')) {
280      return 'phpseclib3\Crypt\AES::ENGINE_OPENSSL';
281    }
282
283    if (class_exists('phpseclib\Crypt\RSA')) {
284      return 'phpseclib\Crypt\RSA::MODE_OPENSSL';
285    }
286
287    if (class_exists('Crypt_RSA')) {
288      return 'CRYPT_RSA_MODE_OPENSSL';
289    }
290
291    throw new Exception('Cannot find RSA class');
292  }
293
294  /**
295   * phpseclib calls "phpinfo" by default, which requires special
296   * whitelisting in the AppEngine VM environment. This function
297   * sets constants to bypass the need for phpseclib to check phpinfo
298   *
299   * @see phpseclib/Math/BigInteger
300   * @see https://github.com/GoogleCloudPlatform/getting-started-php/issues/85
301   */
302  private function setPhpsecConstants()
303  {
304    if (filter_var(getenv('GAE_VM'), FILTER_VALIDATE_BOOLEAN)) {
305      if (!defined('MATH_BIGINTEGER_OPENSSL_ENABLED')) {
306        define('MATH_BIGINTEGER_OPENSSL_ENABLED', true);
307      }
308      if (!defined('CRYPT_RSA_MODE')) {
309        define('CRYPT_RSA_MODE', constant($this->getOpenSslConstant()));
310      }
311    }
312  }
313}
314