Files
koc-loop/lib/wecom-oauth-service.ts
ABAPPLO bd80b2d7d8 feat: 项目负责人+内部任务群+portal 内部身份领取+群主链接归属
- 迁移 0012:projects.owner_userid / tasks.task_chat_id / claims.owner_partner_id
- lib/task-group-service.ts:懒建 appchat 任务群(首个内部群主领取时创建,成员 diff 补员),播报/卡片文案
- wecom-client 新增 appchat create/update/get/send 封装
- OAuth ticket 增可选 uid(v:1 兼容),内部成员领取按 wecom_userid 解析群主身份
- 外部 OAuth 领取人 upsert 打 partner_type='group_owner'
- portal 链接带 ?owner= 穿透 OAuth 往返,claims 记录归属群主
- 后台任务卡「群主链接」面板:复制链接/推送内部群主/任务群状态
- 催办与超期升级同步播报进任务群
2026-09-03 11:30:26 +08:00

174 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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;
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<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;
}
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 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 };
}