OAuth 2 with Passport - 10 Steps Recipe

Recently I found myself integrating OAuth 2 into a React/node.js app. The web is full of blog posts and questions answered on how to do it, but I still had to scratch my head a few times to get everything right. This post is a simple step by step recipe on how to do it with Passport.

Step 1. Express boilerplate

I am using express so first things first:

1
2
3
4
5
6
7
8
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');

const app = express();

app.use(cors());
app.use(bodyParser.urlencoded({ extended: false }));

Step 2. Session middleware first

If you are using sessions and plan on using passport with sessions, make sure that session middleware goes first:

1
2
3
const session = require('express-session');

app.use(session({ ... }));

Step 3. Passport

Now we can wire in passport:

1
2
3
4
const passport = require('passport');

app.use(passport.initialize());
app.use(passport.session());

Step 4. Authentication route

This wasn’t apparent to me from the beginning and from the simple examples that I looked at, but:

passport is designed to only facilitate the authentication process. In other words, the only route that should have passport middleware on it is the /login route where you would send your unauthenticated users to

1
2
3
4
app.get('/login', passport.authenticate('oauth2', {
session: true,
successReturnToOrRedirect: '/'
}));

Actually, you should also add the passport middleware to your callback route or just make /login your OAuth callback. That’s what I did. The OAuth strategy looks at ?code= in the URL to decide whether to initiate the authentication sequence or process the callback:

1
2
3
4
5
6
7
8
9
10
11
12
13
OAuth2Strategy.prototype.authenticate = function(req, options) {
// ...

if (req.query && req.query.error) {
// fail authentication sequence
}

if (req.query && req.query.code) {
// process the callback from the identity provider
} else {
// start the authentication sequence
}
};

Step 5. Protected routes

To protect your routes and ensure authenticated access, you can use something like connect-ensure-login. Passport itself can’t help you with that:

1
2
3
4
const { ensureLoggedIn } = require('connect-ensure-login');
const api = require('./endpoints/api');

app.use(`/api/v1`, ensureLoggedIn('/login'), api);

Step 6. OAuth 2 strategy

To be able to do passport.authenticate('oauth2', {...}) as I showed in step 4, you should set up passport with the OAuth 2 strategy first:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const OAuth2Strategy = require('passport-oauth2');

const tokenToProfile = async () => {} // <-- I will explain in step 9

const strategy = new OAuth2Strategy({
state: true,
authorizationURL: process.env.AUTH_AUTHORIZATION_URL,
tokenURL: process.env.AUTH_TOKEN_URL,
clientID: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET,
callbackURL: process.env.AUTH_CALLBACK_URL,
passReqToCallback: true // <-- I will explain in step 9
}, tokenToProfile);

passport.use(strategy);

Step 7. De/Serialize user

In order not to run the authentication sequence on every request, you would typically store the authenticated user ID in the session and then trust it for as long as the session is active. You need to implement serializeUser and deserializeUser to do it. Passport doesn’t do it automatically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
passport.serializeUser((user, done) => {
// The user object in the arguments is the result of your authentication process
// (see step 9)

done(null, user);

// If your user object is large or has transient state,
// you may want to only store the user id in the session instead:

// done(null, user.user_id)
});

passport.deserializeUser(async (user, done) => {
// The user object in the arguments is what you have stored in the session

// If you stored the entire user object when you serialized it to session,
// you can skip re-quering your user store on every request

user = await User.getUserByID(user.user_id);

done(null, user);
});

Step 8. Tokens

OAuth 2 can send back access_token and it can also send the id_token. The latter is always a JWT token and the former is typically an opaque string.

Sometimes all you need is the access_token that you pass on to the back-end APIs. I, however, needed to authenticate the user and match the user’s identity with the application’s user record.

Two options:

  • Use the /userinfo endpoint with the access_token to retrieve the profile from your identity provider
  • Ask for the id_token and get profile attributes from there. To receive the id_token in the callback, you need to add scope=openid to your authorization request. If you need user’s email or additional attributes like name, for example, you will need to ask for more scopes (scope=openid email or scope=openid profile).

OAuth 2.0 is not an authentication protocol, apparently. Read the User Authentication article on oauth.net if you want to learn more. The id_token, claims, scopes, and /userinfo are all part of OpenID Connect.

Step 9. Retrieve Profile

When we set up the OAuth 2 strategy in step 6, we had to supply a tokenToProfile callback. If you read the documentation, you will see that it has the following signature:

1
2
3
function (accessToken, refreshToken, profile, cb) {
// Note: no id_token passed in the arguments
}

Don’t be surprised to always receive an empty object in profile:

OAuth 2 strategy for passport does not implement retrieval of the user profile

Here’s how it looks in the library:

1
2
3
4
5
OAuth2Strategy.prototype.userProfile = function(accessToken, done) {
return done(null, {}); // <-- always {}, oops!

// Note: no id_token passed in the arguments
};

You can either override it and use /userinfo endpoint or you can rely on id_token. Here’s how you would do the former:

1
2
3
4
5
const strategy = new OAuth2Strategy({ ... });

strategy.userProfile = function(accessToken, done) {
// access /userinfo with accessToken
}

The latter requires you to not only ask for the id_token from your identity provider using scope=openid, but to also have it exposed to you by the OAuth 2 strategy in passport. To do so, you need to set passReqToCallback to true when you instantiate the strategy (we did in step 6), and then you can use a different signatue for your callback:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const jwt = require('jsonwebtoken');

const tokenToProfile = async (req, accessToken, refreshToken, params, profile, done) => {
const idToken = params['id_token'];

// !!<-- Make sure you validate the token's signature -->!!
// And make sure you handle errors. I simplified the code for the blog post

const employeeID = jwt.decode(idToken)[process.env.AUTH_EMPLOYEE_ID || 'sub'];

profile = await User.getUserByEmployeeID(employeeID);

done(null, profile);
};

Step 10. Logout

The easiest and the most effective way to logout a user is to destroy the session:

1
2
3
router.get('/logout', function (req, res) {
req.session.destroy(() => res.redirect('/'));
});

Step 11 (Bonus). Spoof Authentication

If you have gotten this far, I have a bonus step for you. I found it very helpful to be able to spoof authentication in local environment for development and testing.

First, the environment variable in my .env file to signal that the auth should be bypassed and to tell the app what user to run on behalf of:

1
AUTH_LOCAL_SPOOF_USER={"user_id": 2, "employeeID": "pavel@dontemailme.com", "role_id": 0}

And then a bypass strategy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const strategy = new OAuth2Strategy({ ... });

if (process.env.AUTH_LOCAL_SPOOF_USER) {
passport.use({
name: 'oauth2',
authenticate: function () {
try {
this.success(JSON.parse(process.env.AUTH_LOCAL_SPOOF_USER));
} catch (error) {
this.error(error);
}
}
});
} else {
passport.use(strategy);
}

And that’s it! Enjoy!