@zereight/mcp-gitlab 2.1.70 → 2.2.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.
Files changed (157) hide show
  1. package/README.ko.md +3 -1
  2. package/README.md +6 -2
  3. package/README.pt-BR.md +1 -1
  4. package/README.zh-CN.md +3 -1
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +369 -184
  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 +226 -57
  16. package/build/tools/tool-profile.js +66 -0
  17. package/build/utils/compact-tool-allowlist.js +14 -0
  18. package/build/utils/logger.js +34 -12
  19. package/package.json +3 -3
  20. package/build/scripts/check-skill-sync.js +0 -143
  21. package/build/scripts/generate-server-card.js +0 -39
  22. package/build/scripts/generate-tool-coverage.js +0 -58
  23. package/build/scripts/generate-tool-docs.js +0 -430
  24. package/build/scripts/tool-coverage/coverage.js +0 -341
  25. package/build/scripts/tool-coverage/coverage.test.js +0 -142
  26. package/build/test/callback-proxy-tests.js +0 -321
  27. package/build/test/cli/args.test.js +0 -175
  28. package/build/test/cli/boolean-flags.test.js +0 -106
  29. package/build/test/cli/compact-mcp-result.test.js +0 -93
  30. package/build/test/cli/credentials.test.js +0 -153
  31. package/build/test/cli/exposure.test.js +0 -104
  32. package/build/test/cli/load-config-snapshot.js +0 -5
  33. package/build/test/cli/output.test.js +0 -74
  34. package/build/test/cli/replay-command.test.js +0 -37
  35. package/build/test/cli/router.test.js +0 -183
  36. package/build/test/client-pool-test.js +0 -109
  37. package/build/test/clients/client.js +0 -34
  38. package/build/test/clients/custom-header-client.js +0 -122
  39. package/build/test/clients/sse-client.js +0 -95
  40. package/build/test/clients/stdio-client.js +0 -113
  41. package/build/test/clients/streamable-http-client.js +0 -95
  42. package/build/test/config-allowed-groups.test.js +0 -97
  43. package/build/test/create-commit-actions.test.js +0 -102
  44. package/build/test/draft-note-schema.test.js +0 -44
  45. package/build/test/dynamic-api-url-allowlist.test.js +0 -121
  46. package/build/test/dynamic-api-url-test.js +0 -304
  47. package/build/test/dynamic-routing-tests.js +0 -495
  48. package/build/test/get-merge-request-discussion-schema.test.js +0 -30
  49. package/build/test/gitlab-api-tool-errors.test.js +0 -112
  50. package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
  51. package/build/test/gitlab-diff-schema.test.js +0 -36
  52. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  53. package/build/test/gitlab-tag-schema.test.js +0 -51
  54. package/build/test/group-milestone-schema.test.js +0 -45
  55. package/build/test/mcp-oauth-tests.js +0 -905
  56. package/build/test/mcp-server-name.test.js +0 -87
  57. package/build/test/merge-merge-request-schema.test.js +0 -75
  58. package/build/test/multi-server-test.js +0 -190
  59. package/build/test/no-proxy-integration-test.js +0 -183
  60. package/build/test/no-proxy-test.js +0 -149
  61. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  62. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  63. package/build/test/oauth-device-flow-tests.js +0 -450
  64. package/build/test/oauth-download-url.test.js +0 -157
  65. package/build/test/oauth-startup.test.js +0 -204
  66. package/build/test/oauth-tests.js +0 -428
  67. package/build/test/path-segment-encoding.test.js +0 -98
  68. package/build/test/remote-auth-simple-test.js +0 -277
  69. package/build/test/remote-auth-tests.js +0 -314
  70. package/build/test/response-masking.test.js +0 -445
  71. package/build/test/schema-tests.js +0 -1679
  72. package/build/test/server/metrics.test.js +0 -47
  73. package/build/test/server/request-helpers.test.js +0 -48
  74. package/build/test/server/version.test.js +0 -13
  75. package/build/test/sse-auth-guard.test.js +0 -96
  76. package/build/test/sse-session-limits.test.js +0 -273
  77. package/build/test/stateless/callback-proxy.test.js +0 -510
  78. package/build/test/stateless/client-id.test.js +0 -177
  79. package/build/test/stateless/codec.test.js +0 -328
  80. package/build/test/stateless/config-ttl.test.js +0 -149
  81. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  82. package/build/test/stateless/session-id-integration.test.js +0 -675
  83. package/build/test/stateless/session-id.test.js +0 -157
  84. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  85. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  86. package/build/test/streamable-http-sse-stream.test.js +0 -350
  87. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  88. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  89. package/build/test/test-all-transport-server.js +0 -255
  90. package/build/test/test-auth-retry.js +0 -195
  91. package/build/test/test-blank-filters.js +0 -156
  92. package/build/test/test-ci-catalog.js +0 -177
  93. package/build/test/test-ci-lint.js +0 -191
  94. package/build/test/test-ci-variables.js +0 -309
  95. package/build/test/test-create-repository.js +0 -120
  96. package/build/test/test-dependency-proxy.js +0 -195
  97. package/build/test/test-deployment-tools.js +0 -463
  98. package/build/test/test-download-attachment.js +0 -190
  99. package/build/test/test-dynamic-project-scope.js +0 -524
  100. package/build/test/test-empty-toolsets.js +0 -535
  101. package/build/test/test-get-file-blame.js +0 -145
  102. package/build/test/test-get-file-contents.js +0 -128
  103. package/build/test/test-geteffectiveprojectid.js +0 -804
  104. package/build/test/test-health-check.js +0 -115
  105. package/build/test/test-issue-description-patch.js +0 -256
  106. package/build/test/test-job-artifacts.js +0 -275
  107. package/build/test/test-json-schema.js +0 -166
  108. package/build/test/test-list-group-members.js +0 -150
  109. package/build/test/test-list-group-merge-requests.js +0 -165
  110. package/build/test/test-list-issues.js +0 -170
  111. package/build/test/test-list-merge-requests.js +0 -201
  112. package/build/test/test-list-milestones.js +0 -145
  113. package/build/test/test-list-project-members.js +0 -132
  114. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  115. package/build/test/test-merge-request-approvals.js +0 -187
  116. package/build/test/test-merge-request-pipelines.js +0 -106
  117. package/build/test/test-mr-diffs-filter.js +0 -132
  118. package/build/test/test-mr-file-diffs.js +0 -251
  119. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  120. package/build/test/test-orbit.js +0 -155
  121. package/build/test/test-permission-mode.js +0 -424
  122. package/build/test/test-pipeline-schedules.js +0 -484
  123. package/build/test/test-protected-branches.js +0 -174
  124. package/build/test/test-remote-downloads.js +0 -510
  125. package/build/test/test-search-code.js +0 -275
  126. package/build/test/test-snippets.js +0 -735
  127. package/build/test/test-tags.js +0 -220
  128. package/build/test/test-todos.js +0 -195
  129. package/build/test/test-token-optimizations.js +0 -691
  130. package/build/test/test-toolset-filtering.js +0 -499
  131. package/build/test/test-update-issue-slim.js +0 -141
  132. package/build/test/test-update-project.js +0 -112
  133. package/build/test/test-upload-markdown.js +0 -206
  134. package/build/test/test-vulnerabilities.js +0 -399
  135. package/build/test/test-webhooks.js +0 -218
  136. package/build/test/tool-description-quality.test.js +0 -50
  137. package/build/test/utils/bulk-publish-options.test.js +0 -32
  138. package/build/test/utils/download-token.test.js +0 -35
  139. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  140. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  141. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  142. package/build/test/utils/graphql-query.test.js +0 -168
  143. package/build/test/utils/helpers-path.test.js +0 -104
  144. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  145. package/build/test/utils/masking.test.js +0 -384
  146. package/build/test/utils/merge-request-position.test.js +0 -57
  147. package/build/test/utils/mock-gitlab-server.js +0 -614
  148. package/build/test/utils/proxy-client-ip.test.js +0 -28
  149. package/build/test/utils/redact-sensitive.test.js +0 -32
  150. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  151. package/build/test/utils/server-launcher.js +0 -231
  152. package/build/test/utils/tool-args.test.js +0 -210
  153. package/build/test/utils/url.test.js +0 -141
  154. package/build/test/utils/version-check.test.js +0 -239
  155. package/build/test/utils/wiki-title.test.js +0 -21
  156. package/build/test-note.js +0 -54
  157. package/build/test-resolve-issue-note.js +0 -127
@@ -1,510 +0,0 @@
1
- /**
2
- * Cross-pod integration tests for the callback-proxy flow in stateless mode.
3
- *
4
- * Verifies that /authorize, /callback, and /token can all land on different
5
- * provider instances sharing only OAUTH_STATELESS_SECRET, reproducing the
6
- * exact HPA scenario that breaks today's _pendingAuth / _storedTokens
7
- * BoundedLRUMaps.
8
- *
9
- * GitLab's /oauth/token endpoint is stubbed via a fetch monkey-patch so the
10
- * tests run offline.
11
- */
12
- import assert from "node:assert/strict";
13
- import { randomBytes, createHash } from "node:crypto";
14
- import { after, afterEach, beforeEach, describe, test } from "node:test";
15
- import { createGitLabOAuthProvider } from "../../oauth-proxy.js";
16
- import { loadKeyMaterialFromEnv, looksLikeStatelessState, looksLikeStatelessStoredTokensCode, mintPendingAuthState, mintStoredTokensCode, openPendingAuthState, openStoredTokensCode, } from "../../stateless/index.js";
17
- // ---------------------------------------------------------------------------
18
- // Fixtures
19
- // ---------------------------------------------------------------------------
20
- const GITLAB_BASE = "https://gitlab.example.com";
21
- const CALLBACK_URL = "https://mcp.example.com/callback";
22
- const CLIENT_REDIRECT = "https://client.example.com/cb";
23
- function secret() {
24
- return randomBytes(32).toString("base64url");
25
- }
26
- function loadMaterial(current, previous) {
27
- const env = {
28
- OAUTH_STATELESS_SECRET: current,
29
- };
30
- if (previous)
31
- env.OAUTH_STATELESS_SECRET_PREVIOUS = previous;
32
- const m = loadKeyMaterialFromEnv(true, env);
33
- assert.ok(m);
34
- return m;
35
- }
36
- function makeProvider(material, { callbackProxy = true } = {}) {
37
- return createGitLabOAuthProvider(GITLAB_BASE, "real-gitlab-app-id", "Test Server", false, undefined, undefined, callbackProxy, callbackProxy ? CALLBACK_URL : "", material
38
- ? {
39
- material,
40
- clientTtlSeconds: 86400,
41
- pendingTtlSeconds: 600,
42
- storedTtlSeconds: 600,
43
- }
44
- : null);
45
- }
46
- function installFetchStub(response) {
47
- const origFetch = globalThis.fetch;
48
- const calls = [];
49
- // Use the typed global fetch signature so TS is happy.
50
- globalThis.fetch = (async (input, init) => {
51
- const url = typeof input === "string" ? input : input.toString();
52
- const bodyText = typeof init?.body === "string" ? init.body : "";
53
- const body = {};
54
- new URLSearchParams(bodyText).forEach((v, k) => (body[k] = v));
55
- calls.push({ url, body });
56
- return new Response(JSON.stringify({
57
- access_token: response.access_token,
58
- refresh_token: response.refresh_token ?? "r-token",
59
- token_type: response.token_type ?? "Bearer",
60
- expires_in: response.expires_in ?? 7200,
61
- }), {
62
- status: 200,
63
- headers: { "content-type": "application/json" },
64
- });
65
- });
66
- return {
67
- restore: () => {
68
- globalThis.fetch = origFetch;
69
- },
70
- calls,
71
- };
72
- }
73
- function makeFakeRes() {
74
- const res = {
75
- statusCode: 200,
76
- body: null,
77
- redirectedTo: null,
78
- status(code) {
79
- this.statusCode = code;
80
- return this;
81
- },
82
- send(text) {
83
- this.body = text;
84
- return this;
85
- },
86
- redirect(url) {
87
- this.statusCode = 302;
88
- this.redirectedTo = url;
89
- return this;
90
- },
91
- };
92
- return res;
93
- }
94
- function fakeReq(query) {
95
- return { query };
96
- }
97
- function makeAuthorizeRes() {
98
- const r = {
99
- redirectedTo: null,
100
- redirect(url) {
101
- r.redirectedTo = url;
102
- },
103
- };
104
- return r;
105
- }
106
- // ---------------------------------------------------------------------------
107
- // Unit-level tests for the mint/open helpers
108
- // ---------------------------------------------------------------------------
109
- describe("mintPendingAuthState / openPendingAuthState", () => {
110
- test("roundtrip across pods", () => {
111
- const s = secret();
112
- const podA = loadMaterial(s);
113
- const podB = loadMaterial(s);
114
- const state = mintPendingAuthState(podA, {
115
- clientId: "v1.cid.xyz",
116
- clientRedirectUri: CLIENT_REDIRECT,
117
- clientState: "client-state-123",
118
- clientCodeChallenge: "challenge-abc",
119
- proxyCodeVerifier: "proxy-verifier-def",
120
- });
121
- assert.ok(looksLikeStatelessState(state));
122
- const opened = openPendingAuthState(podB, state, 600);
123
- assert.ok(opened);
124
- assert.equal(opened.cid, "v1.cid.xyz");
125
- assert.equal(opened.cru, CLIENT_REDIRECT);
126
- assert.equal(opened.cs, "client-state-123");
127
- assert.equal(opened.ccc, "challenge-abc");
128
- assert.equal(opened.pcv, "proxy-verifier-def");
129
- });
130
- test("tampered state rejected", () => {
131
- const m = loadMaterial(secret());
132
- const state = mintPendingAuthState(m, {
133
- clientId: "a",
134
- clientRedirectUri: "b",
135
- clientCodeChallenge: "c",
136
- proxyCodeVerifier: "d",
137
- });
138
- const parts = state.split(".");
139
- const blob = Buffer.from(parts[2], "base64url");
140
- blob[Math.floor(blob.length / 2)] ^= 0xff;
141
- parts[2] = blob.toString("base64url");
142
- const bad = parts.join(".");
143
- assert.equal(openPendingAuthState(m, bad, 600), null);
144
- });
145
- test("expired state rejected", () => {
146
- const m = loadMaterial(secret());
147
- const past = Math.floor(Date.now() / 1000) - 3600;
148
- const state = mintPendingAuthState(m, {
149
- clientId: "a",
150
- clientRedirectUri: "b",
151
- clientCodeChallenge: "c",
152
- proxyCodeVerifier: "d",
153
- now: () => past,
154
- });
155
- assert.equal(openPendingAuthState(m, state, 60), null);
156
- });
157
- test("omitted clientState roundtrips", () => {
158
- const m = loadMaterial(secret());
159
- const state = mintPendingAuthState(m, {
160
- clientId: "a",
161
- clientRedirectUri: "b",
162
- clientCodeChallenge: "c",
163
- proxyCodeVerifier: "d",
164
- });
165
- const opened = openPendingAuthState(m, state, 600);
166
- assert.ok(opened);
167
- assert.equal(opened.cs, undefined);
168
- });
169
- });
170
- describe("mintStoredTokensCode / openStoredTokensCode", () => {
171
- test("roundtrip across pods", () => {
172
- const s = secret();
173
- const podA = loadMaterial(s);
174
- const podB = loadMaterial(s);
175
- const tokens = {
176
- access_token: "gitlab-access-token-value",
177
- refresh_token: "gitlab-refresh-token-value",
178
- token_type: "Bearer",
179
- expires_in: 7200,
180
- };
181
- const code = mintStoredTokensCode(podA, {
182
- tokens,
183
- clientId: "v1.cid.xyz",
184
- clientRedirectUri: CLIENT_REDIRECT,
185
- clientCodeChallenge: "challenge",
186
- });
187
- assert.ok(looksLikeStatelessStoredTokensCode(code));
188
- const opened = openStoredTokensCode(podB, code, 600);
189
- assert.ok(opened);
190
- assert.equal(opened.t.access_token, tokens.access_token);
191
- assert.equal(opened.t.refresh_token, tokens.refresh_token);
192
- assert.equal(opened.cid, "v1.cid.xyz");
193
- });
194
- });
195
- // ---------------------------------------------------------------------------
196
- // Cross-pod provider integration: /authorize on A → /callback on B → /token on C
197
- // ---------------------------------------------------------------------------
198
- describe("callback-proxy cross-pod flow (stateless)", () => {
199
- let stub;
200
- beforeEach(() => {
201
- stub = installFetchStub({ access_token: "gl-access-123" });
202
- });
203
- afterEach(() => {
204
- stub.restore();
205
- });
206
- test("authorize(A) → callback(B) → exchangeAuthorizationCode(C) all work", async () => {
207
- const sharedSecret = secret();
208
- const podA = makeProvider(loadMaterial(sharedSecret));
209
- const podB = makeProvider(loadMaterial(sharedSecret));
210
- const podC = makeProvider(loadMaterial(sharedSecret));
211
- // Step 1: register client on podA (signed client_id → every pod can read it)
212
- const registered = await podA.clientsStore.registerClient({
213
- redirect_uris: [CLIENT_REDIRECT],
214
- token_endpoint_auth_method: "none",
215
- });
216
- // Step 2: authorize on podA — captures the redirect URL to GitLab
217
- const clientCodeVerifier = randomBytes(32).toString("base64url");
218
- const clientCodeChallenge = createHash("sha256")
219
- .update(clientCodeVerifier)
220
- .digest("base64url");
221
- const authorizeRes = makeAuthorizeRes();
222
- await podA.authorize(registered, {
223
- state: "client-state-xyz",
224
- scopes: ["api"],
225
- redirectUri: CLIENT_REDIRECT,
226
- codeChallenge: clientCodeChallenge,
227
- }, authorizeRes);
228
- assert.ok(authorizeRes.redirectedTo, "authorize should redirect");
229
- const gitlabUrl = new URL(authorizeRes.redirectedTo);
230
- assert.equal(gitlabUrl.origin + gitlabUrl.pathname, `${GITLAB_BASE}/oauth/authorize`);
231
- const proxyState = gitlabUrl.searchParams.get("state");
232
- assert.ok(proxyState, "proxy state present");
233
- assert.ok(looksLikeStatelessState(proxyState), "state should be sealed in stateless mode");
234
- // Step 3: GitLab redirects to our /callback — on a DIFFERENT pod (B).
235
- // No state shared; only OAUTH_STATELESS_SECRET is shared.
236
- const cbReq = fakeReq({ code: "gitlab-auth-code-abc", state: proxyState });
237
- const cbRes = makeFakeRes();
238
- await podB.handleCallback(cbReq, cbRes);
239
- assert.equal(cbRes.statusCode, 302, "callback should redirect to client");
240
- assert.ok(cbRes.redirectedTo);
241
- const clientCallbackUrl = new URL(cbRes.redirectedTo);
242
- assert.equal(clientCallbackUrl.origin + clientCallbackUrl.pathname, CLIENT_REDIRECT);
243
- assert.equal(clientCallbackUrl.searchParams.get("state"), "client-state-xyz");
244
- const proxyCode = clientCallbackUrl.searchParams.get("code");
245
- assert.ok(proxyCode);
246
- assert.ok(looksLikeStatelessStoredTokensCode(proxyCode), "proxy code should be sealed in stateless mode");
247
- // Verify the GitLab token exchange happened with the correct verifier.
248
- assert.equal(stub.calls.length, 1);
249
- assert.equal(stub.calls[0].url, `${GITLAB_BASE}/oauth/token`);
250
- assert.equal(stub.calls[0].body.code, "gitlab-auth-code-abc");
251
- assert.equal(stub.calls[0].body.redirect_uri, CALLBACK_URL);
252
- assert.ok(stub.calls[0].body.code_verifier, "proxy code_verifier in token call");
253
- // Step 4: MCP client redeems the proxy code on a THIRD pod (C).
254
- const podCClient = await podC.clientsStore.getClient(registered.client_id);
255
- assert.ok(podCClient);
256
- const tokens = await podC.exchangeAuthorizationCode(podCClient, proxyCode, clientCodeVerifier, CLIENT_REDIRECT);
257
- assert.equal(tokens.access_token, "gl-access-123");
258
- });
259
- test("callback rejects repeated query parameters", async () => {
260
- const pod = makeProvider(loadMaterial(secret()));
261
- const cbRes = makeFakeRes();
262
- await pod.handleCallback(fakeReq({ code: "gitlab-code", state: ["one", "two"] }), cbRes);
263
- assert.equal(cbRes.statusCode, 400);
264
- assert.equal(cbRes.body, "Invalid query parameter");
265
- assert.equal(stub.calls.length, 0);
266
- });
267
- test("exchangeAuthorizationCode rejects wrong PKCE verifier", async () => {
268
- const s = secret();
269
- const pod = makeProvider(loadMaterial(s));
270
- const registered = await pod.clientsStore.registerClient({
271
- redirect_uris: [CLIENT_REDIRECT],
272
- token_endpoint_auth_method: "none",
273
- });
274
- const correctVerifier = randomBytes(32).toString("base64url");
275
- const correctChallenge = createHash("sha256")
276
- .update(correctVerifier)
277
- .digest("base64url");
278
- const authorizeRes = makeAuthorizeRes();
279
- await pod.authorize(registered, {
280
- state: "s",
281
- scopes: ["api"],
282
- redirectUri: CLIENT_REDIRECT,
283
- codeChallenge: correctChallenge,
284
- }, authorizeRes);
285
- const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
286
- const cbRes = makeFakeRes();
287
- await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
288
- const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
289
- // Redeem with wrong code_verifier
290
- const wrongVerifier = randomBytes(32).toString("base64url");
291
- await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
292
- });
293
- test("failed PKCE does not burn the code; legitimate exchange still succeeds", async () => {
294
- const s = secret();
295
- const pod = makeProvider(loadMaterial(s));
296
- const registered = await pod.clientsStore.registerClient({
297
- redirect_uris: [CLIENT_REDIRECT],
298
- token_endpoint_auth_method: "none",
299
- });
300
- const correctVerifier = randomBytes(32).toString("base64url");
301
- const correctChallenge = createHash("sha256")
302
- .update(correctVerifier)
303
- .digest("base64url");
304
- const authorizeRes = makeAuthorizeRes();
305
- await pod.authorize(registered, {
306
- state: "s",
307
- scopes: ["api"],
308
- redirectUri: CLIENT_REDIRECT,
309
- codeChallenge: correctChallenge,
310
- }, authorizeRes);
311
- const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
312
- const cbRes = makeFakeRes();
313
- await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
314
- const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
315
- const wrongVerifier = randomBytes(32).toString("base64url");
316
- await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
317
- const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, correctVerifier, CLIENT_REDIRECT);
318
- assert.equal(tokens.access_token, "gl-access-123");
319
- });
320
- test("successful exchange then replay is rejected on the same pod", async () => {
321
- const s = secret();
322
- const pod = makeProvider(loadMaterial(s));
323
- const registered = await pod.clientsStore.registerClient({
324
- redirect_uris: [CLIENT_REDIRECT],
325
- token_endpoint_auth_method: "none",
326
- });
327
- const verifier = randomBytes(32).toString("base64url");
328
- const challenge = createHash("sha256").update(verifier).digest("base64url");
329
- const authorizeRes = makeAuthorizeRes();
330
- await pod.authorize(registered, {
331
- state: "s",
332
- scopes: ["api"],
333
- redirectUri: CLIENT_REDIRECT,
334
- codeChallenge: challenge,
335
- }, authorizeRes);
336
- const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
337
- const cbRes = makeFakeRes();
338
- await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
339
- const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
340
- const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
341
- assert.equal(tokens.access_token, "gl-access-123");
342
- await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT), /Authorization code already used/);
343
- });
344
- test("exchangeAuthorizationCode reports busy when replay cache is full", async () => {
345
- const s = secret();
346
- const pod = makeProvider(loadMaterial(s));
347
- const registered = await pod.clientsStore.registerClient({
348
- redirect_uris: [CLIENT_REDIRECT],
349
- token_endpoint_auth_method: "none",
350
- });
351
- const verifier = randomBytes(32).toString("base64url");
352
- const challenge = createHash("sha256").update(verifier).digest("base64url");
353
- const authorizeRes = makeAuthorizeRes();
354
- await pod.authorize(registered, {
355
- state: "s",
356
- scopes: ["api"],
357
- redirectUri: CLIENT_REDIRECT,
358
- codeChallenge: challenge,
359
- }, authorizeRes);
360
- const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
361
- const cbRes = makeFakeRes();
362
- await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
363
- const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
364
- const cache = pod._usedProxyCodes;
365
- for (let i = 0; i < cache.maxSize; i++) {
366
- cache.tryReserve(`fill-${i}`, 600);
367
- }
368
- await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT), /Authorization server busy/);
369
- });
370
- test("exchangeAuthorizationCode rejects wrong client_id", async () => {
371
- const s = secret();
372
- const pod = makeProvider(loadMaterial(s));
373
- const r1 = await pod.clientsStore.registerClient({
374
- redirect_uris: [CLIENT_REDIRECT],
375
- token_endpoint_auth_method: "none",
376
- });
377
- const r2 = await pod.clientsStore.registerClient({
378
- redirect_uris: [CLIENT_REDIRECT],
379
- token_endpoint_auth_method: "none",
380
- });
381
- const verifier = randomBytes(32).toString("base64url");
382
- const challenge = createHash("sha256").update(verifier).digest("base64url");
383
- const aRes = makeAuthorizeRes();
384
- await pod.authorize(r1, {
385
- state: "s",
386
- scopes: ["api"],
387
- redirectUri: CLIENT_REDIRECT,
388
- codeChallenge: challenge,
389
- }, aRes);
390
- const proxyState = new URL(aRes.redirectedTo).searchParams.get("state");
391
- const cbRes = makeFakeRes();
392
- await pod.handleCallback(fakeReq({ code: "g", state: proxyState }), cbRes);
393
- const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
394
- // Try to redeem as r2 (different client_id)
395
- await assert.rejects(() => pod.exchangeAuthorizationCode(r2, proxyCode, verifier, CLIENT_REDIRECT), /Invalid client for authorization code/);
396
- // Failed binding must not burn the code for the legitimate client.
397
- const tokens = await pod.exchangeAuthorizationCode(r1, proxyCode, verifier, CLIENT_REDIRECT);
398
- assert.equal(tokens.access_token, "gl-access-123");
399
- });
400
- test("exchangeAuthorizationCode rejects wrong redirect_uri", async () => {
401
- const s = secret();
402
- const pod = makeProvider(loadMaterial(s));
403
- const registered = await pod.clientsStore.registerClient({
404
- redirect_uris: [CLIENT_REDIRECT],
405
- token_endpoint_auth_method: "none",
406
- });
407
- const verifier = randomBytes(32).toString("base64url");
408
- const challenge = createHash("sha256").update(verifier).digest("base64url");
409
- const aRes = makeAuthorizeRes();
410
- await pod.authorize(registered, {
411
- state: "s",
412
- scopes: ["api"],
413
- redirectUri: CLIENT_REDIRECT,
414
- codeChallenge: challenge,
415
- }, aRes);
416
- const proxyState = new URL(aRes.redirectedTo).searchParams.get("state");
417
- const cbRes = makeFakeRes();
418
- await pod.handleCallback(fakeReq({ code: "g", state: proxyState }), cbRes);
419
- const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
420
- await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, "https://attacker.example/cb"), /Invalid redirect_uri for authorization code/);
421
- // Failed binding must not burn the code for the legitimate client.
422
- const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
423
- assert.equal(tokens.access_token, "gl-access-123");
424
- });
425
- test("stale state rejected at /callback", async () => {
426
- const s = secret();
427
- const pod = makeProvider(loadMaterial(s));
428
- // Bogus state that wasn't minted by this provider (or wasn't minted at all)
429
- const cbRes = makeFakeRes();
430
- await pod.handleCallback(fakeReq({ code: "g", state: "v1.ps.totally-bogus" }), cbRes);
431
- assert.equal(cbRes.statusCode, 400);
432
- assert.match(cbRes.body ?? "", /Unknown or expired state/i);
433
- });
434
- test("legacy non-stateless provider still works (no regression)", async () => {
435
- const pod = makeProvider(null);
436
- const registered = await pod.clientsStore.registerClient({
437
- redirect_uris: [CLIENT_REDIRECT],
438
- token_endpoint_auth_method: "none",
439
- });
440
- const verifier = randomBytes(32).toString("base64url");
441
- const challenge = createHash("sha256").update(verifier).digest("base64url");
442
- const aRes = makeAuthorizeRes();
443
- await pod.authorize(registered, {
444
- state: "s",
445
- scopes: ["api"],
446
- redirectUri: CLIENT_REDIRECT,
447
- codeChallenge: challenge,
448
- }, aRes);
449
- const legacyState = new URL(aRes.redirectedTo).searchParams.get("state");
450
- assert.ok(!looksLikeStatelessState(legacyState), "legacy state is a UUID, not sealed");
451
- const cbRes = makeFakeRes();
452
- await pod.handleCallback(fakeReq({ code: "g", state: legacyState }), cbRes);
453
- assert.equal(cbRes.statusCode, 302);
454
- const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
455
- assert.ok(!looksLikeStatelessStoredTokensCode(proxyCode), "legacy code is a UUID");
456
- const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
457
- assert.equal(tokens.access_token, "gl-access-123");
458
- });
459
- test("legacy: failed PKCE does not burn the code; legitimate exchange still succeeds", async () => {
460
- const pod = makeProvider(null);
461
- const registered = await pod.clientsStore.registerClient({
462
- redirect_uris: [CLIENT_REDIRECT],
463
- token_endpoint_auth_method: "none",
464
- });
465
- const correctVerifier = randomBytes(32).toString("base64url");
466
- const correctChallenge = createHash("sha256")
467
- .update(correctVerifier)
468
- .digest("base64url");
469
- const aRes = makeAuthorizeRes();
470
- await pod.authorize(registered, {
471
- state: "s",
472
- scopes: ["api"],
473
- redirectUri: CLIENT_REDIRECT,
474
- codeChallenge: correctChallenge,
475
- }, aRes);
476
- const legacyState = new URL(aRes.redirectedTo).searchParams.get("state");
477
- const cbRes = makeFakeRes();
478
- await pod.handleCallback(fakeReq({ code: "g", state: legacyState }), cbRes);
479
- const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
480
- const wrongVerifier = randomBytes(32).toString("base64url");
481
- await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
482
- const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, correctVerifier, CLIENT_REDIRECT);
483
- assert.equal(tokens.access_token, "gl-access-123");
484
- });
485
- test("different secret on pod B rejects the sealed state", async () => {
486
- const podA = makeProvider(loadMaterial(secret()));
487
- const podB = makeProvider(loadMaterial(secret())); // different secret
488
- const registered = await podA.clientsStore.registerClient({
489
- redirect_uris: [CLIENT_REDIRECT],
490
- token_endpoint_auth_method: "none",
491
- });
492
- const verifier = randomBytes(32).toString("base64url");
493
- const challenge = createHash("sha256").update(verifier).digest("base64url");
494
- const aRes = makeAuthorizeRes();
495
- await podA.authorize(registered, {
496
- state: "s",
497
- scopes: ["api"],
498
- redirectUri: CLIENT_REDIRECT,
499
- codeChallenge: challenge,
500
- }, aRes);
501
- const proxyState = new URL(aRes.redirectedTo).searchParams.get("state");
502
- const cbRes = makeFakeRes();
503
- await podB.handleCallback(fakeReq({ code: "g", state: proxyState }), cbRes);
504
- assert.equal(cbRes.statusCode, 400);
505
- assert.match(cbRes.body ?? "", /Unknown or expired state/i);
506
- });
507
- });
508
- after(() => {
509
- // no global resources
510
- });