expressoauth-2.0single-sign-onexpress-sessiongrant-oauth

Grant Provider OAuth state mismatch when accessing the two application modules simultaneously on same browser and user is not logged in yet


I have been trying to implement Single SignOn(SSO). I have different frontend application modules which are running on different domain and they all utlize a single API server.

  1. SSO Server https://sso.app.com
  2. API Server https://api.app.com
  3. Frontend Module 1 https://module-1.app.com
  4. Frontend Module 2 https://module-2.app.com

Authentication flow

The flow of authentication is FrontEnd Module check for token in the localstorage. If it do not find the token, it redirect the user to API server endpoint let say https://api.app.com/oauth/connect. API server has the clientId and Secrets for the SSO server. API server set the url of Frontend module in the cookie(so that i can redirect the user back to initiator frontend module) and then redirect the request to SSO server where user is presented with login screen. User enters the creds there, SSO server validate the credientials, creates a session. Once credientials are validated, SSO server calls the API server Endpoint with user profile and access_token. API server gets the profile in the session and query and sign its own token and send that to frontend module through query params. On the frontEnd(React APP) there is a route just for this. In that frontend route I extract the token from queryParams and set in the localstorage. User is in the application. Similarly when user loads the FrontendModule-2 same flow happend but this time because Session is being created by SSO server when FrontendModule-1 flow ran. it never ask for login creds and sign the user in to the system.

Failing Scenario:

The scenario is, assume there is user JHON who is not logged in yet and do not have session. Jhon hit the "Frontend Module 1" URL in the browser. Frontend module check the localStorage for the token, it do not find it there, then Frontend module redirect the user to API server route. API server has the clientSecret and clientId which redirect the request to SSO server. There user will be presented with Login Screen.

Jhon sees the login screen and left it as it is. Now Jhon opens another tab in the same browser and enter the URL of "Frontend Module 2". Same flow happen as above and Jhon lands on login screen. Jhon left that screen as it is and moves back to the first tab where he has Frontend Module 1 session screen loaded up. He enter the creds and hit the login button. It give me error that session state has been changed. This error actually makes sense, because session is a shared.

Expectation

How do I achieve this without the error. I want to redirect the user to the same Frontend Module which initiated the request.

Tools that I am Using

Sample Implementation (API Server)

require('dotenv').config();

var express = require('express')
  , session = require('express-session')
  , morgan = require('morgan')
var Grant = require('grant-express')
  , port = process.env.PORT || 3001
  , oauthConsumer= process.env.OAUTH_CONSUMER || `http://localhost`
  , oauthProvider = process.env.OAUTH_PROVIDER_URL || 'http://localhost'
  , grant = new Grant({
    defaults: {
      protocol: 'https',
      host: oauthConsumer,
      transport: 'session',
      state: true
    },
    myOAuth: {
      key: process.env.CLIENT_ID || 'test',
      secret: process.env.CLIENT_SECRET || 'secret',
      redirect_uri: `${oauthConsumer}/connect/myOAuth/callback`,
      authorize_url: `${oauthProvider}/oauth/authorize`,
      access_url: `${oauthProvider}/oauth/token`,
      oauth: 2,
      scope: ['openid', 'profile'],
      callback: '/done',
      scope_delimiter: ' ',
      dynamic: ['uiState'],
      custom_params: { deviceId: 'abcd', appId: 'com.pud' }
    }
  })

var app = express()

app.use(morgan('dev'))

// REQUIRED: (any session store - see ./examples/express-session)
app.use(session({secret: 'grant'}))
// Setting the FrontEndModule URL in the Dynamic key of Grant.
app.use((req, res, next) => {
  req.locals.grant = { 
    dynamic: {
      uiState: req.query.uiState
    }
  }
  next();
})
// mount grant
app.use(grant)
app.get('/done', (req, res) => {
  if (req.session.grant.response.error) {
    res.status(500).json(req.session.grant.response.error);
  } else {
    res.json(req.session.grant);
  }
})

app.listen(port, () => {
  console.log(`READY port ${port}`)
})


Solution

  • The way I solved this problem by removing the grant-express implementation and use the client-oauth2 package.

    Here is my implementation.

    var createError = require('http-errors');
    var express = require('express');
    var path = require('path');
    var cookieParser = require('cookie-parser');
    const session = require('express-session');
    const { JWT } = require('jose');
    const crypto = require('crypto');
    const ClientOauth2 = require('client-oauth2');
    var logger = require('morgan');
    var oauthRouter = express.Router();
    
    
    
    const clientOauth = new ClientOauth2({
      clientId: process.env.CLIENT_ID,
      clientSecret: process.env.SECRET,
      accessTokenUri: process.env.ACCESS_TOKEN_URI,
      authorizationUri: process.env.AUTHORIZATION_URI,
      redirectUri: process.env.REDIRECT_URI,
      scopes: process.env.SCOPES
    });
    
    oauthRouter.get('/oauth', async function(req, res, next) {
      try {
        if (!req.session.user) {
          // Generate random state
          const state = crypto.randomBytes(16).toString('hex');
          
          // Store state into session
          const stateMap = req.session.stateMap || {};      
          stateMap[state] = req.query.uiState;
          req.session.stateMap = stateMap;
    
          const uri = clientOauth.code.getUri({ state });
          res.redirect(uri);
        } else {
          res.redirect(req.query.uiState);
        }
      } catch (error) {
        console.error(error);
        res.end(error.message);
      }
    });
    
    
    oauthRouter.get('/oauth/callback', async function(req, res, next) {
      try {
        // Make sure it is the callback from what we have initiated
        // Get uiState from state
        const state = req.query.state || '';
        const stateMap = req.session.stateMap || {};
        const uiState = stateMap[state];
        if (!uiState) throw new Error('State is mismatch');    
        delete stateMap[state];
        req.session.stateMap = stateMap;
        
        const { client, data } = await clientOauth.code.getToken(req.originalUrl, { state });
        const user = JWT.decode(data.id_token);
        req.session.user = user;
    
        res.redirect(uiState);
      } catch (error) {
        console.error(error);
        res.end(error.message);
      }
    });
    
    var app = express();
    
    
    app.use(logger('dev'));
    app.use(express.json());
    app.use(express.urlencoded({ extended: false }));
    app.use(cookieParser());
    app.use(session({
      secret: 'My Super Secret',
      saveUninitialized: false,
      resave: true,
      /**
      * This is the most important thing to note here.
      * My application has wild card domain.
      * For Example: My server url is https://api.app.com
      * My first Frontend module is mapped to https://module-1.app.com
      * My Second Frontend module is mapped to  https://module-2.app.com
      * So my COOKIE_DOMAIN is app.com. which would make the cookie accessible to subdomain.
      * And I can share the session.
      * Setting the cookie to httpOnly would make sure that its not accessible by frontend apps and 
      * can only be used by server.
      */
      cookie: { domain: process.env.COOKIE_DOMAIN, httpOnly: true }
    }));
    app.use(express.static(path.join(__dirname, 'public')));
    
    
    app.use('/connect', oauthRouter);
    
    // catch 404 and forward to error handler
    app.use(function(req, res, next) {
      next(createError(404));
    });
    
    // error handler
    app.use(function(err, req, res, next) {
      // set locals, only providing error in development
      res.locals.message = err.message;
      res.locals.error = req.app.get('env') === 'development' ? err : {};
    
      // render the error page
      res.status(err.status || 500);
      res.render('error');
    });
    
    module.exports = app;
    

    In my /connect/oauth endpoint, instead of overriding the state I create a hashmap stateMap and add that to session with the uiState as a value received in the url like this https://api.foo.bar.com?uiState=https://module-1.app.com When in the callback I get the state back from my OAuth server and using the stateMap I get the uiState value.

    Sample stateMap

    req.session.stateMap = {
      "12313213dasdasd13123123": "https://module-1.app.com",
      "qweqweqe131313123123123": "https://module-2.app.com"
    }