@zereight/mcp-gitlab 2.1.69 → 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 (155) hide show
  1. package/README.ko.md +10 -4
  2. package/README.md +238 -233
  3. package/README.pt-BR.md +4 -4
  4. package/README.zh-CN.md +6 -4
  5. package/build/config.js +12 -4
  6. package/build/deprecated-env.js +47 -0
  7. package/build/index.js +456 -237
  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/build/utils/url.js +15 -0
  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/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-artifact-entry-schema.test.js +0 -44
  50. package/build/test/gitlab-diff-schema.test.js +0 -36
  51. package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
  52. package/build/test/gitlab-tag-schema.test.js +0 -51
  53. package/build/test/group-milestone-schema.test.js +0 -45
  54. package/build/test/mcp-oauth-tests.js +0 -905
  55. package/build/test/mcp-server-name.test.js +0 -87
  56. package/build/test/merge-merge-request-schema.test.js +0 -75
  57. package/build/test/multi-server-test.js +0 -190
  58. package/build/test/no-proxy-integration-test.js +0 -183
  59. package/build/test/no-proxy-test.js +0 -149
  60. package/build/test/nullable-gitlab-response-fields.test.js +0 -37
  61. package/build/test/nullish-tool-arguments-schema.test.js +0 -118
  62. package/build/test/oauth-device-flow-tests.js +0 -450
  63. package/build/test/oauth-startup.test.js +0 -204
  64. package/build/test/oauth-tests.js +0 -428
  65. package/build/test/path-segment-encoding.test.js +0 -98
  66. package/build/test/remote-auth-simple-test.js +0 -277
  67. package/build/test/remote-auth-tests.js +0 -314
  68. package/build/test/response-masking.test.js +0 -445
  69. package/build/test/schema-tests.js +0 -1679
  70. package/build/test/server/metrics.test.js +0 -47
  71. package/build/test/server/request-helpers.test.js +0 -48
  72. package/build/test/server/version.test.js +0 -13
  73. package/build/test/sse-auth-guard.test.js +0 -96
  74. package/build/test/sse-session-limits.test.js +0 -273
  75. package/build/test/stateless/callback-proxy.test.js +0 -510
  76. package/build/test/stateless/client-id.test.js +0 -177
  77. package/build/test/stateless/codec.test.js +0 -328
  78. package/build/test/stateless/config-ttl.test.js +0 -149
  79. package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
  80. package/build/test/stateless/session-id-integration.test.js +0 -675
  81. package/build/test/stateless/session-id.test.js +0 -157
  82. package/build/test/streamable-http-concurrent-session.test.js +0 -171
  83. package/build/test/streamable-http-dns-rebinding.test.js +0 -185
  84. package/build/test/streamable-http-sse-stream.test.js +0 -350
  85. package/build/test/streamable-http-static-token-auth.test.js +0 -157
  86. package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
  87. package/build/test/test-all-transport-server.js +0 -255
  88. package/build/test/test-auth-retry.js +0 -195
  89. package/build/test/test-blank-filters.js +0 -156
  90. package/build/test/test-ci-catalog.js +0 -177
  91. package/build/test/test-ci-lint.js +0 -191
  92. package/build/test/test-ci-variables.js +0 -309
  93. package/build/test/test-create-repository.js +0 -120
  94. package/build/test/test-dependency-proxy.js +0 -195
  95. package/build/test/test-deployment-tools.js +0 -463
  96. package/build/test/test-download-attachment.js +0 -190
  97. package/build/test/test-dynamic-project-scope.js +0 -524
  98. package/build/test/test-empty-toolsets.js +0 -535
  99. package/build/test/test-get-file-blame.js +0 -145
  100. package/build/test/test-get-file-contents.js +0 -128
  101. package/build/test/test-geteffectiveprojectid.js +0 -812
  102. package/build/test/test-health-check.js +0 -115
  103. package/build/test/test-issue-description-patch.js +0 -256
  104. package/build/test/test-job-artifacts.js +0 -275
  105. package/build/test/test-json-schema.js +0 -166
  106. package/build/test/test-list-group-members.js +0 -150
  107. package/build/test/test-list-group-merge-requests.js +0 -165
  108. package/build/test/test-list-issues.js +0 -170
  109. package/build/test/test-list-merge-requests.js +0 -201
  110. package/build/test/test-list-milestones.js +0 -145
  111. package/build/test/test-list-project-members.js +0 -132
  112. package/build/test/test-merge-request-approval-state-tools.js +0 -213
  113. package/build/test/test-merge-request-approvals.js +0 -187
  114. package/build/test/test-merge-request-pipelines.js +0 -106
  115. package/build/test/test-mr-diffs-filter.js +0 -132
  116. package/build/test/test-mr-file-diffs.js +0 -251
  117. package/build/test/test-oauth-proxy-rate-limit.js +0 -133
  118. package/build/test/test-orbit.js +0 -155
  119. package/build/test/test-permission-mode.js +0 -424
  120. package/build/test/test-pipeline-schedules.js +0 -482
  121. package/build/test/test-protected-branches.js +0 -174
  122. package/build/test/test-remote-downloads.js +0 -510
  123. package/build/test/test-search-code.js +0 -275
  124. package/build/test/test-snippets.js +0 -735
  125. package/build/test/test-tags.js +0 -220
  126. package/build/test/test-todos.js +0 -195
  127. package/build/test/test-token-optimizations.js +0 -691
  128. package/build/test/test-toolset-filtering.js +0 -499
  129. package/build/test/test-update-issue-slim.js +0 -141
  130. package/build/test/test-update-project.js +0 -112
  131. package/build/test/test-upload-markdown.js +0 -206
  132. package/build/test/test-vulnerabilities.js +0 -399
  133. package/build/test/test-webhooks.js +0 -218
  134. package/build/test/tool-description-quality.test.js +0 -50
  135. package/build/test/utils/bulk-publish-options.test.js +0 -32
  136. package/build/test/utils/download-token.test.js +0 -35
  137. package/build/test/utils/forwarded-public-base-url.test.js +0 -46
  138. package/build/test/utils/gitlab-instance-version.test.js +0 -32
  139. package/build/test/utils/gitlab-version-gate.test.js +0 -74
  140. package/build/test/utils/graphql-query.test.js +0 -168
  141. package/build/test/utils/helpers-path.test.js +0 -104
  142. package/build/test/utils/jmespath-tool-result.test.js +0 -91
  143. package/build/test/utils/masking.test.js +0 -384
  144. package/build/test/utils/merge-request-position.test.js +0 -57
  145. package/build/test/utils/mock-gitlab-server.js +0 -614
  146. package/build/test/utils/proxy-client-ip.test.js +0 -28
  147. package/build/test/utils/redact-sensitive.test.js +0 -32
  148. package/build/test/utils/safe-redirect-fetch.test.js +0 -463
  149. package/build/test/utils/server-launcher.js +0 -231
  150. package/build/test/utils/tool-args.test.js +0 -210
  151. package/build/test/utils/url.test.js +0 -128
  152. package/build/test/utils/version-check.test.js +0 -239
  153. package/build/test/utils/wiki-title.test.js +0 -21
  154. package/build/test-note.js +0 -54
  155. 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
- });