Files
koc-loop/lib/wecom-oauth-service.ts
ABAPPLO 8f1ebf7697 feat: 负责人下钻视图+项目负责人编辑管理+企微授权自助绑定
- 任务/项目负责人 union scope:任务中心分组视图、工作台项目进度卡、收录验证列
- 项目负责人可管理自己项目:编辑/删除项目、编辑任务、分配负责人、推送、项目内建任务;堵死转移归属/越权新建/任务迁移
- 只读依赖放开给已登录:inspect_feishu、wecom_list_internal_contacts
- 企微 OAuth 自助绑定:/api/wecom-bind(state 签名校验+登录态一致性+归属冲突检查),未绑定 banner 增加入口
- 工作台项目进度卡与未绑定 banner 排版修复
- 修 user-auth 判空分支被 Turbopack dev 折叠删除导致匿名请求 500
2026-09-17 15:21:24 +08:00

239 lines
7.4 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 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 };
}
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 };
}