Application State in OAuth 2.0

Jared Hanson
Passport.js
Published in
2 min readJul 1, 2021

--

Support for application state has been greatly enhanced in passport-oauth2 version 1.6.0. It is now possible to pass a state object as an option to passport.authenticate(), have that state automatically persisted to the session, and then automatically rehydrated when the user is returned to the application’s callback URL.

To understand the improvements, let’s examine how state was managed in previous versions. When using an OAuth 2.0-based strategy, such as Google, state support is enabled by setting state: true as an option when constructing the strategy.

var GoogleStrategy = require('passport-google-oauth20');

passport.use(new GoogleStrategy({
clientID: process.env['GOOGLE_CLIENT_ID'],
clientSecret: process.env['GOOGLE_CLIENT_SECRET'],
callbackURL: '/auth/google/callback',
scope: 'profile',
state: true
},
function(accessToken, refreshToken, profile, cb) {
// ...
}
));

With this option enabled, passport-oauth2 generates a nonce, persists it in the session, and adds it as a parameter when redirecting the user to the OAuth 2.0 authorization server. This nonce is echoed back to the application when the authorization server redirects the user back to the callback URL. The value is compared to that persisted in the session, and if they do not match the request is rejected. Use of this option is highly recommended as it prevents CSRF attacks.

This use of state to automatically generate nonces which protect against CSRF is useful, but it is also limiting. Often times there is application-specific state that needs to be restored when a user completes an OAuth 2.0 flow, so they can resume what they were doing prior to being redirected. While storing such state was possible in prior versions, it was error prone and subject to mistakes.

Version 1.6.0 introduces application-level state storage, which is enabled by setting store: true as an option when constructing the strategy.

var GoogleStrategy = require('passport-google-oauth20');

passport.use(new GoogleStrategy({
clientID: process.env['GOOGLE_CLIENT_ID'],
clientSecret: process.env['GOOGLE_CLIENT_SECRET'],
callbackURL: '/auth/google/callback',
scope: 'profile',
store: true
},
function(accessToken, refreshToken, profile, cb) {
// ...
}
));

With the store option enabled, it is possible to pass a stateobject to passport.authenticate().

app.get('/auth/google',
passport.authenticate('google', { state: { beep: 'boop' } }));

This state will be automatically persisted and rehydrated when the user is returned to the callback URL. The state is made available onreq.authInfo.

app.get('/auth/google/callback', 
passport.authenticate('google', { failureRedirect: '/login' }),
function(req, res) {
var state = req.authInfo.state;
// resume state...
});

All of this happens while still providing nonce-based CSRF protection. Note that setting store: true impliesstate: true, and for simplicity the latter doesn’t have to be set explicitly.

--

--