Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]

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