Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 401 and 402] [Versions 401 and 403]

   1  <?php
   2  /*
   3   * Copyright 2008 Google Inc.
   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  if (!class_exists('Google_Client')) {
  19    require_once dirname(__FILE__) . '/../autoload.php';
  20  }
  21  
  22  /**
  23   * Authentication class that deals with the OAuth 2 web-server authentication flow
  24   *
  25   */
  26  class Google_Auth_OAuth2 extends Google_Auth_Abstract
  27  {
  28    const OAUTH2_REVOKE_URI = 'https://accounts.google.com/o/oauth2/revoke';
  29    const OAUTH2_TOKEN_URI = 'https://accounts.google.com/o/oauth2/token';
  30    const OAUTH2_AUTH_URL = 'https://accounts.google.com/o/oauth2/auth';
  31    const CLOCK_SKEW_SECS = 300; // five minutes in seconds
  32    const AUTH_TOKEN_LIFETIME_SECS = 300; // five minutes in seconds
  33    const MAX_TOKEN_LIFETIME_SECS = 86400; // one day in seconds
  34    const OAUTH2_ISSUER = 'accounts.google.com';
  35    const OAUTH2_ISSUER_HTTPS = 'https://accounts.google.com';
  36  
  37    /** @var Google_Auth_AssertionCredentials $assertionCredentials */
  38    private $assertionCredentials;
  39  
  40    /**
  41     * @var string The state parameters for CSRF and other forgery protection.
  42     */
  43    private $state;
  44  
  45    /**
  46     * @var array The token bundle.
  47     */
  48    private $token = array();
  49  
  50    /**
  51     * @var Google_Client the base client
  52     */
  53    private $client;
  54  
  55    /**
  56     * Instantiates the class, but does not initiate the login flow, leaving it
  57     * to the discretion of the caller.
  58     */
  59    public function __construct(Google_Client $client)
  60    {
  61      $this->client = $client;
  62    }
  63  
  64    /**
  65     * Perform an authenticated / signed apiHttpRequest.
  66     * This function takes the apiHttpRequest, calls apiAuth->sign on it
  67     * (which can modify the request in what ever way fits the auth mechanism)
  68     * and then calls apiCurlIO::makeRequest on the signed request
  69     *
  70     * @param Google_Http_Request $request
  71     * @return Google_Http_Request The resulting HTTP response including the
  72     * responseHttpCode, responseHeaders and responseBody.
  73     */
  74    public function authenticatedRequest(Google_Http_Request $request)
  75    {
  76      $request = $this->sign($request);
  77      return $this->client->getIo()->makeRequest($request);
  78    }
  79  
  80    /**
  81     * @param string $code
  82     * @param boolean $crossClient
  83     * @throws Google_Auth_Exception
  84     * @return string
  85     */
  86    public function authenticate($code, $crossClient = false)
  87    {
  88      if (strlen($code) == 0) {
  89        throw new Google_Auth_Exception("Invalid code");
  90      }
  91  
  92      $arguments = array(
  93            'code' => $code,
  94            'grant_type' => 'authorization_code',
  95            'client_id' => $this->client->getClassConfig($this, 'client_id'),
  96            'client_secret' => $this->client->getClassConfig($this, 'client_secret')
  97      );
  98  
  99      if ($crossClient !== true) {
 100          $arguments['redirect_uri'] = $this->client->getClassConfig($this, 'redirect_uri');
 101      }
 102  
 103      // We got here from the redirect from a successful authorization grant,
 104      // fetch the access token
 105      $request = new Google_Http_Request(
 106          self::OAUTH2_TOKEN_URI,
 107          'POST',
 108          array(),
 109          $arguments
 110      );
 111      $request->disableGzip();
 112      $response = $this->client->getIo()->makeRequest($request);
 113  
 114      if ($response->getResponseHttpCode() == 200) {
 115        $this->setAccessToken($response->getResponseBody());
 116        $this->token['created'] = time();
 117        return $this->getAccessToken();
 118      } else {
 119        $decodedResponse = json_decode($response->getResponseBody(), true);
 120        if ($decodedResponse != null && $decodedResponse['error']) {
 121          $errorText = $decodedResponse['error'];
 122          if (isset($decodedResponse['error_description'])) {
 123            $errorText .= ": " . $decodedResponse['error_description'];
 124          }
 125        }
 126        throw new Google_Auth_Exception(
 127            sprintf(
 128                "Error fetching OAuth2 access token, message: '%s'",
 129                $errorText
 130            ),
 131            $response->getResponseHttpCode()
 132        );
 133      }
 134    }
 135  
 136    /**
 137     * Create a URL to obtain user authorization.
 138     * The authorization endpoint allows the user to first
 139     * authenticate, and then grant/deny the access request.
 140     * @param string $scope The scope is expressed as a list of space-delimited strings.
 141     * @return string
 142     */
 143    public function createAuthUrl($scope)
 144    {
 145      $params = array(
 146          'response_type' => 'code',
 147          'redirect_uri' => $this->client->getClassConfig($this, 'redirect_uri'),
 148          'client_id' => $this->client->getClassConfig($this, 'client_id'),
 149          'scope' => $scope,
 150          'access_type' => $this->client->getClassConfig($this, 'access_type'),
 151      );
 152  
 153      // Prefer prompt to approval prompt.
 154      if ($this->client->getClassConfig($this, 'prompt')) {
 155        $params = $this->maybeAddParam($params, 'prompt');
 156      } else {
 157        $params = $this->maybeAddParam($params, 'approval_prompt');
 158      }
 159      $params = $this->maybeAddParam($params, 'login_hint');
 160      $params = $this->maybeAddParam($params, 'hd');
 161      $params = $this->maybeAddParam($params, 'openid.realm');
 162      $params = $this->maybeAddParam($params, 'include_granted_scopes');
 163  
 164      // If the list of scopes contains plus.login, add request_visible_actions
 165      // to auth URL.
 166      $rva = $this->client->getClassConfig($this, 'request_visible_actions');
 167      if (strpos($scope, 'plus.login') && strlen($rva) > 0) {
 168          $params['request_visible_actions'] = $rva;
 169      }
 170  
 171      if (isset($this->state)) {
 172        $params['state'] = $this->state;
 173      }
 174  
 175      return self::OAUTH2_AUTH_URL . "?" . http_build_query($params, '', '&');
 176    }
 177  
 178    /**
 179     * @param string $token
 180     * @throws Google_Auth_Exception
 181     */
 182    public function setAccessToken($token)
 183    {
 184      $token = json_decode($token, true);
 185      if ($token == null) {
 186        throw new Google_Auth_Exception('Could not json decode the token');
 187      }
 188      if (! isset($token['access_token'])) {
 189        throw new Google_Auth_Exception("Invalid token format");
 190      }
 191      $this->token = $token;
 192    }
 193  
 194    public function getAccessToken()
 195    {
 196      return json_encode($this->token);
 197    }
 198  
 199    public function getRefreshToken()
 200    {
 201      if (array_key_exists('refresh_token', $this->token)) {
 202        return $this->token['refresh_token'];
 203      } else {
 204        return null;
 205      }
 206    }
 207  
 208    public function setState($state)
 209    {
 210      $this->state = $state;
 211    }
 212  
 213    public function setAssertionCredentials(Google_Auth_AssertionCredentials $creds)
 214    {
 215      $this->assertionCredentials = $creds;
 216    }
 217  
 218    /**
 219     * Include an accessToken in a given apiHttpRequest.
 220     * @param Google_Http_Request $request
 221     * @return Google_Http_Request
 222     * @throws Google_Auth_Exception
 223     */
 224    public function sign(Google_Http_Request $request)
 225    {
 226      // add the developer key to the request before signing it
 227      if ($this->client->getClassConfig($this, 'developer_key')) {
 228        $request->setQueryParam('key', $this->client->getClassConfig($this, 'developer_key'));
 229      }
 230  
 231      // Cannot sign the request without an OAuth access token.
 232      if (null == $this->token && null == $this->assertionCredentials) {
 233        return $request;
 234      }
 235  
 236      // Check if the token is set to expire in the next 30 seconds
 237      // (or has already expired).
 238      if ($this->isAccessTokenExpired()) {
 239        if ($this->assertionCredentials) {
 240          $this->refreshTokenWithAssertion();
 241        } else {
 242          $this->client->getLogger()->debug('OAuth2 access token expired');
 243          if (! array_key_exists('refresh_token', $this->token)) {
 244            $error = "The OAuth 2.0 access token has expired,"
 245                    ." and a refresh token is not available. Refresh tokens"
 246                    ." are not returned for responses that were auto-approved.";
 247  
 248            $this->client->getLogger()->error($error);
 249            throw new Google_Auth_Exception($error);
 250          }
 251          $this->refreshToken($this->token['refresh_token']);
 252        }
 253      }
 254  
 255      $this->client->getLogger()->debug('OAuth2 authentication');
 256  
 257      // Add the OAuth2 header to the request
 258      $request->setRequestHeaders(
 259          array('Authorization' => 'Bearer ' . $this->token['access_token'])
 260      );
 261  
 262      return $request;
 263    }
 264  
 265    /**
 266     * Fetches a fresh access token with the given refresh token.
 267     * @param string $refreshToken
 268     * @return void
 269     */
 270    public function refreshToken($refreshToken)
 271    {
 272      $this->refreshTokenRequest(
 273          array(
 274            'client_id' => $this->client->getClassConfig($this, 'client_id'),
 275            'client_secret' => $this->client->getClassConfig($this, 'client_secret'),
 276            'refresh_token' => $refreshToken,
 277            'grant_type' => 'refresh_token'
 278          )
 279      );
 280    }
 281  
 282    /**
 283     * Fetches a fresh access token with a given assertion token.
 284     * @param Google_Auth_AssertionCredentials $assertionCredentials optional.
 285     * @return void
 286     */
 287    public function refreshTokenWithAssertion($assertionCredentials = null)
 288    {
 289      if (!$assertionCredentials) {
 290        $assertionCredentials = $this->assertionCredentials;
 291      }
 292  
 293      $cacheKey = $assertionCredentials->getCacheKey();
 294  
 295      if ($cacheKey) {
 296        // We can check whether we have a token available in the
 297        // cache. If it is expired, we can retrieve a new one from
 298        // the assertion.
 299        $token = $this->client->getCache()->get($cacheKey);
 300        if ($token) {
 301          $this->setAccessToken($token);
 302        }
 303        if (!$this->isAccessTokenExpired()) {
 304          return;
 305        }
 306      }
 307  
 308      $this->client->getLogger()->debug('OAuth2 access token expired');
 309      $this->refreshTokenRequest(
 310          array(
 311            'grant_type' => 'assertion',
 312            'assertion_type' => $assertionCredentials->assertionType,
 313            'assertion' => $assertionCredentials->generateAssertion(),
 314          )
 315      );
 316  
 317      if ($cacheKey) {
 318        // Attempt to cache the token.
 319        $this->client->getCache()->set(
 320            $cacheKey,
 321            $this->getAccessToken()
 322        );
 323      }
 324    }
 325  
 326    private function refreshTokenRequest($params)
 327    {
 328      if (isset($params['assertion'])) {
 329        $this->client->getLogger()->info(
 330            'OAuth2 access token refresh with Signed JWT assertion grants.'
 331        );
 332      } else {
 333        $this->client->getLogger()->info('OAuth2 access token refresh');
 334      }
 335  
 336      $http = new Google_Http_Request(
 337          self::OAUTH2_TOKEN_URI,
 338          'POST',
 339          array(),
 340          $params
 341      );
 342      $http->disableGzip();
 343      $request = $this->client->getIo()->makeRequest($http);
 344  
 345      $code = $request->getResponseHttpCode();
 346      $body = $request->getResponseBody();
 347      if (200 == $code) {
 348        $token = json_decode($body, true);
 349        if ($token == null) {
 350          throw new Google_Auth_Exception("Could not json decode the access token");
 351        }
 352  
 353        if (! isset($token['access_token']) || ! isset($token['expires_in'])) {
 354          throw new Google_Auth_Exception("Invalid token format");
 355        }
 356  
 357        if (isset($token['id_token'])) {
 358          $this->token['id_token'] = $token['id_token'];
 359        }
 360        $this->token['access_token'] = $token['access_token'];
 361        $this->token['expires_in'] = $token['expires_in'];
 362        $this->token['created'] = time();
 363      } else {
 364        throw new Google_Auth_Exception("Error refreshing the OAuth2 token, message: '$body'", $code);
 365      }
 366    }
 367  
 368    /**
 369     * Revoke an OAuth2 access token or refresh token. This method will revoke the current access
 370     * token, if a token isn't provided.
 371     * @throws Google_Auth_Exception
 372     * @param string|null $token The token (access token or a refresh token) that should be revoked.
 373     * @return boolean Returns True if the revocation was successful, otherwise False.
 374     */
 375    public function revokeToken($token = null)
 376    {
 377      if (!$token) {
 378        if (!$this->token) {
 379          // Not initialized, no token to actually revoke
 380          return false;
 381        } elseif (array_key_exists('refresh_token', $this->token)) {
 382          $token = $this->token['refresh_token'];
 383        } else {
 384          $token = $this->token['access_token'];
 385        }
 386      }
 387      $request = new Google_Http_Request(
 388          self::OAUTH2_REVOKE_URI,
 389          'POST',
 390          array(),
 391          "token=$token"
 392      );
 393      $request->disableGzip();
 394      $response = $this->client->getIo()->makeRequest($request);
 395      $code = $response->getResponseHttpCode();
 396      if ($code == 200) {
 397        $this->token = null;
 398        return true;
 399      }
 400  
 401      return false;
 402    }
 403  
 404    /**
 405     * Returns if the access_token is expired.
 406     * @return bool Returns True if the access_token is expired.
 407     */
 408    public function isAccessTokenExpired()
 409    {
 410      if (!$this->token || !isset($this->token['created'])) {
 411        return true;
 412      }
 413  
 414      // If the token is set to expire in the next 30 seconds.
 415      $expired = ($this->token['created']
 416          + ($this->token['expires_in'] - 30)) < time();
 417  
 418      return $expired;
 419    }
 420  
 421    // Gets federated sign-on certificates to use for verifying identity tokens.
 422    // Returns certs as array structure, where keys are key ids, and values
 423    // are PEM encoded certificates.
 424    private function getFederatedSignOnCerts()
 425    {
 426      return $this->retrieveCertsFromLocation(
 427          $this->client->getClassConfig($this, 'federated_signon_certs_url')
 428      );
 429    }
 430  
 431    /**
 432     * Retrieve and cache a certificates file.
 433     *
 434     * @param $url string location
 435     * @throws Google_Auth_Exception
 436     * @return array certificates
 437     */
 438    public function retrieveCertsFromLocation($url)
 439    {
 440      // If we're retrieving a local file, just grab it.
 441      if ("http" != substr($url, 0, 4)) {
 442        $file = file_get_contents($url);
 443        if ($file) {
 444          return json_decode($file, true);
 445        } else {
 446          throw new Google_Auth_Exception(
 447              "Failed to retrieve verification certificates: '" .
 448              $url . "'."
 449          );
 450        }
 451      }
 452  
 453      // This relies on makeRequest caching certificate responses.
 454      $request = $this->client->getIo()->makeRequest(
 455          new Google_Http_Request(
 456              $url
 457          )
 458      );
 459      if ($request->getResponseHttpCode() == 200) {
 460        $certs = json_decode($request->getResponseBody(), true);
 461        if ($certs) {
 462          return $certs;
 463        }
 464      }
 465      throw new Google_Auth_Exception(
 466          "Failed to retrieve verification certificates: '" .
 467          $request->getResponseBody() . "'.",
 468          $request->getResponseHttpCode()
 469      );
 470    }
 471  
 472    /**
 473     * Verifies an id token and returns the authenticated apiLoginTicket.
 474     * Throws an exception if the id token is not valid.
 475     * The audience parameter can be used to control which id tokens are
 476     * accepted.  By default, the id token must have been issued to this OAuth2 client.
 477     *
 478     * @param $id_token
 479     * @param $audience
 480     * @return Google_Auth_LoginTicket
 481     */
 482    public function verifyIdToken($id_token = null, $audience = null)
 483    {
 484      if (!$id_token) {
 485        $id_token = $this->token['id_token'];
 486      }
 487      $certs = $this->getFederatedSignonCerts();
 488      if (!$audience) {
 489        $audience = $this->client->getClassConfig($this, 'client_id');
 490      }
 491  
 492      return $this->verifySignedJwtWithCerts(
 493          $id_token,
 494          $certs,
 495          $audience,
 496          array(self::OAUTH2_ISSUER, self::OAUTH2_ISSUER_HTTPS)
 497      );
 498    }
 499  
 500    /**
 501     * Verifies the id token, returns the verified token contents.
 502     *
 503     * @param $jwt string the token
 504     * @param $certs array of certificates
 505     * @param $required_audience string the expected consumer of the token
 506     * @param [$issuer] the expected issues, defaults to Google
 507     * @param [$max_expiry] the max lifetime of a token, defaults to MAX_TOKEN_LIFETIME_SECS
 508     * @throws Google_Auth_Exception
 509     * @return mixed token information if valid, false if not
 510     */
 511    public function verifySignedJwtWithCerts(
 512        $jwt,
 513        $certs,
 514        $required_audience,
 515        $issuer = null,
 516        $max_expiry = null
 517    ) {
 518      if (!$max_expiry) {
 519        // Set the maximum time we will accept a token for.
 520        $max_expiry = self::MAX_TOKEN_LIFETIME_SECS;
 521      }
 522  
 523      $segments = explode(".", $jwt);
 524      if (count($segments) != 3) {
 525        throw new Google_Auth_Exception("Wrong number of segments in token: $jwt");
 526      }
 527      $signed = $segments[0] . "." . $segments[1];
 528      $signature = Google_Utils::urlSafeB64Decode($segments[2]);
 529  
 530      // Parse envelope.
 531      $envelope = json_decode(Google_Utils::urlSafeB64Decode($segments[0]), true);
 532      if (!$envelope) {
 533        throw new Google_Auth_Exception("Can't parse token envelope: " . $segments[0]);
 534      }
 535  
 536      // Parse token
 537      $json_body = Google_Utils::urlSafeB64Decode($segments[1]);
 538      $payload = json_decode($json_body, true);
 539      if (!$payload) {
 540        throw new Google_Auth_Exception("Can't parse token payload: " . $segments[1]);
 541      }
 542  
 543      // Check signature
 544      $verified = false;
 545      foreach ($certs as $keyName => $pem) {
 546        $public_key = new Google_Verifier_Pem($pem);
 547        if ($public_key->verify($signed, $signature)) {
 548          $verified = true;
 549          break;
 550        }
 551      }
 552  
 553      if (!$verified) {
 554        throw new Google_Auth_Exception("Invalid token signature: $jwt");
 555      }
 556  
 557      // Check issued-at timestamp
 558      $iat = 0;
 559      if (array_key_exists("iat", $payload)) {
 560        $iat = $payload["iat"];
 561      }
 562      if (!$iat) {
 563        throw new Google_Auth_Exception("No issue time in token: $json_body");
 564      }
 565      $earliest = $iat - self::CLOCK_SKEW_SECS;
 566  
 567      // Check expiration timestamp
 568      $now = time();
 569      $exp = 0;
 570      if (array_key_exists("exp", $payload)) {
 571        $exp = $payload["exp"];
 572      }
 573      if (!$exp) {
 574        throw new Google_Auth_Exception("No expiration time in token: $json_body");
 575      }
 576      if ($exp >= $now + $max_expiry) {
 577        throw new Google_Auth_Exception(
 578            sprintf("Expiration time too far in future: %s", $json_body)
 579        );
 580      }
 581  
 582      $latest = $exp + self::CLOCK_SKEW_SECS;
 583      if ($now < $earliest) {
 584        throw new Google_Auth_Exception(
 585            sprintf(
 586                "Token used too early, %s < %s: %s",
 587                $now,
 588                $earliest,
 589                $json_body
 590            )
 591        );
 592      }
 593      if ($now > $latest) {
 594        throw new Google_Auth_Exception(
 595            sprintf(
 596                "Token used too late, %s > %s: %s",
 597                $now,
 598                $latest,
 599                $json_body
 600            )
 601        );
 602      }
 603  
 604      // support HTTP and HTTPS issuers
 605      // @see https://developers.google.com/identity/sign-in/web/backend-auth
 606      $iss = $payload['iss'];
 607      if ($issuer && !in_array($iss, (array) $issuer)) {
 608        throw new Google_Auth_Exception(
 609            sprintf(
 610                "Invalid issuer, %s not in %s: %s",
 611                $iss,
 612                "[".implode(",", (array) $issuer)."]",
 613                $json_body
 614            )
 615        );
 616      }
 617  
 618      // Check audience
 619      $aud = $payload["aud"];
 620      if ($aud != $required_audience) {
 621        throw new Google_Auth_Exception(
 622            sprintf(
 623                "Wrong recipient, %s != %s:",
 624                $aud,
 625                $required_audience,
 626                $json_body
 627            )
 628        );
 629      }
 630  
 631      // All good.
 632      return new Google_Auth_LoginTicket($envelope, $payload);
 633    }
 634  
 635    /**
 636     * Add a parameter to the auth params if not empty string.
 637     */
 638    private function maybeAddParam($params, $name)
 639    {
 640      $param = $this->client->getClassConfig($this, $name);
 641      if ($param != '') {
 642        $params[$name] = $param;
 643      }
 644      return $params;
 645    }
 646  }