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 18require_once "Google_Verifier.php"; 19require_once "Google_LoginTicket.php"; 20require_once "service/Google_Utils.php"; 21 22/** 23 * Authentication class that deals with the OAuth 2 web-server authentication flow 24 * 25 * @author Chris Chabot <chabotc@google.com> 26 * @author Chirag Shah <chirags@google.com> 27 * 28 */ 29class Google_OAuth2 extends Google_Auth { 30 public $clientId; 31 public $clientSecret; 32 public $developerKey; 33 public $token; 34 public $redirectUri; 35 public $state; 36 public $accessType = 'offline'; 37 public $approvalPrompt = 'force'; 38 public $requestVisibleActions; 39 40 /** @var Google_AssertionCredentials $assertionCredentials */ 41 public $assertionCredentials; 42 43 const OAUTH2_REVOKE_URI = 'https://accounts.google.com/o/oauth2/revoke'; 44 const OAUTH2_TOKEN_URI = 'https://accounts.google.com/o/oauth2/token'; 45 const OAUTH2_AUTH_URL = 'https://accounts.google.com/o/oauth2/auth'; 46 const OAUTH2_FEDERATED_SIGNON_CERTS_URL = 'https://www.googleapis.com/oauth2/v1/certs'; 47 const CLOCK_SKEW_SECS = 300; // five minutes in seconds 48 const AUTH_TOKEN_LIFETIME_SECS = 300; // five minutes in seconds 49 const MAX_TOKEN_LIFETIME_SECS = 86400; // one day in seconds 50 51 /** 52 * Instantiates the class, but does not initiate the login flow, leaving it 53 * to the discretion of the caller (which is done by calling authenticate()). 54 */ 55 public function __construct() { 56 global $apiConfig; 57 58 if (! empty($apiConfig['developer_key'])) { 59 $this->developerKey = $apiConfig['developer_key']; 60 } 61 62 if (! empty($apiConfig['oauth2_client_id'])) { 63 $this->clientId = $apiConfig['oauth2_client_id']; 64 } 65 66 if (! empty($apiConfig['oauth2_client_secret'])) { 67 $this->clientSecret = $apiConfig['oauth2_client_secret']; 68 } 69 70 if (! empty($apiConfig['oauth2_redirect_uri'])) { 71 $this->redirectUri = $apiConfig['oauth2_redirect_uri']; 72 } 73 74 if (! empty($apiConfig['oauth2_access_type'])) { 75 $this->accessType = $apiConfig['oauth2_access_type']; 76 } 77 78 if (! empty($apiConfig['oauth2_approval_prompt'])) { 79 $this->approvalPrompt = $apiConfig['oauth2_approval_prompt']; 80 } 81 82 } 83 84 /** 85 * @param $service 86 * @param string|null $code 87 * @throws Google_AuthException 88 * @return string 89 */ 90 public function authenticate($service, $code = null) { 91 if (!$code && isset($_GET['code'])) { 92 $code = $_GET['code']; 93 } 94 95 if ($code) { 96 // We got here from the redirect from a successful authorization grant, fetch the access token 97 $request = Google_Client::$io->makeRequest(new Google_HttpRequest(self::OAUTH2_TOKEN_URI, 'POST', array(), array( 98 'code' => $code, 99 'grant_type' => 'authorization_code', 100 'redirect_uri' => $this->redirectUri, 101 'client_id' => $this->clientId, 102 'client_secret' => $this->clientSecret 103 ))); 104 105 if ($request->getResponseHttpCode() == 200) { 106 $this->setAccessToken($request->getResponseBody()); 107 $this->token['created'] = time(); 108 return $this->getAccessToken(); 109 } else { 110 $response = $request->getResponseBody(); 111 $decodedResponse = json_decode($response, true); 112 if ($decodedResponse != null && $decodedResponse['error']) { 113 $response = $decodedResponse['error']; 114 } 115 throw new Google_AuthException("Error fetching OAuth2 access token, message: '$response'", $request->getResponseHttpCode()); 116 } 117 } 118 119 $authUrl = $this->createAuthUrl($service['scope']); 120 header('Location: ' . $authUrl); 121 return true; 122 } 123 124 /** 125 * Create a URL to obtain user authorization. 126 * The authorization endpoint allows the user to first 127 * authenticate, and then grant/deny the access request. 128 * @param string $scope The scope is expressed as a list of space-delimited strings. 129 * @return string 130 */ 131 public function createAuthUrl($scope) { 132 $params = array( 133 'response_type=code', 134 'redirect_uri=' . urlencode($this->redirectUri), 135 'client_id=' . urlencode($this->clientId), 136 'scope=' . urlencode($scope), 137 'access_type=' . urlencode($this->accessType), 138 'approval_prompt=' . urlencode($this->approvalPrompt), 139 ); 140 141 // if the list of scopes contains plus.login, add request_visible_actions 142 // to auth URL 143 if(strpos($scope, 'plus.login') && count($this->requestVisibleActions) > 0) { 144 $params[] = 'request_visible_actions=' . 145 urlencode($this->requestVisibleActions); 146 } 147 148 if (isset($this->state)) { 149 $params[] = 'state=' . urlencode($this->state); 150 } 151 $params = implode('&', $params); 152 return self::OAUTH2_AUTH_URL . "?$params"; 153 } 154 155 /** 156 * @param string $token 157 * @throws Google_AuthException 158 */ 159 public function setAccessToken($token) { 160 $token = json_decode($token, true); 161 if ($token == null) { 162 throw new Google_AuthException('Could not json decode the token'); 163 } 164 if (! isset($token['access_token'])) { 165 throw new Google_AuthException("Invalid token format"); 166 } 167 $this->token = $token; 168 } 169 170 public function getAccessToken() { 171 return json_encode($this->token); 172 } 173 174 public function setDeveloperKey($developerKey) { 175 $this->developerKey = $developerKey; 176 } 177 178 public function setState($state) { 179 $this->state = $state; 180 } 181 182 public function setAccessType($accessType) { 183 $this->accessType = $accessType; 184 } 185 186 public function setApprovalPrompt($approvalPrompt) { 187 $this->approvalPrompt = $approvalPrompt; 188 } 189 190 public function setAssertionCredentials(Google_AssertionCredentials $creds) { 191 $this->assertionCredentials = $creds; 192 } 193 194 /** 195 * Include an accessToken in a given apiHttpRequest. 196 * @param Google_HttpRequest $request 197 * @return Google_HttpRequest 198 * @throws Google_AuthException 199 */ 200 public function sign(Google_HttpRequest $request) { 201 // add the developer key to the request before signing it 202 if ($this->developerKey) { 203 $requestUrl = $request->getUrl(); 204 $requestUrl .= (strpos($request->getUrl(), '?') === false) ? '?' : '&'; 205 $requestUrl .= 'key=' . urlencode($this->developerKey); 206 $request->setUrl($requestUrl); 207 } 208 209 // Cannot sign the request without an OAuth access token. 210 if (null == $this->token && null == $this->assertionCredentials) { 211 return $request; 212 } 213 214 // Check if the token is set to expire in the next 30 seconds 215 // (or has already expired). 216 if ($this->isAccessTokenExpired()) { 217 if ($this->assertionCredentials) { 218 $this->refreshTokenWithAssertion(); 219 } else { 220 if (! array_key_exists('refresh_token', $this->token)) { 221 throw new Google_AuthException("The OAuth 2.0 access token has expired, " 222 . "and a refresh token is not available. Refresh tokens are not " 223 . "returned for responses that were auto-approved."); 224 } 225 $this->refreshToken($this->token['refresh_token']); 226 } 227 } 228 229 // Add the OAuth2 header to the request 230 $request->setRequestHeaders( 231 array('Authorization' => 'Bearer ' . $this->token['access_token']) 232 ); 233 234 return $request; 235 } 236 237 /** 238 * Fetches a fresh access token with the given refresh token. 239 * @param string $refreshToken 240 * @return void 241 */ 242 public function refreshToken($refreshToken) { 243 $this->refreshTokenRequest(array( 244 'client_id' => $this->clientId, 245 'client_secret' => $this->clientSecret, 246 'refresh_token' => $refreshToken, 247 'grant_type' => 'refresh_token' 248 )); 249 } 250 251 /** 252 * Fetches a fresh access token with a given assertion token. 253 * @param Google_AssertionCredentials $assertionCredentials optional. 254 * @return void 255 */ 256 public function refreshTokenWithAssertion($assertionCredentials = null) { 257 if (!$assertionCredentials) { 258 $assertionCredentials = $this->assertionCredentials; 259 } 260 261 $this->refreshTokenRequest(array( 262 'grant_type' => 'assertion', 263 'assertion_type' => $assertionCredentials->assertionType, 264 'assertion' => $assertionCredentials->generateAssertion(), 265 )); 266 } 267 268 private function refreshTokenRequest($params) { 269 $http = new Google_HttpRequest(self::OAUTH2_TOKEN_URI, 'POST', array(), $params); 270 $request = Google_Client::$io->makeRequest($http); 271 272 $code = $request->getResponseHttpCode(); 273 $body = $request->getResponseBody(); 274 if (200 == $code) { 275 $token = json_decode($body, true); 276 if ($token == null) { 277 throw new Google_AuthException("Could not json decode the access token"); 278 } 279 280 if (! isset($token['access_token']) || ! isset($token['expires_in'])) { 281 throw new Google_AuthException("Invalid token format"); 282 } 283 284 $this->token['access_token'] = $token['access_token']; 285 $this->token['expires_in'] = $token['expires_in']; 286 $this->token['created'] = time(); 287 } else { 288 throw new Google_AuthException("Error refreshing the OAuth2 token, message: '$body'", $code); 289 } 290 } 291 292 /** 293 * Revoke an OAuth2 access token or refresh token. This method will revoke the current access 294 * token, if a token isn't provided. 295 * @throws Google_AuthException 296 * @param string|null $token The token (access token or a refresh token) that should be revoked. 297 * @return boolean Returns True if the revocation was successful, otherwise False. 298 */ 299 public function revokeToken($token = null) { 300 if (!$token) { 301 $token = $this->token['access_token']; 302 } 303 $request = new Google_HttpRequest(self::OAUTH2_REVOKE_URI, 'POST', array(), "token=$token"); 304 $response = Google_Client::$io->makeRequest($request); 305 $code = $response->getResponseHttpCode(); 306 if ($code == 200) { 307 $this->token = null; 308 return true; 309 } 310 311 return false; 312 } 313 314 /** 315 * Returns if the access_token is expired. 316 * @return bool Returns True if the access_token is expired. 317 */ 318 public function isAccessTokenExpired() { 319 if (null == $this->token) { 320 return true; 321 } 322 323 // If the token is set to expire in the next 30 seconds. 324 $expired = ($this->token['created'] 325 + ($this->token['expires_in'] - 30)) < time(); 326 327 return $expired; 328 } 329 330 // Gets federated sign-on certificates to use for verifying identity tokens. 331 // Returns certs as array structure, where keys are key ids, and values 332 // are PEM encoded certificates. 333 private function getFederatedSignOnCerts() { 334 // This relies on makeRequest caching certificate responses. 335 $request = Google_Client::$io->makeRequest(new Google_HttpRequest( 336 self::OAUTH2_FEDERATED_SIGNON_CERTS_URL)); 337 if ($request->getResponseHttpCode() == 200) { 338 $certs = json_decode($request->getResponseBody(), true); 339 if ($certs) { 340 return $certs; 341 } 342 } 343 throw new Google_AuthException( 344 "Failed to retrieve verification certificates: '" . 345 $request->getResponseBody() . "'.", 346 $request->getResponseHttpCode()); 347 } 348 349 /** 350 * Verifies an id token and returns the authenticated apiLoginTicket. 351 * Throws an exception if the id token is not valid. 352 * The audience parameter can be used to control which id tokens are 353 * accepted. By default, the id token must have been issued to this OAuth2 client. 354 * 355 * @param $id_token 356 * @param $audience 357 * @return Google_LoginTicket 358 */ 359 public function verifyIdToken($id_token = null, $audience = null) { 360 if (!$id_token) { 361 $id_token = $this->token['id_token']; 362 } 363 364 $certs = $this->getFederatedSignonCerts(); 365 if (!$audience) { 366 $audience = $this->clientId; 367 } 368 return $this->verifySignedJwtWithCerts($id_token, $certs, $audience); 369 } 370 371 // Verifies the id token, returns the verified token contents. 372 // Visible for testing. 373 function verifySignedJwtWithCerts($jwt, $certs, $required_audience) { 374 $segments = explode(".", $jwt); 375 if (count($segments) != 3) { 376 throw new Google_AuthException("Wrong number of segments in token: $jwt"); 377 } 378 $signed = $segments[0] . "." . $segments[1]; 379 $signature = Google_Utils::urlSafeB64Decode($segments[2]); 380 381 // Parse envelope. 382 $envelope = json_decode(Google_Utils::urlSafeB64Decode($segments[0]), true); 383 if (!$envelope) { 384 throw new Google_AuthException("Can't parse token envelope: " . $segments[0]); 385 } 386 387 // Parse token 388 $json_body = Google_Utils::urlSafeB64Decode($segments[1]); 389 $payload = json_decode($json_body, true); 390 if (!$payload) { 391 throw new Google_AuthException("Can't parse token payload: " . $segments[1]); 392 } 393 394 // Check signature 395 $verified = false; 396 foreach ($certs as $keyName => $pem) { 397 $public_key = new Google_PemVerifier($pem); 398 if ($public_key->verify($signed, $signature)) { 399 $verified = true; 400 break; 401 } 402 } 403 404 if (!$verified) { 405 throw new Google_AuthException("Invalid token signature: $jwt"); 406 } 407 408 // Check issued-at timestamp 409 $iat = 0; 410 if (array_key_exists("iat", $payload)) { 411 $iat = $payload["iat"]; 412 } 413 if (!$iat) { 414 throw new Google_AuthException("No issue time in token: $json_body"); 415 } 416 $earliest = $iat - self::CLOCK_SKEW_SECS; 417 418 // Check expiration timestamp 419 $now = time(); 420 $exp = 0; 421 if (array_key_exists("exp", $payload)) { 422 $exp = $payload["exp"]; 423 } 424 if (!$exp) { 425 throw new Google_AuthException("No expiration time in token: $json_body"); 426 } 427 if ($exp >= $now + self::MAX_TOKEN_LIFETIME_SECS) { 428 throw new Google_AuthException( 429 "Expiration time too far in future: $json_body"); 430 } 431 432 $latest = $exp + self::CLOCK_SKEW_SECS; 433 if ($now < $earliest) { 434 throw new Google_AuthException( 435 "Token used too early, $now < $earliest: $json_body"); 436 } 437 if ($now > $latest) { 438 throw new Google_AuthException( 439 "Token used too late, $now > $latest: $json_body"); 440 } 441 442 // TODO(beaton): check issuer field? 443 444 // Check audience 445 $aud = $payload["aud"]; 446 if ($aud != $required_audience) { 447 throw new Google_AuthException("Wrong recipient, $aud != $required_audience: $json_body"); 448 } 449 450 // All good. 451 return new Google_LoginTicket($envelope, $payload); 452 } 453} 454