@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,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
- });