import assert from "node:assert/strict"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import type { AppConfig } from "../src/config.js"; import { DingTalkMcpOAuthProvider } from "../src/oauth.js"; function config(databasePath: string): AppConfig { return { clientId: "ding-app-key", clientSecret: "ding-app-secret", corpId: "ding-corp", apiBaseUrl: "https://api.example.test", oapiBaseUrl: "https://oapi.example.test", publicBaseUrl: "https://approval.example.test", bindHost: "127.0.0.1", port: 3000, requestTimeoutMs: 1000, officialLookbackDays: 120, officialScanConcurrency: 2, officialClientOnlyProcessCodes: [], batchLimit: 20, approvalRefSecret: "ref-secret", remoteAuth: { issuerUrl: "https://approval.example.test", databasePath, sessionSecret: "session-secret", accessTokenTtlSeconds: 900, refreshTokenTtlSeconds: 3600, }, }; } function jsonResponse(value: unknown, status = 200): Response { return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); } test("OAuth provider performs DCR, DingTalk login, PKCE exchange and refresh rotation", async () => { const root = mkdtempSync(join(tmpdir(), "ding-oauth-")); const database = join(root, "auth.sqlite"); const requests: Array<{ url: string; body?: Record }> = []; const fetchFn: typeof fetch = async (input, init) => { const url = String(input); const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record : undefined; requests.push({ url, body }); if (url.endsWith("/v1.0/oauth2/userAccessToken") && body?.grantType === "authorization_code") { return jsonResponse({ accessToken: "ding-user-access", refreshToken: "ding-user-refresh", expireIn: 7200, corpId: "ding-corp" }); } if (url.endsWith("/v1.0/contact/users/me")) { return jsonResponse({ userId: "user-1", nick: "测试用户", corpId: "ding-corp" }); } throw new Error(`unexpected request ${url}`); }; const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn); try { const client = await provider.clientsStore.registerClient!({ redirect_uris: ["http://127.0.0.1:49152/callback"], token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], client_name: "test-agent", }); let upstream = ""; await provider.authorize(client, { redirectUri: "http://127.0.0.1:55001/callback", codeChallenge: "pkce-challenge", scopes: ["mcp:tools"], state: "agent-state", resource: new URL("https://approval.example.test/mcp"), }, { redirect: (_status: number, value: string) => { upstream = value; } } as never); const loginState = new URL(upstream).searchParams.get("login_state"); assert.ok(loginState); assert.deepEqual(provider.consentDetails(loginState!), { clientName: "test-agent", scopes: ["mcp:tools"], redirectOrigin: "http://127.0.0.1:55001", }); upstream = provider.approveConsent(loginState!); assert.equal(new URL(upstream).searchParams.get("state"), loginState); const callback = await provider.completeDingTalkLogin("ding-code", loginState!); const returned = new URL(callback); const authorizationCode = returned.searchParams.get("code"); assert.ok(authorizationCode); assert.equal(returned.searchParams.get("state"), "agent-state"); assert.equal(await provider.challengeForAuthorizationCode(client, authorizationCode!), "pkce-challenge"); const tokens = await provider.exchangeAuthorizationCode( client, authorizationCode!, "verifier", returned.origin + returned.pathname, new URL("https://approval.example.test/mcp"), ); const auth = await provider.verifyAccessToken(tokens.access_token); assert.equal(auth.extra?.userId, "user-1"); assert.equal(await provider.getDingTalkUserAccessToken("user-1"), "ding-user-access"); await assert.rejects(provider.exchangeAuthorizationCode(client, authorizationCode!), /无效/); const rotated = await provider.exchangeRefreshToken(client, tokens.refresh_token!); await assert.rejects(provider.exchangeRefreshToken(client, tokens.refresh_token!), /无效/); assert.equal((await provider.verifyAccessToken(rotated.access_token)).extra?.userId, "user-1"); await provider.revokeToken(client, { token: rotated.access_token }); await assert.rejects(provider.verifyAccessToken(rotated.access_token), /无效/); const file = readFileSync(database); assert.equal(file.includes(Buffer.from(tokens.access_token)), false); assert.equal(file.includes(Buffer.from(tokens.refresh_token!)), false); assert.equal(requests[0]!.body?.clientSecret, "ding-app-secret"); } finally { provider.close(); } }); test("OAuth provider requires explicit client consent before DingTalk callback completion", async () => { const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-consent-")), "auth.sqlite"); let upstreamCalls = 0; const provider = new DingTalkMcpOAuthProvider(config(database), async () => { upstreamCalls += 1; throw new Error("consent bypass must not reach DingTalk"); }); try { const client = await provider.clientsStore.registerClient!({ redirect_uris: ["https://agent.example/callback"], token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"], client_name: "untrusted-test-client", }); let consentUrl = ""; await provider.authorize(client, { redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"], }, { redirect: (_status: number, value: string) => { consentUrl = value; } } as never); const loginState = new URL(consentUrl).searchParams.get("login_state"); assert.ok(loginState); await assert.rejects(provider.completeDingTalkLogin("code", loginState!), /尚未确认/); assert.equal(upstreamCalls, 0); } finally { provider.close(); } }); test("OAuth provider refreshes a DingTalk identity once for concurrent callers", async () => { const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-refresh-")), "auth.sqlite"); let refreshCalls = 0; const provider = new DingTalkMcpOAuthProvider(config(database), async (input, init) => { const url = String(input); const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record : {}; if (url.endsWith("/v1.0/oauth2/userAccessToken") && body.grantType === "authorization_code") { return jsonResponse({ accessToken: "initial-access", refreshToken: "refresh-1", expireIn: 1, corpId: "ding-corp" }); } if (url.endsWith("/v1.0/oauth2/userAccessToken") && body.grantType === "refresh_token") { refreshCalls += 1; await new Promise((resolve) => setTimeout(resolve, 20)); return jsonResponse({ accessToken: "fresh-access", refreshToken: "refresh-2", expireIn: 7200, corpId: "ding-corp" }); } if (url.endsWith("/v1.0/contact/users/me")) return jsonResponse({ userId: "user-1", nick: "测试用户", corpId: "ding-corp" }); throw new Error(`unexpected request ${url}`); }); try { const client = await provider.clientsStore.registerClient!({ redirect_uris: ["https://agent.example/callback"], token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], client_name: "agent", }); let consentUrl = ""; await provider.authorize(client, { redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"], }, { redirect: (_status: number, value: string) => { consentUrl = value; } } as never); const loginState = new URL(consentUrl).searchParams.get("login_state")!; provider.approveConsent(loginState); await provider.completeDingTalkLogin("code", loginState); const tokens = await Promise.all([ provider.getDingTalkUserAccessToken("user-1"), provider.getDingTalkUserAccessToken("user-1"), provider.getDingTalkUserAccessToken("user-1"), ]); assert.deepEqual(tokens, ["fresh-access", "fresh-access", "fresh-access"]); assert.equal(refreshCalls, 1); } finally { provider.close(); } }); test("OAuth provider rejects unsafe redirects and users from another corp", async () => { const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-corp-")), "auth.sqlite"); const fetchFn: typeof fetch = async (input) => String(input).endsWith("/userAccessToken") ? jsonResponse({ accessToken: "foreign-token", refreshToken: "refresh", expireIn: 7200, corpId: "other-corp" }) : jsonResponse({}); const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn); try { await assert.rejects(async () => provider.clientsStore.registerClient!({ redirect_uris: ["http://attacker.example/callback"], token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"], }), /HTTPS/); const client = await provider.clientsStore.registerClient!({ redirect_uris: ["https://agent.example/callback"], token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"], }); let upstream = ""; await provider.authorize(client, { redirectUri: "https://agent.example/callback", codeChallenge: "challenge", scopes: ["mcp:tools"], }, { redirect: (_status: number, value: string) => { upstream = value; } } as never); const loginState = new URL(upstream).searchParams.get("login_state"); assert.ok(loginState); provider.approveConsent(loginState!); await assert.rejects( provider.completeDingTalkLogin("code", loginState!), /不属于允许的企业/, ); } finally { provider.close(); } }); test("device compatibility flow returns a link, polls, and issues a user-bound token", async () => { const database = join(mkdtempSync(join(tmpdir(), "ding-oauth-device-")), "auth.sqlite"); const fetchFn: typeof fetch = async (input) => String(input).endsWith("/userAccessToken") ? jsonResponse({ accessToken: "device-user-token", refreshToken: "device-refresh", expireIn: 7200, corpId: "ding-corp" }) : jsonResponse({ userId: "device-user", nick: "设备用户", corpId: "ding-corp" }); const provider = new DingTalkMcpOAuthProvider(config(database), fetchFn); try { const client = await provider.clientsStore.registerClient!({ redirect_uris: ["https://localhost.invalid/device"], token_endpoint_auth_method: "none", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], }); const challenge = provider.startDeviceAuthorization(client.client_id, ["mcp:tools"], "https://approval.example.test/mcp"); assert.match(challenge.user_code, /^[A-Z2-9]{4}-[A-Z2-9]{4}$/); assert.equal(provider.exchangeDeviceCode(client.client_id, challenge.device_code), "authorization_pending"); const upstream = provider.beginDeviceLogin(challenge.user_code.toLowerCase()); const state = new URL(upstream).searchParams.get("login_state"); assert.ok(state); provider.approveConsent(state!); assert.equal(await provider.completeDingTalkLogin("device-code", state!), "https://approval.example.test/oauth/device/success"); const tokens = provider.exchangeDeviceCode(client.client_id, challenge.device_code); assert.notEqual(typeof tokens, "string"); if (typeof tokens !== "string") { assert.equal((await provider.verifyAccessToken(tokens.access_token)).extra?.userId, "device-user"); } assert.throws(() => provider.exchangeDeviceCode(client.client_id, challenge.device_code), /无效/); } finally { provider.close(); } });