Несоответствие состояния OAuth поставщика грантов при одновременном доступе к двум модулям приложения в одном браузере, и пользователь еще не вошел в систему
Я пытался внедрить систему единого входа (SSO). У меня есть разные модули внешнего интерфейса, которые работают в разных доменах, и все они используют один сервер API.
- Сервер единого входа https://sso.app.com/
- Сервер API https://api.app.com/
- Модуль 1 внешнего интерфейса https://module-1.app.com/
- Модуль внешнего интерфейса 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 перенаправления.
пример
- Настройте свое приложение со следующим URI перенаправления
https://foo1.bar.com/connect/google/callback
- Перейдите к
https://foo1.bar.com/login
в вашем браузере - Приложение браузера перенаправляет на ваш API
https://api.bar.com/connect/google
- Перед перенаправлением пользователя в Google приведенный выше код настраивает
redirect_uri
на основе входящихHost
заголовок запроса кhttps://foo1.bar.com/connect/google/callback
- Пользователь входит в Google и его перенаправляют обратно на
https://foo1.bar.com/connect/google/callback
- Этот конкретный маршрут должен быть перенаправлен обратно в ваш 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 вы используете??