在同一浏览器上同时访问两个应用程序模块且用户尚未登录时授予提供者 OAuth 状态不匹配

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

我一直在尝试实施单点登录 (SSO)。我有不同的前端应用程序模块,它们在不同的域上 运行,它们都使用单个 API 服务器。

  1. SSO 服务器https://sso.app.com
  2. API 服务器 https://api.app.com
  3. 前端模块 1 https://module-1.app.com
  4. 前端模块 2 https://module-2.app.com

身份验证流程

身份验证流程是前端模块检查本地存储中的令牌。如果找不到令牌,它会将用户重定向到 API 服务器端点,比如说 https://api.app.com/oauth/connect。 API 服务器具有 SSO 服务器的 clientId 和 Secrets。 API 服务器在 cookie 中设置前端模块的 url(以便我可以将用户重定向回启动器前端模块),然后将请求重定向到向用户显示登录屏幕的 SSO 服务器。用户在那里输入凭据,SSO 服务器验证凭据,创建会话。 验证凭据后,SSO 服务器将使用用户配置文件和 access_token 调用 API 服务器端点。 API 服务器获取会话中的配置文件并查询并签署自己的令牌,然后通过查询参数将其发送到前端模块。在前端(React APP)上有一条专门用于此的路线。在那个前端路由中,我从 queryParams 中提取令牌并在本地存储中设置。用户在应用程序中。 类似地,当用户加载 FrontendModule-2 时会发生相同的流程,但这次是因为当 FrontendModule-1 流程 运行 时 SSO 服务器正在创建会话。它从不要求登录凭据并将用户登录到系统。

失败场景:

场景是,假设有用户 JHON 尚未登录且没有会话。 Jhon 在浏览器中点击“前端模块 1”URL。前端模块检查 localStorage 中的令牌,没有在那里找到它,然后前端模块将用户重定向到 API 服务器路由。 API 服务器具有将请求重定向到 SSO 服务器的 clientSecret 和 clientId。用户将看到登录屏幕。

Jhon 看到登录屏幕并保持原样。现在 Jhon 在同一浏览器中打开另一个选项卡并输入“前端模块 2”的 URL。与上面相同的流程发生,Jhon 登陆登录屏幕。 Jhon 离开该屏幕并返回到第一个选项卡,他在其中加载了前端模块 1 会话屏幕。他输入信用并点击登录按钮。它给我错误,会话状态已更改。 这个错误其实是有道理的,因为session是共享的。

预期

如何在没有错误的情况下实现这一目标。我想将用户重定向到发起请求的同一个前端模块。

我正在使用的工具

示例实现(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}`)
})

您在访问 SSO 服务器时有 relay_state 选项,它在发送到 SSO 服务器时返回,只是为了在请求 SSO 之前跟踪应用程序状态。

要了解有关中继状态的更多信息:https://developer.okta.com/docs/concepts/saml/

您使用的是哪种 SSO 服务??

您必须将用户重定向回原始 app URL 而不是 API 服务器 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')))

那么你需要同时指定:

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

允许重定向 OAuth 应用程序的 URI。

最后,您必须路由一些应用域路由到您的 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 之前,上述代码根据传入的 Host header 请求配置 redirect_uri 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

我通过删除 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 端点中,我没有覆盖状态,而是创建了一个 hashmap stateMap 并将其添加到与 uiState 的会话中作为 [=27= 中收到的值] 像这样 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"
}