Authenticating Users with Node.js

This part of the Node.js Bookshelf tutorial shows how to create a sign-in flow for users and how to use profile information to provide users with personalized functionality.

By using Google Identity Platform, you can easily access information about your users while ensuring their sign-in credentials are safely managed by Google. OAuth 2.0 makes it easy to provide a sign-in flow for all users of your app and provides your application with access to basic profile information about authenticated users.

This page is part of a multi-page tutorial. To start from the beginning and see instructions for setting up, go to Node.js Bookshelf App.

Creating a web application client ID

A web application client ID allows your application to authorize users and access Google APIs on behalf of your users.

  1. Go to the credentials section in the Google Cloud Platform Console.

  2. Click OAuth consent screen. For the product name, enter Node.js Bookshelf App. Fill in any relevant optional fields. Click Save.

  3. Click Create credentials > OAuth client ID.

  4. Under Application type, select Web Application.

  5. Under Name, enter Node.js Bookshelf Client.

  6. Under Authorized redirect URIs enter the following URLs, one at a time. Replace [YOUR_PROJECT_ID] with your project ID:

    http://localhost:8080/auth/google/callback
    http://[YOUR_PROJECT_ID].appspot.com/auth/google/callback
    https://[YOUR_PROJECT_ID].appspot.com/auth/google/callback
    http://[YOUR_PROJECT_ID].appspot-preview.com/auth/google/callback
    https://[YOUR_PROJECT_ID].appspot-preview.com/auth/google/callback

  7. Click Create.

  8. Copy the client ID and client secret and save them for later use.

Creating a Memcached instance

To properly store sessions across multiple App Engine VMs, session data must be stored in locations that are accessible to all App Engine instances. In order to do this, we'll be using a Memcache instance to store our cross-server session data.

If you do not have a Memcached instance already, get a free one from Redis Labs. If you're using Redis Labs, the Endpoint property lists the URL that points to your Memcached instance.

Configuring settings

Copy your config.json file from the Cloud Storage part of this tutorial to the nodejs-getting-started/4-auth directory. Add these lines to the copied file:

"OAUTH2_CLIENT_ID": "[YOUR_OAUTH2_CLIENT_ID]",
"OAUTH2_CLIENT_SECRET": "[YOUR_OAUTH2_CLIENT_SECRET]",
"OAUTH2_CALLBACK": "http://localhost:8080/auth/google/callback",
"MEMCACHE_URL": "[YOUR_MEMCACHE_URL]"
  1. Replace [YOUR_OAUTH2_CLIENT_ID] and [YOUR_OAUTH2_CLIENT_SECRET] with the application client ID and secret that you created previously.
  2. Replace [YOUR_MEMCACHE_URL] with a URL pointing to your Memcached instance. This URL should be of the form hostname:port.

Installing dependencies

Install dependencies in the nodejs-getting-started/4-auth directory:

  • Using npm:

    npm install
    
  • Using yarn:

    yarn install
    

Running the app on your local machine

  1. Start a local web server using npm or yarn:

    • Using npm:

      npm start
      
    • Using yarn:

      yarn start
      
  2. In your web browser, enter this address.

    http://localhost:8080

Now you can browse the app's web pages, sign in using your Google account, add books, and see the books you've added using the My Books link in the top navigation bar.

Deploying the app to the App Engine flexible environment

  1. Change the OAUTH2_CALLBACK line of config.json to the following to enable online authentication. Replace [YOUR_PROJECT_ID] with your project ID:

    "OAUTH2_CALLBACK": "https://[YOUR_PROJECT_ID].appspot.com/auth/google/callback"
    
  2. Deploy the sample app:

    gcloud app deploy
    

  3. In your web browser, enter this address. Replace [YOUR_PROJECT_ID] with your project ID:

    https://[YOUR_PROJECT_ID].appspot.com
    

If you update your app, you can deploy the updated version by entering the same command you used to deploy the app the first time. The new deployment creates a new version of your app and promotes it to the default version. The older versions of your app remain, as do their associated VM instances. Be aware that all of these app versions and VM instances are billable resources.

You can reduce costs by deleting the non-default versions of your app.

To delete an app version:

  1. In the Cloud Platform Console, go to the App Engine Versions page.

    Go to the Versions page

  2. Click the checkbox next to the non-default app version you want to delete.
  3. Click the Delete button at the top of the page to delete the app version.

For complete information about cleaning up billable resources, see the Cleaning up section in the final step of this tutorial.

Application structure

The following diagram shows the application's components and how they connect to one another.

Auth sample structure

Understanding the code

This section walks you through the application code and explains how it works.

About sessions

Before your application can authenticate users, you need a way to store information about the current user in a session. Express comes with a memory-based implementation, but that is unsuitable for an application that can be served from multiple VM instances, as the session that is recorded in one instance might differ from other instances. The Bookshelf sample uses a MemcachedStore object to store session data in a remote Memcache service specified in config.json.

// Configure the session and session storage.
const sessionConfig = {
  resave: false,
  saveUninitialized: false,
  secret: config.get('SECRET'),
  signed: true
};

// In production use the App Engine Memcache instance to store session data,
// otherwise fallback to the default MemoryStore in development.
if (config.get('NODE_ENV') === 'production' && config.get('MEMCACHE_URL')) {
  sessionConfig.store = new MemcachedStore({
    hosts: [config.get('MEMCACHE_URL')]
  });
}

app.use(session(sessionConfig));

Authenticating users

The Bookshelf sample uses Passport.js to manage user authentication. Passport.js requires some setup before it can authenticate users:

const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

function extractProfile (profile) {
  let imageUrl = '';
  if (profile.photos && profile.photos.length) {
    imageUrl = profile.photos[0].value;
  }
  return {
    id: profile.id,
    displayName: profile.displayName,
    image: imageUrl
  };
}

// Configure the Google strategy for use by Passport.js.
//
// OAuth 2-based strategies require a `verify` function which receives the
// credential (`accessToken`) for accessing the Google API on the user's behalf,
// along with the user's profile. The function must invoke `cb` with a user
// object, which will be set at `req.user` in route handlers after
// authentication.
passport.use(new GoogleStrategy({
  clientID: config.get('OAUTH2_CLIENT_ID'),
  clientSecret: config.get('OAUTH2_CLIENT_SECRET'),
  callbackURL: config.get('OAUTH2_CALLBACK'),
  accessType: 'offline'
}, (accessToken, refreshToken, profile, cb) => {
  // Extract the minimal profile information we need from the profile object
  // provided by Google
  cb(null, extractProfile(profile));
}));

passport.serializeUser((user, cb) => {
  cb(null, user);
});
passport.deserializeUser((obj, cb) => {
  cb(null, obj);
});

Scopes indicate which items of the user’s information your application is allowed to access. Google provides multiple services along with a set of scopes for each service. For example, there is a scope that allows read-only access to Google Drive and another scope that allows read-write access. This sample requires only the basic scopes email and profile, which grant the application access to the user's email address and basic profile information.

Authenticating the user involves two basic steps, which together are called the web service flow:

  1. Redirect the user to Google’s authorization service.
  2. Process the response when Google redirects the user back to your application.

Redirect to Google

Your application sends the user to Google’s authorization service. The application generates a URL by using your client ID and the scopes that the application needs.

router.get(
  // Login url
  '/auth/login',

  // Save the url of the user's current page so the app can redirect back to
  // it after authorization
  (req, res, next) => {
    if (req.query.return) {
      req.session.oauth2return = req.query.return;
    }
    next();
  },

  // Start OAuth 2 flow using Passport.js
  passport.authenticate('google', { scope: ['email', 'profile'] })
);

Your application then redirects the user to the generated URL. The user is prompted to allow your application to access the specified scopes. After accepting, the user is redirected back to your application.

Process the authorization response

Google sends the user back to your application along with an authorization code. This code, along with your client secret, can be exchanged for the user’s credentials and other information about the user. This step of the flow happens entirely on the server without having to redirect the user. This extra step is required to verify that the authorization code is authentic and that your application is the one requesting credentials. Passport.js makes this step easy:

router.get(
  // OAuth 2 callback url. Use this url to configure your OAuth client in the
  // Google Developers console
  '/auth/google/callback',

  // Finish OAuth 2 flow using Passport.js
  passport.authenticate('google'),

  // Redirect back to the original page, if any
  (req, res) => {
    const redirect = req.session.oauth2return || '/';
    delete req.session.oauth2return;
    res.redirect(redirect);
  }
);

After you obtain credentials, you can store information about the user. Passport.js automatically serializes the user to the session. After the user’s information is in the session, you can make a couple of middleware functions to make it easier to work with authentication:

// Middleware that requires the user to be logged in. If the user is not logged
// in, it will redirect the user to authorize the application and then return
// them to the original URL they requested.
function authRequired (req, res, next) {
  if (!req.user) {
    req.session.oauth2return = req.originalUrl;
    return res.redirect('/auth/login');
  }
  next();
}

// Middleware that exposes the user's profile as well as login/logout URLs to
// any templates. These are available as `profile`, `login`, and `logout`.
function addTemplateVariables (req, res, next) {
  res.locals.profile = req.user;
  res.locals.login = `/auth/login?return=${encodeURIComponent(req.originalUrl)}`;
  res.locals.logout = `/auth/logout?return=${encodeURIComponent(req.originalUrl)}`;
  next();
}

You can use the info provided by the middleware in your templates to indicate to the user that they are logged in or logged out:

if profile
  if profile.image
    img.img-circle(src=profile.image, width=24)
  span #{profile.displayName}  
    a(href=logout) (logout)
else
  a(href=login) Login

Personalization

Now that you have the logged-in user's information available through middleware, you can keep track of which user added which book to the database.

router.post(
  '/add',
  images.multer.single('image'),
  images.sendUploadToGCS,
  (req, res, next) => {
    const data = req.body;

    // If the user is logged in, set them as the creator of the book.
    if (req.user) {
      data.createdBy = req.user.displayName;
      data.createdById = req.user.id;
    } else {
      data.createdBy = 'Anonymous';
    }

    // Was an image uploaded? If so, we'll use its public URL
    // in cloud storage.
    if (req.file && req.file.cloudStoragePublicUrl) {
      data.imageUrl = req.file.cloudStoragePublicUrl;
    }

    // Save the data to the database.
    getModel().create(data, (err, savedData) => {
      if (err) {
        next(err);
        return;
      }
      res.redirect(`${req.baseUrl}/${savedData.id}`);
    });
  }
);

Now that you have that information in the database, you can show the user which books they have personally added to the database.

// Use the oauth2.required middleware to ensure that only logged-in users
// can access this handler.
router.get('/mine', oauth2.required, (req, res, next) => {
  getModel().listBy(
    req.user.id,
    10,
    req.query.pageToken,
    (err, entities, cursor, apiResponse) => {
      if (err) {
        next(err);
        return;
      }
      res.render('books/list.pug', {
        books: entities,
        nextPageToken: cursor
      });
    }
  );
});

This code uses a new model method called listBy. The implementation depends on which database backend you chose.

Datastore

function listBy (userId, limit, token, cb) {
  const q = ds.createQuery([kind])
    .filter('createdById', '=', userId)
    .limit(limit)
    .start(token);

  ds.runQuery(q, (err, entities, nextQuery) => {
    if (err) {
      cb(err);
      return;
    }
    const hasMore = nextQuery.moreResults !== Datastore.NO_MORE_RESULTS ? nextQuery.endCursor : false;
    cb(null, entities.map(fromDatastore), hasMore);
  });
}

Cloud SQL

function listBy (userId, limit, token, cb) {
  token = token ? parseInt(token, 10) : 0;
  connection.query(
    'SELECT * FROM `books` WHERE `createdById` = ? LIMIT ? OFFSET ?',
    [userId, limit, token],
    (err, results) => {
      if (err) {
        cb(err);
        return;
      }
      const hasMore = results.length === limit ? token + results.length : false;
      cb(null, results, hasMore);
    });
}

MongoDB

function listBy (userid, limit, token, cb) {
  token = token ? parseInt(token, 10) : 0;
  if (isNaN(token)) {
    cb(new Error('invalid token'));
    return;
  }
  getCollection((err, collection) => {
    if (err) {
      cb(err);
      return;
    }
    collection.find({createdById: userid})
      .skip(token)
      .limit(limit)
      .toArray((err, results) => {
        if (err) {
          cb(err);
          return;
        }
        const hasMore =
          results.length === limit ? token + results.length : false;
        cb(null, results.map(fromMongo), hasMore);
      });
  });
}

Send feedback about...