CS 491 Lecture 24 – OAuth2 Access Tokens

Agenda

  • what ?s
  • final exam time: R 5-7
  • exercise: getting OAuth2 access tokens

Exercise

Last time we started integrating access to a Picasa Web account into our mobile apps. All the interaction with the Picasa Web hinged on having a special string—the access token—which we sent along with every request in the Authorization header. We used Google’s OAuth2 playground to get a temporary access token. In today’s exercise, we’ll have our apps get the token directly. This will involve a few steps:

  1. Create a project on Google Cloud Console for our app. Google will generate a client ID and a client secret that Google will use to identify our app.
  2. Authenticate once manually with our client credentials to get our first access token and (more importantly) a refresh token. The access token will expire eventually, but the refresh token lasts into perpetuity. We can use it to generate a new access token when the old one expires.
  3. Store our client ID, client secret, and refresh token in our app or on the web somewhere. If a request from our app fails with an HTTP 403 error (access forbidden), we can use these three values to get a new access token.

Create a Project on Google Cloud Console

Any app that wants to make use of Google’s web services must be registered with Google Cloud Console. Visit the console and create a new project. Give it a meaningful name, like Acknet. The project ID is irrelevant.

Each project can have many apps associated with it. Click on Registered Apps and register a new application on a Native platform. I don’t fully know the implications of this choice. The web options don’t give us a refresh token, and the Android and iOS options seem to assume use of an SDK.

After you register, you will be granted a client ID, a client secret, and two redirect URIs which will be used when you authenticate. You will need the first two in the next steps.

Authenticate Manually

OAuth2 is a three-party authentication protocol. Its designers assume your app sits between a user and the user’s content locked up in some web service. If your app managed the authentication all by itself, you, the developer, might decide to capture a user’s username and password for your own nefarious deeds. OAuth2 attempts to fix that danger by eliminating your app from the authentication process entirely. When it’s time to authenticate, the user is directed to a browser page with a login form. After the user successfully authenticates in the browser, your app gets control again. All your app sees of the authentication process are two strings that the browser sends back: an access token and a refresh token.

In Acknet, the OAuth2 model isn’t a great fit. Our app has a Google account, not the users of our app. We can’t expect the users to authenticate on the app’s behalf. Maybe the app could programmatically authenticate, but that involves a fair bit of work. The authentication system is built around a browser, and we’d end up parsing HTML and pretending we’re a web server. Instead, we’ll authenticate once by hand. We’ll take the access token and refresh token that we get into our app.

Authenticating once should be easy, right? Well, not exactly. We’ll need to create our own little OAuth2 playground for our particular client. The first step is to login in to our app’s account, grant permissions to access Picasa Web, and get back a string called the authorization code. We can use the following PHP script to accomplish this first step. I called it get_authorization_code.php.

<?php
// If we've got at least one request parameter, let's fire off a request
// for authentication, from which we get an authorization code. With that
// we can get an access token.
if (isset($_REQUEST['response_type'])) {

  // According to https://developers.google.com/accounts/docs/OAuth2InstalledApp#formingtheurl.
  $url = "https://accounts.google.com/o/oauth2/auth";
  $params = array(
    "response_type" => $_REQUEST['response_type'],
    "client_id" => $_REQUEST['client_id'],
    "redirect_uri" => $_REQUEST['redirect_uri'],
    "scope" => $_REQUEST['scope']
  );

  $request_to = $url . '?' . http_build_query($params);

  // Now, redirect to a page that asks for authentication and gives the
  // authorization code.                                                                                                                                               
  header("Location: " . $request_to);
}

// Otherwise, let's pop up a form that does requests the authentication
// request parameters.
else {
?>

<!DOCTYPE html>
<html>
<body>

<form method="post" action="https://accounts.google.com/o/oauth2/auth">
response_type: <input type="text" name="response_type" value="code" size="100" /><br />
client_id: <input type="text" name="client_id" size="100" /><br />
redirect_uri: <input type="text" name="redirect_uri" value="urn:ietf:wg:oauth:2.0:oob" size="100" /><br />
scope: <input type="text" name="scope" value="https://picasaweb.google.com/data/" size="100" /><br />
<input type="submit" />
</form>

</body>
</html>

<?php
}
?>

With the authorization code in hand, we can then request an access token. We’ll need to send the authorization code we just got from Google, our client ID, and our client secret back to Google according to their OAuth2 documentation. The following PHP script, which I call get_tokens.html, accomplishes this step:

<!DOCTYPE html>
<html>
<body>

<form method="post" action="https://accounts.google.com/o/oauth2/token">
code: <input type="text" name="code" size="100" /><br />
client_id: <input type="text" name="client_id" size="100" /><br />
client_secret: <input type="text" name="client_secret" size="100" /><br />
redirect_uri: <input type="text" name="redirect_uri" value="urn:ietf:wg:oauth:2.0:oob" size="100" /><br />
grant_type: <input type="text" name="grant_type" value="authorization_code" size="100" /><br />                                                                        
<input type="submit" />
</form>
</li>

</body>
</html>

The result is pure treasure:

{
  "access_token" : "...",
  "token_type" : "Bearer",
  "expires_in" : 3600,
  "refresh_token" : "..."
}

Grab the access token and the refresh token for the next step. These are precious and hard-won.

Get a Fresh Access Token

We can use the initial access token get from the previous step for the next hour. Once it expires, all HTTP requests that include it in the Authorization header will return with an HTTP response with status 403. That means Forbidden. I don’t see that this status code is documented, so maybe it’ll change some day.

If we get a 403 status, we can issue a POST request that includes our client ID, client secret, and the refresh token to get a new access token.

On iOS, this can be done with the following non-robust code. Note that I’ve switched the NSURLResponse from the last lecture’s exercise to an NSHTTPURLResponse in order to have access to the HTTP status code.

// Make a request to Picasa Web. Prefer the access token from preferences.
NSURL *url = [[NSURL alloc] initWithString:@"https://picasaweb.google.com/data/feed/api/user/default?alt=json&prettyprint=true"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
[request setHTTPMethod:@"GET"];
NSString *accessToken = [[NSUserDefaults standardUserDefaults] objectForKey:@"accessToken"];
if (!accessToken) {
    accessToken = ACCESS_TOKEN;
}
[request setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] forHTTPHeaderField:@"Authorization"];

// Issue request and catch result.
NSError *error = nil;
NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] init];
NSData *data = [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&error];
NSLog(@"%d", response.statusCode);

// Our requested was Forbidden. Time to get a new access token!
if (response.statusCode == 403) {
    NSURL *refreshURL = [NSURL URLWithString:@"https://accounts.google.com/o/oauth2/token"];
    NSMutableURLRequest *refreshRequest = [NSMutableURLRequest requestWithURL:refreshURL];
    [refreshRequest setHTTPMethod:@"POST"];

    // Google says POST with the following parameters.
    NSMutableDictionary *postDictionary = [[NSMutableDictionary alloc] init];        
    [postDictionary setValue:REFRESH_TOKEN forKey:@"refresh_token"];
    [postDictionary setValue:CLIENT_ID forKey:@"client_id"];
    [postDictionary setValue:CLIENT_SECRET forKey:@"client_secret"];
    [postDictionary setValue:@"refresh_token" forKey:@"grant_type"];

    // iOS doesn't make it easy to add POST parameters. Let's walk through the
    // parameter dictionary, form the key=value entries in an array, join up
    // the results as key1=value1&key2=value2..., and escape it all.
    NSMutableArray *postArray = [[NSMutableArray alloc] init];
    for (NSString *key in postDictionary) {
        NSString *keyValue = [NSString stringWithFormat:@"%@=%@", [key stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding], [postDictionary[key] stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]];
        [postArray addObject:keyValue];
    }
    NSString *postQuery = [postArray componentsJoinedByString:@"&"];
    NSLog(@"%@", postQuery);

    // The request body needs to be NSData, not NSString.
    NSData *postQueryAsData = [postQuery dataUsingEncoding:NSUTF8StringEncoding];
    refreshRequest.HTTPBody = postQueryAsData;

    NSError *refreshError = nil;
    NSHTTPURLResponse *refreshResponse = [[NSHTTPURLResponse alloc] init];
    NSData *refreshData = [NSURLConnection sendSynchronousRequest:refreshRequest returningResponse:&refreshResponse error:&refreshError];

    // Assuming that worked...

    // Fetch the new access token and throw it in preferences.
    NSDictionary *refreshResult = [NSJSONSerialization JSONObjectWithData:refreshData options:0 error:&refreshError];
    accessToken = refreshResult[@"access_token"];
    [[NSUserDefaults standardUserDefaults] setObject:accessToken forKey:@"accessToken"];

    // Now, let's update our previous Forbidden request and try again.
    [request setValue:[NSString stringWithFormat:@"Bearer %@", accessToken] forHTTPHeaderField:@"Authorization"];
    error = nil;
    response = [[NSHTTPURLResponse alloc] init];
    data = [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&error];
}

On Android, this can be done in almost identical fashion with:

// Make a request to Picasa Web. Prefer the access token from preferences.
URL url = new URL("https://picasaweb.google.com/data/feed/api/user/default?alt=json&prettyprint=true");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
String accessToken = preferences.getString("accessToken", Constants.ACCESS_TOKEN);
connection.addRequestProperty("Authorization", "Bearer " + accessToken);

int responseCode = connection.getResponseCode();
Log.d("FOO", String.format("Response code: %d", responseCode));

// Our request was Forbidden. Time to get a new access token!
if (responseCode == 403) {
  URL refreshUrl = new URL("https://accounts.google.com/o/oauth2/token");
  HttpURLConnection refreshConnection = (HttpURLConnection) refreshUrl.openConnection();
  refreshConnection.setRequestMethod("POST");
  refreshConnection.setDoOutput(true);

  // Google says POST with the following parameters.
  HashMap<String, String> postDictionary = new HashMap<String, String>();
  postDictionary.put("refresh_token", Constants.REFRESH_TOKEN);
  postDictionary.put("client_id", Constants.CLIENT_ID);
  postDictionary.put("client_secret", Constants.CLIENT_SECRET);
  postDictionary.put("grant_type", "refresh_token");

  // Android doesn't make it easy to add POST parameters. Let's walk
  // through the parameter dictionary, form the key=value entries in an
  // array, join up the results as key1=value1&key2=value2..., and escape
  // it all.
  ArrayList<String> postArray = new ArrayList<String>();
  for (Entry<String, String> entry : postDictionary.entrySet()) {
    String keyValue = String.format("%s=%s", URLEncoder.encode(entry.getKey(), "UTF-8"), URLEncoder.encode(entry.getValue(), "UTF-8"));
    postArray.add(keyValue);
  }
  String postQuery = TextUtils.join("&", postArray);

  OutputStream out = refreshConnection.getOutputStream();
  out.write(postQuery.getBytes("UTF-8"));
  out.close();

  int refreshStatus = refreshConnection.getResponseCode();
  Log.d("FOO", "refresh status: " + refreshStatus);

  // Assuming that worked...

  // Let's read the response body up into a string.
  InputStream is = refreshConnection.getInputStream();

  BufferedReader in = new BufferedReader(new InputStreamReader(is));
  StringBuffer buffer = new StringBuffer();
  String line = in.readLine();
  while (line != null) {
    Log.d("FOO", line);
    buffer.append(line);
    line = in.readLine();
  }
  in.close();

  // Fetch the new access token and throw it in preferences.
  JSONObject refreshResult = new JSONObject(buffer.toString());
  Log.d("FOO", refreshResult.toString());
  accessToken = refreshResult.getString("access_token");
  preferences.edit().putString("accessToken", accessToken).apply();

  // Now, let's update our previous Forbidden request and try again.
  connection = (HttpURLConnection) url.openConnection();
  connection.setRequestMethod("GET");
  connection.addRequestProperty("Authorization", "Bearer " + accessToken);
}

Use the same refresh token in both Android and iOS, as only one will be granted per user-app pair. If you request a second refresh token, the first one will become invalid. This brittleness would probably best be handled by putting the access token and refresh token up in some web service of your own, a practice which presents problems of its own. Security is hard.

It’d be a good idea to store your access token in preferences. Only request a new one if the one retrieved from preferences fails.

Haiku

Who invented keys?
Evil poured the foundation
Good built keys on top

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *