diff --git a/app/api/wecom-oauth/route.ts b/app/api/wecom-oauth/route.ts new file mode 100644 index 0000000..93501fe --- /dev/null +++ b/app/api/wecom-oauth/route.ts @@ -0,0 +1,65 @@ +import { getRuntimeEnv } from "../../../lib/runtime-env"; +import { + exchangeWecomOAuthCode, + fetchWecomExternalContactName, + resolveWecomConfig, + WecomClientError, + type WecomBindings, +} from "../../../lib/wecom-client"; +import { signWecomIdentityTicket } from "../../../lib/wecom-oauth-service"; +import { partnerOptions, withPartnerCors } from "../../../lib/partner-cors"; + +const env = getRuntimeEnv(); + +export function OPTIONS(request: Request) { + return partnerOptions(request); +} + +async function handlePost(request: Request) { + try { + const body = (await request.json().catch(() => ({}))) as { code?: string }; + const code = String(body.code ?? "").trim(); + const config = resolveWecomConfig(env as unknown as WecomBindings); + if (!config.corpId || !config.agentId || !config.secret) { + return Response.json( + { error: "未配置企业微信授权(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)" }, + { status: 501 }, + ); + } + if (!code) { + return Response.json({ error: "缺少授权 code" }, { status: 400 }); + } + const identity = await exchangeWecomOAuthCode(config, code); + if (identity.externalUserId) { + const displayName = + (await fetchWecomExternalContactName(config, identity.externalUserId)) ?? + "企微客户"; + const { ticket, expiresIn } = await signWecomIdentityTicket( + identity.externalUserId, + displayName, + ); + return Response.json({ + userType: "external", + identityTicket: ticket, + displayName, + expiresIn, + }); + } + return Response.json({ + userType: "internal", + userId: identity.userId ?? "", + }); + } catch (error) { + const status = error instanceof WecomClientError ? error.status : 500; + return Response.json( + { error: error instanceof Error ? error.message : "授权失败" }, + { status }, + ); + } +} + +export async function POST(request: Request) { + const response = await handlePost(request); + response.headers.set("Cache-Control", "private, no-store"); + return withPartnerCors(request, response); +} diff --git a/lib/wecom-oauth-service.ts b/lib/wecom-oauth-service.ts new file mode 100644 index 0000000..2b9136c --- /dev/null +++ b/lib/wecom-oauth-service.ts @@ -0,0 +1,160 @@ +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; + name: string; + iat: number; + exp: number; + jti: string; +}; + +export type WecomIdentity = { + externalUserId: string; + displayName: 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 } = {}, +): 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(); + if (!ext) 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))), + }; + 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" || + !payload.ext.trim() || + typeof payload.exp !== "number" + ) { + 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: payload.ext.trim(), + displayName: payload.name || "企微客户", + }; +} + +export function wecomClaimantPartnerId(externalUserId: string): string { + return `partner-ext-${hashText(`claimant:wecom:${externalUserId}`)}`; +} + +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 }; +} diff --git a/tests/wecom-oauth-service.test.mjs b/tests/wecom-oauth-service.test.mjs new file mode 100644 index 0000000..5574568 --- /dev/null +++ b/tests/wecom-oauth-service.test.mjs @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + getWecomOAuthSecret, + resolveWecomClaimant, + signWecomIdentityTicket, + verifyWecomIdentityTicket, + wecomClaimantPartnerId, + WECOM_IDENTITY_TTL_MS, +} from "../lib/wecom-oauth-service.ts"; + +const SECRET = "internal-token-test"; + +function fakePartnerDb(partners = []) { + return { + prepare(sql) { + return { + bind(...params) { + return { + async first() { + if (/WHERE wecom_external_user_id = \?/.test(sql)) { + const row = partners.find( + (item) => item.wecom_external_user_id === params[0], + ); + return row ? { id: row.id } : null; + } + return null; + }, + }; + }, + }; + }, + }; +} + +test("identity ticket sign → verify roundtrip", async () => { + const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", { + secret: SECRET, + }); + const identity = await verifyWecomIdentityTicket(ticket, { secret: SECRET }); + assert.deepEqual(identity, { externalUserId: "wmExt123", displayName: "张三" }); +}); + +test("identity ticket falls back to default display name when empty", async () => { + const { ticket } = await signWecomIdentityTicket("wmExt123", " ", { + secret: SECRET, + }); + const identity = await verifyWecomIdentityTicket(ticket, { secret: SECRET }); + assert.equal(identity.displayName, "企微客户"); +}); + +test("identity ticket default TTL is 30 minutes and expiresIn matches", async () => { + const now = Date.now(); + const { ticket, expiresIn } = await signWecomIdentityTicket("wmExt123", "张三", { + secret: SECRET, + nowMs: now, + }); + assert.equal(expiresIn, 30 * 60); + const identity = await verifyWecomIdentityTicket(ticket, { + secret: SECRET, + nowMs: now + WECOM_IDENTITY_TTL_MS - 1, + }); + assert.ok(identity, "still valid just before expiry"); + const expired = await verifyWecomIdentityTicket(ticket, { + secret: SECRET, + nowMs: now + WECOM_IDENTITY_TTL_MS + 61_000, + }); + assert.equal(expired, null, "expired beyond grace window"); +}); + +test("identity ticket passes within 60s grace window", async () => { + const now = Date.now(); + const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", { + secret: SECRET, + nowMs: now, + }); + const identity = await verifyWecomIdentityTicket(ticket, { + secret: SECRET, + nowMs: now + WECOM_IDENTITY_TTL_MS + 30_000, + }); + assert.deepEqual(identity?.externalUserId, "wmExt123"); +}); + +test("identity ticket verification fails on wrong secret", async () => { + const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", { + secret: SECRET, + }); + assert.equal( + await verifyWecomIdentityTicket(ticket, { secret: "other-secret" }), + null, + ); +}); + +test("identity ticket verification fails on tampered payload", async () => { + const { ticket } = await signWecomIdentityTicket("wmExt123", "张三", { + secret: SECRET, + }); + const [payload, signature] = ticket.split("."); + const decoded = JSON.parse( + Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString( + "utf8", + ), + ); + decoded.ext = "wmVictim"; + const forgedPayload = Buffer.from(JSON.stringify(decoded)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + assert.equal( + await verifyWecomIdentityTicket(`${forgedPayload}.${signature}`, { + secret: SECRET, + }), + null, + ); +}); + +test("identity ticket verification fails on malformed input", async () => { + for (const bad of ["", "no-dot", ".abc", "abc.", "a b.c d", "xxx.yyy"]) { + assert.equal( + await verifyWecomIdentityTicket(bad, { secret: SECRET }), + null, + `rejects ${JSON.stringify(bad)}`, + ); + } +}); + +test("getWecomOAuthSecret throws when ADMIN_INTERNAL_TOKEN missing", () => { + const previous = process.env.ADMIN_INTERNAL_TOKEN; + delete process.env.ADMIN_INTERNAL_TOKEN; + try { + assert.throws(() => getWecomOAuthSecret(), /ADMIN_INTERNAL_TOKEN/); + } finally { + if (previous !== undefined) process.env.ADMIN_INTERNAL_TOKEN = previous; + } +}); + +test("wecomClaimantPartnerId derives stable partner-ext id", () => { + const first = wecomClaimantPartnerId("wmExt123"); + assert.match(first, /^partner-ext-[a-z0-9]+$/); + assert.equal(first, wecomClaimantPartnerId("wmExt123")); + assert.notEqual(first, wecomClaimantPartnerId("wmExt456")); +}); + +test("resolveWecomClaimant reuses partner bound by operator", async () => { + const db = fakePartnerDb([ + { id: "partner-9", wecom_external_user_id: "wmExt123" }, + ]); + const resolved = await resolveWecomClaimant(db, "wmExt123"); + assert.deepEqual(resolved, { partnerId: "partner-9", matchedExisting: true }); +}); + +test("resolveWecomClaimant derives new id when unbound", async () => { + const db = fakePartnerDb(); + const resolved = await resolveWecomClaimant(db, "wmExt123"); + assert.equal(resolved.matchedExisting, false); + assert.equal(resolved.partnerId, wecomClaimantPartnerId("wmExt123")); +});