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
19use Firebase\JWT\ExpiredException as ExpiredExceptionV3;
20use Firebase\JWT\SignatureInvalidException;
21use GuzzleHttp\Client;
22use GuzzleHttp\ClientInterface;
23use Psr\Cache\CacheItemPoolInterface;
24use Google\Auth\Cache\MemoryCacheItemPool;
25use Stash\Driver\FileSystem;
26use Stash\Pool;
27
28/**
29 * Wrapper around Google Access Tokens which provides convenience functions
30 *
31 */
32class Google_AccessToken_Verify
33{
34  const FEDERATED_SIGNON_CERT_URL = 'https://www.googleapis.com/oauth2/v3/certs';
35  const OAUTH2_ISSUER = 'accounts.google.com';
36  const OAUTH2_ISSUER_HTTPS = 'https://accounts.google.com';
37
38  /**
39   * @var GuzzleHttp\ClientInterface The http client
40   */
41  private $http;
42
43  /**
44   * @var Psr\Cache\CacheItemPoolInterface cache class
45   */
46  private $cache;
47
48  /**
49   * Instantiates the class, but does not initiate the login flow, leaving it
50   * to the discretion of the caller.
51   */
52  public function __construct(
53      ClientInterface $http = null,
54      CacheItemPoolInterface $cache = null,
55      $jwt = null
56  ) {
57    if (null === $http) {
58      $http = new Client();
59    }
60
61    if (null === $cache) {
62      $cache = new MemoryCacheItemPool;
63    }
64
65    $this->http = $http;
66    $this->cache = $cache;
67    $this->jwt = $jwt ?: $this->getJwtService();
68  }
69
70  /**
71   * Verifies an id token and returns the authenticated apiLoginTicket.
72   * Throws an exception if the id token is not valid.
73   * The audience parameter can be used to control which id tokens are
74   * accepted.  By default, the id token must have been issued to this OAuth2 client.
75   *
76   * @param $audience
77   * @return array the token payload, if successful
78   */
79  public function verifyIdToken($idToken, $audience = null)
80  {
81    if (empty($idToken)) {
82      throw new LogicException('id_token cannot be null');
83    }
84
85    // set phpseclib constants if applicable
86    $this->setPhpsecConstants();
87
88    // Check signature
89    $certs = $this->getFederatedSignOnCerts();
90    foreach ($certs as $cert) {
91      $bigIntClass = $this->getBigIntClass();
92      $rsaClass = $this->getRsaClass();
93      $modulus = new $bigIntClass($this->jwt->urlsafeB64Decode($cert['n']), 256);
94      $exponent = new $bigIntClass($this->jwt->urlsafeB64Decode($cert['e']), 256);
95
96      $rsa = new $rsaClass();
97      $rsa->loadKey(array('n' => $modulus, 'e' => $exponent));
98
99      try {
100        $payload = $this->jwt->decode(
101            $idToken,
102            $rsa->getPublicKey(),
103            array('RS256')
104        );
105
106        if (property_exists($payload, 'aud')) {
107          if ($audience && $payload->aud != $audience) {
108            return false;
109          }
110        }
111
112        // support HTTP and HTTPS issuers
113        // @see https://developers.google.com/identity/sign-in/web/backend-auth
114        $issuers = array(self::OAUTH2_ISSUER, self::OAUTH2_ISSUER_HTTPS);
115        if (!isset($payload->iss) || !in_array($payload->iss, $issuers)) {
116          return false;
117        }
118
119        return (array) $payload;
120      } catch (ExpiredException $e) {
121        return false;
122      } catch (ExpiredExceptionV3 $e) {
123        return false;
124      } catch (SignatureInvalidException $e) {
125        // continue
126      } catch (DomainException $e) {
127        // continue
128      }
129    }
130
131    return false;
132  }
133
134  private function getCache()
135  {
136    return $this->cache;
137  }
138
139  /**
140   * Retrieve and cache a certificates file.
141   *
142   * @param $url string location
143   * @throws Google_Exception
144   * @return array certificates
145   */
146  private function retrieveCertsFromLocation($url)
147  {
148    // If we're retrieving a local file, just grab it.
149    if (0 !== strpos($url, 'http')) {
150      if (!$file = file_get_contents($url)) {
151        throw new Google_Exception(
152            "Failed to retrieve verification certificates: '" .
153            $url . "'."
154        );
155      }
156
157      return json_decode($file, true);
158    }
159
160    $response = $this->http->get($url);
161
162    if ($response->getStatusCode() == 200) {
163      return json_decode((string) $response->getBody(), true);
164    }
165    throw new Google_Exception(
166        sprintf(
167            'Failed to retrieve verification certificates: "%s".',
168            $response->getBody()->getContents()
169        ),
170        $response->getStatusCode()
171    );
172  }
173
174  // Gets federated sign-on certificates to use for verifying identity tokens.
175  // Returns certs as array structure, where keys are key ids, and values
176  // are PEM encoded certificates.
177  private function getFederatedSignOnCerts()
178  {
179    $certs = null;
180    if ($cache = $this->getCache()) {
181      $cacheItem = $cache->getItem('federated_signon_certs_v3');
182      $certs = $cacheItem->get();
183    }
184
185
186    if (!$certs) {
187      $certs = $this->retrieveCertsFromLocation(
188          self::FEDERATED_SIGNON_CERT_URL
189      );
190
191      if ($cache) {
192        $cacheItem->expiresAt(new DateTime('+1 hour'));
193        $cacheItem->set($certs);
194        $cache->save($cacheItem);
195      }
196    }
197
198    if (!isset($certs['keys'])) {
199      throw new InvalidArgumentException(
200          'federated sign-on certs expects "keys" to be set'
201      );
202    }
203
204    return $certs['keys'];
205  }
206
207  private function getJwtService()
208  {
209    $jwtClass = 'JWT';
210    if (class_exists('\Firebase\JWT\JWT')) {
211      $jwtClass = 'Firebase\JWT\JWT';
212    }
213
214    if (property_exists($jwtClass, 'leeway') && $jwtClass::$leeway < 1) {
215      // Ensures JWT leeway is at least 1
216      // @see https://github.com/google/google-api-php-client/issues/827
217      $jwtClass::$leeway = 1;
218    }
219
220    return new $jwtClass;
221  }
222
223  private function getRsaClass()
224  {
225    if (class_exists('phpseclib\Crypt\RSA')) {
226      return 'phpseclib\Crypt\RSA';
227    }
228
229    return 'Crypt_RSA';
230  }
231
232  private function getBigIntClass()
233  {
234    if (class_exists('phpseclib\Math\BigInteger')) {
235      return 'phpseclib\Math\BigInteger';
236    }
237
238    return 'Math_BigInteger';
239  }
240
241  private function getOpenSslConstant()
242  {
243    if (class_exists('phpseclib\Crypt\RSA')) {
244      return 'phpseclib\Crypt\RSA::MODE_OPENSSL';
245    }
246
247    if (class_exists('Crypt_RSA')) {
248      return 'CRYPT_RSA_MODE_OPENSSL';
249    }
250
251    throw new \Exception('Cannot find RSA class');
252  }
253
254  /**
255   * phpseclib calls "phpinfo" by default, which requires special
256   * whitelisting in the AppEngine VM environment. This function
257   * sets constants to bypass the need for phpseclib to check phpinfo
258   *
259   * @see phpseclib/Math/BigInteger
260   * @see https://github.com/GoogleCloudPlatform/getting-started-php/issues/85
261   */
262  private function setPhpsecConstants()
263  {
264    if (filter_var(getenv('GAE_VM'), FILTER_VALIDATE_BOOLEAN)) {
265      if (!defined('MATH_BIGINTEGER_OPENSSL_ENABLED')) {
266        define('MATH_BIGINTEGER_OPENSSL_ENABLED', true);
267      }
268      if (!defined('CRYPT_RSA_MODE')) {
269        define('CRYPT_RSA_MODE', constant($this->getOpenSslConstant()));
270      }
271    }
272  }
273}
274