Несоответствие состояния OAuth поставщика грантов при одновременном доступе к двум модулям приложения в одном браузере, и пользователь еще не вошел в систему

Я пытался внедрить систему единого входа (SSO). У меня есть разные модули внешнего интерфейса, которые работают в разных доменах, и все они используют один сервер API.

  1. Сервер единого входа https://sso.app.com/
  2. Сервер API https://api.app.com/
  3. Модуль 1 внешнего интерфейса https://module-1.app.com/
  4. Модуль внешнего интерфейса 2 https://module-2.app.com/

Процесс аутентификации

Процесс аутентификации - это проверка модуля FrontEnd на токен в локальном хранилище. Если он не находит токен, он перенаправляет пользователя на конечную точку сервера API, скажем, https://api.app.com/oauth/connect. Сервер API имеет clientId и секреты для сервера SSO. Сервер API устанавливает URL-адрес модуля Frontend в файле cookie (чтобы я мог перенаправить пользователя обратно к модулю внешнего интерфейса инициатора), а затем перенаправить запрос на сервер SSO, где пользователю предоставляется экран входа в систему. Пользователь вводит там кредиты, SSO-сервер проверяет кредиты, создает сеанс. После проверки прав доступа сервер SSO вызывает конечную точку сервера API с профилем пользователя и access_token. Сервер API получает профиль в сеансе, запрашивает и подписывает собственный токен и отправляет его в интерфейсный модуль через параметры запроса. На frontEnd(React APP) есть маршрут именно для этого. В этом внешнем маршруте я извлекаю токен из queryParams и устанавливаю его в localstorage. Пользователь находится в приложении.Точно так же, когда пользователь загружает FrontendModule-2, происходит такой же поток, но на этот раз потому, что сеанс создается сервером SSO при запуске потока FrontendModule-1. он никогда не запрашивает кредиты для входа и не выполняет вход в систему.

Неудачный сценарий:

Сценарий такой: предположим, что есть пользователь JHON, который еще не вошел в систему и не имеет сеанса. Jhon перешел по URL-адресу "Frontend Module 1" в браузере. Модуль Frontend проверяет localStorage на наличие токена, он не находит его там, затем модуль Frontend перенаправляет пользователя на маршрут сервера API. Сервер API имеет clientSecret и clientId, которые перенаправляют запрос на сервер SSO. Там пользователю будет представлен экран входа в систему.

Джон видит экран входа в систему и оставил его как есть. Теперь Jhon открывает другую вкладку в том же браузере и вводит URL-адрес "Frontend Module 2". Произойдет тот же процесс, что и выше, и Джон попадает на экран входа в систему. Джон оставил этот экран как есть и вернулся к первой вкладке, где у него был загружен экран сеанса Frontend Module 1. Он вводит кредиты и нажимает кнопку входа в систему. Это дает мне ошибку, что состояние сеанса было изменено. Эта ошибка действительно имеет смысл, потому что сеанс является общим.

Ожидание

Как мне добиться этого без ошибки. Я хочу перенаправить пользователя на тот же модуль Frontend, который инициировал запрос.

Инструменты, которые я использую

Пример реализации (сервер API)

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 ответа

Решение

Как я решил эту проблему, удалив реализацию grant-express и применив пакет client-oauth2.

Вот моя реализация.

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;

В моем /connect/oauth конечная точка, вместо того, чтобы переопределять состояние, я создаю хэш-карту stateMap и добавьте это в сеанс с uiState как значение, полученное в URL-адресе, как это https://api.foo.bar.com?uiState=https://module-1.app.com Когда в обратном вызове я возвращаю состояние с моего сервера OAuth и использую stateMap, я получаю uiState значение.

Пример StateMap

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

Вы должны перенаправить пользователя обратно на URL-адрес исходного приложения, а не на URL-адрес сервера API:

.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')))

Затем вам нужно указать оба:

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

как разрешенные URI перенаправления вашего приложения OAuth.

Наконец, вам нужно направить некоторые из маршрутов домена приложения на свой сервер API, где Grant обрабатывает URI перенаправления.

пример

  1. Настройте свое приложение со следующим URI перенаправления https://foo1.bar.com/connect/google/callback
  2. Перейдите к https://foo1.bar.com/login в вашем браузере
  3. Приложение браузера перенаправляет на ваш API https://api.bar.com/connect/google
  4. Перед перенаправлением пользователя в Google приведенный выше код настраивает redirect_uri на основе входящих Host заголовок запроса к https://foo1.bar.com/connect/google/callback
  5. Пользователь входит в Google и его перенаправляют обратно на https://foo1.bar.com/connect/google/callback
  6. Этот конкретный маршрут должен быть перенаправлен обратно в ваш API.https://api.bar.com/connect/google/callback

Повторите для https://foo2.bar.com

У вас есть опция relay_state при обращении к серверу SSO, которая возвращается, поскольку она была отправлена ​​на сервер SSO, просто для отслеживания состояния приложения перед запросом SSO.

Чтобы узнать больше о состоянии реле: https://developer.okta.com/docs/concepts/saml/

И какой сервис SSO вы используете??

Другие вопросы по тегам