diff --git a/services/dingtalk-login/server.mjs b/services/dingtalk-login/server.mjs index 6932eb5..fde437c 100644 --- a/services/dingtalk-login/server.mjs +++ b/services/dingtalk-login/server.mjs @@ -27,20 +27,25 @@ function redacted(s) { } function scheduleCleanup(s) { - setTimeout(() => { closeSession(s.id); }, Math.max(0, s.expiresAt - Date.now())).unref?.(); + if (s.cleanupTimer) clearTimeout(s.cleanupTimer); + s.cleanupTimer = setTimeout(() => { void closeSession(s.id); }, Math.max(0, s.expiresAt - Date.now())); + s.cleanupTimer.unref?.(); } -function closeSession(id) { +async function closeSession(id) { const s = sessions.get(id); if (!s) return; s.state = "closed"; sessions.delete(id); - s.context?.close().catch(() => {}); + 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(() => {}); } -function evictOld() { - const live = [...sessions.values()].sort((a, b) => a.createdAt - b.createdAt); - while (live.length > 3) closeSession(live.shift().id); +async function closeAllSessions() { + await Promise.all([...sessions.keys()].map((id) => closeSession(id))); } // ---------- capture ---------- @@ -53,11 +58,11 @@ function loadCapture() { } catch { return null; } } -function writeCapture(headers) { +function writeCapture(headers, space) { const payload = { capturedAt: new Date().toISOString(), method: "GET", - url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${VERIFY_SPACE}`, + url: `https://alidocs.dingtalk.com/box/api/v2/dentry/list?dentryUuid=${space}`, headers, }; // The scanner reads this file through a single-file bind mount: replacing @@ -161,9 +166,12 @@ async function startSession(id, space) { let capturedHeaders = null; const finish = async (headers) => { - const probe = await boxApi(headers, `/box/api/v2/dentry/list?dentryUuid=${VERIFY_SPACE}&pageSize=1&orderType=SORT_KEY&sortType=desc&listDentrySource=2&withParentAncestors=true`); + // Validate the space the operator actually opened. Validating against an + // unrelated default space rejects perfectly good logins when that account + // has no access to the old/default knowledge base. + const probe = await boxApi(headers, `/box/api/v2/dentry/list?dentryUuid=${encodeURIComponent(space)}&pageSize=1&orderType=SORT_KEY&sortType=desc&listDentrySource=2&withParentAncestors=true`); if (probe.status === 200 && probe.body && probe.body.data && probe.body.data.spaceProfile) { - writeCapture(headers); + writeCapture(headers, space); s.state = "success"; s.error = null; } else { s.state = "failed"; s.error = "capture_validation_failed"; @@ -172,25 +180,30 @@ async function startSession(id, space) { scheduleCleanup(s); }; - const captureRequest = async (request) => { - const url = request.url(); - if (!url.includes("dingtalk.com")) return; - // Playwright request.headers() intentionally omits security-related - // headers on some Chromium builds, notably Cookie. allHeaders() is the - // complete API and is required for the capture consumed by the scanner. + 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 (capturedHeaders) return; if (!h["a-token"] || !h.cookie) return; + let body; + try { body = await response.json(); } catch { return; } + if (!body?.data?.spaceProfile) return; capturedHeaders = { ...h }; delete capturedHeaders["content-length"]; delete capturedHeaders["accept-encoding"]; delete capturedHeaders["host"]; s.state = s.state === "qr" ? "scanned" : "reusing"; - finish(capturedHeaders); + void finish(capturedHeaders); }; // Login may redirect the original tab or open another page. Listening on // the whole context keeps credential capture alive across both paths. - context.on("request", captureRequest); + 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 @@ -233,6 +246,16 @@ async function startSession(id, space) { 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. @@ -260,6 +283,7 @@ async function startSession(id, space) { } }, 8000); regrab.unref?.(); + s.regrabTimer = regrab; } } @@ -276,7 +300,10 @@ const server = http.createServer(async (req, res) => { 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") { - evictOld(); + // 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"); @@ -289,7 +316,7 @@ const server = http.createServer(async (req, res) => { 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") { closeSession(sessionMatch[1]); return send(200, { status: "closed" }); } + 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();