Files
wmailhub/tests/oidc-sso.test.ts
T

277 lines
13 KiB
TypeScript

/**
* 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";
import { authedFetch, loginCookie, makeTestEnv, type TestEnv } from "./helpers.ts";
import { resetOidcDiscoveryCache } from "../console/src/services/oidc.ts";
/** # 成员变量: 桩 IdP 的 issuer(测试配置写入 settings) **/
const ISSUER = "https://sso.example.test";
/** # 成员变量: 桩返回的 userinfo(subject 可按用例切换) **/
let mockUserInfo: Record<string, unknown> = { sub: "sub-001", name: "Zed", preferred_username: "zed" };
/** # 成员变量: 桩记录到的 IdP 调用(token 请求体里应能验到 PKCE verifier 的存在) **/
let idpCalls: { url: string; body: string | null }[] = [];
/** # 成员变量: 真实 fetch(用例结束后恢复) **/
const REAL_FETCH = globalThis.fetch;
/** # 函数
* 安装 IdP 传输层打桩:discovery / token / userinfo 三个端点,其余放行
*/
function installIdpStub() {
idpCalls = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === `${ISSUER}/.well-known/openid-configuration`) {
return Response.json({
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/oauth/v2/authorize`,
token_endpoint: `${ISSUER}/oauth/v2/token`,
userinfo_endpoint: `${ISSUER}/oauth/v2/userinfo`,
});
}
if (url === `${ISSUER}/oauth/v2/token`) {
idpCalls.push({ url, body: typeof init?.body === "string" ? init.body : null });
return Response.json({ access_token: "at_test", token_type: "Bearer", expires_in: 600 });
}
if (url === `${ISSUER}/oauth/v2/userinfo`) {
return Response.json(mockUserInfo);
}
return REAL_FETCH(input as Request, init);
}) as typeof fetch;
}
/** # 函数
* 在测试 env 里启用统一登录(后台 settings 配置)
*/
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 } }),
});
expect(resp.status).toBe(200);
return cookie;
}
/** # 函数
* 走一遍 /auth/login,取回 302 Location 与其中的 state
*/
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") ?? "" };
}
/** # 函数
* 从 Set-Cookie 头取会话 cookie
*/
function cookieOf(resp: Response): string {
const m = /wmh_session=([^;]+)/.exec(resp.headers.get("Set-Cookie") ?? "");
if (!m) throw new Error(`no session cookie: ${resp.headers.get("Set-Cookie")}`);
return `wmh_session=${m[1]}`;
}
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 };
expect(status.enabled).toBe(false);
const resp = await t.app.fetch(new Request("http://localhost/auth/login"), t.env);
expect(resp.status).toBe(302);
expect(resp.headers.get("Location")).toContain("/login?error=oidc_disabled");
});
test("配置校验:issuer 非 HTTPS 拒绝(localhost 例外);启用缺 Client ID 拒绝", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
const put = (body: unknown) =>
authedFetch(t, cookie, "http://localhost/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
expect((await put({ oidc: { issuer: "ftp://x.example", client_id: "c", enabled: false } })).status).toBe(400);
expect((await put({ oidc: { issuer: "", client_id: "c", enabled: false } })).status).toBe(400);
expect(
(await put({ oidc: { issuer: "http://localhost:8080", client_id: "c", enabled: false } })).status,
).toBe(200); // localhost 允许 http(联调)
expect((await put({ oidc: { issuer: "https://sso2.example.test", client_id: "", enabled: true } })).status).toBe(400);
});
test("启用后 status=true;发起授权 302 携带 PKCE 与 state,入库一次性", async () => {
installIdpStub();
resetOidcDiscoveryCache();
const t = await makeTestEnv();
await enableOidc(t);
const status = (await (await t.app.fetch(new Request("http://localhost/auth/status"), t.env)).json()) as { enabled: boolean };
expect(status.enabled).toBe(true);
const { resp, loc, state } = await startLogin(t);
expect(resp.status).toBe(302);
expect(loc.startsWith(`${ISSUER}/oauth/v2/authorize?`)).toBe(true);
expect(loc).toContain("code_challenge_method=S256");
expect(loc).toContain("response_type=code");
expect(state).toBeTruthy();
const row = await t.env.DB.prepare("SELECT code_verifier, return_to FROM oidc_states WHERE state = ?").bind(state).first();
expect(row).toBeTruthy();
});
test("完整登录链:任意 ZITADEL 身份登录唯一用户(admin)→ 会话可用 → 不建新账号", async () => {
installIdpStub();
resetOidcDiscoveryCache();
const t = await makeTestEnv();
await enableOidc(t);
mockUserInfo = { sub: "sub-jit", name: "Zed Zhan", email: "zed@wtox.net" };
// 登录:绑定到唯一用户(种子 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 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("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");
// 库里:仍只有 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 再登录:仍是同一唯一用户
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 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(1);
});
test("篡改 state 拒绝;userinfo 缺 sub 时 fail closed", async () => {
installIdpStub();
resetOidcDiscoveryCache();
const t = await makeTestEnv();
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");
mockUserInfo = { preferred_username: "nosub" };
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_exchange_failed");
});
test("展示名兜底链:name → given+family → preferred_username → subject 前缀", async () => {
const { oidcDisplayName } = await import("../console/src/services/oidc.ts");
expect(oidcDisplayName({ sub: "s1", name: "A" })).toBe("A");
expect(oidcDisplayName({ sub: "s2", given_name: "San", family_name: "Zhang" })).toBe("San Zhang");
expect(oidcDisplayName({ sub: "s3", preferred_username: "pz" })).toBe("pz");
expect(oidcDisplayName({ sub: "1234567890abcdef" })).toBe("统一账号 1234567890ab");
});
});
describe("修改用户名(PUT /api/settings/username)", () => {
test("需当前密码;改后新名可登录、旧名失效、/me 返回新名", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 密码错误拒绝
const bad = await authedFetch(t, cookie, "http://localhost/api/settings/username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_username: "boss", password: "wrong" }),
});
expect(bad.status).toBe(400);
// 非法字符拒绝
const invalid = await authedFetch(t, cookie, "http://localhost/api/settings/username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_username: "has space", password: "admin" }),
});
expect(invalid.status).toBe(400);
// 正常修改:重签会话
const ok = await authedFetch(t, cookie, "http://localhost/api/settings/username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_username: "boss", password: "admin" }),
});
expect(ok.status).toBe(200);
const renewed = cookieOf(ok);
// 旧 cookie(旧 payload 用户名)仍可用,但 /me 以库为准返回新名
const meOld = (await (await authedFetch(t, cookie, "http://localhost/api/auth/me")).json()) as { user: { username: string } };
expect(meOld.user.username).toBe("boss");
// 新签 cookie 同样
const meNew = (await (await authedFetch(t, renewed, "http://localhost/api/auth/me")).json()) as { user: { username: string } };
expect(meNew.user.username).toBe("boss");
// 新用户名 + 原密码可登录;旧用户名登录 401
const loginNew = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "boss", password: "admin" }),
}),
t.env,
);
expect(loginNew.status).toBe(200);
const loginOld = await t.app.fetch(
new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "admin", password: "admin" }),
}),
t.env,
);
expect(loginOld.status).toBe(401);
});
test("与其他用户重名返回 409;GET /api/settings 回传 oidc 配置与上次登录", async () => {
const t = await makeTestEnv();
const cookie = await loginCookie(t);
// 建第二个用户直接插库
const { hashPassword, ids } = await import("../shared/src/index.ts");
await t.env.DB.prepare("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)")
.bind(ids.user(), "taken", await hashPassword("x".repeat(8)), new Date().toISOString())
.run();
const dup = await authedFetch(t, cookie, "http://localhost/api/settings/username", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_username: "taken", password: "admin" }),
});
expect(dup.status).toBe(409);
const info = (await (await authedFetch(t, cookie, "http://localhost/api/settings")).json()) as {
username: 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.last_login).toBeNull();
});
});