修复通用 OAuth 弹窗登录与账号关联并隔离迟到结果
This commit is contained in:
1 parent
d330ad01cc
commit
e27f08e36e
5 files changed
+690
-78
No files matched your search
@@ -1,11 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
/** 根据站点 oauth 配置显示第三方登录入口;启动跳转及错误处理复用 useOAuth。 */
|
/** 根据站点 oauth 配置显示第三方登录入口;弹窗授权与结果确认复用 useOAuth。 */
|
||||||
import "~/assets/styles/GenericOAuthProviders.Bv8oNwXP.css";
|
import "~/assets/styles/GenericOAuthProviders.Bv8oNwXP.css";
|
||||||
import { toDisplayString } from "vue";
|
import { toDisplayString } from "vue";
|
||||||
import { renderList } from "vue";
|
import { renderList } from "vue";
|
||||||
import { onMounted } from "vue";
|
import { onMounted } from "vue";
|
||||||
import { createBlock } from "vue";
|
import { createBlock } from "vue";
|
||||||
import { ref } from "vue";
|
|
||||||
import { withCtx } from "vue";
|
import { withCtx } from "vue";
|
||||||
import { createCommentVNode } from "vue";
|
import { createCommentVNode } from "vue";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
@@ -15,7 +14,6 @@ import { createTextVNode } from "vue";
|
|||||||
import { unref } from "vue";
|
import { unref } from "vue";
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import { openBlock } from "vue";
|
import { openBlock } from "vue";
|
||||||
import { NuxtLink } from "#components";
|
|
||||||
import { t as useSiteConfig_t } from "~/lib/useSiteConfig.js";
|
import { t as useSiteConfig_t } from "~/lib/useSiteConfig.js";
|
||||||
import button from "openvue/button";
|
import button from "openvue/button";
|
||||||
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
|
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
|
||||||
@@ -38,22 +36,15 @@ var x = {
|
|||||||
setup(f) {
|
setup(f) {
|
||||||
let _ = useSiteConfig_t(),
|
let _ = useSiteConfig_t(),
|
||||||
w = computed(() => oauth_t(_.config.value?.oauth)),
|
w = computed(() => oauth_t(_.config.value?.oauth)),
|
||||||
{ busy: T, error: E, start: D } = useOAuth_t(),
|
{ busy: T, error: E, start: D } = useOAuth_t();
|
||||||
O = ref(!1);
|
|
||||||
return (
|
return (
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
_.load();
|
_.load();
|
||||||
try {
|
|
||||||
// 只读取浏览器会话中的待完成提示,不将该标志当作登录身份或授权凭证。
|
|
||||||
O.value =
|
|
||||||
sessionStorage.getItem(`console-oauth-pending`) === `1`;
|
|
||||||
} catch {}
|
|
||||||
}),
|
}),
|
||||||
(n, i) => {
|
(n, i) => {
|
||||||
let s = button,
|
let s = button,
|
||||||
f = message,
|
f = message;
|
||||||
h = NuxtLink;
|
return unref(w).length
|
||||||
return unref(w).length || unref(O)
|
|
||||||
? (openBlock(),
|
? (openBlock(),
|
||||||
createElementBlock(`div`, x, [
|
createElementBlock(`div`, x, [
|
||||||
unref(w).length
|
unref(w).length
|
||||||
@@ -126,27 +117,6 @@ var x = {
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
: createCommentVNode(``, !0),
|
: createCommentVNode(``, !0),
|
||||||
unref(O)
|
|
||||||
? (openBlock(),
|
|
||||||
createBlock(
|
|
||||||
h,
|
|
||||||
{
|
|
||||||
key: 2,
|
|
||||||
to: `/oauth/callback`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: withCtx(() => [
|
|
||||||
...(i[0] ||= [
|
|
||||||
createTextVNode(
|
|
||||||
`已完成第三方授权?继续登录`,
|
|
||||||
-1,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
_: 1,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
: createCommentVNode(``, !0),
|
|
||||||
]))
|
]))
|
||||||
: createCommentVNode(``, !0);
|
: createCommentVNode(``, !0);
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-40
@@ -1,45 +1,157 @@
|
|||||||
/** OAuth 授权发起器:校验提供商和返回 URL,再整页跳转;不在前端构造第三方授权参数。 */
|
/** OAuth 授权由发起页持有弹窗;窗口关闭只表示授权结束,身份结果仍须向后端确认。 */
|
||||||
import { onBeforeUnmount } from "vue";
|
import { navigateTo, useRoute, useRuntimeConfig } from "#app";
|
||||||
import { ref } from "vue";
|
import { onBeforeUnmount, ref } from "vue";
|
||||||
import { useApi } from "~/lib/auth.js";
|
import { useApi, useAuth } from "~/lib/auth.js";
|
||||||
import { r as oauth_r } from "~/lib/oauth.js";
|
import { r as safeRedirect } from "~/lib/auth-redirect.js";
|
||||||
function i() {
|
import {
|
||||||
let i = useApi(),
|
n as parseOAuthResult,
|
||||||
a = ref(!1),
|
r as authorizeUrl,
|
||||||
o = ref(``),
|
t as providers,
|
||||||
s = !0;
|
} from "~/lib/oauth.js";
|
||||||
|
|
||||||
|
function useOAuth() {
|
||||||
|
const api = useApi();
|
||||||
|
const auth = useAuth();
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const route = useRoute();
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
let mounted = true;
|
||||||
|
let cancel;
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
s = !1;
|
mounted = false;
|
||||||
|
cancel?.();
|
||||||
});
|
});
|
||||||
// busy 防止重复发起,卸载标记防止用户离开页面后被迟到的授权响应再次跳走。
|
|
||||||
async function c(e) {
|
function start(provider) {
|
||||||
if (s && !a.value) {
|
if (!mounted || busy.value) return Promise.resolve(false);
|
||||||
if (((o.value = ``), !/^[a-z][a-z0-9_]*$/i.test(e))) {
|
error.value = "";
|
||||||
o.value = `授权方式无效`;
|
if (typeof provider !== "string" || !/^[a-z][a-z0-9_]*$/i.test(provider)) {
|
||||||
return;
|
error.value = "授权方式无效";
|
||||||
}
|
return Promise.resolve(false);
|
||||||
a.value = !0;
|
|
||||||
try {
|
|
||||||
let t = await i.get(`/oauth/${encodeURIComponent(e)}`);
|
|
||||||
if (!s) return;
|
|
||||||
let n = oauth_r(t?.url, window.location.origin);
|
|
||||||
if (!n) throw Error(`授权地址无效,请联系支持`);
|
|
||||||
try {
|
|
||||||
// 仅记录当前标签页曾发起授权,不存储 OAuth 凭证或 JWT。
|
|
||||||
sessionStorage.setItem(`console-oauth-pending`, `1`);
|
|
||||||
} catch {}
|
|
||||||
window.location.assign(n);
|
|
||||||
} catch (e) {
|
|
||||||
s && (o.value = e instanceof Error ? e.message : `授权失败`);
|
|
||||||
} finally {
|
|
||||||
s && (a.value = !1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 必须在点击的同步调用栈创建窗口,等待接口后再打开会触发浏览器弹窗拦截。
|
||||||
|
const popup = window.open(
|
||||||
|
"about:blank",
|
||||||
|
"_blank",
|
||||||
|
"popup,width=800,height=800",
|
||||||
|
);
|
||||||
|
if (!popup) {
|
||||||
|
error.value = "授权窗口被浏览器拦截,请允许本站弹窗后重试";
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
// 保留父页检查 closed 的句柄,同时防止外部授权页反向控制发起页。
|
||||||
|
popup.opener = null;
|
||||||
|
const attempt = auth.prepareLogin();
|
||||||
|
const linking = !!auth.token;
|
||||||
|
const redirect = safeRedirect(route.query.redirect);
|
||||||
|
const deadline = Date.now() + 10 * 60 * 1000;
|
||||||
|
busy.value = true;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let finished = false;
|
||||||
|
let launched = false;
|
||||||
|
let consuming = false;
|
||||||
|
const isCurrent = () => mounted && !finished && attempt.isCurrent();
|
||||||
|
|
||||||
|
function finish(message = "", success = false) {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
// 外站加载后 opener 已隔离,浏览器不允许父页强关;后端回调或用户自行关闭窗口。
|
||||||
|
if (!launched && !popup.closed) popup.close();
|
||||||
|
if (cancel === finish) cancel = undefined;
|
||||||
|
if (mounted) {
|
||||||
|
busy.value = false;
|
||||||
|
if (attempt.isCurrent()) error.value = message;
|
||||||
|
}
|
||||||
|
resolve(success);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCurrent() {
|
||||||
|
if (!isCurrent()) {
|
||||||
|
finish();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
finish("授权已超时,请关闭授权窗口后重新授权");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeResult() {
|
||||||
|
try {
|
||||||
|
const data = await api.get(linking ? "/account" : "/oauth/token");
|
||||||
|
if (!ensureCurrent()) return;
|
||||||
|
if (linking) {
|
||||||
|
// 已登录关联会清除后端 OAuth 会话,因此通过账户资料确认,不能再次消费登录令牌。
|
||||||
|
if (
|
||||||
|
!providers(data.account?.oauth).some(
|
||||||
|
(item) => item.name === provider && item.linked,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw Error("授权未完成,请重新关联账号");
|
||||||
|
finish("", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = parseOAuthResult(
|
||||||
|
data,
|
||||||
|
window.location.origin,
|
||||||
|
new URL(
|
||||||
|
String(config.public.apiBase || window.location.origin),
|
||||||
|
window.location.origin,
|
||||||
|
).origin,
|
||||||
|
);
|
||||||
|
if (result.kind === "login") {
|
||||||
|
attempt.commit(result.jwt);
|
||||||
|
finish("", true);
|
||||||
|
await navigateTo(redirect);
|
||||||
|
} else if (result.kind === "bind") {
|
||||||
|
const target = new URL(result.url);
|
||||||
|
if (redirect !== "/") target.searchParams.set("redirect", redirect);
|
||||||
|
finish("", true);
|
||||||
|
await navigateTo(target.pathname + target.search + target.hash);
|
||||||
|
} else {
|
||||||
|
throw Error("授权未完成或已过期,请重新授权");
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
if (ensureCurrent())
|
||||||
|
finish(cause instanceof Error ? cause.message : "授权失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端授权状态有效期为十分钟;离页、身份切换、取消与超时统一结束这一次授权。
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (!ensureCurrent()) return;
|
||||||
|
if (popup.closed && !consuming) {
|
||||||
|
if (!launched) return finish();
|
||||||
|
consuming = true;
|
||||||
|
void consumeResult();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
cancel = finish;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/oauth/${encodeURIComponent(provider)}`);
|
||||||
|
if (!ensureCurrent()) return;
|
||||||
|
if (popup.closed) return finish();
|
||||||
|
const url = authorizeUrl(data?.url, window.location.origin);
|
||||||
|
if (!url) throw Error("授权地址无效,请联系支持");
|
||||||
|
popup.location.href = url;
|
||||||
|
launched = true;
|
||||||
|
} catch (cause) {
|
||||||
|
if (ensureCurrent())
|
||||||
|
finish(cause instanceof Error ? cause.message : "授权失败");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
busy: a,
|
return { busy, error, start };
|
||||||
error: o,
|
|
||||||
start: c,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
export { i as t };
|
|
||||||
|
export { useOAuth as t };
|
||||||
@@ -95,6 +95,14 @@ var D = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 授权弹窗完成后重新读取关联列表;成功提示只属于仍然存活的当前页面。
|
||||||
|
async function connect(provider) {
|
||||||
|
B.value = ``;
|
||||||
|
if ((await K(provider)) && J) {
|
||||||
|
B.value = `已关联账号`;
|
||||||
|
await Y();
|
||||||
|
}
|
||||||
|
}
|
||||||
// 仅对已关联的提供方执行解绑;请求完成后重新加载列表,以服务端结果为准。
|
// 仅对已关联的提供方执行解绑;请求完成后重新加载列表,以服务端结果为准。
|
||||||
async function X() {
|
async function X() {
|
||||||
if (U.value?.linked && !H.value && !W.value && J) {
|
if (U.value?.linked && !H.value && !W.value && J) {
|
||||||
@@ -314,7 +322,7 @@ var D = {
|
|||||||
label: `关联账号`,
|
label: `关联账号`,
|
||||||
disabled:
|
disabled:
|
||||||
unref(H) || unref(W) || unref(R),
|
unref(H) || unref(W) || unref(R),
|
||||||
onClick: (e) => unref(K)(t.name),
|
onClick: (e) => connect(t.name),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
8,
|
8,
|
||||||
|
|||||||
@@ -70,9 +70,6 @@ var w = {
|
|||||||
);
|
);
|
||||||
if (n.kind === `login`) {
|
if (n.kind === `login`) {
|
||||||
loginAttempt.commit(n.jwt);
|
loginAttempt.commit(n.jwt);
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(`console-oauth-pending`);
|
|
||||||
} catch {}
|
|
||||||
await navigateTo(`/`);
|
await navigateTo(`/`);
|
||||||
} else if (n.kind === `bind`) {
|
} else if (n.kind === `bind`) {
|
||||||
if (new URL(n.url).pathname === `/oauth/callback`)
|
if (new URL(n.url).pathname === `/oauth/callback`)
|
||||||
|
|||||||
@@ -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(
|
test(
|
||||||
"OAuth callback establishes a current session normally",
|
"OAuth callback establishes a current session normally",
|
||||||
{ timeout: 25000 },
|
{ timeout: 25000 },
|
||||||
|
|||||||
Reference in new issue
Block a user