Skip to content

[bug] 从保留 window.opener 的标签页打开时,OIDC 登录会自行关闭标签页并丢失会话 #2347

Open

Description

@xlwreally

What happened

当仪表盘是从另一个页面/窗口打开的、且保留了 window.opener(例如公司门户的快捷方式使用 window.open(...)、没有 rel="noopener" 的 <a target="_blank">、内嵌 webview 等)时,OIDC 登录完成会跳转到 /login/oidc/complete。该页面仅因为存在 window.opener 就把自己判定为 SSO 弹窗,于是通过 postMessage(..., window.location.origin) 把 token 发给 opener 并调用 window.close()。

但 opener 属于另一个 origin,也没有实现 octop-sso 消息协议,消息被丢弃、标签页被关闭,用户被退回启动页,看起来像“没有登录成功”——而服务端的登录实际上是成功的(审计日志 auth.oidc_login 成功,POST /api/auth/oidc/exchange 返回 200)。

实际结果:跳转到 /login/oidc/complete 后,标签页自行关闭,焦点回到启动页,仪表盘并未登录。

Expected behavior

标签页保持打开,保存会话并跳转到 /chat(若失败则显示错误页),与“在新标签页直接打开仪表盘”时的行为一致。

Steps to reproduce

  1. 配置 OIDC SSO,并确认正常流程可用:在新标签页直接打开 https://octop.example.com/login,点击「使用 … 登录」并完成 IdP 登录 → 进入仪表盘,登录成功。
  2. 从另一个页面(门户/内网启动页)用保留 opener 的方式打开仪表盘:
    window.open("https://octop.example.com/login", "_blank"); // 注意:没有 "noopener"
    或使用没有 rel="noopener" 的 <a href="https://octop.example.com/login" target="_blank">…</a>。
  3. 在这个新标签页里点击「使用 … 登录」,完成 IdP 登录。
  4. 观察到标签页在 /login/oidc/complete 后自行关闭。

Environment

  • octop version: 1.0.2(自托管 Docker 镜像,org.opencontainers.image.revision=dd9dce470844c25d59effb50615bb3c667ea25cd)
  • Python version: 3.12(镜像内置)
  • OS: Linux amd64(Docker 运行;宿主机 Ubuntu 26.04)
  • Install method: Docker
  • LLM provider (if relevant): 不适用(问题发生在 SSO 登录完成阶段,与模型无关)
  • OIDC provider: MaxKey(issuer=https://idp.example.com/sign/maxkey),已启用
  • 仪表盘:同源 HTTPS 反向代理之后(https://octop.example.com)
  • 浏览器:Chromium/Edge(任何具备标准 window.opener 语义的浏览器)

Logs / screenshots

服务端没有任何错误日志,登录实际是成功的:

  • 审计日志:auth.oidc_login(成功,target=<username>)
  • 访问日志:GET /api/auth/oidc/callback?...&state=... → 302,GET /login/oidc/complete → 200,POST /api/auth/oidc/exchange → 200
INFO: ... "GET /api/auth/oidc/callback?code=...&state=... HTTP/1.1" 302 Found
INFO: ... "GET /login/oidc/complete HTTP/1.1" 200 OK
INFO: ... "POST /api/auth/oidc/exchange HTTP/1.1" 200 OK

Root cause

dashboard/src/utils/ssoPopup.ts:14-20 把任何存在 opener 的页面都当成 SSO 弹窗:

export function isSsoPopup(): boolean {
  try {
    return Boolean(window.opener && !window.opener.closed);
  } catch {
    return false;
  }
}

ssoPopup.ts:22-30 随后把结果交给 opener 并关闭当前窗口:

export function notifySsoOpener(payload: Omit<SsoPopupMessage, "type">): boolean {
  if (!isSsoPopup()) return false;
  const message: SsoPopupMessage = { type: SSO_MESSAGE_TYPE, ...payload };
  window.opener.postMessage(message, window.location.origin);
  window.close();
  return true;
}

dashboard/src/pages/Login/OidcComplete.tsx:97-111 在 notifySsoOpener() 返回 true 时会跳过保存 token:

const remember = getRememberLoginPreference();
if (
  notifySsoOpener({
    ok: true,
    redirect: dest,
    access_token: res.access_token,
    remember,
  })
) {
  return; // ← 该标签页永远没有保存 token
}
setAuthToken(res.access_token, remember);
await applyUserLocale(res.user.locale);
void refreshServerLabels(res.user.locale);
navigate(dest, { replace: true });

这里有两个独立的问题:

  1. 判定条件过宽。 应用自己打开的弹窗有明确的 window name——dashboard/src/utils/ssoPopup.ts:32-38:
    export function openSsoPopup(): Window | null {
      return window.open(
        "about:blank",
        "octop-sso",
        "popup=yes,width=520,height=720,noopener=no",
      );
    }
    window name 是一个可靠的判别依据,但 isSsoPopup() 没有使用它。
  2. 跨 origin 的 opener 本来就收不到消息。 postMessage(message, window.location.origin) 的目标 origin 是仪表盘自身,另一个 origin 的启动页按规范无法接收;即使启动页实现了该协议也会被过滤。

同一处误判还会吞掉错误:dashboard/src/pages/Login/index.tsx:145-149 对 ?oidc_error=… 也调用 notifySsoOpener(),于是只要标签页有 opener,就会关闭标签页而不是渲染错误页。

影响面:所有“从另一个页面打开仪表盘”的 SSO 入口(公司门户快捷方式、内网启动页、内嵌 webview、没有 noopener 的 window.open / target="_blank")都会以标签页关闭、用户被退回启动页结束;并且交换得到的 token 不会被持久化,用户需要再次登录。错误路径同样受影响:oidc_error 页面被关闭而不是展示,用户看不到失败原因。从服务端极难定位(没有任何错误日志)。

Suggested fix

用 openSsoPopup() 使用的 popup 标记来判断,而不是仅凭是否存在 opener:

// dashboard/src/utils/ssoPopup.ts
const SSO_POPUP_WINDOW_NAME = "octop-sso"; // 必须与 openSsoPopup() 保持一致

export function isSsoPopup(): boolean {
  try {
    return (
      window.name === SSO_POPUP_WINDOW_NAME &&
      Boolean(window.opener && !window.opener.closed)
    );
  } catch {
    return false;
  }
}

export function openSsoPopup(): Window | null {
  return window.open(
    "about:blank",
    SSO_POPUP_WINDOW_NAME,
    "popup=yes,width=520,height=720,noopener=no",
  );
}

这样,由启动页打开的标签页(window.name 不是 octop-sso)会走正常导航路径并保留会话;应用自身的“绑定 SSO 账号”弹窗(window.name === "octop-sso")行为不变。

可选加固:

  • 对跨 origin 的 opener 跳过 postMessage 路径;
  • 或改为 postMessage(message, "*"),并在接收端校验 event.origin + event.data.type(如果确实要支持跨 origin 启动页);
  • 在仪表盘 origin 上返回 Cross-Origin-Opener-Policy: same-origin,Chromium 系浏览器会直接切断 opener。

Workarounds (no code change)

  • 启动页使用 noopener 打开:window.open(url, "_blank", "noopener") 或 <a target="_blank" rel="noopener noreferrer">;或者干脆在同标签页跳转。
  • 给仪表盘增加响应头 Cross-Origin-Opener-Policy: same-origin。

Verification after the fix

  1. 用 window.open("https://octop.example.com/login", "_blank") 打开 → 点击 SSO → 标签页应跳转到 /chat 并保留会话(不会关闭)。
  2. 在设置里点击“绑定 SSO 账号”(应用自己的弹窗,window name 为 octop-sso)→ 弹窗仍应关闭并把结果交给 opener。
  3. 在有 opener 的标签页打开 /login?oidc_error=state → 应渲染错误页,而不是关闭。

Additional context

我在自托管的 1.0.2 部署(revision dd9dce4)+ MaxKey OIDC 上复现了该问题,并通过在构建后的 dashboard bundle 中把 isSsoPopup() 改为要求 window.name === "octop-sso" 验证了修复:强制刷新后,从 window.open 创建的标签页发起 OIDC 登录可以正常完成并保留会话;“绑定账号”弹窗流程不受影响(它正是以该 window name 打开的)。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions