v2.9.1: 统一登录单用户化——删除 JIT 开关与 oidc_identities 映射表(0007),任意 ZITADEL 身份登录唯一用户,上次登录身份存 settings 展示
This commit is contained in:
+32
-44
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* ZITADEL 统一登录与用户名修改测试(v2.9.0):
|
||||
* - /auth/status、/auth/login(302 到授权端点)、/auth/callback(state 一次性 + PKCE + JIT 建号/复用)
|
||||
* - 未绑定 + JIT 关闭拒绝;配置校验(issuer 必须 HTTPS);discovery/token/userinfo 在传输层打桩
|
||||
* ZITADEL 统一登录(v2.9.1 单用户化)与用户名修改测试:
|
||||
* - /auth/status、/auth/login(302 到授权端点)、/auth/callback(state 一次性 + PKCE + 登录唯一用户)
|
||||
* - 单用户系统:无身份映射表/JIT,任何通过 IdP 认证的身份都登录本平台唯一用户;上次登录身份记录在 settings
|
||||
* - 配置校验(issuer 必须 HTTPS);discovery/token/userinfo 在传输层打桩
|
||||
* - PUT /api/settings/username:验密、重名 409、改后新旧用户名登录行为、/me 读库返回新名
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
@@ -47,14 +48,14 @@ function installIdpStub() {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 在测试 env 里启用统一登录(后台 settings 配置,JIT 可选)
|
||||
* 在测试 env 里启用统一登录(后台 settings 配置)
|
||||
*/
|
||||
async function enableOidc(t: TestEnv, jit = true) {
|
||||
async function enableOidc(t: TestEnv) {
|
||||
const cookie = await loginCookie(t);
|
||||
const resp = await authedFetch(t, cookie, "http://localhost/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ oidc: { issuer: ISSUER, client_id: "cid-123", enabled: true, jit } }),
|
||||
body: JSON.stringify({ oidc: { issuer: ISSUER, client_id: "cid-123", enabled: true } }),
|
||||
});
|
||||
expect(resp.status).toBe(200);
|
||||
return cookie;
|
||||
@@ -63,7 +64,7 @@ async function enableOidc(t: TestEnv, jit = true) {
|
||||
/** # 函数
|
||||
* 走一遍 /auth/login,取回 302 Location 与其中的 state
|
||||
*/
|
||||
async function startLogin(t: import("./helpers.ts").TestEnv) {
|
||||
async function startLogin(t: TestEnv) {
|
||||
const resp = await t.app.fetch(new Request("http://localhost/auth/login"), t.env);
|
||||
const loc = resp.headers.get("Location") ?? "";
|
||||
return { resp, loc, state: new URL(loc).searchParams.get("state") ?? "" };
|
||||
@@ -78,7 +79,7 @@ function cookieOf(resp: Response): string {
|
||||
return `wmh_session=${m[1]}`;
|
||||
}
|
||||
|
||||
describe("ZITADEL 统一登录(/auth)", () => {
|
||||
describe("ZITADEL 统一登录(/auth,单用户)", () => {
|
||||
test("未配置时 status enabled=false;/auth/login 拒绝并回登录页", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const status = (await (await t.app.fetch(new Request("http://localhost/auth/status"), t.env)).json()) as { enabled: boolean };
|
||||
@@ -123,67 +124,54 @@ describe("ZITADEL 统一登录(/auth)", () => {
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
|
||||
test("完整登录链:JIT 自动建号(即管理员)→ 会话可用 → 同 subject 复用同一用户", async () => {
|
||||
test("完整登录链:任意 ZITADEL 身份登录唯一用户(admin)→ 会话可用 → 不建新账号", async () => {
|
||||
installIdpStub();
|
||||
resetOidcDiscoveryCache();
|
||||
const t = await makeTestEnv();
|
||||
await enableOidc(t, true);
|
||||
await enableOidc(t);
|
||||
mockUserInfo = { sub: "sub-jit", name: "Zed Zhan", email: "zed@wtox.net" };
|
||||
|
||||
// 第一次登录:JIT 建号
|
||||
// 登录:绑定到唯一用户(种子 admin),不建新用户
|
||||
const first = await startLogin(t);
|
||||
const cb = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${first.state}`), t.env);
|
||||
expect(cb.status).toBe(302);
|
||||
expect(cb.headers.get("Location")).toBe("/");
|
||||
const cookie1 = cookieOf(cb);
|
||||
const me = (await (await authedFetch(t, cookie1, "http://localhost/api/auth/me")).json()) as {
|
||||
const cookie = cookieOf(cb);
|
||||
const me = (await (await authedFetch(t, cookie, "http://localhost/api/auth/me")).json()) as {
|
||||
user: { username: string };
|
||||
};
|
||||
expect(me.user.username).toBe("zed-zhan"); // 展示名 slug 化
|
||||
expect(me.user.username).toBe("admin");
|
||||
|
||||
// state 一次性:重放同一回调被拒
|
||||
const replay = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${first.state}`), t.env);
|
||||
expect(replay.headers.get("Location")).toContain("state_invalid");
|
||||
|
||||
// 库里:用户 + 身份绑定 + token 请求带 code_verifier(PKCE)
|
||||
const users = await t.env.DB.prepare("SELECT id, username FROM users").all<{ id: string; username: string }>();
|
||||
expect(users.results.filter((u) => u.username === "zed-zhan")).toHaveLength(1);
|
||||
const ident = await t.env.DB.prepare("SELECT user_id, display_name, email FROM oidc_identities WHERE issuer = ? AND subject = ?")
|
||||
.bind(ISSUER, "sub-jit")
|
||||
.first<{ user_id: string; display_name: string; email: string }>();
|
||||
expect(ident?.email).toBe("zed@wtox.net");
|
||||
// 库里:仍只有 1 个用户(无建号);无身份映射表;上次登录身份已记录;token 请求带 code_verifier(PKCE)
|
||||
const users = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
|
||||
expect(users?.n).toBe(1);
|
||||
expect(await t.env.DB.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='oidc_identities'").first()).toBeNull();
|
||||
const last = await t.env.DB.prepare("SELECT value FROM settings WHERE key = 'oidc_last_login'").first<{ value: string }>();
|
||||
expect(last && JSON.parse(last.value).subject).toBe("sub-jit");
|
||||
expect(idpCalls[0]?.body).toContain("code_verifier=");
|
||||
|
||||
// 第二次登录:同一 subject 复用同一用户,不重复建号
|
||||
// 换一个全新 subject 再登录:仍是同一唯一用户
|
||||
mockUserInfo = { sub: "someone-else", preferred_username: "stranger" };
|
||||
const second = await startLogin(t);
|
||||
const cb2 = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${second.state}`), t.env);
|
||||
expect(cb2.status).toBe(302);
|
||||
const ident2 = await t.env.DB.prepare("SELECT user_id FROM oidc_identities WHERE issuer = ? AND subject = ?")
|
||||
.bind(ISSUER, "sub-jit")
|
||||
.first<{ user_id: string }>();
|
||||
expect(ident2?.user_id).toBe(ident?.user_id);
|
||||
const me2 = (await (await authedFetch(t, cookieOf(cb2), "http://localhost/api/auth/me")).json()) as {
|
||||
user: { username: string };
|
||||
};
|
||||
expect(me2.user.username).toBe("admin");
|
||||
const users2 = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
|
||||
expect(users2?.n).toBe(2); // admin + zed-zhan
|
||||
});
|
||||
|
||||
test("JIT 关闭:未绑定身份拒绝登录,不建用户", async () => {
|
||||
installIdpStub();
|
||||
resetOidcDiscoveryCache();
|
||||
const t = await makeTestEnv();
|
||||
await enableOidc(t, false);
|
||||
mockUserInfo = { sub: "sub-unknown", preferred_username: "stranger" };
|
||||
const { state } = await startLogin(t);
|
||||
const cb = await t.app.fetch(new Request(`http://localhost/auth/callback?code=good&state=${state}`), t.env);
|
||||
expect(cb.headers.get("Location")).toContain("oidc_not_linked");
|
||||
const users = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>();
|
||||
expect(users?.n).toBe(1); // 仍只有 admin
|
||||
expect(users2?.n).toBe(1);
|
||||
});
|
||||
|
||||
test("篡改 state 拒绝;userinfo 缺 sub 时 fail closed", async () => {
|
||||
installIdpStub();
|
||||
resetOidcDiscoveryCache();
|
||||
const t = await makeTestEnv();
|
||||
await enableOidc(t, true);
|
||||
await enableOidc(t);
|
||||
const bad = await t.app.fetch(new Request("http://localhost/auth/callback?code=x&state=bogus"), t.env);
|
||||
expect(bad.headers.get("Location")).toContain("state_invalid");
|
||||
|
||||
@@ -260,7 +248,7 @@ describe("修改用户名(PUT /api/settings/username)", () => {
|
||||
expect(loginOld.status).toBe(401);
|
||||
});
|
||||
|
||||
test("与其他用户重名返回 409;GET /api/settings 回传 oidc 配置与未绑定身份", async () => {
|
||||
test("与其他用户重名返回 409;GET /api/settings 回传 oidc 配置与上次登录", async () => {
|
||||
const t = await makeTestEnv();
|
||||
const cookie = await loginCookie(t);
|
||||
// 建第二个用户直接插库
|
||||
@@ -277,12 +265,12 @@ describe("修改用户名(PUT /api/settings/username)", () => {
|
||||
|
||||
const info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
|
||||
username: string;
|
||||
oidc: { enabled: boolean; redirect_uri: string; identity: unknown; source: string };
|
||||
oidc: { enabled: boolean; redirect_uri: string; last_login: unknown; source: string };
|
||||
};
|
||||
expect(info.username).toBe("admin");
|
||||
expect(info.oidc.enabled).toBe(false);
|
||||
expect(info.oidc.source).toBe("none");
|
||||
expect(info.oidc.redirect_uri).toBe("http://localhost/auth/callback");
|
||||
expect(info.oidc.identity).toBeNull();
|
||||
expect(info.oidc.last_login).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+5
-4
@@ -122,15 +122,16 @@ const redirectText =
|
||||
check("Cloudflare 接入 tab 显示授权回调地址", redirectText.includes("/api/oauth/cloudflare/callback"), redirectText.trim());
|
||||
await page.screenshot({ path: `${SHOT_DIR}/09b-settings-cloudflare.png` });
|
||||
|
||||
// ZITADEL 统一登录 tab(v2.9.0):回调地址 /auth/callback + Issuer/Client ID 表单 + 启用/JIT 开关
|
||||
// ZITADEL 统一登录 tab(v2.9.1 单用户化):回调地址 /auth/callback + Issuer/Client ID 表单 + 启用开关(无 JIT)
|
||||
await page.click(".ant-modal .ant-tabs-tab:has-text('ZITADEL 统一登录')");
|
||||
await page.waitForSelector(".ant-modal :text('Issuer')", { timeout: 8000 });
|
||||
const oidcModalText = (await page.locator(".ant-modal").innerText()) ?? "";
|
||||
check(
|
||||
"ZITADEL tab 显示回调地址 /auth/callback 与启用/JIT 开关",
|
||||
"ZITADEL tab 显示回调地址 /auth/callback、启用开关与单用户说明(无 JIT)",
|
||||
oidcModalText.includes("/auth/callback") &&
|
||||
(await page.locator(".ant-modal .ant-switch").count()) === 2 &&
|
||||
oidcModalText.includes("管理员"),
|
||||
(await page.locator(".ant-modal .ant-switch").count()) === 1 &&
|
||||
oidcModalText.includes("单用户系统") &&
|
||||
!oidcModalText.includes("JIT"),
|
||||
oidcModalText.split("\n").slice(0, 6).join(" / "),
|
||||
);
|
||||
await page.screenshot({ path: `${SHOT_DIR}/09c-settings-oidc.png` });
|
||||
|
||||
Reference in New Issue
Block a user