feat: initial public release
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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<string, unknown> }> = [];
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record<string, unknown> : 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<string, unknown> : {};
|
||||
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<void>((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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user