const crypto = require("node:crypto"); const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { buildAuditLogData } = require("../utils/audit"); const { randomRefreshToken, sha256Hex } = require("../utils/crypto"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { sendData } = require("../utils/reply"); const { WecomOAuthOnboardingError, fetchWecomOAuthOnboardingProfile, provisionWecomOAuthEmployee, } = require("../services/integrations/wecomOAuthOnboarding"); const { WecomLoginAutoBindingError, autoBindExistingWecomEmployee, fetchWecomLoginIdentity, } = require("../services/integrations/wecomLoginAutoBinding"); const BIND_COOKIE_NAME = "wecom_bind"; const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind"; const DEVICE_FINGERPRINT_COOKIE_NAME = "hyb_oa_device_fingerprint"; const DATE_WINDOW_MS = 10 * 60 * 1000; const DEFAULT_RETURN_TO = "/mobile"; 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(), deviceFingerprint: z.string().trim().min(1).max(128).optional(), }); const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional(); function refreshCookieOptions(config) { return { httpOnly: true, sameSite: "strict", secure: config.cookieSecure, path: "/api/auth", maxAge: config.refreshTtlSeconds, }; } function sessionHintCookieOptions(config) { return { httpOnly: false, sameSite: "strict", secure: config.cookieSecure, path: "/", maxAge: config.refreshTtlSeconds, }; } function bindCookieOptions(config) { return { httpOnly: true, sameSite: "lax", secure: config.cookieSecure, path: "/api/auth/wecom", maxAge: DATE_WINDOW_MS / 1000, }; } function pendingLoginBindCookieOptions(config) { return { httpOnly: true, sameSite: "lax", secure: config.cookieSecure, path: "/api/auth", maxAge: DATE_WINDOW_MS / 1000, }; } function deviceFingerprintCookieOptions(config) { return { httpOnly: false, sameSite: "lax", secure: config.cookieSecure, path: "/", maxAge: config.refreshTtlSeconds, }; } function resolvePrimaryAppOrigin(config, request) { const configured = typeof config.appOrigin === "string" ? config.appOrigin : ""; const first = configured .split(",") .map((s) => s.trim()) .filter(Boolean)[0]; if (first) return first; const host = request.headers.host ?? "127.0.0.1:3000"; const protocol = request.protocol ?? "http"; return `${protocol}://${host}`; } function parseAllowedReturnHosts(config, request) { const hosts = new Set(); const appOrigins = String(config.appOrigin || "") .split(",") .map((item) => item.trim()) .filter(Boolean); for (const item of appOrigins) { try { hosts.add(new URL(item).host); } catch { } } for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) { hosts.add(item); } if (!hosts.size) { try { hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host); } catch { } } return hosts; } function sanitizeReturnTo(rawValue, config, request) { if (typeof rawValue !== "string") { return DEFAULT_RETURN_TO; } const trimmed = rawValue.trim(); if (!trimmed) { return DEFAULT_RETURN_TO; } if (trimmed.startsWith("/")) { return trimmed.startsWith("//") ? DEFAULT_RETURN_TO : trimmed; } try { const url = new URL(trimmed); const allowedHosts = parseAllowedReturnHosts(config, request); if (!allowedHosts.has(url.host)) { return DEFAULT_RETURN_TO; } return `${url.pathname || DEFAULT_RETURN_TO}${url.search || ""}${url.hash || ""}`; } catch { return DEFAULT_RETURN_TO; } } function resolveWecomEntryConfig(config) { const statusReasons = []; if (!config.wecomEnabled) { statusReasons.push("wecom_disabled"); } if (!config.wecomEntryEnabled) { statusReasons.push("entry_disabled"); } if (!config.wecomOauthEnabled) { statusReasons.push("oauth_disabled"); } if (!config.wecomCorpId) { statusReasons.push("corp_id_missing"); } if (!config.wecomAgentId) { statusReasons.push("agent_id_missing"); } if (!config.wecomRedirectUri) { statusReasons.push("redirect_uri_missing"); } const bindEnabled = Boolean( config.wecomEnabled && config.wecomOauthEnabled && config.wecomCorpId && config.wecomAgentId && config.wecomRedirectUri, ); const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled); const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled); const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason)); return { enabled: entryEnabled, entryEnabled, oauthEnabled: Boolean(config.wecomOauthEnabled), corpId: String(config.wecomCorpId ?? ""), agentId: String(config.wecomAgentId ?? ""), loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null, bindEnabled, onboardingEnabled, onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null, defaultReturnTo: DEFAULT_RETURN_TO, status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured", statusReasons, }; } function assertWecomOauthReady(config) { const entryConfig = resolveWecomEntryConfig(config); if (!config.wecomEnabled || !config.wecomOauthEnabled) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } if (!entryConfig.bindEnabled) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { wecom: "missing_required_config", statusReasons: entryConfig.statusReasons }, }); } return entryConfig; } function assertWecomEntryReady(config) { const entryConfig = assertWecomOauthReady(config); if (!config.wecomEntryEnabled) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } return entryConfig; } function assertWecomOnboardingReady(config) { assertWecomOauthReady(config); if (!config.wecomOauthOnboardingEnabled) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); } } function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") { const params = new URLSearchParams({ returnTo }); if (mode === "bind" || mode === "onboard") { params.set("mode", mode); } return `/api/auth/wecom/login?${params.toString()}`; } async function getWecomAccessToken(config) { if (!config.wecomAgentSecret) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { wecom: "missing_agent_secret" }, }); } const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken"); url.searchParams.set("corpid", config.wecomCorpId); url.searchParams.set("corpsecret", config.wecomAgentSecret); let response; try { response = await fetch(url); } catch { throw new AppError({ code: "AUTH_003", message: "企业微信认证失败", statusCode: 401, details: { wecom: "token_exchange_failed" }, }); } const payload = await response.json().catch(() => null); if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) { throw new AppError({ code: "AUTH_003", message: "企业微信认证失败", statusCode: 401, details: { wecom: "token_exchange_failed" }, }); } return String(payload.access_token); } async function exchangeWecomCode(config, code) { const accessToken = await getWecomAccessToken(config); const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo"); url.searchParams.set("access_token", accessToken); url.searchParams.set("code", code); let response; try { response = await fetch(url); } catch { throw new AppError({ code: "AUTH_003", message: "企业微信认证失败", statusCode: 401, details: { wecom: "userinfo_exchange_failed" }, }); } const payload = await response.json().catch(() => null); if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) { throw new AppError({ code: "AUTH_003", message: "企业微信认证失败", statusCode: 401, details: { wecom: "userinfo_exchange_failed" }, }); } const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : []; const hasStringValue = (value) => typeof value === "string" && value.trim().length > 0; const pickCandidate = (...values) => { for (const value of values) { if (hasStringValue(value)) { return value.trim(); } } return null; }; return { externalUserId: pickCandidate(payload.UserId, payload.userid), externalOpenUserId: pickCandidate(payload.OpenId, payload.openid), deviceId: pickCandidate(payload.DeviceId, payload.deviceid), userTicket: pickCandidate(payload.user_ticket), accessToken, diagnostics: { errcode: Number(payload.errcode ?? 0), errmsg: typeof payload.errmsg === "string" ? payload.errmsg : null, payloadKeys, fieldPresence: { UserId: hasStringValue(payload.UserId), userid: hasStringValue(payload.userid), OpenId: hasStringValue(payload.OpenId), openid: hasStringValue(payload.openid), DeviceId: hasStringValue(payload.DeviceId), deviceid: hasStringValue(payload.deviceid), external_userid: hasStringValue(payload.external_userid), user_ticket: hasStringValue(payload.user_ticket), corpid: hasStringValue(payload.corpid), }, }, }; } function buildWecomAuthorizeUrl(config, state, mode = "login") { const redirectUri = encodeURIComponent(config.wecomRedirectUri); const scope = mode === "onboard" ? "snsapi_privateinfo" : "snsapi_base"; 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 resolveOAuthDeviceFingerprint(value) { if (typeof value === "string" && value.trim()) { return value.trim(); } return `wecom:${crypto.randomUUID()}`; } async function createAuthenticatedSession({ prisma, request, config, employeeId, tokenVersion, deviceFingerprint, auditAction, extra = {} }) { const refreshToken = randomRefreshToken(); const refreshTokenHash = sha256Hex(refreshToken); const now = new Date(); const expiresAt = new Date(now.getTime() + config.refreshTtlSeconds * 1000); await prisma.$transaction(async (tx) => { const activeSessions = await tx.authSession.findMany({ where: { employee_id: employeeId, revoked_at: null, expires_at: { gt: now }, }, orderBy: { created_at: "asc" }, select: { id: true }, }); const maxSessions = 5; const allowedExisting = maxSessions - 1; if (activeSessions.length > allowedExisting) { const revokeCount = activeSessions.length - allowedExisting; const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id); await tx.authSession.updateMany({ where: { id: { in: idsToRevoke } }, data: { revoked_at: now } }); } await tx.authSession.create({ data: { employee_id: employeeId, refresh_token_hash: refreshTokenHash, device_fingerprint: deviceFingerprint, ip_address: request.ip, user_agent: String(request.headers["user-agent"] ?? ""), expires_at: expiresAt, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: employeeId, action: auditAction, targetType: "employee", targetId: employeeId, extra, }), }); }); return { refreshToken, accessToken: request.server.issueAccessToken({ id: employeeId, token_version: tokenVersion }), }; } async function wecomAuthRoutes(fastify) { fastify.get("/wecom/config", async (request, reply) => { return sendData(reply, request, resolveWecomEntryConfig(fastify.config)); }); fastify.get("/wecom/login", async (request, reply) => { const parsed = QUERY_SCHEMA.safeParse(request.query ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() }); } const mode = parsed.data.mode ?? "login"; if (mode === "bind") { assertWecomOauthReady(fastify.config); const bindCookie = request.cookies?.[BIND_COOKIE_NAME]; const bindState = verifyToken(bindCookie, fastify.config.jwtSecret); if (!bindState.ok) { reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config)); throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 }); } } else if (mode === "onboard") { assertWecomOnboardingReady(fastify.config); } else { assertWecomEntryReady(fastify.config); } const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request); const deviceFingerprint = resolveOAuthDeviceFingerprint(parsed.data.deviceFingerprint); const state = signPayload({ exp: Date.now() + DATE_WINDOW_MS, nonce: crypto.randomUUID(), returnTo, mode, deviceFingerprint, }, fastify.config.jwtSecret); return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode)); }); fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => { assertWecomOauthReady(fastify.config); const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() }); } const returnTo = sanitizeReturnTo(parsed.data?.returnTo, fastify.config, request); const token = signPayload({ exp: Date.now() + DATE_WINDOW_MS, employeeId: request.employee.id, returnTo, }, fastify.config.jwtSecret); reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config)); return sendData(reply, request, { loginUrl: buildWecomLoginUrl(returnTo, "bind"), }); }); fastify.post("/wecom/unbind", { preHandler: fastify.authenticate }, async (request, reply) => { await fastify.prisma.wecomIdentityBinding.deleteMany({ where: { employee_id: request.employee.id } }); try { await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "AUTH_WECOM_UNBIND", targetType: "employee", targetId: request.employee.id, extra: {}, }), }); } catch (error) { request.log.error({ err: error }, "wecom unbind audit failed"); } return sendData(reply, request, { ok: true }); }); fastify.get("/wecom/callback", async (request, reply) => { assertWecomOauthReady(fastify.config); 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) { throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 }); } const ctx = verified.payload ?? {}; if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) { 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) { request.log.warn( { requestId: request.id, mode, hasUserId: false, hasOpenId: Boolean(profile.externalOpenUserId), hasDeviceId: Boolean(profile.deviceId), wecomErrCode: profile.diagnostics?.errcode ?? null, wecomErrMsg: profile.diagnostics?.errmsg ?? null, payloadKeys: profile.diagnostics?.payloadKeys ?? [], fieldPresence: profile.diagnostics?.fieldPresence ?? {}, }, "wecom callback missing internal user id", ); throw new AppError({ code: "AUTH_003", message: "企业微信认证失败", statusCode: 401, details: { wecom: "missing_user_id", hasOpenId: Boolean(profile.externalOpenUserId), mode, }, }); } const origin = resolvePrimaryAppOrigin(fastify.config, request); if (mode === "onboard") { assertWecomOnboardingReady(fastify.config); if (!profile.userTicket) { return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=consent_required`); } try { const onboardingProfile = await fetchWecomOAuthOnboardingProfile({ accessToken: profile.accessToken, externalUserId: profile.externalUserId, userTicket: profile.userTicket, }); const employee = await provisionWecomOAuthEmployee({ prisma: fastify.prisma, request, profile: onboardingProfile, externalOpenUserId: profile.externalOpenUserId, }); const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint); const session = await createAuthenticatedSession({ prisma: fastify.prisma, request, config: fastify.config, employeeId: employee.id, tokenVersion: employee.token_version, deviceFingerprint, auditAction: "AUTH_WECOM_ONBOARDING_LOGIN_SUCCESS", extra: { created: employee.created }, }); reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config)); reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); reply.setCookie(DEVICE_FINGERPRINT_COOKIE_NAME, deviceFingerprint, deviceFingerprintCookieOptions(fastify.config)); return reply.redirect(`${origin}${returnTo}`); } catch (error) { if (error instanceof WecomOAuthOnboardingError) { return reply.redirect( `${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=${encodeURIComponent(error.code)}`, ); } throw error; } } if (mode === "bind") { const bindCookie = request.cookies?.[BIND_COOKIE_NAME]; const bindVerified = verifyToken(bindCookie, fastify.config.jwtSecret); if (!bindVerified.ok) { reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config)); return reply.redirect(`${origin}${returnTo}?wecomBind=expired`); } const bindPayload = bindVerified.payload ?? {}; if (typeof bindPayload.employeeId !== "string") { reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config)); return reply.redirect(`${origin}${returnTo}?wecomBind=invalid`); } const employee = await fastify.prisma.employee.findUnique({ where: { id: bindPayload.employeeId }, select: { id: true, status: true }, }); if (!employee || employee.status !== "active") { reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config)); return reply.redirect(`${origin}${returnTo}?wecomBind=employee_inactive`); } const conflicting = await fastify.prisma.wecomIdentityBinding.findFirst({ where: { external_user_id: profile.externalUserId, NOT: { employee_id: employee.id } }, select: { employee_id: true }, }); if (conflicting) { reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config)); return reply.redirect(`${origin}${returnTo}?wecomBind=conflict`); } await fastify.prisma.wecomIdentityBinding.upsert({ where: { employee_id: employee.id }, update: { external_user_id: profile.externalUserId, external_open_user_id: profile.externalOpenUserId, updated_by: employee.id, }, create: { employee_id: employee.id, external_user_id: profile.externalUserId, external_open_user_id: profile.externalOpenUserId, created_by: employee.id, updated_by: employee.id, }, }); await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: employee.id, action: "AUTH_WECOM_BIND", targetType: "employee", targetId: employee.id, extra: { externalUserId: profile.externalUserId }, }), }); reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config)); return reply.redirect(`${origin}${returnTo}?wecomBind=success`); } const binding = await fastify.prisma.wecomIdentityBinding.findUnique({ where: { external_user_id: profile.externalUserId }, select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } }, }); let loginEmployee = binding?.employee ?? null; let bindingSource = "existing"; if (loginEmployee && loginEmployee.status !== "active") { reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config)); return reply.redirect( `${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=account_mapping_inactive`, ); } if (!loginEmployee) { try { const identity = await fetchWecomLoginIdentity({ accessToken: profile.accessToken, externalUserId: profile.externalUserId, }); loginEmployee = await autoBindExistingWecomEmployee({ prisma: fastify.prisma, request, identity, externalOpenUserId: profile.externalOpenUserId, }); bindingSource = loginEmployee.autoBound ? "auto" : "concurrent_existing"; } catch (error) { if (!(error instanceof WecomLoginAutoBindingError)) throw error; const conflictCodes = new Set([ "oa_employee_conflict", "wecom_userid_conflict", "oa_employee_binding_conflict", "binding_race_conflict", "wecom_identity_mismatch", ]); const inactiveCodes = new Set(["wecom_member_inactive", "oa_employee_inactive"]); const notice = conflictCodes.has(error.code) ? "account_mapping_conflict" : inactiveCodes.has(error.code) ? "account_mapping_inactive" : "account_mapping_required"; request.log.warn( { requestId: request.id, errorCode: error.code, notice }, "wecom automatic login binding failed closed", ); reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config)); return reply.redirect( `${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=${notice}`, ); } } const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint); const session = await createAuthenticatedSession({ prisma: fastify.prisma, request, config: fastify.config, employeeId: loginEmployee.id, tokenVersion: loginEmployee.token_version, deviceFingerprint, auditAction: "AUTH_WECOM_LOGIN_SUCCESS", extra: { bindingSource }, }); reply.clearCookie(PENDING_LOGIN_BIND_COOKIE_NAME, pendingLoginBindCookieOptions(fastify.config)); reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config)); reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); reply.setCookie(DEVICE_FINGERPRINT_COOKIE_NAME, deviceFingerprint, deviceFingerprintCookieOptions(fastify.config)); return reply.redirect(`${origin}${returnTo}`); }); } module.exports = wecomAuthRoutes;