Repository navigation
[bug] 从保留 window.opener 的标签页打开时,OIDC 登录会自行关闭标签页并丢失会话 #2347
Description
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
- 配置 OIDC SSO,并确认正常流程可用:在新标签页直接打开
https://octop.example.com/login,点击「使用 … 登录」并完成 IdP 登录 → 进入仪表盘,登录成功。 - 从另一个页面(门户/内网启动页)用保留 opener 的方式打开仪表盘:
或使用没有
window.open("https://octop.example.com/login", "_blank"); // 注意:没有 "noopener"
rel="noopener"的<a href="https://octop.example.com/login" target="_blank">…</a>。 - 在这个新标签页里点击「使用 … 登录」,完成 IdP 登录。
- 观察到标签页在
/login/oidc/complete后自行关闭。
Environment
octopversion: 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 });这里有两个独立的问题:
- 判定条件过宽。 应用自己打开的弹窗有明确的 window name——
dashboard/src/utils/ssoPopup.ts:32-38:window name 是一个可靠的判别依据,但export function openSsoPopup(): Window | null { return window.open( "about:blank", "octop-sso", "popup=yes,width=520,height=720,noopener=no", ); }
isSsoPopup()没有使用它。 - 跨 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
- 用
window.open("https://octop.example.com/login", "_blank")打开 → 点击 SSO → 标签页应跳转到/chat并保留会话(不会关闭)。 - 在设置里点击“绑定 SSO 账号”(应用自己的弹窗,window name 为
octop-sso)→ 弹窗仍应关闭并把结果交给 opener。 - 在有 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