teaching machines

CS 347: Lecture 24 – OAuth2

December 1, 2020 by . Filed under fall-2020, lectures, webdev.

Dear students:

Today we discuss OAuth2, a popular system for managing access to data controlled by a web service but accessed by a separate client application. I will be frank with you. Security is not my favorite subject, and I do not feel qualified to be speaking on it. However, OAuth2 is too much of a superpower to ignore, and many web services are not even usable without it.

OAuth2 is something I use nearly every day. When you find a typo in one of my blog posts, I change my local copy of the post and upload it to WordPress instantaneously thanks to OAuth2. On GitHub I have 93 repositories, on Bitbucket 85, and on GitLab 60. Backing all these up could be very painful, but with OAuth2, I can access these providers’ services and get a list of all of them. If I want to mine your Slack posts, I’d use OAuth2.

We start our discussion of OAuth2 with an overview of its workflow. Then we’ll write a script that lists all of my files on Google Drive, something that we simply cannot do without OAuth2.

Authentication vs. Authorization

On Twitter recently I saw a computer science professor recall the time he got corrected for confusing the terms authorization and authentication. Let’s start there since the difference is probably something many of us may not be able to articulate.

Authentication is the process of confirming that users are who they claim to be. I say that I am JOHNS8CR and authenticate that claim by providing my password. That’s a 1-factor authentication scheme. We also have 2-factor authentication schemes in which we provide further evidence. The first factor is something we know: our password. The second factor is something we have: our phone. We click a button on our phone’s Duo client. We haven’t yet reached a third factor at JMU, which might be something that we are, like our fingerprints or retinal patterns.

Authorization is the process of granting access to a resource. OAuth2 is an authorization protocol. It’s purpose is to allow the user to tell a service that it’s okay for a third-party app to access the user’s data. One of its steps may be an authentication, but ultimately OAuth2 is about permission.

Protocol

Suppose we have an application that needs to access a web service on a user’s behalf. What must we do to authorize the application with the service with OAuth2? There are several steps, some of which need to be done more often than others.

The OAuth2 specification includes several protocols. The discussion here is limited to the authorization code grant type.

Per Application

The developer of the application registers the application with the service. The service grants the application a client ID and a client secret. These should be kept private. If a nefarious party gets hold of them, others can masquerade as your app. They might do this to make users run an app that they are not actually running. They may also run up the developer’s bill with the service provider, since the accesses will count against the developer.

Generally the ID and secret are generated only once. If a developer accidentally leaks the ID or secret by embedding them literally in source code or pushing a credentials file to a public repository, then a new ID and secret should be generated.

Per Authorization

When a user runs the application for the first time (or a previous authorization has expired or been revoked), these several steps must be followed for the application to gain authorization:

  1. The application requests authorization from the service, sending along its client ID and client secret.
  2. The service prompts the user to authenticate and authorize the application’s access. The application is not involved in this step. By design, it never gets its hands on the user’s credentials.
  3. The service sends the application an authorization code. This authorization code is housed within a URL parameter and is by itself not considered very secure. Any party sniffing traffic could get their hands on an authorization code.
  4. The application sends the authorization code and its secret to the service. These two paired together are more secure.
  5. The service sends back an access token and possibly a refresh token. Unlike the authorization code, these are tucked away inside the body an encrypted HTTP response.

The tokens are often stored by the application and reused on subsequent accesses, so these steps do not need to be completed every time the application accesses the service. If the access token expires, the refresh token may be used to secure a new one without going through the complete authorization process.

Per Request

When the application submits a request to an endpoint of the service, it includes a header containing the access token. The service compares this access token to the one it has on file. If the tokens match, the request is authorized and the user’s data is sent in the response.

Accessing Google Drive

The high-level view seems innocent. The actual implementation is mired in technical details. But let’s talk about them anyway. Let’s write a Node.js script that lists all the files I have on my Google Drive account.

Register Application

The first step is to register our application with Google.

  1. Visit the Google API Console.
  2. Add a new project.
  3. Enable the Google Drive API for this project.

We next configure the authorization screen that the user sees when the service takes over.

  1. Click OAuth Consent Screen.
  2. Set the User Type to External. If we were part of an organization participating in Google’s G Suite program, we could choose Internal, and Google would consider our application to be in-house and waive verification. Since we are not in G Suite, our app will be branded as not verified.
  3. On the first screen, enter the application name and your email address where prompted. The other fields can be left empty since we are not going to verify our application.
  4. On the second screen, declare what kind of access your application needs. The levels of access are called scopes. Google provides a list of all of its service’s scopes. For today, we will use https://www.googleapis.com/auth/drive.readonly.
  5. On the third screen, add the accounts that should be allowed to authorize the unverified application.

Finally we secure a client ID and secret.

  1. In the Credentials panel, click Create Credentials.
  2. Choose OAuth Client ID.
  3. Set the application type to Web application and enter a name.
  4. Add the redirect URI http://localhost:4545. We’ll come back to the significance of this in a moment.
  5. Copy the client ID and secret to file credentials.json using a format like this:
    {
      "clientId": "...",
      "clientSecret": "..."
    }
    

Load Credentials

We have registered our application with Google, and now it’s time to write that application. It first needs to load in its ID and secret. We’ll use the fs package to read in the file. We’ll use the asynchronous version of readFile and fulfill the promise in its callback.

const fs = require('fs');

function loadConfig() {
  return new Promise((resolve, reject) => { 
    fs.readFile('credentials.json', 'utf8', (error, data) => {
      if (error) {
        reject(error);
      } else {
        resolve(JSON.parse(data));
      }
    });
  })
}

loadConfig()
  .then(config => console.log(config));

Gain Authorization

Next our application must seek authorization. Google documents how this is done. In short, the application needs to send an HTTP request to particular Google URL and provide some required parameters.

To stay organized, let’s add a new step to our promise pipeline. We start by creating an object to hold the parameters we want to pass.

const redirectUri = 'http://localhost:4545';

function authorize(config) {
  return new Promise((resolve, reject) => {
    const parameters = {
      scope: 'https://www.googleapis.com/auth/drive.readonly',
      response_type: 'code',
      redirect_uri: redirectUri,
      client_id: config.clientId,
      access_type: 'offline',
    };

    // access Google URL
  });
}

loadConfig()
  .then(config => authorize(config));

After initiating the authorization, the application must step back while the user talks to the service directly to authenticate and authorize. The application triggers the Google URL to be loaded in the web browser. To make that task a little easier, we write a couple of help functions for forming the URL and opening the browser.

const childProcess = require('child_process');
const authorizationEndpoint = 'https://accounts.google.com/o/oauth2/v2/auth';

function makeUrl(baseUrl, parameters) {
  const url = new URL(baseUrl);
  for (let [key, value] of Object.entries(parameters)) {
    url.searchParams.append(key, value);
  }
  return url;
}

function open(url) {
  var start = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start': 'xdg-open';
  childProcess.exec(`${start} "${url}"`);
}

function authorize(config) {
  return new Promise((resolve, reject) => {
    // ...
    open(makeUrl(authorizationEndpoint, parameters).href);
  });
}

The next step is my least favorite part of the workflow. After the user and service are done talking to each other, the service has to give control back to the application somehow and give the application an authorization code. The mechanism is to redirect the browser to a URL that ties in with the application somehow. For us, “somehow” means we’ll start our own little server that handles the redirect.

const net = require('net');

function authorize(config) {
  return new Promise((resolve, reject) => {
    // ...

    const server = net.createServer();

    server.listen(4545, 'localhost', () => {
      console.log('listening...');
    });

    server.on('connection', socket => {
      socket.setEncoding('utf8');

      socket.on('data', data => {
        console.log(data);
      });

      socket.on('close', () => {
        server.close();
      });
    });
  });
}

In our data listener, we must parse the body and pull out the authorization code. There are robust ways to do this, and then there’s our way. We’ll just rip out the code using a regular expression. Once we have it, we can resolve our promise by passing along config with the code included, and we send back an HTTP response to the browser.

function authorize(config) {
  return new Promise((resolve, reject) => {
    // ...

    const server = net.createServer();

    server.listen(4545, 'localhost', () => {
      console.log('listening...');
    });

    server.on('connection', socket => {
      socket.setEncoding('utf8');

      socket.on('data', data => {
        const matches = data.match(/code=([^&\s]+)/);
        if (matches) {
          const code = decodeURIComponent(matches[1]);
          resolve({
            ...config,
            authorizationCode: code,
          });

          socket.write("HTTP/1.1 200\r\n");
          socket.write("Content-Type: text/html\r\n");
          socket.write("\r\n");
          socket.write("You have authorized the app. Close this window.")
          socket.end();
        } else {
          reject(Error('no code given'));
        }
      });

      socket.on('close', () => {
        server.close();
      });
    });
  });
}

A better application would ensure that all the requested scopes were granted.

Gain Access Token

With the authorization code in hand, the application trades it in for an access token. It does this by accessing another one of Google’s URLs, but this time, the user isn’t involve. The URL is accessed programmatically. We use a version of fetch written for Node.js. This time we POST the parameters instead of passing them in the URL.

const fetch = require('node-fetch');
const tokenEndpoint = 'https://oauth2.googleapis.com/token';

function tokenize(config) {
  return new Promise((resolve, reject) => {
    const tokenParameters = {
      code: config.authorizationCode,
      client_id: config.clientId,
      client_secret: config.clientSecret,
      redirect_uri: redirectUri,
      grant_type: 'authorization_code',
    };

    const options = {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(tokenParameters),
    };

    fetch(tokenEndpoint, options)
      .then(response => response.json())
      .then(data => resolve({
        ...config,
        accessToken: data.access_token,
      }));
  });
}

loadConfig()
  .then(config => authorize(config))
  .then(config => tokenize(config))
  .then(config => console.log(config));

The parameters and URL are documented in Google’s OAuth2 workflow.

Consume Endpoints

With the access token, the application can get the user’s data from the service. The service is accessed through its endpoints just like we’ve accessed other services this semester—with one exception. Any requests must pass along the access token in a custom header of the form Authorization: Bearer ACCESS-TOKEN.

To get a list of files from Google Drive, we consult the Drive API reference. We find that a GET request to /files gives us back an array of file objects. Let’s map this array to just a list of names.

const filesEndPoint = 'https://www.googleapis.com/drive/v3/files';

function fetchFiles(accessToken) {
  return new Promise((resolve, reject) => {
    const options = {
      headers: {
        'Authorization': `Bearer ${accessToken}`,
      },
    };

    fetch(filesEndPoint, options)
      .then(response => response.json())
      .then(data => resolve(data.files.map(file => file.name)));
  });
}

loadConfig()
  .then(config => authorize(config))
  .then(config => tokenize(config))
  .then(config => fetchFiles(config.accessToken))
  .then(files => console.log(files));

Next

That’s a first peek at OAuth2. We have built an application that can access a user’s data. Our application is specific to Google Drive, but most web service providers offer similar workflows. We just need to swap in the URLs of their OAuth2 endpoints, tweak the parameters that we pass on to these endpoints, and tailor our calls to the service according their service’s API.

There are a few things we haven’t done in our application that would make it nicer. Really, we should store the access token. Google’s tokens last for 3600 seconds. Even when they do expire, we can use the refresh token that Google also gives the application to get a new one. But that’s enough detail for today.

TODO

Here’s your TODO list for next time:

See you next time.

Sincerely,