增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
这个提交包含在:
@@ -68,9 +68,16 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
|
||||
# ===== 企业微信(入口/打卡同步,可选)=====
|
||||
# WECOM_ENABLED="false"
|
||||
# WECOM_ENTRY_ENABLED="false"
|
||||
# WECOM_PC_WEB_LOGIN_ENABLED="false"
|
||||
# WECOM_OAUTH_ENABLED="false"
|
||||
# WECOM_OAUTH_ONBOARDING_ENABLED="false"
|
||||
# WECOM_CORP_ID=""
|
||||
# WECOM_AGENT_ID=""
|
||||
# WECOM_AGENT_SECRET=""
|
||||
# WECOM_REDIRECT_URI="https://oa.example.com/api/auth/wecom/callback"
|
||||
# WECOM_ALLOWED_RETURN_HOSTS="oa.example.com"
|
||||
# WECOM_CHECKIN_SYNC_ENABLED="false"
|
||||
# WECOM_CHECKIN_SECRET=""
|
||||
# WECOM_CONTACT_SYNC_ENABLED="false"
|
||||
|
||||
@@ -253,6 +253,7 @@ function getConfig() {
|
||||
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
|
||||
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
|
||||
wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true",
|
||||
wecomPcWebLoginEnabled: process.env.WECOM_PC_WEB_LOGIN_ENABLED === "true",
|
||||
projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true",
|
||||
projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true",
|
||||
projectCommunicationLogEnabled: process.env.PROJECT_COMMUNICATION_LOG_ENABLED === "true",
|
||||
|
||||
+115
-7
@@ -19,13 +19,18 @@ const {
|
||||
|
||||
const BIND_COOKIE_NAME = "wecom_bind";
|
||||
const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind";
|
||||
const PC_LOGIN_STATE_COOKIE_NAME = "wecom_pc_login_state";
|
||||
const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint";
|
||||
const DATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
const DEFAULT_RETURN_TO = "/mobile";
|
||||
const PC_DEFAULT_RETURN_TO = "/portal";
|
||||
const ENTRY_SOURCE_WECOM_WORKBENCH = "wecom_workbench";
|
||||
const ENTRY_SOURCE_PC_QR = "pc_qr";
|
||||
const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]);
|
||||
const QUERY_SCHEMA = z.object({
|
||||
returnTo: z.string().optional(),
|
||||
mode: z.enum(["login", "bind", "onboard"]).optional(),
|
||||
entrySource: z.enum([ENTRY_SOURCE_WECOM_WORKBENCH, ENTRY_SOURCE_PC_QR]).optional(),
|
||||
deviceFingerprint: z.string().trim().min(1).max(128).optional(),
|
||||
});
|
||||
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
|
||||
@@ -70,6 +75,16 @@ function pendingLoginBindCookieOptions(config) {
|
||||
};
|
||||
}
|
||||
|
||||
function pcLoginStateCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
path: "/api/auth/wecom",
|
||||
maxAge: DATE_WINDOW_MS / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceFingerprintCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
@@ -169,6 +184,7 @@ function resolveWecomEntryConfig(config) {
|
||||
&& config.wecomRedirectUri,
|
||||
);
|
||||
const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled);
|
||||
const pcWebLoginEnabled = Boolean(entryEnabled && config.wecomPcWebLoginEnabled);
|
||||
const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled);
|
||||
const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason));
|
||||
|
||||
@@ -179,6 +195,10 @@ function resolveWecomEntryConfig(config) {
|
||||
corpId: String(config.wecomCorpId ?? ""),
|
||||
agentId: String(config.wecomAgentId ?? ""),
|
||||
loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null,
|
||||
pcWebLoginEnabled,
|
||||
pcLoginUrl: pcWebLoginEnabled
|
||||
? buildWecomLoginUrl(PC_DEFAULT_RETURN_TO, "login", ENTRY_SOURCE_PC_QR)
|
||||
: null,
|
||||
bindEnabled,
|
||||
onboardingEnabled,
|
||||
onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null,
|
||||
@@ -212,6 +232,14 @@ function assertWecomEntryReady(config) {
|
||||
return entryConfig;
|
||||
}
|
||||
|
||||
function assertWecomPcWebLoginReady(config) {
|
||||
const entryConfig = assertWecomEntryReady(config);
|
||||
if (!entryConfig.pcWebLoginEnabled) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
return entryConfig;
|
||||
}
|
||||
|
||||
function assertWecomOnboardingReady(config) {
|
||||
assertWecomOauthReady(config);
|
||||
if (!config.wecomOauthOnboardingEnabled) {
|
||||
@@ -219,11 +247,14 @@ function assertWecomOnboardingReady(config) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") {
|
||||
function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login", entrySource = ENTRY_SOURCE_WECOM_WORKBENCH) {
|
||||
const params = new URLSearchParams({ returnTo });
|
||||
if (mode === "bind" || mode === "onboard") {
|
||||
params.set("mode", mode);
|
||||
}
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
params.set("entrySource", ENTRY_SOURCE_PC_QR);
|
||||
}
|
||||
return `/api/auth/wecom/login?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -328,6 +359,48 @@ function buildWecomAuthorizeUrl(config, state, mode = "login") {
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
|
||||
}
|
||||
|
||||
function buildWecomPcAuthorizeUrl(config, state) {
|
||||
const url = new URL("https://login.work.weixin.qq.com/wwlogin/sso/login");
|
||||
url.searchParams.set("login_type", "CorpApp");
|
||||
url.searchParams.set("appid", config.wecomCorpId);
|
||||
url.searchParams.set("agentid", config.wecomAgentId);
|
||||
url.searchParams.set("redirect_uri", config.wecomRedirectUri);
|
||||
url.searchParams.set("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function safeEqualString(left, right) {
|
||||
if (typeof left !== "string" || typeof right !== "string") return false;
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
if (leftBuffer.length !== rightBuffer.length) return false;
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function resolveCallbackEntrySource(value) {
|
||||
if (value == null) return ENTRY_SOURCE_WECOM_WORKBENCH;
|
||||
if (value === ENTRY_SOURCE_WECOM_WORKBENCH || value === ENTRY_SOURCE_PC_QR) return value;
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
|
||||
function validatePcLoginStateCookie({ request, reply, config, ctx }) {
|
||||
const cookieToken = request.cookies?.[PC_LOGIN_STATE_COOKIE_NAME];
|
||||
const verified = verifyToken(cookieToken, config.jwtSecret);
|
||||
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(config));
|
||||
assertWecomPcWebLoginReady(config);
|
||||
if (!verified.ok) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const payload = verified.payload ?? {};
|
||||
const valid = payload.entrySource === ENTRY_SOURCE_PC_QR
|
||||
&& typeof payload.exp === "number"
|
||||
&& payload.exp > Date.now()
|
||||
&& safeEqualString(payload.nonce, ctx.nonce);
|
||||
if (!valid) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOAuthDeviceFingerprint(value) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
@@ -398,6 +471,15 @@ async function wecomAuthRoutes(fastify) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||
}
|
||||
const mode = parsed.data.mode ?? "login";
|
||||
const entrySource = parsed.data.entrySource ?? ENTRY_SOURCE_WECOM_WORKBENCH;
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR && mode !== "login") {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { entrySource: "unsupported_for_mode" },
|
||||
});
|
||||
}
|
||||
if (mode === "bind") {
|
||||
assertWecomOauthReady(fastify.config);
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
@@ -408,18 +490,28 @@ async function wecomAuthRoutes(fastify) {
|
||||
}
|
||||
} else if (mode === "onboard") {
|
||||
assertWecomOnboardingReady(fastify.config);
|
||||
} else if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
assertWecomPcWebLoginReady(fastify.config);
|
||||
} else {
|
||||
assertWecomEntryReady(fastify.config);
|
||||
}
|
||||
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
|
||||
const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint);
|
||||
const exp = Date.now() + DATE_WINDOW_MS;
|
||||
const nonce = crypto.randomUUID();
|
||||
const state = signPayload({
|
||||
exp: Date.now() + DATE_WINDOW_MS,
|
||||
nonce: crypto.randomUUID(),
|
||||
exp,
|
||||
nonce,
|
||||
returnTo,
|
||||
mode,
|
||||
entrySource,
|
||||
deviceFingerprint,
|
||||
}, fastify.config.jwtSecret);
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
const correlationToken = signPayload({ exp, nonce, entrySource }, fastify.config.jwtSecret);
|
||||
reply.setCookie(PC_LOGIN_STATE_COOKIE_NAME, correlationToken, pcLoginStateCookieOptions(fastify.config));
|
||||
return reply.redirect(buildWecomPcAuthorizeUrl(fastify.config, state));
|
||||
}
|
||||
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode));
|
||||
});
|
||||
|
||||
@@ -464,14 +556,28 @@ async function wecomAuthRoutes(fastify) {
|
||||
const stateToken = String(request.query?.state ?? "").trim();
|
||||
const code = String(request.query?.code ?? "").trim();
|
||||
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
|
||||
if (!verified.ok || !code) {
|
||||
if (!verified.ok) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const ctx = verified.payload ?? {};
|
||||
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
|
||||
const entrySource = resolveCallbackEntrySource(ctx.entrySource);
|
||||
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
|
||||
}
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
if (entrySource === ENTRY_SOURCE_PC_QR) {
|
||||
if (mode !== "login") {
|
||||
reply.clearCookie(PC_LOGIN_STATE_COOKIE_NAME, pcLoginStateCookieOptions(fastify.config));
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
validatePcLoginStateCookie({ request, reply, config: fastify.config, ctx });
|
||||
}
|
||||
if (!code) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login";
|
||||
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
|
||||
const profile = await exchangeWecomCode(fastify.config, code);
|
||||
if (!profile.externalUserId) {
|
||||
@@ -479,6 +585,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
{
|
||||
requestId: request.id,
|
||||
mode,
|
||||
entrySource,
|
||||
hasUserId: false,
|
||||
hasOpenId: Boolean(profile.externalOpenUserId),
|
||||
hasDeviceId: Boolean(profile.deviceId),
|
||||
@@ -497,6 +604,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
wecom: "missing_user_id",
|
||||
hasOpenId: Boolean(profile.externalOpenUserId),
|
||||
mode,
|
||||
entrySource,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -645,7 +753,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
? "account_mapping_inactive"
|
||||
: "account_mapping_required";
|
||||
request.log.warn(
|
||||
{ requestId: request.id, errorCode: error.code, notice },
|
||||
{ requestId: request.id, errorCode: error.code, notice, entrySource },
|
||||
"wecom automatic login binding failed closed",
|
||||
);
|
||||
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
|
||||
@@ -664,7 +772,7 @@ async function wecomAuthRoutes(fastify) {
|
||||
tokenVersion: loginEmployee.token_version,
|
||||
deviceFingerprint,
|
||||
auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { bindingSource },
|
||||
extra: { bindingSource, entrySource },
|
||||
});
|
||||
|
||||
reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config));
|
||||
|
||||
@@ -225,6 +225,10 @@ function buildEntryOauthCheck(config) {
|
||||
evidence: {
|
||||
endpoint: "/api/auth/wecom/config",
|
||||
loginUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=login",
|
||||
pcWebLoginEnabled: Boolean(config.wecomPcWebLoginEnabled),
|
||||
pcLoginUrl: missing.length || !config.wecomPcWebLoginEnabled
|
||||
? null
|
||||
: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
|
||||
bindUrl: missing.length ? null : "/api/auth/wecom/login?returnTo=%2Fmobile&mode=bind",
|
||||
returnHosts,
|
||||
enabled: Boolean(config.wecomEnabled && config.wecomEntryEnabled && config.wecomOauthEnabled),
|
||||
|
||||
@@ -38,7 +38,12 @@ function evaluateWecom(config) {
|
||||
return {
|
||||
state: 'ready',
|
||||
detail: '企业微信入口与免登配置齐全',
|
||||
checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`, `redirectUri=${config.wecomRedirectUri}`],
|
||||
checks: [
|
||||
`corpId=${config.wecomCorpId}`,
|
||||
`agentId=${config.wecomAgentId}`,
|
||||
`redirectUri=${config.wecomRedirectUri}`,
|
||||
`pcWebLogin=${config.wecomPcWebLoginEnabled ? 'on' : 'off'}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions =
|
||||
cookieSecure: true,
|
||||
wecomEnabled: true,
|
||||
wecomEntryEnabled: true,
|
||||
wecomPcWebLoginEnabled: false,
|
||||
wecomOauthEnabled: true,
|
||||
wecomOauthOnboardingEnabled: false,
|
||||
wecomCorpId: "wx-test-corp",
|
||||
@@ -138,11 +139,83 @@ describe("wecom auth routes R27", () => {
|
||||
status: "ready",
|
||||
defaultReturnTo: "/mobile",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
|
||||
pcWebLoginEnabled: false,
|
||||
pcLoginUrl: null,
|
||||
}));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns an independently gated PC Web login entry", async () => {
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/api/auth/wecom/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual(expect.objectContaining({
|
||||
entryEnabled: true,
|
||||
pcWebLoginEnabled: true,
|
||||
pcLoginUrl: "/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
|
||||
}));
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("builds the hosted PC Web login URL and browser correlation cookie", async () => {
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Freimbursements&deviceFingerprint=pc-device-1",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
const location = new URL(response.headers.location);
|
||||
expect(location.origin).toBe("https://login.work.weixin.qq.com");
|
||||
expect(location.pathname).toBe("/wwlogin/sso/login");
|
||||
expect(location.searchParams.get("login_type")).toBe("CorpApp");
|
||||
expect(location.searchParams.get("appid")).toBe("wx-test-corp");
|
||||
expect(location.searchParams.get("agentid")).toBe("1000001");
|
||||
expect(location.searchParams.get("redirect_uri")).toBe("https://oa.hyinda.com/api/auth/wecom/callback");
|
||||
const state = verifyToken(location.searchParams.get("state"), JWT_SECRET);
|
||||
expect(state).toEqual(expect.objectContaining({
|
||||
ok: true,
|
||||
payload: expect.objectContaining({
|
||||
returnTo: "/reimbursements",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
deviceFingerprint: "pc-device-1",
|
||||
}),
|
||||
}));
|
||||
const setCookie = String(response.headers["set-cookie"] ?? "");
|
||||
expect(setCookie).toContain("wecom_pc_login_state=");
|
||||
expect(setCookie).toContain("Path=/api/auth/wecom");
|
||||
expect(setCookie).toContain("HttpOnly");
|
||||
expect(setCookie).toContain("Secure");
|
||||
expect(setCookie).toContain("SameSite=Lax");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects PC entry for bind/onboard and while its independent switch is off", async () => {
|
||||
const disabledApp = await buildApp();
|
||||
const disabled = await disabledApp.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/wecom/login?entrySource=pc_qr&returnTo=%2Fportal",
|
||||
});
|
||||
expect(disabled.statusCode).toBe(404);
|
||||
await disabledApp.close();
|
||||
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const unsupported = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/wecom/login?entrySource=pc_qr&mode=onboard&returnTo=%2Fportal",
|
||||
});
|
||||
expect(unsupported.statusCode).toBe(400);
|
||||
expect(unsupported.json().error.details).toEqual({ entrySource: "unsupported_for_mode" });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps onboarding disabled by default", async () => {
|
||||
const app = await buildApp();
|
||||
|
||||
@@ -560,7 +633,7 @@ describe("wecom auth routes R27", () => {
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { bindingSource: "auto" },
|
||||
extra: { bindingSource: "auto", entrySource: "wecom_workbench" },
|
||||
}),
|
||||
});
|
||||
expect(authSession.create).toHaveBeenCalledWith({
|
||||
@@ -630,6 +703,229 @@ describe("wecom auth routes R27", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed before code exchange when the PC browser correlation cookie is missing", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "pc-nonce-missing-cookie",
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
deviceFingerprint: "pc-device-missing-cookie",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().error.code).toBe("AUTH_004");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("fails closed before code exchange when the PC browser nonce does not match", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "pc-nonce-state",
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
const cookie = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "pc-nonce-cookie",
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
headers: { cookie: `wecom_pc_login_state=${cookie}` },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("clears PC correlation state for expired state, missing code, or a disabled PC switch", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const nonce = "pc-nonce-terminal-failures";
|
||||
const cookie = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce,
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
const cookieHeader = { cookie: `wecom_pc_login_state=${cookie}` };
|
||||
|
||||
const enabledApp = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const expiredState = signPayload({
|
||||
exp: Date.now() - 1,
|
||||
nonce,
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
const expired = await enabledApp.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(expiredState)}&code=test-code`,
|
||||
headers: cookieHeader,
|
||||
});
|
||||
expect(expired.statusCode).toBe(401);
|
||||
expect(String(expired.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
|
||||
const validState = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce,
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
const missingCode = await enabledApp.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}`,
|
||||
headers: cookieHeader,
|
||||
});
|
||||
expect(missingCode.statusCode).toBe(401);
|
||||
expect(String(missingCode.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
await enabledApp.close();
|
||||
|
||||
const disabledApp = await buildApp();
|
||||
const disabled = await disabledApp.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(validState)}&code=test-code`,
|
||||
headers: cookieHeader,
|
||||
});
|
||||
expect(disabled.statusCode).toBe(404);
|
||||
expect(String(disabled.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
await disabledApp.close();
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses the mapped login flow for PC Web login and records a sanitized entry source", async () => {
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: "access-token" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, UserId: "wecom-pc-user", OpenId: "wecom-open-pc" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: "access-token-replay" }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 40029, errmsg: "invalid code" }),
|
||||
});
|
||||
const authSession = {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue({ id: "session-pc" }),
|
||||
};
|
||||
const prisma = buildPrisma({
|
||||
authSession,
|
||||
wecomIdentityBinding: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
employee: {
|
||||
id: EMPLOYEE_ID,
|
||||
status: "active",
|
||||
token_version: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true }, prisma });
|
||||
const nonce = "pc-nonce-success";
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce,
|
||||
returnTo: "/reimbursements",
|
||||
mode: "login",
|
||||
entrySource: "pc_qr",
|
||||
deviceFingerprint: "pc-device-success",
|
||||
}, JWT_SECRET);
|
||||
const cookie = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce,
|
||||
entrySource: "pc_qr",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
headers: { cookie: `wecom_pc_login_state=${cookie}` },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe("https://oa.hyinda.com/reimbursements");
|
||||
expect(String(response.headers["set-cookie"] ?? "")).toContain("wecom_pc_login_state=;");
|
||||
expect(authSession.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
employee_id: EMPLOYEE_ID,
|
||||
device_fingerprint: "pc-device-success",
|
||||
}),
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { bindingSource: "existing", entrySource: "pc_qr" },
|
||||
}),
|
||||
}));
|
||||
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("test-code");
|
||||
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain(state);
|
||||
expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("wecom-pc-user");
|
||||
|
||||
const replay = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
headers: { cookie: `wecom_pc_login_state=${cookie}` },
|
||||
});
|
||||
expect(replay.statusCode).toBe(401);
|
||||
expect(replay.json().error).toEqual(expect.objectContaining({
|
||||
code: "AUTH_003",
|
||||
details: { wecom: "userinfo_exchange_failed" },
|
||||
}));
|
||||
expect(authSession.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects a signed but unknown callback entry source before code exchange", async () => {
|
||||
global.fetch = vi.fn();
|
||||
const app = await buildApp({ config: { wecomPcWebLoginEnabled: true } });
|
||||
const state = signPayload({
|
||||
exp: Date.now() + 60_000,
|
||||
nonce: "pc-nonce-unknown-source",
|
||||
returnTo: "/portal",
|
||||
mode: "login",
|
||||
entrySource: "unknown_source",
|
||||
}, JWT_SECRET);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns structured diagnostics when wecom callback has only open id", async () => {
|
||||
const logs = [];
|
||||
mockWecomFetchWithPayload({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const {
|
||||
assertSendAllowed,
|
||||
buildApprovalConfigChecks,
|
||||
buildEntryOauthCheck,
|
||||
buildSecretMatrix,
|
||||
evaluateConclusion,
|
||||
executeRuntimeReady,
|
||||
@@ -24,6 +25,7 @@ function baseConfig(overrides = {}) {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
wecomEnabled: true,
|
||||
wecomEntryEnabled: true,
|
||||
wecomPcWebLoginEnabled: false,
|
||||
wecomOauthEnabled: true,
|
||||
wecomCorpId: "corp-runtime",
|
||||
wecomAgentId: "1000002",
|
||||
@@ -73,6 +75,22 @@ function wecomProfile(overrides = {}) {
|
||||
}
|
||||
|
||||
describe("check-wecom-runtime-ready", () => {
|
||||
it("reports the independently gated PC Web login entry without blocking workbench readiness", () => {
|
||||
const disabled = buildEntryOauthCheck(baseConfig());
|
||||
expect(disabled.status).toBe("OK");
|
||||
expect(disabled.evidence).toEqual(expect.objectContaining({
|
||||
enabled: true,
|
||||
pcWebLoginEnabled: false,
|
||||
pcLoginUrl: null,
|
||||
}));
|
||||
|
||||
const enabled = buildEntryOauthCheck(baseConfig({ wecomPcWebLoginEnabled: true }));
|
||||
expect(enabled.status).toBe("OK");
|
||||
expect(enabled.evidence.pcLoginUrl).toBe(
|
||||
"/api/auth/wecom/login?returnTo=%2Fportal&entrySource=pc_qr",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses runner options and enforces message send double confirmation", () => {
|
||||
const options = parseArgs([
|
||||
"--live",
|
||||
|
||||
在新工单中引用
屏蔽一个用户