Saturday, December 13, 2014

Google OAuth2 with PicasaWeb

Update: I think the problem was refresh token limits. See below. Also, the auth I describe is vulnerable to CSRF attacks. You must also protect your callback url with an anti-forgery state token as described below.

Circa 2012, I wrote a little web-app that integrated with the Picasa Web Albums Data API and the other day, I noticed that my authentication had stopped working. I found two [1] [2] stackoverflow people with a similar problem, but no solution. Eventually I determined that my problem was a change in the google developers console which caused my project to no longer receive API access to PicasaWeb.

I had created my project long ago in an older version of the developers console, and I noted that my project wouldn't open in the newer version. I eventually gave it a projectID in the older version so that I could view it in the newer version, but that didn't fix my PicasaWeb problems. I created a new project in the new console and used those credentials with my existing PicasaWeb code and that solved my problem.

I concluded the above was due to something inside google by testing on the OAuth Playground. I noted that their OAuth2 url requesting https://picasaweb.google.com/data/ scope displayed a prompt that said "Manage your photos and videos", whereas the same url with my clientID just said "Have offline access". When I proceeded with the OAuth2 flow, I would receive a token, but when attempting to use that token against PicasaWeb, I would get 404, which is the same error an unauthenticated user gets.

Here's How I Fixed It

Go to https://console.developers.google.com

Click "Create Project"

Choose a project name and ID. It takes a few minutes for the new project to create.

To use the picasa api, you need to enable OAuth. To use OAuth you need to configure a consent screen. To configure a consent screen you need to make an email publicly available. If you don't want your personal email available, you'll first have to add an administrator to your project, for example support@yoursite.com.

Adding An Alternate Email

From the sidebar select "Your Project Name" then "Permissions".

Click "Add Member".

Enter the email you want displayed on your consent screen, for example support@yoursite.com. Choose the "Can edit" permission. Click "Add".

Google will send an email to the address you specified. In a different browser, follow the link in that email. If that email doesn't have its own google account, you'll have to create one. You can click "I prefer to use my current email address" to prevent creating an unnecessary associated gmail account. The account creation process will send another verification email. In the browser where you created the new account, follow the link from the second verification email. You don't need to create a Google+ Profile. After this it will prompt you "Back to Google Developers Console". Accept the terms and accept the invitation.

The avatar displayed on the consent screen will be of the user who is giving permission to your app. The email displayed in the consent screen is based on the administrator who configures the consent screen, so that must be done as the alternate user.

Go back to the developer console as the email you just configured. From the sidebar select "APIs & auth" then "Consent screen".

Only one email address is available. This will be visible to anyone who sees the consent screen (by clicking the arrow left of your project name). Enter the remaining fields and save the consent screen. You can now continue with this account in the console or go back to your main account for the remainder.

Authorizing an App for the PicasaWeb API

From the sidebar select "APIs & auth" then "Credentials".

Under OAuth click "Create new Client ID".

Select "Web application".

Under "authorized javascript origins", enter your SSL domain, i.e. https://yoursite.com

Under "authorized redirect uris", enter the SSL page that will implement OAuth, i.e. https://yoursite.com/yourapp/oauth2.php

Click "Create Client ID".

This produces a "Client ID" and a "Client Secret", which you will need in your oauth implementation.

Before writing any code, you can test this right away. Open a browser in which you're signed into a test google account (not one of your developer accounts) and go to https://developers.google.com/oauthplayground/

Click "Picasa Web v2".

Click "https://picasaweb.google.com/data/".

Click "Authorize APIs".

The browser navigates to the following url:

https://accounts.google.com/o/oauth2/auth?scope=https://picasaweb.google.com/data/&response_type=code&access_type=offline&redirect_uri=https://developers.google.com/oauthplayground&approval_prompt=force&client_id=407408718192.apps.googleusercontent.com&hl=en&from_login=1&as=-26008a8eb210b8c&pli=1&authuser=0

The browser displays the consent screen of the "Google OAuth 2.0 Playground" app. The browser shows that the app is requesting to "Manage your photos and videos"

The arguments of that url are as follows:

// these say that the app want access to picasa
scope=https://picasaweb.google.com/data/
response_type=code
access_type=offline
approval_prompt=force

// these identify the app
redirect_uri=https://developers.google.com/oauthplayground
client_id=407408718192.apps.googleusercontent.com

// these aren't necessary, you will get the same screen without them
hl=en
from_login=1
as=-26008a8eb210b8c
pli=1
authuser=0

Here's the url without the unnecessary arguments. If you go to it you should see same page.

https://accounts.google.com/o/oauth2/auth?scope=https://picasaweb.google.com/data/&response_type=code&access_type=offline&redirect_uri=https://developers.google.com/oauthplayground&approval_prompt=force&client_id=407408718192.apps.googleusercontent.com

You can swap the redirect_uri and client_id with the values from your app and you should see the same page, but this time identifying your app.

You should see:
- the test user's avatar
- your app's logo
- your app's name
- by clicking the down arrow beside your app's name, your app's support email

The only request should be
- Manage your photos and videos

When the user clicks accept they will be redirected to the redirect_uri you specified. That should be an SSL url as it will receive confidential information.

In the long run your app should:
1. notice that it doesn't have a token or get 301 from an api call indicating that the token is expired
2. use javascript to open a popup window with a request url like the one above
3. implement the redirect_uri such that the received code is converted to a token which is recorded and the popup triggers some javascript on the page that opened it before closing itself
4. proceed with the api call now that you have a valid token

Here's how that might look.

Page that launches the login-with-google popup

https://yoursite.com/yourapp/page.php

function authorize()
{
  var oauthWindow = window.open("https://accounts.google.com/o/oauth2/auth?scope=https://picasaweb.google.com/data/&response_type=code&access_type=offline&redirect_uri=https://yoursite.com/yourapp/oauth2.php&approval_prompt=force&client_id=407408718192.apps.googleusercontent.com","_blank","width=700,height=400");
  if(!oauthWindow || oauthWindow.closed || typeof oauthWindow.closed=='undefined')
  {
    // popup blocked, for example on ios you can't programatically
    // launch a popup from a tab that was a programatically launched popup
    alert('Failed');      
  }
  // else flow is now in the popup
  // we have designed it to trigger our oauthComplete when finished
  // we will remain idle until then
}

function oauthComplete()
{
  // now we have a token, make some ajax calls and do some work. 
}

Page that gets the OAuth Token

https://yoursite.com/yourapp/oauth2.php

<?
  // visit this page in a browser
  // https://accounts.google.com/o/oauth2/auth?scope=https://picasaweb.google.com/data/&response_type=code&access_type=offline&redirect_uri=https://yoursite.com/yourapp/oauth2.php&approval_prompt=force&client_id=407408718192.apps.googleusercontent.com
  //
  // and click approve and you will be redirected here
  // https://yoursite.com/yourapp/oauth2.php?code=PkqD6reYULMKKrrasEB2G5D0osYXmFaGhtV8o4/WqYg8dkXvfARQvtJS9iHerQj1C0.4iWMDCKlAI
  //
  // which will send the code to
  // https://accounts.google.com/o/oauth2/token
  //
  // and get a response like the follwing
  // { "access_token"  : "ym2BEd9.ej9XnOINtBVUbDa222Oy0bYXhhT_mvGaD4ixiSXYwD4Y8lv0EyzggaV6Ifn7MMyoYTJdoaqL0zA"
  // , "token_type"    : "Bearer"
  // , "expires_in"    : 3600
  // , "refresh_token" : "1/Pv3c5PgT6s3bFxndmfv89sIWls-LgTvMEudVrK5jSx2P9mTDCzXwupoR30zcRFq6" 
  // }

  if (isset($_GET['code']))
  {
    $clientId = '407408718192.apps.googleusercontent.com';
    $clientSecret = 'du5hdohyen58hdleuj49jgy3'; 
    $referer = 'https://yoursite.com/yourapp/oauth2.php';
    
    $postBody = 'code='.urlencode($_GET['code'])
              .'&grant_type=authorization_code'
              .'&redirect_uri='.urlencode($referer)
              .'&client_id='.urlencode($clientId)
              .'&client_secret='.urlencode($clientSecret);

    $curl = curl_init();
    curl_setopt_array( $curl,
                     array( CURLOPT_CUSTOMREQUEST => 'POST'
                           , CURLOPT_URL => 'https://accounts.google.com/o/oauth2/token'
                           , CURLOPT_HTTPHEADER => array( 'Content-Type: application/x-www-form-urlencoded'
                                                         , 'Content-Length: '.strlen($postBody)
                                                         , 'User-Agent: YourApp/0.1 +http://yoursite.com/yourapp'
                                                         )
                           , CURLOPT_POSTFIELDS => $postBody                              
                           , CURLOPT_REFERER => $referer
                           , CURLOPT_RETURNTRANSFER => 1 // means output will be a return value from curl_exec() instead of simply echoed
                           , CURLOPT_TIMEOUT => 12 // max seconds to wait
                           , CURLOPT_FOLLOWLOCATION => 0 // don't follow any Location headers, use only the CURLOPT_URL, this is for security
                           , CURLOPT_FAILONERROR => 0 // do not fail verbosely fi the http_code is an error, this is for security
                           , CURLOPT_SSL_VERIFYPEER => 1 // do verify the SSL of CURLOPT_URL, this is for security
                           , CURLOPT_VERBOSE => 0 // don't output verbosely to stderr, this is for security
                     ) );
    $response = curl_exec($curl);
    $http_code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
    curl_close($curl);  
    // store that $response in your database somewhere, you'll need the token it contains
    ?>
    <!DOCTYPE html>
    <html>
    <head>
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta http-equiv="content-type" content="text/html; charset=utf-8" />
      <title>Sign In</title>
      <script type="text/javascript">
        function closeThis()
        {
          window.opener.oauthComplete(); 
          window.close();
        }
      </script>
    </head>
    <body onload="closeThis();">
    </body>
    </html>
    <?
  }
  else { echo 'Code was not provided.'; }
?>

Page that uses the OAuth Token

And here's how you use that token to do something useful like get info about a private album.

// https://developers.google.com/accounts/docs/OAuth2WebServer#callinganapi
$curl = curl_init();
$url = 'https://picasaweb.google.com/data/entry/api/user/'.$picasaUserID.'/albumid/'.$albumid;
curl_setopt_array( $curl, 
                 array( CURLOPT_CUSTOMREQUEST => 'GET'
                       , CURLOPT_URL => $url
                       , CURLOPT_HTTPHEADER => array( 'GData-Version: 2'
                                                     , 'Authorization: Bearer '.$access_token )
                       , CURLOPT_REFERER => 'https://yoursite.com/yourapp/page.php'
                       , CURLOPT_RETURNTRANSFER => 1 // means output will be a return value from curl_exec() instead of simply echoed
                 ) );
$response = curl_exec($curl);
$http_code = curl_getinfo($curl,CURLINFO_HTTP_CODE);
curl_close($curl);

Refresh Token

Thinking about this some more, my problem may have been the refresh token limits, since I was never using the refresh token but always asking for authorization each session that required access to Picasa. That's dumb. It seems that your refresh token is good forever, (or at least until revoked). So if you store it in your db, you never need to prompt the user for authentication again. You just use the refresh token to get a new access token.

Yeah, that was definitely a problem. Certainly, seeing the "Have offline access" prompt instead of the "Manage your photos and videos" prompt is a symptom of asking for auth while an auth_token or refresh_token are still valid. You really need to store these (and the token expiry) in your db and use the refresh to get a new auth whenever the auth expires. The refresh never expires until revoked.

Here's how that looks:

function GetPicasaToken()
{
  if (/*not logged in*/)
  {
    // die
  }
  else if (/*no auth token*/)
  {
    // will have to show the google dialog
  }
  else if (/*auth token expired*/)
  {
    $postBody = 'client_id='.urlencode($GOOGLE_OAUTH2_CLIENT_ID)
              .'&client_secret='.urlencode($GOOGLE_OAUTH2_CLIENT_SECRET)
              .'&refresh_token='.urlencode($PICASA_REFRESH_TOKEN)
              .'&grant_type=refresh_token';
          
    $curl = curl_init();
    curl_setopt_array( $curl,
                     array( CURLOPT_CUSTOMREQUEST => 'POST'
                           , CURLOPT_URL => 'https://www.googleapis.com/oauth2/v3/token'
                           , CURLOPT_HTTPHEADER => array( 'Content-Type: application/x-www-form-urlencoded'
                                                         , 'Content-Length: '.strlen($postBody)
                                                         , 'User-Agent: HoltstromLifeCounter/0.1 +http://holtstrom.com/michael'
                                                         )
                           , CURLOPT_POSTFIELDS => $postBody                              
                           , CURLOPT_REFERER => $GOOGLE_OAUTH2_REFERER
                           , CURLOPT_RETURNTRANSFER => 1 // means output will be a return value from curl_exec() instead of simply echoed
                           , CURLOPT_TIMEOUT => 12 // max seconds to wait
                           , CURLOPT_FOLLOWLOCATION => 0 // don't follow any Location headers, use only the CURLOPT_URL, this is for security
                           , CURLOPT_FAILONERROR => 0 // do not fail verbosely fi the http_code is an error, this is for security
                           , CURLOPT_SSL_VERIFYPEER => 1 // do verify the SSL of CURLOPT_URL, this is for security
                           , CURLOPT_VERBOSE => 0 // don't output verbosely to stderr, this is for security
                     ) );
    $response = curl_exec($curl);
    $http_code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
    curl_close($curl);  

    if (strlen($response) < 1)
    { /*fail*/ }

    $response = json_decode($response, true); // convert returned objects into associative arrays
    $expires = $NOW - 60 + SafeInteger($response['expires_in']);
    if ( empty($response['access_token'])
      || $expires <= $NOW )
    { /*fail*/ }

    // store the updated token/expiry in your db
    // pass our the updated token for use
  }
}

Anti-Forgery State Token

twobotechnologies describes how to attack the redirect URI. Google describes how to prevent the attack. Basically you have to realize that an attacker can cause a user to GET any of your authenticated URLs by putting those urls as the src attribute of an image on their site.

Say you have authenticated pages show.php and cmd_add.php and cmd_delete.php each of which an attacker triggers via an img src on their site. Because an authenticated user is browsing their site, each resource does execute. show.php causes no harm because it just displays data, in this case returning it to the authenticated user's browser where it is rendered as a broken image. cmd_add.php and cmd_delete.php can be a problem because they change state. If they require POST parameters then you're safe because the img src triggers a GET. If they accept GET or URL parameters then you're at risk. You can protect against this by requiring that $_SERVER["HTTP_REFERER"] contain your domain or even the full path to show.php since the img src attack will relay the attack site as the referrer.

However if you've designed a page (like oauth2.php) which is expected to be accessed from another site then you're at risk. Also if you believe the attacker can post links on your site (say your site is a blog, and the attacker has an account), then the referrer trick won't work. The only protection is a transaction token. When the user clicks the button on show.php that triggers a request to cmd_delete.php, before triggering that request, javascript on show.php sets a short lived cookie and passes that same value as an argument to cmd_delete.php, then before cmd_delete.php does any work it checks that the input argument matches the session cookie. Since the attacker is unable to set session cookies, they gain nothing by forcing users to navigate to state-changing urls.

For this reason, I am hereafter navigating to state-changing urls via a hidden form with a POST/cookie transaction token. It looks like this:

...
<script src="https://holtstrom.com/michael/base/util.js" type="text/javascript">
<body>
  <form name="navigate" id="navigate" method="post"><input type="hidden" name="TransactionToken"/></form>
...
util.navigateTo('cmd_dostuff.php');
...
/**
 * Generate 1 minute nonce. Store it in the 'TransactionToken' cookie, and return it.
 * The caller should submit this with their GET/POST. Then the php can compare the form
 * value with the session value to know the request came in valid sequence.
 */
util.transactionToken = function()
{
  var rnd = Math.random();
  util.setCookie('TransactionToken',rnd,1);
  return rnd;
};

/**
 * Every page is assumed to contain the following hidden form.
 * <form name="navigate" id="navigate" method="post"><input type="hidden" name="TransactionToken"/></form>
 * This function sets the action of that form to the provided url,
 * then sets both the the from and cookie to the same new transactiontoken,
 * then submits the form, thus the target url can validate that we intended the navigation.
 */
util.navigateTo = function(url)
{
  var token = util.transactionToken(); // set the cookie
  var navigate = document.getElementById('navigate');
  if (navigate != null)
  {
    navigate.action = url;
    navigate.elements['TransactionToken'].value = token;
    navigate.submit();
  }  
};
/**
 * Ensure that the POST token matches the cookie.
 * Then kill the cookie.
 */
function ValidateTransactionToken()
{
  $v1 = $_COOKIE['TransactionToken'];
  $v2 = $_POST['TransactionToken'];
  if (strlen($v1) > 0 && $v1 == $v2)
  {
    // success
    PutCookie('TransactionToken','',0);
    return;
  }
  else
  {
    // failure
    echo 'Out Of Sequence.';
    exit;
  }  
}
web
{ "loggedin": false, "owner": false, "avatar": "", "render": "nothing", "trackingID": "UA-36983794-1", "description": "", "page": { "blogIds": [ 522 ] }, "domain": "holtstrom.com", "base": "\/michael", "url": "https:\/\/holtstrom.com\/michael\/", "frameworkFiles": "https:\/\/holtstrom.com\/michael\/_framework\/_files.4\/", "commonFiles": "https:\/\/holtstrom.com\/michael\/_common\/_files.3\/", "mediaFiles": "https:\/\/holtstrom.com\/michael\/media\/_files.3\/", "tmdbUrl": "http:\/\/www.themoviedb.org\/", "tmdbPoster": "http:\/\/image.tmdb.org\/t\/p\/w342" }