OAuth is a protocol for allowing one application controlled access to a user's account on another application. It is commonly used for two purposes:

In this article, I'll describe how to build a minimal OAuth server with Node.js and Express, no OAuth modules allowed. The only exception is Matt Mueller's excellent oauth-open package for displaying an OAuth popup on the client side to verify that we actually have a working OAuth setup.

The OAuth Flow

Your standard web OAuth 2.0 flow has 3 steps:

  1. Your app client opens a dialog that displays a dialog that asks the user to authorize your app. The dialog is usually on a different domain, like Facebook's OAuth login dialog.
  2. The dialog redirects back to your app client's domain with an auth code in the query string. An auth code is a short-lived code that you can exchange for a long-lived access token.
  3. Your app pulls the code parameter from the query string, and makes a POST request to the authorizing app's server with the access code. The authorizing app's server verifies the access code and sends back an access token your app can use for authorization going forward.

For the purposes of this example, there's 2 components involved in the OAuth flow:

  1. The client app. You can think of this as your app that's trying to get access to data from the authorizing app.
  2. The authorizing app. You can think of this as Facebook, Google, Twitter, or some other app that your client app is trying to access on the user's behalf.

Client App Implementation

Note that this code is meant as a minimal didactic example. The below code is most definitely not a production-grade OAuth authorization server. Don't copy/paste it into your prod app.

First, let's take a look at the client app to see what endpoints the authorization server needs to implement. The client app server's entry point is a simple static server listening on port 3000:

'use strict';

const express = require('express');

run().catch(err => console.log(err));

async function run() {
  const app = express();

  app.use(express.static('./'));

  await app.listen(3000);
  console.log('Listening on port 3000');
}

The client app has one file, index.html. This file is responsible for opening an OAuth dialog, exchanging the auth code for an access token, and making an HTTP request to a secure endpoint using the access token as authorization. The auth server will run on http://localhost:3001.

<html>
  <body>
    <div id="content"></div>

    <script type="text/javascript" src="https://codebarbarian-images.s3.amazonaws.com/open.dist.js"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script type="text/javascript">
      // Step 1: open an OAuth dialog
      oauthOpen('http://localhost:3001/oauth-dialog.html', async (err, code) => {
        // Step 2: exchange the code for an access token
        const resToken = await axios.post('http://localhost:3001/token', { code: code.code });

        // Step 3: use the access token to make a request to a secure
        // endpoint and display some data
        const res = await axios.get('http://localhost:3001/secure', {
          headers: { authorization: resToken.data['access_token'] }
        });

        document.querySelector('#content').innerHTML =
          `The secret answer is ${res.data.answer}`;
      });
    </script>
  </body>
</html>

The https://codebarbarian-images.s3.amazonaws.com/open.dist.js file in the above example is a Webpack bundle of the below script. I pre-compiled that script for convenience so the code in this article doesn't require a compiler.

window.oauthOpen = require('oauth-open');

The client also needs an oauth-callback.html file. The oauth-callback.html file doesn't need to do anything, the oauthOpen() function takes care of pulling out the auth code. Here's a minimal oauth-callback.html:

<html>
  <body>
    <div>Authorized</div>
  </body>
</html>

Auth Server Implementation

The auth server needs 4 endpoints:

  1. An OAuth dialog that asks the user to authorize the client app.
  2. A route that generates an auth code and redirects to the client app.
  3. A route to exchange an auth code for an access token.
  4. A "secure" endpoint that only responds if it is given a valid access token via the Authorization HTTP header.

Since the auth server will run on a different domain than the client app, it also needs CORS for the access token and secure endpoints in order to avoid the browser throwing errors about cross-origin requests. However, it is important to protect the endpoint for getting an auth code secure from cross-origin requests because of CSRF attacks.

The flow starts when the user opens the OAuth dialog. In this example, the authorization server has a static oauth-dialog.html file that shows a single button the user can click to authorize the client app. Clicking the button redirects to a /code route that is responsible for generating an auth code and redirecting to the client app.

<html>
  <body>
    <div>Authorize OAuth Test App?</div>

    <form action="/code" method="POST">
      <button type="submit">OK</button>
    </form>
  </body>
</html>

Below is the full auth-server.js file:

'use strict';

const cors = require('cors');
const express = require('express');

run().catch(err => console.log(err));

async function run() {
  const app = express();

  // Store the auth codes and access tokens in memory. In a real
  // auth server, you would store these in a database.
  const authCodes = new Set();
  const accessTokens = new Set();

  app.use(express.json());

  // Generate an auth code and redirect to your app client's
  // domain with the auth code
  app.post('/code', (req, res) => {
    // Generate a string of 10 random digits
    const authCode = new Array(10).fill(null).map(() => Math.floor(Math.random() * 10)).join('');

    authCodes.add(authCode);

    // Normally this would be a `redirect_uri` parameter, but for
    // this example it is hard coded.
    res.redirect(`http://localhost:3000/oauth-callback.html?code=${authCode}`);
  });

  app.options('/token', cors(), (req, res) => res.end());
  app.options('/secure', cors(), (req, res) => res.end());

  // Verify an auth code and exchange it for an access token
  app.post('/token', cors(), (req, res) => {
    if (authCodes.has(req.body.code)) {
      // Generate a string of 50 random digits
      const token = new Array(50).fill(null).map(() => Math.floor(Math.random() * 10)).join('');

      authCodes.delete(req.body.code);
      accessTokens.add(token);
      res.json({ 'access_token': token, 'expires_in': 60 * 60 * 24 });
    } else {
      res.status(400).json({ message: 'Invalid auth token' });
    }
  });

    // Endpoint secured by auth token
  app.get('/secure', cors(), (req, res) => {
    const authorization = req.get('authorization');
    if (!accessTokens.has(authorization)) {
      return res.status(403).json({ message: 'Unauthorized' });
    }

    return res.json({ answer: 42 });
  });

  // Serve up `oauth-dialog.html`
  app.use(express.static('./'));

  await app.listen(3001);
  console.log('Listening on port 3001');
}

Note that the /code endpoint, as written, is vulnerable to cross-site request forgery attacks. A malicious website could POST a form to the /code endpoint and that would trigger the OAuth flow without the user's knowledge. You can use a module like csurf to generate CSRF tokens.

Moving On

OAuth may seem baffling to beginners, but the process of implementing an OAuth server is simple once you understand the OAuth flow. All you need is a dialog, an endpoint to get an auth code, and an endpoint to exchange an auth code for an access token. Once you give a user an access token, they are effectively "logged in" to the authorizing app.

If you're looking to implement a real OAuth server, the next step is to store the auth codes and access tokens in a database. For more sophisticated apps, you may want to add support for OAuth scopes, which inform the user what permissions the client app has, like whether the client app has permission to tweet on your behalf.

Found a typo or error? Open up a pull request! This post is available as markdown on Github
comments powered by Disqus