2026-08-31 17:11:20 +08:00
|
|
|
|
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;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
uid?: string;
|
2026-08-31 17:11:20 +08:00
|
|
|
|
name: string;
|
|
|
|
|
|
iat: number;
|
|
|
|
|
|
exp: number;
|
|
|
|
|
|
jti: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export type WecomIdentity = {
|
|
|
|
|
|
externalUserId: string;
|
|
|
|
|
|
displayName: string;
|
2026-09-03 11:30:26 +08:00
|
|
|
|
userId?: string;
|
2026-08-31 17:11:20 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-03 11:30:26 +08:00
|
|
|
|
options: {
|
|
|
|
|
|
secret?: string;
|
|
|
|
|
|
nowMs?: number;
|
|
|
|
|
|
ttlMs?: number;
|
|
|
|
|
|
userId?: string;
|
|
|
|
|
|
} = {},
|
2026-08-31 17:11:20 +08:00
|
|
|
|
): 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();
|
2026-09-03 11:30:26 +08:00
|
|
|
|
const uid = String(options.userId ?? "").trim().slice(0, 64);
|
|
|
|
|
|
if (!ext && !uid) throw new Error("缺少企微身份 ID(外部联系人或内部成员)");
|
2026-08-31 17:11:20 +08:00
|
|
|
|
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))),
|
|
|
|
|
|
};
|
2026-09-03 11:30:26 +08:00
|
|
|
|
if (uid) payload.uid = uid;
|
2026-08-31 17:11:20 +08:00
|
|
|
|
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" ||
|
|
|
|
|
|
typeof payload.exp !== "number"
|
|
|
|
|
|
) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-09-03 11:30:26 +08:00
|
|
|
|
const extId = payload.ext.trim();
|
|
|
|
|
|
const uid =
|
|
|
|
|
|
typeof payload.uid === "string" ? payload.uid.trim().slice(0, 64) : "";
|
|
|
|
|
|
if (!extId && !uid) return null;
|
2026-08-31 17:11:20 +08:00
|
|
|
|
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 {
|
2026-09-03 11:30:26 +08:00
|
|
|
|
externalUserId: extId,
|
2026-08-31 17:11:20 +08:00
|
|
|
|
displayName: payload.name || "企微客户",
|
2026-09-03 11:30:26 +08:00
|
|
|
|
...(uid ? { userId: uid } : {}),
|
2026-08-31 17:11:20 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function wecomClaimantPartnerId(externalUserId: string): string {
|
|
|
|
|
|
return `partner-ext-${hashText(`claimant:wecom:${externalUserId}`)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 15:21:24 +08:00
|
|
|
|
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<string> {
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 17:11:20 +08:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|