修复通用 OAuth 弹窗登录与账号关联并隔离迟到结果

This commit is contained in:
cieanyau committed 2026-11-07 08:21:42 +08:00
1 parent d330ad01cc
commit e27f08e36e
5 files changed
+690 -78

No files matched your search

+525
View File
@@ -107,6 +107,531 @@ async function sessionIsPreserved(page, transition) {
);
}
async function genericOAuthBoundary(page, handle = async () => false) {
// 外部授权页与后端回调均通过真实弹窗访问;回调遵循生产的 about:blank + close 协议。
await page.context().route(`${backendOrigin}/oauth-provider`, (route) =>
route.fulfill({
contentType: "text/html",
body: `<meta charset="utf-8"><form action="${backendOrigin}/console/v1/oauth/callback/testprovider"><button>确认本地授权</button></form>`,
}),
);
await oauthBoundary(page, async (route, path) => {
if (await handle(route, path)) return true;
if (path === "/common") {
await respond(route, {
website_name: "本地授权验收",
login_phone_password: 1,
login_email_password: 1,
register_phone: 1,
register_email: 1,
oauth: [{ name: "testprovider", title: "本地授权" }],
});
return true;
}
if (path === "/oauth/testprovider") {
await respond(route, { url: `${backendOrigin}/oauth-provider` });
return true;
}
if (path === "/oauth/callback/testprovider") {
await route.fulfill({
contentType: "text/html",
body: '<script>window.location.href="about:blank";window.close()</script>',
});
return true;
}
return false;
});
}
async function openGenericOAuth(page, button = "本地授权登录") {
const opened = page.waitForEvent("popup", { timeout: 2500 });
await page.getByRole("button", { name: button, exact: true }).click();
const popup = await opened;
await popup.waitForURL(`${backendOrigin}/oauth-provider`);
assert.equal(await popup.evaluate(() => window.opener === null), true);
return popup;
}
test(
"generic OAuth login completes from its real popup and preserves the requested page",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
let tokenReads = 0;
await genericOAuthBoundary(page, async (route, path) => {
if (path !== "/oauth/token") return false;
tokenReads += 1;
await respond(route, { jwt: "e2e-session-token" });
return true;
});
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
const popup = await openGenericOAuth(page);
assert.equal(new URL(page.url()).pathname, "/login");
await popup.getByRole("button", { name: "确认本地授权" }).click();
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
await page.getByText("audit-01", { exact: true }).waitFor();
await sessionIsPreserved(page, "login");
assert.equal(popup.isClosed(), true);
assert.equal(tokenReads, 1);
return { popupClosed: true, requestedPageRestored: true, tokenReads };
}),
);
test(
"generic OAuth continues a verified binding and keeps the requested page",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
await genericOAuthBoundary(page, async (route, path) => {
if (path === "/oauth/token") {
await respond(route, { url: `${backendOrigin}/oauth.htm` });
return true;
}
if (path === "/oauth/client/bind") {
await respond(route, { jwt: "e2e-session-token" });
return true;
}
return false;
});
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
const popup = await openGenericOAuth(page);
await popup.getByRole("button", { name: "确认本地授权" }).click();
await page.waitForURL((url) => url.pathname === "/oauth/bind");
assert.equal(
new URL(page.url()).searchParams.get("redirect"),
"/security/ssh-keys",
);
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
await page.getByLabel("验证码", { exact: true }).fill("123456");
await page.getByRole("checkbox").check();
await page.getByRole("button", { name: "确认绑定", exact: true }).click();
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
await sessionIsPreserved(page, "login");
return { bindingVerified: true, requestedPageRestored: true };
}),
);
test(
"closing a generic OAuth popup requires server confirmation and permits retry",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
let tokenReads = 0;
await genericOAuthBoundary(page, async (route, path) => {
if (path !== "/oauth/token") return false;
tokenReads += 1;
await respond(route, {});
return true;
});
await page.goto(`${appOrigin}/login`);
const popup = await openGenericOAuth(page);
await popup.close();
await page.getByRole("alert").waitFor();
assert.equal(new URL(page.url()).pathname, "/login");
await sessionIsPreserved(page, "logout");
assert.equal(tokenReads, 1);
const retry = await openGenericOAuth(page);
await retry.close();
await eventually(
() => page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
Boolean,
"Cancelled authorization must permit another attempt",
);
assert.equal(tokenReads, 2);
return { noUnconfirmedLogin: true, retryAllowed: true, tokenReads };
}),
);
test(
"generic OAuth expires its external popup without consuming a later result",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
let tokenReads = 0;
await genericOAuthBoundary(page, async (route, path) => {
if (path !== "/oauth/token") return false;
tokenReads += 1;
await respond(route, { jwt: "e2e-session-token" });
return true;
});
await page.goto(`${appOrigin}/login`);
const popup = await openGenericOAuth(page);
// 只推进外部墙上时钟,保留浏览器真实轮询调度,避免混用已启动页面的虚拟计时器。
await page.clock.setFixedTime(
await page.evaluate(() => Date.now() + 10 * 60 * 1000 + 1),
);
await page.getByRole("alert").waitFor();
assert.equal(
await page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
true,
);
assert.equal(popup.isClosed(), false);
await popup.getByRole("button", { name: "确认本地授权" }).click();
await eventually(
() => popup.isClosed(),
Boolean,
"The external callback must close its own popup",
);
await page.evaluate(
() =>
new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)),
),
);
await sessionIsPreserved(page, "logout");
assert.equal(tokenReads, 0);
return {
expiredAttemptReleased: true,
lateCallbackIgnored: true,
noResultConsumed: true,
};
}),
);
test(
"generic OAuth cleans up a blocked popup without starting server authorization",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
let authorizations = 0;
await genericOAuthBoundary(page, async (route, path) => {
if (path === "/oauth/testprovider") authorizations += 1;
return false;
});
await page.goto(`${appOrigin}/login`);
// 浏览器能力是外部边界:模拟弹窗策略返回 null,不替换应用内部模块。
await page.evaluate(() => {
window.open = () => null;
});
await page.getByRole("button", { name: "本地授权登录" }).click();
await page.getByRole("alert").waitFor();
assert.equal(
await page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
true,
);
assert.equal(authorizations, 0);
await sessionIsPreserved(page, "logout");
return { blockedPopupReported: true, noAuthorizationStarted: true };
}),
);
test(
"generic OAuth rejects an unsafe authorization URL and closes its blank popup",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
await genericOAuthBoundary(page, async (route, path) => {
if (path !== "/oauth/testprovider") return false;
await respond(route, { url: "javascript:window.name='unsafe'" });
return true;
});
await page.goto(`${appOrigin}/login`);
const opened = page.waitForEvent("popup");
await page.getByRole("button", { name: "本地授权登录" }).click();
const popup = await opened;
await page.getByRole("alert").waitFor();
await eventually(
() => popup.isClosed(),
Boolean,
"An unsafe authorization address must close its popup",
);
assert.equal(new URL(page.url()).pathname, "/login");
await sessionIsPreserved(page, "logout");
return { unsafeUrlRejected: true, popupClosed: true };
}),
);
test(
"generic OAuth discards a pending login result after leaving its page",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
const started = deferred();
const release = deferred();
await genericOAuthBoundary(page, async (route, path) => {
if (path !== "/oauth/token") return false;
started.resolve();
await release.promise;
await respond(route, { jwt: "test-older-oauth-session" });
return true;
});
await page.goto(`${appOrigin}/login`);
const popup = await openGenericOAuth(page);
await popup.getByRole("button", { name: "确认本地授权" }).click();
await started.promise;
await page.getByRole("link", { name: "注册账号", exact: true }).click();
await page.waitForURL((url) => url.pathname === "/register");
const response = page.waitForResponse((entry) =>
entry.url().endsWith("/oauth/token"),
);
release.resolve();
await (await response).finished();
await page.evaluate(
() =>
new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)),
),
);
assert.equal(new URL(page.url()).pathname, "/register");
await sessionIsPreserved(page, "logout");
return { unmountedResultIgnored: true, noOldNavigation: true };
}),
);
for (const abandon of ["close", "leave", "login", "logout"]) {
test(
`generic OAuth ignores a late authorization URL after ${abandon}`,
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
const started = deferred();
const release = deferred();
let tokenReads = 0;
await genericOAuthBoundary(page, async (route, path) => {
if (path === "/oauth/testprovider") {
started.resolve();
await release.promise;
await respond(route, { url: `${backendOrigin}/oauth-provider` });
return true;
}
if (path === "/oauth/token") {
tokenReads += 1;
await respond(route, { jwt: "test-older-oauth-session" });
return true;
}
return false;
});
await page.goto(`${appOrigin}/login`);
const opened = page.waitForEvent("popup");
await page.getByRole("button", { name: "本地授权登录" }).click();
const popup = await opened;
await started.promise;
if (abandon === "close") await popup.close();
else if (abandon === "leave")
await page
.getByRole("link", { name: "注册账号", exact: true })
.click();
else await changeSession(page, abandon);
await eventually(
() => popup.isClosed(),
Boolean,
"Abandoned authorization must close its popup",
);
const response = page.waitForResponse((entry) =>
entry.url().endsWith("/oauth/testprovider"),
);
release.resolve();
await (await response).finished();
await page.evaluate(
() =>
new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)),
),
);
assert.equal(
new URL(page.url()).pathname,
abandon === "leave" ? "/register" : "/login",
);
await sessionIsPreserved(
page,
abandon === "login" ? "login" : "logout",
);
assert.equal(tokenReads, 0);
assert.equal(await page.getByRole("alert").count(), 0);
return { abandon, lateUrlIgnored: true, popupClosed: true, tokenReads };
}),
);
}
for (const transition of ["login", "logout"]) {
for (const outcome of ["jwt", "bind", "error"]) {
test(
`generic OAuth ignores a late ${outcome} after cross-tab ${transition}`,
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
const started = deferred();
const release = deferred();
await genericOAuthBoundary(page, async (route, path) => {
if (path !== "/oauth/token") return false;
started.resolve();
await release.promise;
await respond(
route,
outcome === "jwt"
? { jwt: "test-older-oauth-session" }
: { url: "/oauth/bind" },
outcome === "error" ? 422 : 200,
outcome === "error" ? "过期授权请求失败" : "",
);
return true;
});
await page.goto(`${appOrigin}/login`);
const popup = await openGenericOAuth(page);
await popup.getByRole("button", { name: "确认本地授权" }).click();
await started.promise;
await changeSession(page, transition);
const response = page.waitForResponse((entry) =>
entry.url().endsWith("/oauth/token"),
);
release.resolve();
await (await response).finished();
await eventually(
() =>
page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
Boolean,
"A stale authorization must release its login button",
);
await page.evaluate(
() =>
new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)),
),
);
assert.equal(new URL(page.url()).pathname, "/login");
assert.equal(await page.getByRole("alert").count(), 0);
await sessionIsPreserved(page, transition);
return {
transition,
outcome,
lateResultIgnored: true,
newerSessionPreserved: true,
};
}),
);
}
}
for (const abandon of ["leave", "login", "logout"]) {
test(
`generic OAuth does not consume a popup closed after ${abandon}`,
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
let tokenReads = 0;
await genericOAuthBoundary(page, async (route, path) => {
if (path !== "/oauth/token") return false;
tokenReads += 1;
await respond(route, { jwt: "test-older-oauth-session" });
return true;
});
await page.goto(`${appOrigin}/login`);
const popup = await openGenericOAuth(page);
if (abandon === "leave") {
await page
.getByRole("link", { name: "注册账号", exact: true })
.click();
await page.waitForURL((url) => url.pathname === "/register");
} else {
await changeSession(page, abandon);
await eventually(
() =>
page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
Boolean,
"A newer session must release the old authorization attempt",
);
}
// 外部窗口保留 opener 隔离;完成旧回调后,已结束的发起页也不得再消费结果。
await popup.getByRole("button", { name: "确认本地授权" }).click();
await eventually(
() => popup.isClosed(),
Boolean,
"The old callback must close its own popup",
);
await page.evaluate(
() =>
new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)),
),
);
assert.equal(
new URL(page.url()).pathname,
abandon === "leave" ? "/register" : "/login",
);
assert.equal(await page.getByRole("alert").count(), 0);
await sessionIsPreserved(
page,
abandon === "login" ? "login" : "logout",
);
assert.equal(tokenReads, 0);
return { abandon, endedAttemptNotResumed: true, tokenReads };
}),
);
}
for (const completed of [true, false]) {
test(
`generic OAuth verifies a ${completed ? "completed" : "cancelled"} authenticated link without consuming a login token`,
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
let linked = false;
let tokenReads = 0;
await genericOAuthBoundary(page, async (route, path) => {
if (path === "/account") {
await respond(route, {
account: {
id: 1,
username: "audit-user",
oauth: [
{
name: "testprovider",
title: "本地授权",
link: linked ? 1 : 0,
},
],
},
});
return true;
}
if (path === "/oauth/callback/testprovider") linked = true;
if (path === "/oauth/token") {
tokenReads += 1;
await respond(route, {});
return true;
}
return false;
});
await login(page, "/account/connections");
await page.getByText("未关联", { exact: true }).waitFor();
const popup = await openGenericOAuth(page, "关联账号");
if (completed) {
await popup.getByRole("button", { name: "确认本地授权" }).click();
await page.getByText("已关联", { exact: true }).waitFor();
assert.equal(
await page
.getByRole("button", { name: "取消关联", exact: true })
.isVisible(),
true,
);
} else {
await popup.close();
await page.getByRole("alert").waitFor();
await page.getByText("未关联", { exact: true }).waitFor();
assert.equal(
await page
.getByRole("button", { name: "关联账号", exact: true })
.isEnabled(),
true,
);
assert.equal(
await page
.getByRole("button", { name: "取消关联", exact: true })
.count(),
0,
);
}
assert.equal(tokenReads, 0);
await sessionIsPreserved(page, "login");
return {
serverLinkVerified: true,
completed,
tokenReads,
};
}),
);
}
test(
"OAuth callback establishes a current session normally",
{ timeout: 25000 },