3

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}`)
})
simo
  • 15,078
  • 7
  • 45
  • 59
  • How about maintaining an array in the `uiState`. Don't override the uiState but instead push the states in the array. And when sending response back to the user check from which Frontend-Module the request was initiated from and send the respective response. Won't work? – Omair Nabiel Aug 19 '20 at 08:31
  • [Omair Nabiel](https://stackoverflow.com/users/7698672/omair-nabiel) There is no way to identify which module initiated the request. When you say push to uiState what does that mean? I believe if we add new Value to array it would still be considered as mutation right? – Taimoor Ali Aug 19 '20 at 08:59
  • The idea was not to overwrite the previous FrontEndModule URL, but append them in an array or create a hashmap – Omair Nabiel Aug 20 '20 at 06:31
  • @OmairNabiel can you provide a sample implementation what you mean by that? cause as per my understanding even if we append new value to a hashmap or array, it will still be considered as mutation. like for example if I got request from **APP 1** and i have `uiState` as `["http://app1.com"]` and then i make another request I do not overwrite it but do a push into the array like this `["http://app1.com", "http://app2.com"]` then it is still a change which will cause mismatch error. Please Correct me if I am wrong.? – Taimoor Ali Aug 24 '20 at 12:00

3 Answers3

0

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

simo
  • 15,078
  • 7
  • 45
  • 59
  • You see there is problem with the approach you mentioned. It will still throw an error that state is Mismatch. Cause as soon you set this ```lang-js res.locals.grant = {dynamic: {redirect_uri: `http://${req.headers.host}/connect/${req.params.provider}/callback` }}; next(); ``` You can test this by running my sample code implementation. I have tried that. – Taimoor Ali Aug 24 '20 at 11:48
  • It works on my end. By default express-session sets the `Domain` key of your cookie based on the request `Host` header value. So as long as you go through your specific frontend app URL cookies are always different - different `Domain` means different Session ID as well. I suggest you to pick some of the Grant examples available in the repo and work from there. If you follow the above steps it is going to work. – simo Aug 24 '20 at 12:00
  • Does that require any additional change in the Grant Configuration? I mean should I keep my configuration as in the question? I just have to change the `res.locals.grant` right? – Taimoor Ali Aug 24 '20 at 12:22
  • I think your configuration is ok. The `redirect_uri` set dynamically will be override the necessary parts in your configuration. Also using State Override does not require adding the `redirect_uri` in the `dynamic` configuration key as that is required only for Dynamic HTTP Overrides. – simo Aug 24 '20 at 12:37
-1

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??

-1

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