feat: 新增企微 OAuth 换码路由与 HMAC 身份票据服务
This commit is contained in:
65
app/api/wecom-oauth/route.ts
Normal file
65
app/api/wecom-oauth/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
160
lib/wecom-oauth-service.ts
Normal file
160
lib/wecom-oauth-service.ts
Normal file
@@ -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<Uint8Array> {
|
||||||
|
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<WecomIdentity | null> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
158
tests/wecom-oauth-service.test.mjs
Normal file
158
tests/wecom-oauth-service.test.mjs
Normal file
@@ -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"));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user