import type { DatabaseClient } from "./database"; import { getRuntimeEnv } from "./runtime-env"; import { hashText } from "./mvp-db"; export const WECOM_IDENTITY_TTL_MS = 30 * 60 * 1_000; const WECOM_IDENTITY_GRACE_MS = 60 * 1_000; type WecomIdentityPayload = { v: 1; ext: string; uid?: string; name: string; iat: number; exp: number; jti: string; }; export type WecomIdentity = { externalUserId: string; displayName: string; userId?: string; }; export function getWecomOAuthSecret(): string { const secret = String(getRuntimeEnv().ADMIN_INTERNAL_TOKEN ?? "").trim(); if (!secret) { throw new Error("ADMIN_INTERNAL_TOKEN 未配置,无法签发企微身份票据"); } return secret; } function toBase64Url(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function fromBase64Url(value: string): Uint8Array { const padded = value .replace(/-/g, "+") .replace(/_/g, "/") .padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes; } async function hmacSha256(secret: string, data: string): Promise { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const signature = await crypto.subtle.sign( "HMAC", key, new TextEncoder().encode(data), ); return new Uint8Array(signature); } function constantTimeEquals(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let index = 0; index < a.length; index += 1) { diff |= a[index] ^ b[index]; } return diff === 0; } export async function signWecomIdentityTicket( externalUserId: string, displayName: string, options: { secret?: string; nowMs?: number; ttlMs?: number; userId?: string; } = {}, ): Promise<{ ticket: string; expiresIn: number }> { const secret = options.secret ?? getWecomOAuthSecret(); const issuedAt = options.nowMs ?? Date.now(); const ttl = options.ttlMs ?? WECOM_IDENTITY_TTL_MS; const ext = String(externalUserId ?? "").trim(); const uid = String(options.userId ?? "").trim().slice(0, 64); if (!ext && !uid) throw new Error("缺少企微身份 ID(外部联系人或内部成员)"); const payload: WecomIdentityPayload = { v: 1, ext, name: String(displayName ?? "").trim().slice(0, 120), iat: issuedAt, exp: issuedAt + ttl, jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))), }; if (uid) payload.uid = uid; const payloadSegment = toBase64Url( new TextEncoder().encode(JSON.stringify(payload)), ); const signature = await hmacSha256(secret, payloadSegment); return { ticket: `${payloadSegment}.${toBase64Url(signature)}`, expiresIn: Math.floor(ttl / 1_000), }; } export async function verifyWecomIdentityTicket( ticket: string, options: { secret?: string; nowMs?: number } = {}, ): Promise { const raw = String(ticket ?? "").trim(); const dotIndex = raw.indexOf("."); if (dotIndex <= 0 || dotIndex === raw.length - 1) return null; const payloadSegment = raw.slice(0, dotIndex); const signatureSegment = raw.slice(dotIndex + 1); if (/[^A-Za-z0-9_-]/.test(payloadSegment) || /[^A-Za-z0-9_-]/.test(signatureSegment)) { return null; } let payload: WecomIdentityPayload; try { payload = JSON.parse( new TextDecoder().decode(fromBase64Url(payloadSegment)), ) as WecomIdentityPayload; } catch { return null; } if ( payload?.v !== 1 || typeof payload.ext !== "string" || typeof payload.exp !== "number" ) { return null; } const extId = payload.ext.trim(); const uid = typeof payload.uid === "string" ? payload.uid.trim().slice(0, 64) : ""; if (!extId && !uid) return null; const secret = options.secret ?? getWecomOAuthSecret(); const expected = await hmacSha256(secret, payloadSegment); const provided = fromBase64Url(signatureSegment); if (!constantTimeEquals(expected, provided)) return null; const now = options.nowMs ?? Date.now(); if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null; return { externalUserId: extId, displayName: payload.name || "企微客户", ...(uid ? { userId: uid } : {}), }; } export function wecomClaimantPartnerId(externalUserId: string): string { return `partner-ext-${hashText(`claimant:wecom:${externalUserId}`)}`; } export const WECOM_BIND_STATE_TTL_MS = 10 * 60 * 1_000; type WecomBindStatePayload = { v: 2; uid: string; iat: number; exp: number; jti: string; }; export async function signWecomBindState( userId: string, options: { secret?: string; nowMs?: number; ttlMs?: number } = {}, ): Promise { const secret = options.secret ?? getWecomOAuthSecret(); const issuedAt = options.nowMs ?? Date.now(); const ttl = options.ttlMs ?? WECOM_BIND_STATE_TTL_MS; const uid = String(userId ?? "").trim().slice(0, 64); if (!uid) throw new Error("缺少后台账号 ID,无法发起企微绑定"); const payload: WecomBindStatePayload = { v: 2, uid, iat: issuedAt, exp: issuedAt + ttl, jti: toBase64Url(crypto.getRandomValues(new Uint8Array(12))), }; const payloadSegment = toBase64Url( new TextEncoder().encode(JSON.stringify(payload)), ); const signature = await hmacSha256(secret, payloadSegment); return `${payloadSegment}.${toBase64Url(signature)}`; } export async function verifyWecomBindState( state: string, options: { secret?: string; nowMs?: number } = {}, ): Promise<{ userId: string } | null> { const raw = String(state ?? "").trim(); const dotIndex = raw.indexOf("."); if (dotIndex <= 0 || dotIndex === raw.length - 1) return null; const payloadSegment = raw.slice(0, dotIndex); const signatureSegment = raw.slice(dotIndex + 1); if (/[^A-Za-z0-9_-]/.test(payloadSegment) || /[^A-Za-z0-9_-]/.test(signatureSegment)) { return null; } let payload: WecomBindStatePayload; try { payload = JSON.parse( new TextDecoder().decode(fromBase64Url(payloadSegment)), ) as WecomBindStatePayload; } catch { return null; } if (payload?.v !== 2 || typeof payload.uid !== "string") return null; const uid = payload.uid.trim().slice(0, 64); if (!uid) return null; const secret = options.secret ?? getWecomOAuthSecret(); const expected = await hmacSha256(secret, payloadSegment); const provided = fromBase64Url(signatureSegment); if (!constantTimeEquals(expected, provided)) return null; const now = options.nowMs ?? Date.now(); if (now > payload.exp + WECOM_IDENTITY_GRACE_MS) return null; return { userId: uid }; } export async function resolveWecomClaimant( db: DatabaseClient, externalUserId: string, ): Promise<{ partnerId: string; matchedExisting: boolean }> { const ext = String(externalUserId ?? "").trim(); if (!ext) throw new Error("缺少企微外部联系人 ID"); const existing = await db .prepare("SELECT id FROM partners WHERE wecom_external_user_id = ?") .bind(ext) .first<{ id: string }>(); if (existing?.id) { return { partnerId: existing.id, matchedExisting: true }; } return { partnerId: wecomClaimantPartnerId(ext), matchedExisting: false }; }