llng-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +77 -0
- package/.prettierrc +7 -0
- package/LICENSE +661 -0
- package/README.md +502 -0
- package/dist/__tests__/api-transport.test.d.ts +1 -0
- package/dist/__tests__/api-transport.test.js +577 -0
- package/dist/__tests__/api-transport.test.js.map +1 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +472 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/__tests__/integration/api-mode.test.d.ts +1 -0
- package/dist/__tests__/integration/api-mode.test.js +199 -0
- package/dist/__tests__/integration/api-mode.test.js.map +1 -0
- package/dist/__tests__/integration/oidc-rp.test.d.ts +1 -0
- package/dist/__tests__/integration/oidc-rp.test.js +120 -0
- package/dist/__tests__/integration/oidc-rp.test.js.map +1 -0
- package/dist/__tests__/integration/ssh-mode.test.d.ts +1 -0
- package/dist/__tests__/integration/ssh-mode.test.js +101 -0
- package/dist/__tests__/integration/ssh-mode.test.js.map +1 -0
- package/dist/__tests__/k8s-transport.test.d.ts +1 -0
- package/dist/__tests__/k8s-transport.test.js +254 -0
- package/dist/__tests__/k8s-transport.test.js.map +1 -0
- package/dist/__tests__/oidc-tools.test.d.ts +1 -0
- package/dist/__tests__/oidc-tools.test.js +457 -0
- package/dist/__tests__/oidc-tools.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +1 -0
- package/dist/__tests__/registry.test.js +96 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/ssh-transport.test.d.ts +1 -0
- package/dist/__tests__/ssh-transport.test.js +618 -0
- package/dist/__tests__/ssh-transport.test.js.map +1 -0
- package/dist/__tests__/tools.test.d.ts +1 -0
- package/dist/__tests__/tools.test.js +525 -0
- package/dist/__tests__/tools.test.js.map +1 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +506 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/documentation.d.ts +5 -0
- package/dist/resources/documentation.js +56 -0
- package/dist/resources/documentation.js.map +1 -0
- package/dist/tools/cli-utilities.d.ts +3 -0
- package/dist/tools/cli-utilities.js +187 -0
- package/dist/tools/cli-utilities.js.map +1 -0
- package/dist/tools/config.d.ts +6 -0
- package/dist/tools/config.js +326 -0
- package/dist/tools/config.js.map +1 -0
- package/dist/tools/consents.d.ts +3 -0
- package/dist/tools/consents.js +39 -0
- package/dist/tools/consents.js.map +1 -0
- package/dist/tools/instances.d.ts +3 -0
- package/dist/tools/instances.js +14 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/oidc-rp.d.ts +6 -0
- package/dist/tools/oidc-rp.js +246 -0
- package/dist/tools/oidc-rp.js.map +1 -0
- package/dist/tools/oidc.d.ts +3 -0
- package/dist/tools/oidc.js +343 -0
- package/dist/tools/oidc.js.map +1 -0
- package/dist/tools/secondfactors.d.ts +3 -0
- package/dist/tools/secondfactors.js +62 -0
- package/dist/tools/secondfactors.js.map +1 -0
- package/dist/tools/sessions.d.ts +6 -0
- package/dist/tools/sessions.js +300 -0
- package/dist/tools/sessions.js.map +1 -0
- package/dist/transport/api.d.ts +35 -0
- package/dist/transport/api.js +327 -0
- package/dist/transport/api.js.map +1 -0
- package/dist/transport/interface.d.ts +50 -0
- package/dist/transport/interface.js +2 -0
- package/dist/transport/interface.js.map +1 -0
- package/dist/transport/k8s.d.ts +41 -0
- package/dist/transport/k8s.js +303 -0
- package/dist/transport/k8s.js.map +1 -0
- package/dist/transport/registry.d.ts +20 -0
- package/dist/transport/registry.js +91 -0
- package/dist/transport/registry.js.map +1 -0
- package/dist/transport/ssh.d.ts +37 -0
- package/dist/transport/ssh.js +353 -0
- package/dist/transport/ssh.js.map +1 -0
- package/docker-compose.test.yml +16 -0
- package/eslint.config.js +21 -0
- package/package.json +38 -0
- package/src/__tests__/api-transport.test.ts +746 -0
- package/src/__tests__/config.test.ts +587 -0
- package/src/__tests__/integration/api-mode.test.ts +229 -0
- package/src/__tests__/integration/oidc-rp.test.ts +138 -0
- package/src/__tests__/integration/ssh-mode.test.ts +113 -0
- package/src/__tests__/k8s-transport.test.ts +342 -0
- package/src/__tests__/oidc-tools.test.ts +554 -0
- package/src/__tests__/registry.test.ts +110 -0
- package/src/__tests__/ssh-transport.test.ts +805 -0
- package/src/__tests__/tools.test.ts +735 -0
- package/src/config.ts +605 -0
- package/src/index.ts +48 -0
- package/src/resources/documentation.ts +65 -0
- package/src/tools/cli-utilities.ts +207 -0
- package/src/tools/config.ts +382 -0
- package/src/tools/consents.ts +50 -0
- package/src/tools/instances.ts +21 -0
- package/src/tools/oidc-rp.ts +299 -0
- package/src/tools/oidc.ts +434 -0
- package/src/tools/secondfactors.ts +78 -0
- package/src/tools/sessions.ts +342 -0
- package/src/transport/api.ts +429 -0
- package/src/transport/interface.ts +58 -0
- package/src/transport/k8s.ts +367 -0
- package/src/transport/registry.ts +105 -0
- package/src/transport/ssh.ts +430 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
- package/vitest.integration.config.ts +9 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { registerOidcTools } from "../tools/oidc.js";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { OidcConfig } from "../config.js";
|
|
6
|
+
import { TransportRegistry } from "../transport/registry.js";
|
|
7
|
+
|
|
8
|
+
function createMockOidcRegistry(config: OidcConfig | undefined): TransportRegistry {
|
|
9
|
+
return {
|
|
10
|
+
getTransport: vi.fn(),
|
|
11
|
+
getOidcConfig: vi.fn().mockReturnValue(config),
|
|
12
|
+
listInstances: vi.fn().mockReturnValue([]),
|
|
13
|
+
} as unknown as TransportRegistry;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("OIDC Tools", () => {
|
|
17
|
+
let originalFetch: typeof global.fetch;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
originalFetch = global.fetch;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
global.fetch = originalFetch;
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("PKCE generation", () => {
|
|
29
|
+
it("should generate code_verifier with 43+ chars in base64url", async () => {
|
|
30
|
+
const config: OidcConfig = {
|
|
31
|
+
issuer: "https://auth.example.com",
|
|
32
|
+
clientId: "test-client",
|
|
33
|
+
redirectUri: "http://localhost:3000/callback",
|
|
34
|
+
scope: "openid profile",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const metadata = {
|
|
38
|
+
authorization_endpoint: "https://auth.example.com/authorize",
|
|
39
|
+
token_endpoint: "https://auth.example.com/token",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
43
|
+
ok: true,
|
|
44
|
+
status: 200,
|
|
45
|
+
json: () => Promise.resolve(metadata),
|
|
46
|
+
});
|
|
47
|
+
global.fetch = mockFetch as any;
|
|
48
|
+
|
|
49
|
+
const toolResults: any[] = [];
|
|
50
|
+
const mockServer = {
|
|
51
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
52
|
+
if (name === "llng_oidc_authorize") {
|
|
53
|
+
toolResults.push({ name, handler });
|
|
54
|
+
}
|
|
55
|
+
}),
|
|
56
|
+
} as unknown as McpServer;
|
|
57
|
+
|
|
58
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
59
|
+
|
|
60
|
+
const authorizeHandler = toolResults.find((t) => t.name === "llng_oidc_authorize")?.handler;
|
|
61
|
+
const result = await authorizeHandler({});
|
|
62
|
+
|
|
63
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
64
|
+
expect(parsed.code_verifier).toMatch(/^[A-Za-z0-9_-]{43,}$/);
|
|
65
|
+
expect(parsed.code_verifier.length).toBeGreaterThanOrEqual(43);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should generate code_challenge as SHA256 of verifier in base64url", async () => {
|
|
69
|
+
const config: OidcConfig = {
|
|
70
|
+
issuer: "https://auth.example.com",
|
|
71
|
+
clientId: "test-client",
|
|
72
|
+
redirectUri: "http://localhost:3000/callback",
|
|
73
|
+
scope: "openid profile",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const metadata = {
|
|
77
|
+
authorization_endpoint: "https://auth.example.com/authorize",
|
|
78
|
+
token_endpoint: "https://auth.example.com/token",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
json: () => Promise.resolve(metadata),
|
|
85
|
+
});
|
|
86
|
+
global.fetch = mockFetch as any;
|
|
87
|
+
|
|
88
|
+
const toolResults: any[] = [];
|
|
89
|
+
const mockServer = {
|
|
90
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
91
|
+
if (name === "llng_oidc_authorize") {
|
|
92
|
+
toolResults.push({ name, handler });
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
95
|
+
} as unknown as McpServer;
|
|
96
|
+
|
|
97
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
98
|
+
|
|
99
|
+
const authorizeHandler = toolResults.find((t) => t.name === "llng_oidc_authorize")?.handler;
|
|
100
|
+
const result = await authorizeHandler({});
|
|
101
|
+
|
|
102
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
103
|
+
const verifier = parsed.code_verifier;
|
|
104
|
+
const challenge = parsed.url.match(/code_challenge=([^&]+)/)?.[1];
|
|
105
|
+
|
|
106
|
+
// Compute expected challenge
|
|
107
|
+
const expectedChallenge = createHash("sha256").update(verifier).digest("base64url");
|
|
108
|
+
|
|
109
|
+
expect(decodeURIComponent(challenge!)).toBe(expectedChallenge);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("JWT decode (whoami)", () => {
|
|
114
|
+
function makeJwt(payload: object): string {
|
|
115
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString(
|
|
116
|
+
"base64url",
|
|
117
|
+
);
|
|
118
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
119
|
+
const sig = "fakesignature";
|
|
120
|
+
return `${header}.${body}.${sig}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it("should decode a valid JWT payload", async () => {
|
|
124
|
+
const config: OidcConfig = {
|
|
125
|
+
issuer: "https://auth.example.com",
|
|
126
|
+
clientId: "test-client",
|
|
127
|
+
redirectUri: "http://localhost:3000/callback",
|
|
128
|
+
scope: "openid profile",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const toolResults: any[] = [];
|
|
132
|
+
const mockServer = {
|
|
133
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
134
|
+
if (name === "llng_oidc_whoami") {
|
|
135
|
+
toolResults.push({ name, handler });
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
} as unknown as McpServer;
|
|
139
|
+
|
|
140
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
141
|
+
|
|
142
|
+
const whoamiHandler = toolResults.find((t) => t.name === "llng_oidc_whoami")?.handler;
|
|
143
|
+
|
|
144
|
+
const payload = {
|
|
145
|
+
sub: "user123",
|
|
146
|
+
name: "John Doe",
|
|
147
|
+
email: "john@example.com",
|
|
148
|
+
iat: 1234567890,
|
|
149
|
+
exp: 1234571490,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const jwt = makeJwt(payload);
|
|
153
|
+
const result = await whoamiHandler({ id_token: jwt });
|
|
154
|
+
|
|
155
|
+
expect(result.isError).toBeUndefined();
|
|
156
|
+
const decoded = JSON.parse(result.content[0].text);
|
|
157
|
+
expect(decoded._warning).toContain("UNVERIFIED");
|
|
158
|
+
expect(decoded.sub).toBe(payload.sub);
|
|
159
|
+
expect(decoded.name).toBe(payload.name);
|
|
160
|
+
expect(decoded.email).toBe(payload.email);
|
|
161
|
+
expect(decoded.iat).toBe(payload.iat);
|
|
162
|
+
expect(decoded.exp).toBe(payload.exp);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("should return error for invalid JWT format", async () => {
|
|
166
|
+
const config: OidcConfig = {
|
|
167
|
+
issuer: "https://auth.example.com",
|
|
168
|
+
clientId: "test-client",
|
|
169
|
+
redirectUri: "http://localhost:3000/callback",
|
|
170
|
+
scope: "openid profile",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const toolResults: any[] = [];
|
|
174
|
+
const mockServer = {
|
|
175
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
176
|
+
if (name === "llng_oidc_whoami") {
|
|
177
|
+
toolResults.push({ name, handler });
|
|
178
|
+
}
|
|
179
|
+
}),
|
|
180
|
+
} as unknown as McpServer;
|
|
181
|
+
|
|
182
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
183
|
+
|
|
184
|
+
const whoamiHandler = toolResults.find((t) => t.name === "llng_oidc_whoami")?.handler;
|
|
185
|
+
|
|
186
|
+
const result = await whoamiHandler({ id_token: "not.a.valid.jwt.token" });
|
|
187
|
+
|
|
188
|
+
expect(result.isError).toBe(true);
|
|
189
|
+
expect(result.content[0].text).toContain("Error: Invalid JWT format");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should handle JWT with special characters in payload", async () => {
|
|
193
|
+
const config: OidcConfig = {
|
|
194
|
+
issuer: "https://auth.example.com",
|
|
195
|
+
clientId: "test-client",
|
|
196
|
+
redirectUri: "http://localhost:3000/callback",
|
|
197
|
+
scope: "openid profile",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const toolResults: any[] = [];
|
|
201
|
+
const mockServer = {
|
|
202
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
203
|
+
if (name === "llng_oidc_whoami") {
|
|
204
|
+
toolResults.push({ name, handler });
|
|
205
|
+
}
|
|
206
|
+
}),
|
|
207
|
+
} as unknown as McpServer;
|
|
208
|
+
|
|
209
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
210
|
+
|
|
211
|
+
const whoamiHandler = toolResults.find((t) => t.name === "llng_oidc_whoami")?.handler;
|
|
212
|
+
|
|
213
|
+
const payload = {
|
|
214
|
+
sub: "user@example.com",
|
|
215
|
+
name: "José García",
|
|
216
|
+
groups: ["admin", "users"],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const jwt = makeJwt(payload);
|
|
220
|
+
const result = await whoamiHandler({ id_token: jwt });
|
|
221
|
+
|
|
222
|
+
const decoded = JSON.parse(result.content[0].text);
|
|
223
|
+
expect(decoded.name).toBe("José García");
|
|
224
|
+
expect(decoded.groups).toEqual(["admin", "users"]);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("OIDC not configured", () => {
|
|
229
|
+
it("should return error when config is undefined", async () => {
|
|
230
|
+
const toolResults: any[] = [];
|
|
231
|
+
const mockServer = {
|
|
232
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
233
|
+
toolResults.push({ name, handler });
|
|
234
|
+
}),
|
|
235
|
+
} as unknown as McpServer;
|
|
236
|
+
|
|
237
|
+
registerOidcTools(mockServer, createMockOidcRegistry(undefined));
|
|
238
|
+
|
|
239
|
+
// Test each tool
|
|
240
|
+
for (const tool of toolResults) {
|
|
241
|
+
const result = await tool.handler({});
|
|
242
|
+
expect(result.isError).toBe(true);
|
|
243
|
+
expect(result.content[0].text).toContain("Error: OIDC not configured");
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("Discovery metadata caching", () => {
|
|
249
|
+
it("should fetch discovery metadata successfully", async () => {
|
|
250
|
+
// Note: Discovery metadata caching is an implementation detail that uses
|
|
251
|
+
// module-scoped state. This test verifies that the discovery endpoint
|
|
252
|
+
// can be fetched successfully. Cache behavior is implicitly tested by
|
|
253
|
+
// the fact that subsequent tool calls don't fail.
|
|
254
|
+
|
|
255
|
+
const config: OidcConfig = {
|
|
256
|
+
issuer: "https://cache-test.example.com",
|
|
257
|
+
clientId: "test-client",
|
|
258
|
+
redirectUri: "http://localhost:3000/callback",
|
|
259
|
+
scope: "openid profile",
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const metadata = {
|
|
263
|
+
issuer: "https://cache-test.example.com",
|
|
264
|
+
authorization_endpoint: "https://cache-test.example.com/authorize",
|
|
265
|
+
token_endpoint: "https://cache-test.example.com/token",
|
|
266
|
+
userinfo_endpoint: "https://cache-test.example.com/userinfo",
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
270
|
+
ok: true,
|
|
271
|
+
status: 200,
|
|
272
|
+
json: () => Promise.resolve(metadata),
|
|
273
|
+
});
|
|
274
|
+
global.fetch = mockFetch as any;
|
|
275
|
+
|
|
276
|
+
const toolResults: any[] = [];
|
|
277
|
+
const mockServer = {
|
|
278
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
279
|
+
toolResults.push({ name, handler });
|
|
280
|
+
}),
|
|
281
|
+
} as unknown as McpServer;
|
|
282
|
+
|
|
283
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
284
|
+
|
|
285
|
+
const metadataHandler = toolResults.find((t) => t.name === "llng_oidc_metadata")?.handler;
|
|
286
|
+
expect(metadataHandler).toBeDefined();
|
|
287
|
+
|
|
288
|
+
const result = await metadataHandler!({});
|
|
289
|
+
|
|
290
|
+
expect(result.isError).toBeUndefined();
|
|
291
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
292
|
+
expect(parsed.issuer).toBe("https://cache-test.example.com");
|
|
293
|
+
expect(parsed.authorization_endpoint).toBe("https://cache-test.example.com/authorize");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("Tool registration", () => {
|
|
298
|
+
it("should register 8 OIDC tools", () => {
|
|
299
|
+
const config: OidcConfig = {
|
|
300
|
+
issuer: "https://auth.example.com",
|
|
301
|
+
clientId: "test-client",
|
|
302
|
+
redirectUri: "http://localhost:3000/callback",
|
|
303
|
+
scope: "openid profile",
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const toolNames: string[] = [];
|
|
307
|
+
const mockServer = {
|
|
308
|
+
tool: vi.fn((name: string) => {
|
|
309
|
+
toolNames.push(name);
|
|
310
|
+
}),
|
|
311
|
+
} as unknown as McpServer;
|
|
312
|
+
|
|
313
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
314
|
+
|
|
315
|
+
expect(toolNames).toEqual([
|
|
316
|
+
"llng_oidc_metadata",
|
|
317
|
+
"llng_oidc_authorize",
|
|
318
|
+
"llng_oidc_tokens",
|
|
319
|
+
"llng_oidc_userinfo",
|
|
320
|
+
"llng_oidc_introspect",
|
|
321
|
+
"llng_oidc_refresh",
|
|
322
|
+
"llng_oidc_whoami",
|
|
323
|
+
"llng_oidc_check_auth",
|
|
324
|
+
]);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("Authorization URL generation", () => {
|
|
329
|
+
it("should include all required PKCE parameters", async () => {
|
|
330
|
+
const config: OidcConfig = {
|
|
331
|
+
issuer: "https://auth.example.com",
|
|
332
|
+
clientId: "test-client",
|
|
333
|
+
redirectUri: "http://localhost:3000/callback",
|
|
334
|
+
scope: "openid profile email",
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const metadata = {
|
|
338
|
+
authorization_endpoint: "https://auth.example.com/authorize",
|
|
339
|
+
token_endpoint: "https://auth.example.com/token",
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
343
|
+
ok: true,
|
|
344
|
+
status: 200,
|
|
345
|
+
json: () => Promise.resolve(metadata),
|
|
346
|
+
});
|
|
347
|
+
global.fetch = mockFetch as any;
|
|
348
|
+
|
|
349
|
+
const toolResults: any[] = [];
|
|
350
|
+
const mockServer = {
|
|
351
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
352
|
+
if (name === "llng_oidc_authorize") {
|
|
353
|
+
toolResults.push({ name, handler });
|
|
354
|
+
}
|
|
355
|
+
}),
|
|
356
|
+
} as unknown as McpServer;
|
|
357
|
+
|
|
358
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
359
|
+
|
|
360
|
+
const authorizeHandler = toolResults.find((t) => t.name === "llng_oidc_authorize")?.handler;
|
|
361
|
+
const result = await authorizeHandler({});
|
|
362
|
+
|
|
363
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
364
|
+
const url = new URL(parsed.url);
|
|
365
|
+
|
|
366
|
+
expect(url.searchParams.get("response_type")).toBe("code");
|
|
367
|
+
expect(url.searchParams.get("client_id")).toBe("test-client");
|
|
368
|
+
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3000/callback");
|
|
369
|
+
expect(url.searchParams.get("scope")).toBe("openid profile email");
|
|
370
|
+
expect(url.searchParams.get("code_challenge")).toBeTruthy();
|
|
371
|
+
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
|
372
|
+
expect(url.searchParams.get("state")).toBeTruthy();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("should use custom scope when provided", async () => {
|
|
376
|
+
const config: OidcConfig = {
|
|
377
|
+
issuer: "https://auth.example.com",
|
|
378
|
+
clientId: "test-client",
|
|
379
|
+
redirectUri: "http://localhost:3000/callback",
|
|
380
|
+
scope: "openid profile",
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const metadata = {
|
|
384
|
+
authorization_endpoint: "https://auth.example.com/authorize",
|
|
385
|
+
token_endpoint: "https://auth.example.com/token",
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
389
|
+
ok: true,
|
|
390
|
+
status: 200,
|
|
391
|
+
json: () => Promise.resolve(metadata),
|
|
392
|
+
});
|
|
393
|
+
global.fetch = mockFetch as any;
|
|
394
|
+
|
|
395
|
+
const toolResults: any[] = [];
|
|
396
|
+
const mockServer = {
|
|
397
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
398
|
+
if (name === "llng_oidc_authorize") {
|
|
399
|
+
toolResults.push({ name, handler });
|
|
400
|
+
}
|
|
401
|
+
}),
|
|
402
|
+
} as unknown as McpServer;
|
|
403
|
+
|
|
404
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
405
|
+
|
|
406
|
+
const authorizeHandler = toolResults.find((t) => t.name === "llng_oidc_authorize")?.handler;
|
|
407
|
+
const result = await authorizeHandler({ scope: "openid email offline_access" });
|
|
408
|
+
|
|
409
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
410
|
+
const url = new URL(parsed.url);
|
|
411
|
+
|
|
412
|
+
expect(url.searchParams.get("scope")).toBe("openid email offline_access");
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe("Token exchange", () => {
|
|
417
|
+
it("should send correct parameters in token request", async () => {
|
|
418
|
+
const config: OidcConfig = {
|
|
419
|
+
issuer: "https://auth.example.com",
|
|
420
|
+
clientId: "test-client",
|
|
421
|
+
redirectUri: "http://localhost:3000/callback",
|
|
422
|
+
scope: "openid profile",
|
|
423
|
+
clientSecret: "secret123",
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const metadata = {
|
|
427
|
+
token_endpoint: "https://auth.example.com/token",
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const tokenResponse = {
|
|
431
|
+
access_token: "access123",
|
|
432
|
+
id_token: "id123",
|
|
433
|
+
token_type: "Bearer",
|
|
434
|
+
expires_in: 3600,
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const fetchCalls: any[] = [];
|
|
438
|
+
const mockFetch = vi.fn().mockImplementation((url: string, options: any) => {
|
|
439
|
+
fetchCalls.push({ url, options });
|
|
440
|
+
// First call is discovery, second is token
|
|
441
|
+
if (url.includes(".well-known")) {
|
|
442
|
+
return Promise.resolve({
|
|
443
|
+
ok: true,
|
|
444
|
+
status: 200,
|
|
445
|
+
json: () => Promise.resolve(metadata),
|
|
446
|
+
});
|
|
447
|
+
} else {
|
|
448
|
+
return Promise.resolve({
|
|
449
|
+
ok: true,
|
|
450
|
+
status: 200,
|
|
451
|
+
json: () => Promise.resolve(tokenResponse),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
global.fetch = mockFetch as any;
|
|
456
|
+
|
|
457
|
+
const toolResults: any[] = [];
|
|
458
|
+
const mockServer = {
|
|
459
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
460
|
+
if (name === "llng_oidc_tokens") {
|
|
461
|
+
toolResults.push({ name, handler });
|
|
462
|
+
}
|
|
463
|
+
}),
|
|
464
|
+
} as unknown as McpServer;
|
|
465
|
+
|
|
466
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
467
|
+
|
|
468
|
+
const tokensHandler = toolResults.find((t) => t.name === "llng_oidc_tokens")?.handler;
|
|
469
|
+
await tokensHandler({ code: "auth_code_123", code_verifier: "verifier123" });
|
|
470
|
+
|
|
471
|
+
// Find the token endpoint call
|
|
472
|
+
const tokenCall = fetchCalls.find((call) => call.url === "https://auth.example.com/token");
|
|
473
|
+
expect(tokenCall).toBeDefined();
|
|
474
|
+
expect(tokenCall.options.method).toBe("POST");
|
|
475
|
+
|
|
476
|
+
const body = new URLSearchParams(tokenCall.options.body);
|
|
477
|
+
expect(body.get("grant_type")).toBe("authorization_code");
|
|
478
|
+
expect(body.get("code")).toBe("auth_code_123");
|
|
479
|
+
expect(body.get("code_verifier")).toBe("verifier123");
|
|
480
|
+
expect(body.get("client_id")).toBe("test-client");
|
|
481
|
+
expect(body.get("client_secret")).toBe("secret123");
|
|
482
|
+
expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback");
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe("Error handling", () => {
|
|
487
|
+
it("should handle fetch errors gracefully", async () => {
|
|
488
|
+
// Use a unique issuer to avoid cache collision
|
|
489
|
+
const config: OidcConfig = {
|
|
490
|
+
issuer: "https://error-test-1.example.com",
|
|
491
|
+
clientId: "test-client",
|
|
492
|
+
redirectUri: "http://localhost:3000/callback",
|
|
493
|
+
scope: "openid profile",
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
|
497
|
+
global.fetch = mockFetch as any;
|
|
498
|
+
|
|
499
|
+
const toolResults: any[] = [];
|
|
500
|
+
const mockServer = {
|
|
501
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
502
|
+
toolResults.push({ name, handler });
|
|
503
|
+
}),
|
|
504
|
+
} as unknown as McpServer;
|
|
505
|
+
|
|
506
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
507
|
+
|
|
508
|
+
const metadataHandler = toolResults.find((t) => t.name === "llng_oidc_metadata")?.handler;
|
|
509
|
+
expect(metadataHandler).toBeDefined();
|
|
510
|
+
|
|
511
|
+
if (metadataHandler) {
|
|
512
|
+
const result = await metadataHandler({});
|
|
513
|
+
expect(result.isError).toBe(true);
|
|
514
|
+
expect(result.content[0].text).toContain("Network error");
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("should handle HTTP error responses", async () => {
|
|
519
|
+
// Use a unique issuer to avoid cache collision
|
|
520
|
+
const config: OidcConfig = {
|
|
521
|
+
issuer: "https://error-test-2.example.com",
|
|
522
|
+
clientId: "test-client",
|
|
523
|
+
redirectUri: "http://localhost:3000/callback",
|
|
524
|
+
scope: "openid profile",
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
528
|
+
ok: false,
|
|
529
|
+
status: 404,
|
|
530
|
+
statusText: "Not Found",
|
|
531
|
+
json: () => Promise.resolve({ error: "not_found" }),
|
|
532
|
+
});
|
|
533
|
+
global.fetch = mockFetch as any;
|
|
534
|
+
|
|
535
|
+
const toolResults: any[] = [];
|
|
536
|
+
const mockServer = {
|
|
537
|
+
tool: vi.fn((name: string, desc: string, schema: any, handler: any) => {
|
|
538
|
+
toolResults.push({ name, handler });
|
|
539
|
+
}),
|
|
540
|
+
} as unknown as McpServer;
|
|
541
|
+
|
|
542
|
+
registerOidcTools(mockServer, createMockOidcRegistry(config));
|
|
543
|
+
|
|
544
|
+
const metadataHandler = toolResults.find((t) => t.name === "llng_oidc_metadata")?.handler;
|
|
545
|
+
expect(metadataHandler).toBeDefined();
|
|
546
|
+
|
|
547
|
+
if (metadataHandler) {
|
|
548
|
+
const result = await metadataHandler({});
|
|
549
|
+
expect(result.isError).toBe(true);
|
|
550
|
+
expect(result.content[0].text).toContain("404");
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { TransportRegistry } from "../transport/registry.js";
|
|
3
|
+
import { LlngMultiConfig } from "../config.js";
|
|
4
|
+
|
|
5
|
+
vi.mock("../transport/api.js", () => {
|
|
6
|
+
const ApiTransport = vi.fn(function (this: any) {
|
|
7
|
+
this._type = "api";
|
|
8
|
+
});
|
|
9
|
+
return { ApiTransport };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
vi.mock("../transport/ssh.js", () => {
|
|
13
|
+
const SshTransport = vi.fn(function (this: any) {
|
|
14
|
+
this._type = "ssh";
|
|
15
|
+
});
|
|
16
|
+
return { SshTransport };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("TransportRegistry", () => {
|
|
20
|
+
function makeConfig(overrides?: Partial<LlngMultiConfig>): LlngMultiConfig {
|
|
21
|
+
return {
|
|
22
|
+
instances: {
|
|
23
|
+
prod: {
|
|
24
|
+
mode: "api",
|
|
25
|
+
api: { baseUrl: "https://prod.example.com" },
|
|
26
|
+
},
|
|
27
|
+
staging: {
|
|
28
|
+
mode: "ssh",
|
|
29
|
+
ssh: {
|
|
30
|
+
host: "staging.example.com",
|
|
31
|
+
cliPath: "/usr/share/lemonldap-ng/bin/lemonldap-ng-cli",
|
|
32
|
+
sessionsPath: "/usr/share/lemonldap-ng/bin/lemonldap-ng-sessions",
|
|
33
|
+
configEditorPath: "/usr/share/lemonldap-ng/bin/lmConfigEditor",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
default: "prod",
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it("returns transport for default instance when no name given", () => {
|
|
43
|
+
const registry = new TransportRegistry(makeConfig());
|
|
44
|
+
const transport = registry.getTransport();
|
|
45
|
+
expect(transport).toBeDefined();
|
|
46
|
+
expect((transport as any)._type).toBe("api");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns transport for named instance", () => {
|
|
50
|
+
const registry = new TransportRegistry(makeConfig());
|
|
51
|
+
const transport = registry.getTransport("staging");
|
|
52
|
+
expect((transport as any)._type).toBe("ssh");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("caches transport instances", () => {
|
|
56
|
+
const registry = new TransportRegistry(makeConfig());
|
|
57
|
+
const t1 = registry.getTransport("prod");
|
|
58
|
+
const t2 = registry.getTransport("prod");
|
|
59
|
+
expect(t1).toBe(t2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws for unknown instance", () => {
|
|
63
|
+
const registry = new TransportRegistry(makeConfig());
|
|
64
|
+
expect(() => registry.getTransport("unknown")).toThrow(
|
|
65
|
+
"Unknown instance 'unknown'. Available instances: prod, staging",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("throws for API mode without api config", () => {
|
|
70
|
+
const config = makeConfig({
|
|
71
|
+
instances: {
|
|
72
|
+
broken: { mode: "api" },
|
|
73
|
+
},
|
|
74
|
+
default: "broken",
|
|
75
|
+
});
|
|
76
|
+
const registry = new TransportRegistry(config);
|
|
77
|
+
expect(() => registry.getTransport()).toThrow("API mode requires 'api' configuration");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns OIDC config for instance", () => {
|
|
81
|
+
const config = makeConfig();
|
|
82
|
+
config.instances.prod.oidc = {
|
|
83
|
+
issuer: "https://auth.example.com",
|
|
84
|
+
clientId: "client",
|
|
85
|
+
redirectUri: "http://localhost/cb",
|
|
86
|
+
scope: "openid",
|
|
87
|
+
};
|
|
88
|
+
const registry = new TransportRegistry(config);
|
|
89
|
+
expect(registry.getOidcConfig("prod")?.issuer).toBe("https://auth.example.com");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns undefined OIDC config when not configured", () => {
|
|
93
|
+
const registry = new TransportRegistry(makeConfig());
|
|
94
|
+
expect(registry.getOidcConfig("prod")).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws for unknown instance in getOidcConfig", () => {
|
|
98
|
+
const registry = new TransportRegistry(makeConfig());
|
|
99
|
+
expect(() => registry.getOidcConfig("nope")).toThrow("Unknown instance 'nope'");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("lists all instances", () => {
|
|
103
|
+
const registry = new TransportRegistry(makeConfig());
|
|
104
|
+
const list = registry.listInstances();
|
|
105
|
+
expect(list).toEqual([
|
|
106
|
+
{ name: "prod", mode: "api", isDefault: true, hasManager: false },
|
|
107
|
+
{ name: "staging", mode: "ssh", isDefault: false, hasManager: false },
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
});
|