Несоответствие состояния 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 и секреты для сервера единого входа. Сервер API устанавливает URL-адрес модуля внешнего интерфейса в файле cookie (чтобы я мог перенаправить пользователя обратно на модуль внешнего интерфейса инициатора), а затем перенаправить запрос на сервер единого входа, где пользователю предоставляется экран входа в систему. Пользователь вводит туда учетные данные, сервер SSO проверяет учетные данные, создает сеанс. После проверки учетных данных сервер единого входа вызывает конечную точку сервера API с профилем пользователя и токеном доступа. Сервер API получает профиль в сеансе и запрашивает и подписывает свой собственный токен и отправляет его в модуль внешнего интерфейса через параметры запроса. На фронтенде (React APP) для этого есть маршрут. В этом внешнем маршруте я извлекаю токен из queryParams и устанавливаю в локальном хранилище. Пользователь находится в приложении. Точно так же, когда пользователь загружает FrontendModule-2, происходит тот же поток, но на этот раз, потому что сеанс создается сервером SSO при запуске потока FrontendModule-1. он никогда не запрашивает учетные данные для входа и не регистрирует пользователя в системе.

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

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

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

Ожидание

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

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

Пример реализации (сервер 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}`)
})


person Taimoor Ali    schedule 17.08.2020    source источник
comment
Как насчет сохранения массива в uiState. Не переопределяйте uiState, а вместо этого добавляйте состояния в массив. И при отправке ответа обратно пользователю проверьте, из какого Frontend-модуля был инициирован запрос, и отправьте соответствующий ответ. Не будет работать?   -  person Omair Nabiel    schedule 19.08.2020
comment
Омайр Набиэль Невозможно определить, какой модуль инициировал запрос. Когда вы говорите push to uiState, что это значит? Я считаю, что если мы добавим новое значение в массив, это все равно будет считаться мутацией, верно?   -  person Taimoor Ali    schedule 19.08.2020
comment
Идея заключалась в том, чтобы не перезаписывать предыдущий URL-адрес FrontEndModule, а добавлять их в массив или создавать хэш-карту.   -  person Omair Nabiel    schedule 20.08.2020
comment
@OmairNabiel, можете ли вы предоставить пример реализации, что вы имеете в виду? потому что, насколько я понимаю, даже если мы добавим новое значение в хэш-карту или массив, это все равно будет считаться мутацией. например, если я получил запрос от APP 1 и у меня есть uiState как ["http://app1.com"], а затем я делаю другой запрос, я не перезаписываю его, а делаю push в массив, как это ["http://app1.com", "http://app2.com"], тогда это все еще изменение что приведет к ошибке несоответствия. Пожалуйста, поправьте меня, если я ошибаюсь.?   -  person Taimoor Ali    schedule 24.08.2020


Ответы (3)


Вы должны перенаправить пользователя обратно на исходный 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, где Грант обрабатывает 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

person simo    schedule 22.08.2020
comment
Вы видите, что есть проблема с подходом, который вы упомянули. Это все равно выдаст ошибку, что состояние не соответствует. Потому что, как только вы установите это lang-js res.locals.grant = {dynamic: {redirect_uri: `http://${req.headers.host}/connect/${req.params.provider}/callback` }}; next(); , вы можете проверить это, запустив реализацию моего примера кода. Я пробовал это. - person Taimoor Ali; 24.08.2020
comment
Это работает на моем конце. По умолчанию экспресс-сеанс устанавливает ключ Domain вашего файла cookie на основе значения заголовка запроса Host. Таким образом, пока вы просматриваете URL-адрес своего конкретного внешнего приложения, файлы cookie всегда разные - разные Domain также означают разные идентификаторы сеанса. Я предлагаю вам выбрать несколько примеров Grant, доступных в репозитории, и работать с ними. Если вы будете следовать вышеуказанным шагам, это сработает. - person simo; 24.08.2020
comment
Требует ли это каких-либо дополнительных изменений в конфигурации гранта? Я имею в виду, должен ли я сохранить свою конфигурацию, как в вопросе? Мне просто нужно изменить res.locals.grant правильно? - person Taimoor Ali; 24.08.2020
comment
Я думаю, что ваша конфигурация в порядке. Динамический набор redirect_uri будет переопределять необходимые части вашей конфигурации. Кроме того, использование State Override не требует добавления redirect_uri в конфигурационный ключ dynamic, так как это требуется только для динамических HTTP-переопределений. - person simo; 24.08.2020

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

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

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

person Tauseef Rehan    schedule 18.08.2020
comment
Я использую пользовательскую реализацию OAuth, как вы можете видеть в примере кода. - person Taimoor Ali; 18.08.2020

Как я решил эту проблему, удалив реализацию 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.

Образец карты состояния

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