Files
knowledge-governance/services/dingtalk-login/server.mjs
T

361 lines
15 KiB
JavaScript

// DingTalk login sidecar for the knowledge governance platform.
// Drives a persistent headless Chromium for scan-code login, and uses the
// resulting capture (a-token + cookie headers, exactly what the scanner
// replays) to resolve and register knowledge-base spaces. Tokens never leave
// this container except inside the capture file.
import { chromium } from "playwright";
import fs from "node:fs";
import http from "node:http";
import crypto from "node:crypto";
const PORT = Number(process.env.PORT || 8091);
const PROFILE_DIR = process.env.PROFILE_DIR || "/profile";
const CAPTURE_FILE = process.env.CAPTURE_FILE || "/secrets/dingtalk_capture.json";
const STATE_FILE = process.env.DINGTALK_STATE_FILE || "";
const VERIFY_SPACE = process.env.VERIFY_SPACE || "9JOGO6332vlBnm4Q";
const QR_TTL_MS = Number(process.env.QR_TTL_MS || 5 * 60 * 1000);
const SCAN_URL = (space) => `https://alidocs.dingtalk.com/i/spaces/${space}/overview`;
const sessions = new Map();
const resolvedSpaces = new Map();
function redacted(s) {
return {
id: s.id, state: s.state, created_at: s.createdAt, expires_at: s.expiresAt,
...(s.error ? { error: s.error } : {}),
...(s.qrPng ? { qr_png: s.qrPng } : {}),
};
}
function scheduleCleanup(s) {
if (s.cleanupTimer) clearTimeout(s.cleanupTimer);
s.cleanupTimer = setTimeout(() => { void closeSession(s.id); }, Math.max(0, s.expiresAt - Date.now()));
s.cleanupTimer.unref?.();
}
async function closeSession(id) {
const s = sessions.get(id);
if (!s) return;
s.state = "closed";
sessions.delete(id);
if (s.cleanupTimer) clearTimeout(s.cleanupTimer);
if (s.regrabTimer) clearInterval(s.regrabTimer);
const context = s.context;
s.context = null;
if (context) await context.close().catch(() => {});
}
async function closeAllSessions() {
await Promise.all([...sessions.keys()].map((id) => closeSession(id)));
}
// ---------- capture ----------
function loadCapture() {
try {
const capture = JSON.parse(fs.readFileSync(CAPTURE_FILE, "utf8"));
const h = capture.headers || {};
return h["a-token"] ? h : null;
} catch { return null; }
}
function writeCapture(headers, rootDentryUuid) {
const payload = {
capturedAt: new Date().toISOString(),
method: "GET",
url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${rootDentryUuid}`,
headers,
};
// The scanner reads this file through a single-file bind mount: replacing
// the file (rename) would swap the inode and the reader would keep seeing
// the old session. Write in place instead.
const fd = fs.openSync(CAPTURE_FILE, "w", 0o600);
fs.writeFileSync(fd, JSON.stringify(payload, null, 2));
fs.fsyncSync(fd);
fs.closeSync(fd);
}
// ---------- box API via the captured browser headers ----------
async function boxApi(headers, path) {
const res = await fetch("https://alidocs.dingtalk.com" + path, {
headers: {
accept: "application/json",
"a-token": headers["a-token"],
"x-xsrf-token": headers["x-xsrf-token"] || "",
cookie: headers.cookie || "",
"user-agent": headers["user-agent"] || "Mozilla/5.0",
referer: "https://alidocs.dingtalk.com/",
},
});
let body = null;
try { body = await res.json(); } catch { /* non-json */ }
return { status: res.status, body };
}
function extractSpace(value) {
const text = String(value || "").trim();
const m = text.match(/\/spaces\/([A-Za-z0-9_-]+)/) || (/^[A-Za-z0-9_-]{8,64}$/.test(text) ? [null, text] : null);
return m ? m[1] : null;
}
async function resolveSpace(spaceId) {
const headers = loadCapture();
if (!headers) return { error: "login_required" };
let known = resolvedSpaces.get(spaceId);
if (!known && STATE_FILE) {
try {
const roots = loadState().roots || {};
const entry = Object.values(roots).find((root) => root.space_id === spaceId);
if (entry?.configured_root_id && entry?.live_space_name) {
known = { space_id: spaceId, name: entry.live_space_name, root: entry.configured_root_id };
}
} catch { /* a browser session can rediscover it */ }
}
// A space URL contains the space id, while dentry/list accepts only the root
// dentry UUID. Unknown spaces must be opened in Chromium once to discover
// that mapping; returning login_required reuses the same recoverable modal.
if (!known) return { error: "login_required" };
const { status, body } = await boxApi(headers,
`/box/api/v2/dentry/list?dentryUuid=${encodeURIComponent(known.root)}&withParentAncestors=true&orderType=SORT_KEY&sortType=desc&listDentrySource=2&pageSize=5`);
if (status === 401 || (body && body.code === "52600001")) return { error: "login_required" };
const sp = body && body.data && body.data.spaceProfile;
if (status !== 200 || !sp || String(sp.id) !== spaceId || !sp.rootDentryUuid || !sp.name) return { error: "space_not_found", http_status: status };
const resolved = { space_id: spaceId, name: String(sp.name).slice(0, 120), root: String(sp.rootDentryUuid) };
resolvedSpaces.set(spaceId, resolved);
return resolved;
}
function loadState() {
if (!STATE_FILE) throw new Error("state file not configured");
return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
}
async function addSpace(spaceId, label) {
const resolved = await resolveSpace(spaceId);
if (resolved.error) return resolved;
const state = loadState();
const roots = state.roots || (state.roots = {});
const existing = Object.entries(roots).find(([, r]) => r.configured_root_id === resolved.root);
const rootKey = /^[a-z][a-z0-9_-]{1,31}$/.test(label || "") ? label : existing ? existing[0] : "space-" + crypto.createHash("sha256").update(spaceId).digest("hex").slice(0, 10);
const clash = Object.entries(roots).find(([k, r]) => r.live_space_name === resolved.name && k !== rootKey);
if (clash) return { error: "name_conflict", message: `已登记同名空间「${resolved.name}」` };
if (!roots[rootKey]) {
roots[rootKey] = {
configured_root_id: resolved.root,
space_id: resolved.space_id,
// Seed the live name so the refresh job matches this root immediately;
// the scanner confirms/overwrites it from the same spaceProfile.
live_space_name: resolved.name,
queue: [resolved.root],
scanned_folders: [],
nodes: {},
errors: [],
};
} else {
roots[rootKey].live_space_name = resolved.name;
roots[rootKey].configured_root_id = resolved.root;
roots[rootKey].space_id = resolved.space_id;
}
// The inventory state is reached through a DIRECTORY bind mount: readers
// open it by path, so an atomic temp+rename swap is safe and avoids
// exposing half-written JSON to a refresh that starts mid-write.
const tmp = STATE_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
fs.renameSync(tmp, STATE_FILE);
return { key: rootKey, name: resolved.name, root: resolved.root, reused: Boolean(existing) };
}
// ---------- login sessions ----------
async function startSession(id, space) {
const s = sessions.get(id);
if (!s) return;
let context;
try {
context = await chromium.launchPersistentContext(PROFILE_DIR, {
headless: true, viewport: { width: 1280, height: 900 }, locale: "zh-CN",
deviceScaleFactor: 3, // crisp QR: canvas screenshots inherit DPR
});
} catch {
s.state = "failed"; s.error = "browser_launch_failed"; return;
}
s.context = context;
const page = context.pages()[0] ?? await context.newPage();
let capturedHeaders = null;
const finish = (headers, profile) => {
const resolved = {
space_id: String(profile.id),
name: String(profile.name).slice(0, 120),
root: String(profile.rootDentryUuid),
};
resolvedSpaces.set(space, resolved);
writeCapture(headers, resolved.root);
s.state = "success"; s.error = null;
s.expiresAt = Math.max(s.expiresAt, Date.now() + 30000);
scheduleCleanup(s);
};
const captureResponse = async (response) => {
if (capturedHeaders || response.status() !== 200) return;
const request = response.request();
let url;
try { url = new URL(response.url()); } catch { return; }
// Capture only a successful Box directory response. The first Alidocs
// request after OAuth may still carry a stale token; freezing that request
// caused the scan to remain failed even after a later valid token arrived.
if (url.hostname !== "alidocs.dingtalk.com" || url.pathname !== "/box/api/v2/dentry/list") return;
const h = await request.allHeaders().catch(() => request.headers());
if (!h["a-token"] || !h.cookie) return;
let body;
try { body = await response.json(); } catch { return; }
const profile = body?.data?.spaceProfile;
if (!profile || String(profile.id) !== space || !profile.name || !profile.rootDentryUuid) return;
capturedHeaders = { ...h };
delete capturedHeaders["content-length"];
delete capturedHeaders["accept-encoding"];
delete capturedHeaders["host"];
s.state = s.state === "qr" ? "scanned" : "reusing";
finish(capturedHeaders, profile);
};
// Login may redirect the original tab or open another page. Listening on
// the whole context keeps credential capture alive across both paths.
context.on("response", captureResponse);
// Read the QR directly from DingTalk's canvas. Do NOT screenshot the DOM:
// Playwright element screenshots on a high-DPR page can apply the crop in
// device pixels twice, producing exactly the clipped page thumbnail the
// user reported. canvas.toDataURL() returns the complete PNG pixels.
const grabQR = async () => {
try {
return await page.evaluate(() => {
let best = null, bestDark = 0;
for (const el of document.querySelectorAll('canvas')) {
const r = el.getBoundingClientRect();
if (r.width < 100 || r.width > 460 || Math.abs(r.width - r.height) > 24) continue;
try {
const pixels = el.getContext('2d').getImageData(0, 0, el.width, el.height).data;
let dark = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] > 0 && pixels[i] + pixels[i + 1] + pixels[i + 2] < 384) dark++;
}
if (dark > bestDark) { best = el; bestDark = dark; }
} catch { /* unreadable canvas: not suitable */ }
}
if (!best || bestDark < 500) return null;
// DingTalk's QR canvas has a transparent background and no guaranteed
// quiet zone. Composite it onto white with 20px padding so phone
// scanners and ordinary QR decoders get a complete code.
const padding = 20;
const out = document.createElement('canvas');
out.width = best.width + padding * 2;
out.height = best.height + padding * 2;
const ctx = out.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(best, padding, padding);
const url = out.toDataURL('image/png');
return url.startsWith('data:image/png;base64,') ? url.slice('data:image/png;base64,'.length) : null;
});
} catch { return null; }
};
await page.goto(SCAN_URL(space), { waitUntil: "domcontentloaded", timeout: 45000 }).catch(() => {});
if (page.url().includes("login.dingtalk.com") && !capturedHeaders) {
// The current DingTalk login UI leaves QR "自动登录" unchecked. Without
// it, the phone can accept the scan while the waiting web challenge stays
// on the login page. Select only the visible QR-mode checkbox.
await page.evaluate(() => {
const target = [...document.querySelectorAll('.module-qrcode-op-item')].find((el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && (el.textContent || '').trim() === '自动登录';
});
if (target?.querySelector('.dingtalk-login-icon-checkbox-undone')) target.click();
}).catch(() => {});
// The canvas is rendered after DOMContentLoaded. Keep the public state at
// starting until a real QR exists so the UI shows its generation loader,
// never an empty "please scan" panel.
const qrReadyDeadline = Date.now() + 20000;
while (!capturedHeaders && !s.qrPng && Date.now() < qrReadyDeadline) {
s.qrPng = await grabQR();
if (!s.qrPng) await new Promise((resolve) => setTimeout(resolve, 400));
}
if (capturedHeaders) return;
if (!s.qrPng) {
s.state = "failed";
s.error = "qr_generation_failed";
scheduleCleanup(s);
return;
}
s.state = "qr";
const deadline = Date.now() + QR_TTL_MS;
const regrab = setInterval(async () => {
// Keep the freshest frame while waiting for the scan.
if (capturedHeaders) return;
try { const png = await grabQR(); if (png) s.qrPng = png; } catch { /* ignore */ }
if (Date.now() > deadline) {
clearInterval(regrab);
if (!capturedHeaders) { s.state = "expired"; scheduleCleanup(s); }
}
}, 8000);
regrab.unref?.();
s.regrabTimer = regrab;
}
}
// ---------- HTTP ----------
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
const readJson = async () => {
let body = "";
for await (const chunk of req) { body += chunk; if (body.length > 8192) break; }
try { return JSON.parse(body || "{}"); } catch { return {}; }
};
try {
if (req.method === "GET" && url.pathname === "/healthz") return send(200, { status: "ok", state_configured: Boolean(STATE_FILE) });
if (req.method === "POST" && url.pathname === "/sessions") {
// One persistent profile must have exactly one owner. Close and await
// every prior context before creating the next challenge; this makes
// cancel/retry deterministic and prevents stale QR/polling state.
await closeAllSessions();
const body = await readJson();
const space = extractSpace(body.space) || VERIFY_SPACE;
const id = crypto.randomBytes(9).toString("hex");
sessions.set(id, { id, state: "starting", space, createdAt: Date.now(), expiresAt: Date.now() + QR_TTL_MS + 60000 });
startSession(id, space);
return send(201, redacted(sessions.get(id)));
}
const sessionMatch = url.pathname.match(/^\/sessions\/([\w-]+)$/);
if (sessionMatch) {
const s = sessions.get(sessionMatch[1]);
if (!s) return send(404, { error: "session_not_found" });
if (req.method === "GET") return send(200, redacted(s));
if (req.method === "DELETE") { await closeSession(sessionMatch[1]); return send(200, { status: "closed" }); }
}
if (req.method === "POST" && (url.pathname === "/spaces/resolve" || url.pathname === "/spaces/add")) {
const body = await readJson();
const space = extractSpace(body.url || body.space);
if (!space) return send(400, { error: "invalid_space_url" });
if (url.pathname.endsWith("resolve")) {
const r = await resolveSpace(space);
return r.error ? send(r.error === "login_required" ? 409 : 404, r) : send(200, r);
}
const r = await addSpace(space, body.label);
if (r.error) {
const code = r.error === "login_required" ? 409 : r.error === "name_conflict" ? 409 : 404;
return send(code, r);
}
return send(200, r);
}
} catch (e) {
return send(500, { error: "sidecar_internal" });
}
send(404, { error: "not_found" });
});
server.listen(PORT, "0.0.0.0", () => console.log(`dingtalk-login sidecar on :${PORT}`));