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 
19 namespace Google\AccessToken;
20 
21 use Firebase\JWT\ExpiredException as ExpiredExceptionV3;
22 use Firebase\JWT\SignatureInvalidException;
23 use Firebase\JWT\Key;
24 use GuzzleHttp\Client;
25 use GuzzleHttp\ClientInterface;
26 use InvalidArgumentException;
27 use phpseclib3\Crypt\PublicKeyLoader;
28 use phpseclib3\Crypt\RSA\PublicKey;
29 use Psr\Cache\CacheItemPoolInterface;
30 use Google\Auth\Cache\MemoryCacheItemPool;
31 use Google\Exception as GoogleException;
32 use DateTime;
33 use DomainException;
34 use Exception;
35 use ExpiredException; // Firebase v2
36 use LogicException;
37 
38 /**
39  * Wrapper around Google Access Tokens which provides convenience functions
40  *
41  */
42 class 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