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

Viewed 942

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}`)
})

3 Answers

You have to redirect the user back to the originating app URL not the API server URL:

.use('/connect/:provider', (req, res, next) => {
  res.locals.grant = {dynamic: {redirect_uri:
    `http://${req.headers.host}/connect/${req.params.provider}/callback`
  }}
  next()
})
.use(grant(require('./config.json')))

Then you need to specify both:

https://foo1.bar.com/connect/google/callback
https://foo2.bar.com/connect/google/callback

as allowed redirect URIs of your OAuth app.

Lastly you have to route some of the app domain routes to your API server where Grant is handling the redirect URI.

Example

  1. Configure your app with the following redirect URI https://foo1.bar.com/connect/google/callback
  2. Navigate to https://foo1.bar.com/login in your browser app
  3. The browser app redirects to your API https://api.bar.com/connect/google
  4. Before redirecting the user to Google, the above code configures the redirect_uri based on the incoming Host header of the request to https://foo1.bar.com/connect/google/callback
  5. The user logs into Google and is being redirected back to https://foo1.bar.com/connect/google/callback
  6. That specific route have to be redirected back to your API https://api.bar.com/connect/google/callback

Repeat for https://foo2.bar.com

you have relay_state option while hitting SSO server, that is returned as it was sent to SSO server, just to keep track of application state before requesting SSO.

TO learn more about relay state: https://developer.okta.com/docs/concepts/saml/

And which SSO service are you using??

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"
}
Related