@zereight/mcp-gitlab 2.1.70 → 2.2.1

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.
Files changed (158) hide show
  1. package/README.ko.md +9 -5
  2. package/README.md +15 -11
  3. package/README.pt-BR.md +9 -5
  4. package/README.zh-CN.md +9 -5
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +63 -0
  7. package/build/index.js +430 -200
  8. package/build/oauth-device-flow.js +80 -0
  9. package/build/oauth.js +135 -18
  10. package/build/schemas.js +1 -1
  11. package/build/server/remote-http-stack.js +39 -0
  12. package/build/tools/discover-activation.js +38 -0
  13. package/build/tools/exposed-tools.js +35 -0
  14. package/build/tools/listed-tools.js +76 -0
  15. package/build/tools/registry.js +232 -63
  16. package/build/tools/tool-descriptions.js +3 -3
  17. package/build/tools/tool-profile.js +66 -0
  18. package/build/utils/compact-tool-allowlist.js +14 -0
  19. package/build/utils/logger.js +34 -12
  20. package/package.json +3 -3
  21. package/build/scripts/check-skill-sync.js +0 -143
  22. package/build/scripts/generate-server-card.js +0 -39
  23. package/build/scripts/generate-tool-coverage.js +0 -58
  24. package/build/scripts/generate-tool-docs.js +0 -430
  25. package/build/scripts/tool-coverage/coverage.js +0 -341
  26. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  27. package/build/test/callback-proxy-tests.js +0 -321
  28. package/build/test/cli/args.test.js +0 -175
  29. package/build/test/cli/boolean-flags.test.js +0 -106
  30. package/build/test/cli/compact-mcp-result.test.js +0 -93
  31. package/build/test/cli/credentials.test.js +0 -153
  32. package/build/test/cli/exposure.test.js +0 -104
  33. package/build/test/cli/load-config-snapshot.js +0 -5
  34. package/build/test/cli/output.test.js +0 -74
  35. package/build/test/cli/replay-command.test.js +0 -37
  36. package/build/test/cli/router.test.js +0 -183
  37. package/build/test/client-pool-test.js +0 -109
  38. package/build/test/clients/client.js +0 -34
  39. package/build/test/clients/custom-header-client.js +0 -122
  40. package/build/test/clients/sse-client.js +0 -95
  41. package/build/test/clients/stdio-client.js +0 -113
  42. package/build/test/clients/streamable-http-client.js +0 -95
  43. package/build/test/config-allowed-groups.test.js +0 -97
  44. package/build/test/create-commit-actions.test.js +0 -102
  45. package/build/test/draft-note-schema.test.js +0 -44
  46. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  47. package/build/test/dynamic-api-url-test.js +0 -304
  48. package/build/test/dynamic-routing-tests.js +0 -495
  49. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  50. package/build/test/gitlab-api-tool-errors.test.js +0 -112
  51. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  52. package/build/test/gitlab-diff-schema.test.js +0 -36
  53. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  54. package/build/test/gitlab-tag-schema.test.js +0 -51
  55. package/build/test/group-milestone-schema.test.js +0 -45
  56. package/build/test/mcp-oauth-tests.js +0 -905
  57. package/build/test/mcp-server-name.test.js +0 -87
  58. package/build/test/merge-merge-request-schema.test.js +0 -75
  59. package/build/test/multi-server-test.js +0 -190
  60. package/build/test/no-proxy-integration-test.js +0 -183
  61. package/build/test/no-proxy-test.js +0 -149
  62. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  63. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  64. package/build/test/oauth-device-flow-tests.js +0 -450
  65. package/build/test/oauth-download-url.test.js +0 -157
  66. package/build/test/oauth-startup.test.js +0 -204
  67. package/build/test/oauth-tests.js +0 -428
  68. package/build/test/path-segment-encoding.test.js +0 -98
  69. package/build/test/remote-auth-simple-test.js +0 -277
  70. package/build/test/remote-auth-tests.js +0 -314
  71. package/build/test/response-masking.test.js +0 -445
  72. package/build/test/schema-tests.js +0 -1679
  73. package/build/test/server/metrics.test.js +0 -47
  74. package/build/test/server/request-helpers.test.js +0 -48
  75. package/build/test/server/version.test.js +0 -13
  76. package/build/test/sse-auth-guard.test.js +0 -96
  77. package/build/test/sse-session-limits.test.js +0 -273
  78. package/build/test/stateless/callback-proxy.test.js +0 -510
  79. package/build/test/stateless/client-id.test.js +0 -177
  80. package/build/test/stateless/codec.test.js +0 -328
  81. package/build/test/stateless/config-ttl.test.js +0 -149
  82. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  83. package/build/test/stateless/session-id-integration.test.js +0 -675
  84. package/build/test/stateless/session-id.test.js +0 -157
  85. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  86. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  87. package/build/test/streamable-http-sse-stream.test.js +0 -350
  88. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  89. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  90. package/build/test/test-all-transport-server.js +0 -255
  91. package/build/test/test-auth-retry.js +0 -195
  92. package/build/test/test-blank-filters.js +0 -156
  93. package/build/test/test-ci-catalog.js +0 -177
  94. package/build/test/test-ci-lint.js +0 -191
  95. package/build/test/test-ci-variables.js +0 -309
  96. package/build/test/test-create-repository.js +0 -120
  97. package/build/test/test-dependency-proxy.js +0 -195
  98. package/build/test/test-deployment-tools.js +0 -463
  99. package/build/test/test-download-attachment.js +0 -190
  100. package/build/test/test-dynamic-project-scope.js +0 -524
  101. package/build/test/test-empty-toolsets.js +0 -535
  102. package/build/test/test-get-file-blame.js +0 -145
  103. package/build/test/test-get-file-contents.js +0 -128
  104. package/build/test/test-geteffectiveprojectid.js +0 -804
  105. package/build/test/test-health-check.js +0 -115
  106. package/build/test/test-issue-description-patch.js +0 -256
  107. package/build/test/test-job-artifacts.js +0 -275
  108. package/build/test/test-json-schema.js +0 -166
  109. package/build/test/test-list-group-members.js +0 -150
  110. package/build/test/test-list-group-merge-requests.js +0 -165
  111. package/build/test/test-list-issues.js +0 -170
  112. package/build/test/test-list-merge-requests.js +0 -201
  113. package/build/test/test-list-milestones.js +0 -145
  114. package/build/test/test-list-project-members.js +0 -132
  115. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  116. package/build/test/test-merge-request-approvals.js +0 -187
  117. package/build/test/test-merge-request-pipelines.js +0 -106
  118. package/build/test/test-mr-diffs-filter.js +0 -132
  119. package/build/test/test-mr-file-diffs.js +0 -251
  120. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  121. package/build/test/test-orbit.js +0 -155
  122. package/build/test/test-permission-mode.js +0 -424
  123. package/build/test/test-pipeline-schedules.js +0 -484
  124. package/build/test/test-protected-branches.js +0 -174
  125. package/build/test/test-remote-downloads.js +0 -510
  126. package/build/test/test-search-code.js +0 -275
  127. package/build/test/test-snippets.js +0 -735
  128. package/build/test/test-tags.js +0 -220
  129. package/build/test/test-todos.js +0 -195
  130. package/build/test/test-token-optimizations.js +0 -691
  131. package/build/test/test-toolset-filtering.js +0 -499
  132. package/build/test/test-update-issue-slim.js +0 -141
  133. package/build/test/test-update-project.js +0 -112
  134. package/build/test/test-upload-markdown.js +0 -206
  135. package/build/test/test-vulnerabilities.js +0 -399
  136. package/build/test/test-webhooks.js +0 -218
  137. package/build/test/tool-description-quality.test.js +0 -50
  138. package/build/test/utils/bulk-publish-options.test.js +0 -32
  139. package/build/test/utils/download-token.test.js +0 -35
  140. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  141. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  142. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  143. package/build/test/utils/graphql-query.test.js +0 -168
  144. package/build/test/utils/helpers-path.test.js +0 -104
  145. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  146. package/build/test/utils/masking.test.js +0 -384
  147. package/build/test/utils/merge-request-position.test.js +0 -57
  148. package/build/test/utils/mock-gitlab-server.js +0 -614
  149. package/build/test/utils/proxy-client-ip.test.js +0 -28
  150. package/build/test/utils/redact-sensitive.test.js +0 -32
  151. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  152. package/build/test/utils/server-launcher.js +0 -231
  153. package/build/test/utils/tool-args.test.js +0 -210
  154. package/build/test/utils/url.test.js +0 -141
  155. package/build/test/utils/version-check.test.js +0 -239
  156. package/build/test/utils/wiki-title.test.js +0 -21
  157. package/build/test-note.js +0 -54
  158. package/build/test-resolve-issue-note.js +0 -127
@@ -1,905 +0,0 @@
1
- /**
2
- * MCP OAuth Tests
3
- * Tests for the GITLAB_MCP_OAUTH=true server-side OAuth proxy mode.
4
- *
5
- * The suite uses a mock GitLab server that implements the minimal OAuth
6
- * endpoints (token/info, DCR register, well-known metadata) so no live
7
- * GitLab instance is required.
8
- */
9
- import { describe, test, after, before } from "node:test";
10
- import assert from "node:assert";
11
- import { launchServer, findAvailablePort, cleanupServers, TransportMode, HOST, } from "./utils/server-launcher.js";
12
- import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
13
- // ---------------------------------------------------------------------------
14
- // Constants
15
- // ---------------------------------------------------------------------------
16
- const MOCK_OAUTH_TOKEN = "ya29.mock-oauth-token-abcdef123456";
17
- const MOCK_CLIENT_ID = "mock-app-uid-from-dcr";
18
- const MOCK_PAT_TOKEN = "glpat-mockpat-testtoken-abcdef12"; // ≥20 chars, valid charset
19
- const MOCK_JOB_TOKEN = "mockjobtoken-testenv-1234567890"; // ≥20 chars, valid charset
20
- const MOCK_GITLAB_PORT_BASE = 9200;
21
- const MCP_SERVER_PORT_BASE = 3200;
22
- // ---------------------------------------------------------------------------
23
- // Helpers
24
- // ---------------------------------------------------------------------------
25
- /**
26
- * Add minimal OAuth endpoints to a MockGitLabServer.
27
- *
28
- * GitLab serves these at the instance root (not under /api/v4), so we use
29
- * addRootHandler rather than addMockHandler.
30
- *
31
- * Endpoints added:
32
- * GET /oauth/token/info — token introspection (Bearer header required)
33
- * POST /oauth/register — Dynamic Client Registration stub
34
- * GET /.well-known/oauth-authorization-server — AS metadata
35
- */
36
- function addOAuthEndpoints(mockGitLab, validToken, clientId, baseUrl) {
37
- // Token introspection — called by verifyAccessToken()
38
- mockGitLab.addRootHandler("get", "/oauth/token/info", (req, res) => {
39
- const auth = req.headers["authorization"];
40
- const token = auth?.replace(/^Bearer\s+/i, "");
41
- if (token !== validToken) {
42
- res.status(401).json({ error: "invalid_token" });
43
- return;
44
- }
45
- res.json({
46
- resource_owner_id: 42,
47
- scopes: ["api"],
48
- expires_in_seconds: 7200,
49
- application: { uid: clientId },
50
- created_at: Math.floor(Date.now() / 1000),
51
- });
52
- });
53
- // Dynamic Client Registration — proxied by mcpAuthRouter
54
- mockGitLab.addRootHandler("post", "/oauth/register", (req, res) => {
55
- res.status(201).json({
56
- client_id: clientId,
57
- client_name: req.body?.client_name ?? "test",
58
- redirect_uris: req.body?.redirect_uris ?? [],
59
- token_endpoint_auth_method: "none",
60
- require_pkce: true,
61
- });
62
- });
63
- // OAuth Authorization Server well-known metadata
64
- mockGitLab.addRootHandler("get", "/.well-known/oauth-authorization-server", (_req, res) => {
65
- res.json({
66
- issuer: baseUrl,
67
- authorization_endpoint: `${baseUrl}/oauth/authorize`,
68
- token_endpoint: `${baseUrl}/oauth/token`,
69
- registration_endpoint: `${baseUrl}/oauth/register`,
70
- revocation_endpoint: `${baseUrl}/oauth/revoke`,
71
- scopes_supported: ["api", "read_api", "read_user"],
72
- response_types_supported: ["code"],
73
- grant_types_supported: ["authorization_code", "refresh_token"],
74
- code_challenge_methods_supported: ["S256"],
75
- });
76
- });
77
- }
78
- // ---------------------------------------------------------------------------
79
- // Test suite: Discovery endpoints
80
- // ---------------------------------------------------------------------------
81
- describe("MCP OAuth — Discovery Endpoints", () => {
82
- let mcpBaseUrl;
83
- let mockGitLab;
84
- let servers = [];
85
- before(async () => {
86
- const mockPort = await findMockServerPort();
87
- mockGitLab = new MockGitLabServer({
88
- port: mockPort,
89
- validTokens: [MOCK_OAUTH_TOKEN],
90
- });
91
- await mockGitLab.start();
92
- const mockGitLabUrl = mockGitLab.getUrl();
93
- addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
94
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
95
- mcpBaseUrl = `http://${HOST}:${mcpPort}`;
96
- const server = await launchServer({
97
- mode: TransportMode.STREAMABLE_HTTP,
98
- port: mcpPort,
99
- timeout: 5000,
100
- env: {
101
- STREAMABLE_HTTP: "true",
102
- GITLAB_MCP_OAUTH: "true",
103
- GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
104
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
105
- MCP_SERVER_URL: mcpBaseUrl,
106
- MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
107
- },
108
- });
109
- servers.push(server);
110
- console.log(`Mock GitLab: ${mockGitLabUrl}`);
111
- console.log(`MCP Server: ${mcpBaseUrl}`);
112
- });
113
- after(async () => {
114
- cleanupServers(servers);
115
- if (mockGitLab) {
116
- await mockGitLab.stop();
117
- }
118
- });
119
- test("GET /.well-known/oauth-authorization-server returns AS metadata", async () => {
120
- const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-authorization-server`);
121
- assert.strictEqual(res.status, 200, "Should return 200");
122
- const body = (await res.json());
123
- assert.ok(body.issuer, "Should have issuer");
124
- assert.ok(body.authorization_endpoint, "Should have authorization_endpoint");
125
- assert.ok(body.token_endpoint, "Should have token_endpoint");
126
- assert.ok(body.registration_endpoint, "Should have registration_endpoint");
127
- console.log(" ✓ AS metadata returned with all required fields");
128
- });
129
- test("GET /.well-known/oauth-protected-resource returns resource metadata", async () => {
130
- // The SDK mounts the protected resource metadata at
131
- // /.well-known/oauth-protected-resource{pathname} where pathname is derived
132
- // from resourceServerUrl (or issuerUrl). With issuerUrl = "http://host:port/"
133
- // the pathname "/" is stripped, yielding the bare endpoint.
134
- const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource`);
135
- assert.strictEqual(res.status, 200, "Should return 200");
136
- const body = (await res.json());
137
- assert.ok(body.resource, "Should have resource field");
138
- console.log(" ✓ Protected resource metadata returned");
139
- });
140
- test("unprefixed MCP_SERVER_URL serves /mcp resource discovery at /.well-known/oauth-protected-resource/mcp", async () => {
141
- // When MCP_SERVER_URL has no path, the server registers a dedicated route
142
- // at /.well-known/oauth-protected-resource/mcp (RFC 9728 path-suffixed
143
- // discovery) so clients connecting to /mcp can discover the resource.
144
- const issuerUrl = new URL(mcpBaseUrl);
145
- const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource/mcp`);
146
- assert.strictEqual(res.status, 200, "Should return 200");
147
- const body = (await res.json());
148
- assert.strictEqual(body.resource, `${issuerUrl.origin}/mcp`, "resource should be ${origin}/mcp");
149
- assert.deepStrictEqual(body.authorization_servers, [issuerUrl.href], "authorization_servers should contain the issuer URL");
150
- console.log(" ✓ Unprefixed MCP_SERVER_URL: /mcp resource discovery returned correct metadata");
151
- });
152
- test("path-prefixed MCP_SERVER_URL serves path-aware discovery metadata", async () => {
153
- const mockPort = await findMockServerPort();
154
- const prefixedMockGitLab = new MockGitLabServer({
155
- port: mockPort,
156
- validTokens: [MOCK_OAUTH_TOKEN],
157
- });
158
- await prefixedMockGitLab.start();
159
- const scopedServers = [];
160
- try {
161
- const mockGitLabUrl = prefixedMockGitLab.getUrl();
162
- addOAuthEndpoints(prefixedMockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
163
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 25);
164
- const mcpBaseUrl = `http://${HOST}:${mcpPort}`;
165
- const issuerPath = "/gitlab-mcp";
166
- const prefixedServerUrl = `${mcpBaseUrl}${issuerPath}`;
167
- const server = await launchServer({
168
- mode: TransportMode.STREAMABLE_HTTP,
169
- port: mcpPort,
170
- timeout: 5000,
171
- env: {
172
- STREAMABLE_HTTP: "true",
173
- GITLAB_MCP_OAUTH: "true",
174
- GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
175
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
176
- MCP_SERVER_URL: prefixedServerUrl,
177
- MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
178
- },
179
- });
180
- scopedServers.push(server);
181
- const authMetadataRes = await fetch(`${mcpBaseUrl}/.well-known/oauth-authorization-server${issuerPath}`);
182
- assert.strictEqual(authMetadataRes.status, 200, "Should return 200");
183
- const authMetadata = (await authMetadataRes.json());
184
- assert.strictEqual(authMetadata.issuer, prefixedServerUrl);
185
- assert.strictEqual(authMetadata.authorization_endpoint, `${prefixedServerUrl}/authorize`);
186
- assert.strictEqual(authMetadata.token_endpoint, `${prefixedServerUrl}/token`);
187
- assert.strictEqual(authMetadata.registration_endpoint, `${prefixedServerUrl}/register`);
188
- assert.strictEqual(authMetadata.revocation_endpoint, `${prefixedServerUrl}/revoke`);
189
- const resourceMetadataRes = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource${issuerPath}/mcp`);
190
- assert.strictEqual(resourceMetadataRes.status, 200, "Should return 200");
191
- const resourceMetadata = (await resourceMetadataRes.json());
192
- assert.strictEqual(resourceMetadata.resource, prefixedServerUrl);
193
- assert.deepStrictEqual(resourceMetadata.authorization_servers, [prefixedServerUrl]);
194
- console.log(" ✓ Path-prefixed discovery metadata returned at RFC well-known URLs");
195
- }
196
- finally {
197
- cleanupServers(scopedServers);
198
- await prefixedMockGitLab.stop();
199
- }
200
- });
201
- });
202
- // ---------------------------------------------------------------------------
203
- // Test suite: /mcp auth enforcement
204
- // ---------------------------------------------------------------------------
205
- describe("MCP OAuth — /mcp Auth Enforcement", () => {
206
- let mcpUrl;
207
- let mcpBaseUrl;
208
- let mockGitLab;
209
- let servers = [];
210
- before(async () => {
211
- const mockPort = await findMockServerPort();
212
- mockGitLab = new MockGitLabServer({
213
- port: mockPort,
214
- validTokens: [MOCK_OAUTH_TOKEN],
215
- });
216
- await mockGitLab.start();
217
- const mockGitLabUrl = mockGitLab.getUrl();
218
- addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
219
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 50);
220
- mcpBaseUrl = `http://${HOST}:${mcpPort}`;
221
- mcpUrl = `${mcpBaseUrl}/mcp`;
222
- const server = await launchServer({
223
- mode: TransportMode.STREAMABLE_HTTP,
224
- port: mcpPort,
225
- timeout: 5000,
226
- env: {
227
- STREAMABLE_HTTP: "true",
228
- GITLAB_MCP_OAUTH: "true",
229
- GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
230
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
231
- MCP_SERVER_URL: mcpBaseUrl,
232
- MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
233
- },
234
- });
235
- servers.push(server);
236
- });
237
- after(async () => {
238
- cleanupServers(servers);
239
- if (mockGitLab) {
240
- await mockGitLab.stop();
241
- }
242
- });
243
- test("POST /mcp without Authorization header returns 401 with WWW-Authenticate", async () => {
244
- const res = await fetch(mcpUrl, {
245
- method: "POST",
246
- headers: { "Content-Type": "application/json" },
247
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
248
- });
249
- assert.strictEqual(res.status, 401, "Should return 401 Unauthorized");
250
- const wwwAuth = res.headers.get("www-authenticate");
251
- assert.ok(wwwAuth, "Should have WWW-Authenticate header");
252
- assert.ok(wwwAuth.toLowerCase().startsWith("bearer"), "WWW-Authenticate should use Bearer scheme");
253
- console.log(" ✓ 401 returned with WWW-Authenticate header (no auth)");
254
- console.log(` ℹ️ WWW-Authenticate: ${wwwAuth}`);
255
- });
256
- test("POST /mcp with invalid token returns 401", async () => {
257
- const res = await fetch(mcpUrl, {
258
- method: "POST",
259
- headers: {
260
- "Content-Type": "application/json",
261
- Authorization: "Bearer invalid-token-xyz",
262
- },
263
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
264
- });
265
- assert.strictEqual(res.status, 401, "Should return 401 for invalid token");
266
- console.log(" ✓ 401 returned for invalid OAuth token");
267
- });
268
- test("POST /mcp with valid Bearer token returns non-401", async () => {
269
- const res = await fetch(mcpUrl, {
270
- method: "POST",
271
- headers: {
272
- "Content-Type": "application/json",
273
- Authorization: `Bearer ${MOCK_OAUTH_TOKEN}`,
274
- Accept: "application/json, text/event-stream",
275
- },
276
- body: JSON.stringify({
277
- jsonrpc: "2.0",
278
- id: 1,
279
- method: "initialize",
280
- params: {
281
- protocolVersion: "2024-11-05",
282
- capabilities: {},
283
- clientInfo: { name: "test-client", version: "1.0.0" },
284
- },
285
- }),
286
- });
287
- assert.notStrictEqual(res.status, 401, "Should not return 401 with valid token");
288
- assert.notStrictEqual(res.status, 403, "Should not return 403 with valid token");
289
- console.log(` ✓ Valid token accepted (status: ${res.status})`);
290
- });
291
- });
292
- // ---------------------------------------------------------------------------
293
- // Test suite: BoundedClientCache unit tests
294
- // ---------------------------------------------------------------------------
295
- describe("MCP OAuth — BoundedClientCache", () => {
296
- // Access the internal class via a minimal provider (it's not exported directly)
297
- // by driving it through the public clientsStore API.
298
- async function buildCachingProvider() {
299
- // Spin up a DCR stub that returns a stable client_id from the request body.
300
- // The stub reads client_id from the incoming body (set by the SDK's register
301
- // handler before calling registerClient), so cache keys are predictable.
302
- const { createServer } = await import("node:http");
303
- const stub = createServer((req, res) => {
304
- let body = "";
305
- req.on("data", chunk => (body += chunk));
306
- req.on("end", () => {
307
- const parsed = JSON.parse(body || "{}");
308
- res.writeHead(201, { "Content-Type": "application/json" });
309
- // Echo client_id back so tests can look it up by a known key
310
- res.end(JSON.stringify({
311
- client_id: parsed.client_id ?? "unknown",
312
- client_name: parsed.client_name ?? "unnamed",
313
- redirect_uris: parsed.redirect_uris ?? [],
314
- token_endpoint_auth_method: "none",
315
- }));
316
- });
317
- });
318
- await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
319
- const addr = stub.address();
320
- const baseUrl = `http://127.0.0.1:${addr.port}`;
321
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
322
- const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
323
- return { provider, stub };
324
- }
325
- test("LRU: both clients remain cached after sequential registration", async () => {
326
- const { provider, stub } = await buildCachingProvider();
327
- try {
328
- const store = provider.clientsStore;
329
- // Register two clients — the provider generates a random UUID as client_id,
330
- // so we capture the returned client_id to look up the cache.
331
- const regA = await store.registerClient({
332
- client_name: "Claude",
333
- redirect_uris: ["https://a.com/cb"],
334
- token_endpoint_auth_method: "none",
335
- });
336
- const regB = await store.registerClient({
337
- client_name: "Cursor",
338
- redirect_uris: ["https://b.com/cb"],
339
- token_endpoint_auth_method: "none",
340
- });
341
- const a = await store.getClient(regA.client_id);
342
- const b = await store.getClient(regB.client_id);
343
- assert.deepStrictEqual(a.redirect_uris, ["https://a.com/cb"], "client-A still cached");
344
- assert.deepStrictEqual(b.redirect_uris, ["https://b.com/cb"], "client-B still cached");
345
- console.log(" ✓ Both clients remain cached after sequential registration");
346
- }
347
- finally {
348
- stub.close();
349
- }
350
- });
351
- test("cache: re-registration returns a new entry with its own client_id", async () => {
352
- const { provider, stub } = await buildCachingProvider();
353
- try {
354
- const store = provider.clientsStore;
355
- const first = await store.registerClient({
356
- client_name: "Claude",
357
- redirect_uris: ["https://old.com/cb"],
358
- token_endpoint_auth_method: "none",
359
- });
360
- const firstLookup = await store.getClient(first.client_id);
361
- assert.deepStrictEqual(firstLookup.redirect_uris, ["https://old.com/cb"]);
362
- const second = await store.registerClient({
363
- client_name: "Claude",
364
- redirect_uris: ["https://new.com/cb"],
365
- token_endpoint_auth_method: "none",
366
- });
367
- const secondLookup = await store.getClient(second.client_id);
368
- assert.deepStrictEqual(secondLookup.redirect_uris, ["https://new.com/cb"], "New registration cached under its own client_id");
369
- // Both entries remain accessible
370
- const firstStill = await store.getClient(first.client_id);
371
- assert.deepStrictEqual(firstStill.redirect_uris, ["https://old.com/cb"], "First entry still cached");
372
- console.log(" ✓ Re-registration creates a new cached entry");
373
- }
374
- finally {
375
- stub.close();
376
- }
377
- });
378
- });
379
- // ---------------------------------------------------------------------------
380
- // Test suite: createGitLabOAuthProvider unit tests
381
- // ---------------------------------------------------------------------------
382
- describe("MCP OAuth — createGitLabOAuthProvider", () => {
383
- test("verifyAccessToken throws on non-OK response", async () => {
384
- // Spin up a tiny local server that always returns 401
385
- const { createServer } = await import("node:http");
386
- const stub = createServer((_req, res) => {
387
- res.writeHead(401);
388
- res.end(JSON.stringify({ error: "invalid_token" }));
389
- });
390
- await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
391
- const addr = stub.address();
392
- const baseUrl = `http://127.0.0.1:${addr.port}`;
393
- try {
394
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
395
- const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
396
- await assert.rejects(() => provider.verifyAccessToken("bad-token"), /invalid or expired/i, "Should throw InvalidTokenError for non-OK response");
397
- console.log(" ✓ verifyAccessToken throws for 401 from GitLab");
398
- }
399
- finally {
400
- stub.close();
401
- }
402
- });
403
- test("verifyAccessToken falls back to access_token query param when Bearer gets 401", async () => {
404
- // Simulates a GitLab behind an edge cache that strips the Authorization
405
- // header on /oauth/* (e.g. git.drupalcode.org behind Varnish): the
406
- // Bearer-header request 401s, the RFC 6750 query-param retry succeeds.
407
- const TOKEN = "tok+en/with?special=chars&more"; // exercises URL encoding
408
- const requests = [];
409
- const { createServer } = await import("node:http");
410
- const stub = createServer((req, res) => {
411
- requests.push({ url: req.url, authHeader: req.headers["authorization"] });
412
- const url = new URL(req.url, "http://localhost");
413
- if (url.searchParams.get("access_token") === TOKEN) {
414
- res.writeHead(200, { "Content-Type": "application/json" });
415
- res.end(JSON.stringify({
416
- resource_owner_id: 7,
417
- scopes: ["api"],
418
- expires_in_seconds: 3600,
419
- application: { uid: "app-uid-abc" },
420
- created_at: Math.floor(Date.now() / 1000),
421
- }));
422
- }
423
- else {
424
- // Header-stripping edge: reject anything not using the query param
425
- res.writeHead(401, { "Content-Type": "application/json" });
426
- res.end(JSON.stringify({ error: "invalid_token" }));
427
- }
428
- });
429
- await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
430
- const addr = stub.address();
431
- const baseUrl = `http://127.0.0.1:${addr.port}`;
432
- try {
433
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
434
- const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
435
- const authInfo = await provider.verifyAccessToken(TOKEN);
436
- assert.strictEqual(requests.length, 2, "exactly one retry after the 401");
437
- assert.strictEqual(requests[0].authHeader, `Bearer ${TOKEN}`, "first attempt sends the token as a Bearer Authorization header");
438
- assert.ok(!requests[0].url.includes("access_token="), "first attempt does not use the query param");
439
- assert.strictEqual(requests[1].authHeader, undefined, "retry omits the Authorization header");
440
- assert.ok(requests[1].url.includes(`access_token=${encodeURIComponent(TOKEN)}`), "retry carries the URL-encoded access_token query param");
441
- assert.strictEqual(authInfo.token, TOKEN, "token preserved through the fallback path");
442
- assert.strictEqual(authInfo.clientId, "app-uid-abc", "AuthInfo built from retry response");
443
- console.log(" ✓ verifyAccessToken falls back to query param after Bearer 401");
444
- }
445
- finally {
446
- stub.close();
447
- }
448
- });
449
- test("verifyAccessToken does not retry when the Bearer request succeeds", async () => {
450
- let requestCount = 0;
451
- const { createServer } = await import("node:http");
452
- const stub = createServer((_req, res) => {
453
- requestCount++;
454
- res.writeHead(200, { "Content-Type": "application/json" });
455
- res.end(JSON.stringify({
456
- resource_owner_id: 7,
457
- scopes: ["api"],
458
- expires_in_seconds: 3600,
459
- application: { uid: "app-uid-abc" },
460
- created_at: Math.floor(Date.now() / 1000),
461
- }));
462
- });
463
- await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
464
- const addr = stub.address();
465
- const baseUrl = `http://127.0.0.1:${addr.port}`;
466
- try {
467
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
468
- const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
469
- await provider.verifyAccessToken("good-token");
470
- assert.strictEqual(requestCount, 1, "no retry on a 200 response");
471
- console.log(" ✓ verifyAccessToken makes a single request on success");
472
- }
473
- finally {
474
- stub.close();
475
- }
476
- });
477
- test("verifyAccessToken does not retry on non-401 errors", async () => {
478
- let requestCount = 0;
479
- const { createServer } = await import("node:http");
480
- const stub = createServer((_req, res) => {
481
- requestCount++;
482
- res.writeHead(503, { "Content-Type": "application/json" });
483
- res.end(JSON.stringify({ error: "service_unavailable" }));
484
- });
485
- await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
486
- const addr = stub.address();
487
- const baseUrl = `http://127.0.0.1:${addr.port}`;
488
- try {
489
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
490
- const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
491
- await assert.rejects(() => provider.verifyAccessToken("tok"), /invalid or expired/i, "non-401 errors still reject");
492
- assert.strictEqual(requestCount, 1, "the query-param fallback is 401-only");
493
- console.log(" ✓ verifyAccessToken does not retry on non-401 errors");
494
- }
495
- finally {
496
- stub.close();
497
- }
498
- });
499
- test("verifyAccessToken maps GitLab token info to AuthInfo", async () => {
500
- const createdAt = Math.floor(Date.now() / 1000);
501
- const { createServer } = await import("node:http");
502
- const stub = createServer((_req, res) => {
503
- res.writeHead(200, { "Content-Type": "application/json" });
504
- res.end(JSON.stringify({
505
- resource_owner_id: 7,
506
- scopes: ["api", "read_user"],
507
- expires_in_seconds: 3600,
508
- application: { uid: "app-uid-abc" },
509
- created_at: createdAt,
510
- }));
511
- });
512
- await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
513
- const addr = stub.address();
514
- const baseUrl = `http://127.0.0.1:${addr.port}`;
515
- try {
516
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
517
- const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
518
- const authInfo = await provider.verifyAccessToken("good-token");
519
- assert.strictEqual(authInfo.token, "good-token", "token must be preserved");
520
- assert.strictEqual(authInfo.clientId, "app-uid-abc", "clientId from application.uid");
521
- assert.deepStrictEqual(authInfo.scopes, ["api", "read_user"], "scopes forwarded");
522
- assert.ok(typeof authInfo.expiresAt === "number", "expiresAt must be a number");
523
- assert.ok(authInfo.expiresAt > Math.floor(Date.now() / 1000), "expiresAt must be in the future");
524
- console.log(" ✓ verifyAccessToken returns correct AuthInfo");
525
- }
526
- finally {
527
- stub.close();
528
- }
529
- });
530
- test("verifyAccessToken uses 'dynamic' clientId when application is null", async () => {
531
- const { createServer } = await import("node:http");
532
- const stub = createServer((_req, res) => {
533
- res.writeHead(200, { "Content-Type": "application/json" });
534
- res.end(JSON.stringify({
535
- resource_owner_id: 1,
536
- scopes: ["read_api"],
537
- expires_in_seconds: null,
538
- application: null,
539
- created_at: Math.floor(Date.now() / 1000),
540
- }));
541
- });
542
- await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
543
- const addr = stub.address();
544
- const baseUrl = `http://127.0.0.1:${addr.port}`;
545
- try {
546
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
547
- const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
548
- const authInfo = await provider.verifyAccessToken("tok");
549
- assert.strictEqual(authInfo.clientId, "dynamic", "clientId should fall back to 'dynamic'");
550
- assert.strictEqual(authInfo.expiresAt, undefined, "expiresAt should be undefined when null");
551
- console.log(" ✓ null application and null expires_in_seconds handled correctly");
552
- }
553
- finally {
554
- stub.close();
555
- }
556
- });
557
- test("getClient returns stub for unknown clientId", async () => {
558
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
559
- const provider = createGitLabOAuthProvider("https://gitlab.example.com", "test-app-id");
560
- const client = await provider.clientsStore.getClient("unknown-client-id");
561
- assert.ok(client, "Should return a client object");
562
- assert.strictEqual(client.client_id, "unknown-client-id", "client_id should match input");
563
- assert.deepStrictEqual(client.redirect_uris, [], "redirect_uris should be empty for unknown client");
564
- assert.strictEqual(client.token_endpoint_auth_method, "none", "Should be a public client");
565
- console.log(" ✓ getClient returns stub for unknown clientId");
566
- });
567
- test("clientsStore caches DCR response so getClient returns real redirect_uris", async () => {
568
- const REGISTERED_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback";
569
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
570
- const provider = createGitLabOAuthProvider("https://gitlab.example.com", "test-app-id", "My MCP Server");
571
- // Before registration: getClient returns a stub with empty redirect_uris
572
- const beforeReg = await provider.clientsStore.getClient("some-unknown-id");
573
- assert.deepStrictEqual(beforeReg.redirect_uris, [], "Should be empty before registration");
574
- // Simulate DCR registration (as the SDK would call it).
575
- // The provider generates a virtual client_id (UUID) — it does NOT proxy to upstream.
576
- const registered = await provider.clientsStore.registerClient({
577
- client_name: "Claude",
578
- redirect_uris: [REGISTERED_REDIRECT_URI],
579
- token_endpoint_auth_method: "none",
580
- });
581
- // client_id should be a UUID generated by the provider
582
- assert.ok(registered.client_id, "client_id should be set");
583
- assert.match(registered.client_id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "client_id should be a UUID");
584
- assert.deepStrictEqual(registered.redirect_uris, [REGISTERED_REDIRECT_URI], "redirect_uris preserved from input");
585
- // client_name should be annotated with the resource name
586
- assert.ok(registered.client_name?.includes("Claude via My MCP Server"), `client_name should include 'Claude via My MCP Server', got: ${registered.client_name}`);
587
- // After registration: getClient with the returned client_id should return the cached entry
588
- const afterReg = await provider.clientsStore.getClient(registered.client_id);
589
- assert.deepStrictEqual(afterReg.redirect_uris, [REGISTERED_REDIRECT_URI], "getClient should return real redirect_uris from cache after registration");
590
- console.log(" ✓ DCR response cached: getClient returns real redirect_uris after registration");
591
- console.log(` ✓ client_name annotated: ${registered.client_name}`);
592
- });
593
- });
594
- // ---------------------------------------------------------------------------
595
- // Test suite: Header auth fallback within GITLAB_MCP_OAUTH mode
596
- // ---------------------------------------------------------------------------
597
- describe("MCP OAuth — Header Auth Fallback", () => {
598
- let mcpUrl;
599
- let mcpBaseUrl;
600
- let mockGitLab;
601
- let servers = [];
602
- before(async () => {
603
- const mockPort = await findMockServerPort();
604
- mockGitLab = new MockGitLabServer({
605
- port: mockPort,
606
- // Include both OAuth token and raw tokens so GitLab API calls succeed
607
- validTokens: [MOCK_OAUTH_TOKEN, MOCK_PAT_TOKEN, MOCK_JOB_TOKEN],
608
- });
609
- await mockGitLab.start();
610
- const mockGitLabUrl = mockGitLab.getUrl();
611
- // OAuth endpoints needed for server startup AS metadata
612
- addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
613
- const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 150);
614
- mcpBaseUrl = `http://${HOST}:${mcpPort}`;
615
- mcpUrl = `${mcpBaseUrl}/mcp`;
616
- const server = await launchServer({
617
- mode: TransportMode.STREAMABLE_HTTP,
618
- port: mcpPort,
619
- timeout: 5000,
620
- env: {
621
- STREAMABLE_HTTP: "true",
622
- GITLAB_MCP_OAUTH: "true",
623
- GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
624
- GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
625
- MCP_SERVER_URL: mcpBaseUrl,
626
- MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
627
- },
628
- });
629
- servers.push(server);
630
- console.log(`Mock GitLab (header-auth): ${mockGitLabUrl}`);
631
- console.log(`MCP Server (header-auth): ${mcpBaseUrl}`);
632
- });
633
- after(async () => {
634
- cleanupServers(servers);
635
- if (mockGitLab) {
636
- await mockGitLab.stop();
637
- }
638
- });
639
- const initBody = JSON.stringify({
640
- jsonrpc: "2.0",
641
- id: 1,
642
- method: "initialize",
643
- params: {
644
- protocolVersion: "2024-11-05",
645
- capabilities: {},
646
- clientInfo: { name: "test-client", version: "1.0.0" },
647
- },
648
- });
649
- test("POST /mcp with Private-Token header bypasses OAuth and is accepted", async () => {
650
- const res = await fetch(mcpUrl, {
651
- method: "POST",
652
- headers: {
653
- "Content-Type": "application/json",
654
- Accept: "application/json, text/event-stream",
655
- "Private-Token": MOCK_PAT_TOKEN,
656
- },
657
- body: initBody,
658
- });
659
- assert.notStrictEqual(res.status, 401, "Should not return 401 with valid Private-Token");
660
- assert.notStrictEqual(res.status, 403, "Should not return 403 with valid Private-Token");
661
- console.log(` ✓ Private-Token header accepted (status: ${res.status})`);
662
- });
663
- test("POST /mcp with JOB-TOKEN header bypasses OAuth and is accepted", async () => {
664
- const res = await fetch(mcpUrl, {
665
- method: "POST",
666
- headers: {
667
- "Content-Type": "application/json",
668
- Accept: "application/json, text/event-stream",
669
- "job-token": MOCK_JOB_TOKEN,
670
- },
671
- body: initBody,
672
- });
673
- assert.notStrictEqual(res.status, 401, "Should not return 401 with valid JOB-TOKEN");
674
- assert.notStrictEqual(res.status, 403, "Should not return 403 with valid JOB-TOKEN");
675
- console.log(` ✓ JOB-TOKEN header accepted (status: ${res.status})`);
676
- });
677
- test("POST /mcp with invalid Private-Token is rejected before session creation", async () => {
678
- const res = await fetch(mcpUrl, {
679
- method: "POST",
680
- headers: {
681
- "Content-Type": "application/json",
682
- Accept: "application/json, text/event-stream",
683
- "Private-Token": "glpat-invalid-token-0000",
684
- },
685
- body: initBody,
686
- });
687
- assert.strictEqual(res.status, 401);
688
- assert.strictEqual(res.headers.get("mcp-session-id"), null);
689
- });
690
- test("POST /mcp with valid OAuth Bearer token still works normally", async () => {
691
- const res = await fetch(mcpUrl, {
692
- method: "POST",
693
- headers: {
694
- "Content-Type": "application/json",
695
- Accept: "application/json, text/event-stream",
696
- Authorization: `Bearer ${MOCK_OAUTH_TOKEN}`,
697
- },
698
- body: initBody,
699
- });
700
- assert.notStrictEqual(res.status, 401, "OAuth Bearer should still work alongside header auth");
701
- assert.notStrictEqual(res.status, 403, "Should not return 403 with valid OAuth token");
702
- console.log(` ✓ OAuth Bearer token still accepted (status: ${res.status})`);
703
- });
704
- test("POST /mcp with no auth still returns 401", async () => {
705
- const res = await fetch(mcpUrl, {
706
- method: "POST",
707
- headers: { "Content-Type": "application/json" },
708
- body: initBody,
709
- });
710
- assert.strictEqual(res.status, 401, "Should return 401 with no auth");
711
- console.log(" ✓ No auth still returns 401");
712
- });
713
- });
714
- // ---------------------------------------------------------------------------
715
- // Test suite: Group Allowlist (unit tests — exchangeAuthorizationCode)
716
- // ---------------------------------------------------------------------------
717
- describe("MCP OAuth — Group Allowlist", () => {
718
- let stubServer;
719
- let stubUrl;
720
- // Mutable state lets each test control the stub's response without
721
- // spinning up a new server.
722
- let groupsForPage = () => [];
723
- let totalPages = 1;
724
- before(async () => {
725
- const { createServer } = await import("node:http");
726
- stubServer = createServer((req, res) => {
727
- const url = new URL(req.url, "http://127.0.0.1");
728
- res.setHeader("Content-Type", "application/json");
729
- // Token exchange — passthrough mode calls POST /oauth/token
730
- if (req.method === "POST" && url.pathname === "/oauth/token") {
731
- res.writeHead(200).end(JSON.stringify({
732
- access_token: MOCK_OAUTH_TOKEN,
733
- token_type: "bearer",
734
- expires_in: 7200,
735
- refresh_token: "mock-refresh",
736
- scope: "api",
737
- }));
738
- return;
739
- }
740
- if (req.method === "GET" && url.pathname === "/api/v4/groups") {
741
- const page = Number.parseInt(url.searchParams.get("page") ?? "1", 10);
742
- res
743
- .writeHead(200, { "x-total-pages": String(totalPages) })
744
- .end(JSON.stringify(groupsForPage(page)));
745
- return;
746
- }
747
- res.writeHead(404).end("{}");
748
- });
749
- await new Promise(resolve => stubServer.listen(0, "127.0.0.1", resolve));
750
- const addr = stubServer.address();
751
- stubUrl = `http://127.0.0.1:${addr.port}`;
752
- });
753
- after(() => {
754
- stubServer.close();
755
- });
756
- async function makeProvider(allowedGroups = undefined, callbackProxyEnabled = false) {
757
- const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
758
- return createGitLabOAuthProvider(stubUrl, "test-app-id", "GitLab MCP Server", false, undefined, // customScopes
759
- allowedGroups, callbackProxyEnabled, callbackProxyEnabled ? "https://mcp.example.test/callback" : "" // callbackUrl
760
- );
761
- }
762
- function exchange(provider) {
763
- return provider.exchangeAuthorizationCode({}, "mock-auth-code");
764
- }
765
- test("no allowedGroups — groups API not called, tokens returned", async () => {
766
- const provider = await makeProvider(undefined);
767
- groupsForPage = () => []; // wrong groups — proves endpoint isn't called
768
- totalPages = 1;
769
- const tokens = await exchange(provider);
770
- assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
771
- console.log(" ✓ No allowedGroups: token issued without group check");
772
- });
773
- test("user is direct member of the allowed group → token issued", async () => {
774
- const provider = await makeProvider(["my-org"]);
775
- groupsForPage = () => [{ full_path: "my-org" }];
776
- totalPages = 1;
777
- const tokens = await exchange(provider);
778
- assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
779
- console.log(" ✓ Direct group member: token issued");
780
- });
781
- test("user in first-level subgroup of allowed group → token issued", async () => {
782
- const provider = await makeProvider(["my-org"]);
783
- groupsForPage = () => [{ full_path: "my-org/team-a" }];
784
- totalPages = 1;
785
- const tokens = await exchange(provider);
786
- assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
787
- console.log(" ✓ First-level subgroup member: token issued");
788
- });
789
- test("user in nested subgroup → token issued", async () => {
790
- const provider = await makeProvider(["my-org"]);
791
- groupsForPage = () => [{ full_path: "my-org/team-a/squad-1" }];
792
- totalPages = 1;
793
- const tokens = await exchange(provider);
794
- assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
795
- console.log(" ✓ Nested subgroup member: token issued");
796
- });
797
- test("user not in any matching group → token issuance denied", async () => {
798
- const provider = await makeProvider(["my-org"]);
799
- groupsForPage = () => [{ full_path: "other-org" }];
800
- totalPages = 1;
801
- await assert.rejects(() => exchange(provider), (err) => {
802
- assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
803
- return true;
804
- });
805
- console.log(" ✓ Non-member: token issuance denied");
806
- });
807
- test("prefix spoofing rejected — my-org2 does not match my-org", async () => {
808
- const provider = await makeProvider(["my-org"]);
809
- groupsForPage = () => [{ full_path: "my-org2" }];
810
- totalPages = 1;
811
- await assert.rejects(() => exchange(provider), (err) => {
812
- assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
813
- return true;
814
- });
815
- console.log(" ✓ my-org2 correctly rejected (not a sub-path of my-org)");
816
- });
817
- test("user matches second of multiple allowed groups → token issued", async () => {
818
- const provider = await makeProvider(["team-x", "my-org"]);
819
- groupsForPage = () => [{ full_path: "my-org/backend" }];
820
- totalPages = 1;
821
- const tokens = await exchange(provider);
822
- assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
823
- console.log(" ✓ Match on second allowed group: token issued");
824
- });
825
- test("match found on page 2 of paginated groups response → token issued", async () => {
826
- const provider = await makeProvider(["my-org"]);
827
- totalPages = 2;
828
- groupsForPage = page => page === 1 ? [{ full_path: "other-org" }] : [{ full_path: "my-org/team-b" }];
829
- const tokens = await exchange(provider);
830
- assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
831
- console.log(" ✓ Group found on page 2: token issued");
832
- });
833
- test("user not in group across all pages → token issuance denied", async () => {
834
- const provider = await makeProvider(["my-org"]);
835
- totalPages = 2;
836
- groupsForPage = () => [{ full_path: "other-org" }];
837
- await assert.rejects(() => exchange(provider), (err) => {
838
- assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
839
- return true;
840
- });
841
- console.log(" ✓ Non-member across all pages: token issuance denied");
842
- });
843
- test("callback-proxy exchange also enforces allowed groups", async () => {
844
- const provider = await makeProvider(["my-org"], true);
845
- groupsForPage = () => [{ full_path: "my-org/team-a" }];
846
- totalPages = 1;
847
- const proxyCode = "proxy-code-for-group-test";
848
- const clientId = "test-client";
849
- const redirectUri = "https://client.example.test/callback";
850
- provider._storedTokens.set(proxyCode, {
851
- tokens: {
852
- access_token: MOCK_OAUTH_TOKEN,
853
- token_type: "bearer",
854
- expires_in: 7200,
855
- refresh_token: "mock-refresh",
856
- scope: "api",
857
- },
858
- clientId,
859
- clientCodeChallenge: "",
860
- clientRedirectUri: redirectUri,
861
- createdAt: Date.now(),
862
- });
863
- const tokens = await provider.exchangeAuthorizationCode({
864
- client_id: clientId,
865
- }, proxyCode, undefined, redirectUri);
866
- assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
867
- console.log(" ✓ Callback-proxy exchange enforces allowedGroups before issuing tokens");
868
- });
869
- test("callback-proxy exchange denies users outside allowed groups", async () => {
870
- const provider = await makeProvider(["my-org"], true);
871
- groupsForPage = () => [{ full_path: "other-org/team-a" }];
872
- totalPages = 1;
873
- const proxyCode = "proxy-code-for-group-deny-test";
874
- const clientId = "test-client-deny";
875
- const redirectUri = "https://client.example.test/callback";
876
- provider._storedTokens.set(proxyCode, {
877
- tokens: {
878
- access_token: MOCK_OAUTH_TOKEN,
879
- token_type: "bearer",
880
- expires_in: 7200,
881
- refresh_token: "mock-refresh",
882
- scope: "api",
883
- },
884
- clientId,
885
- clientCodeChallenge: "",
886
- clientRedirectUri: redirectUri,
887
- createdAt: Date.now(),
888
- });
889
- await assert.rejects(() => provider.exchangeAuthorizationCode({
890
- client_id: clientId,
891
- }, proxyCode, undefined, redirectUri), (err) => {
892
- assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
893
- return true;
894
- });
895
- console.log(" ✓ Callback-proxy exchange denies users outside allowedGroups");
896
- });
897
- test("matching is case-insensitive", async () => {
898
- const provider = await makeProvider(["My-Org"]);
899
- groupsForPage = () => [{ full_path: "my-org/team-a" }];
900
- totalPages = 1;
901
- const tokens = await exchange(provider);
902
- assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
903
- console.log(" ✓ Case-insensitive match: token issued");
904
- });
905
- });