@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.
- package/README.ko.md +10 -4
- package/README.md +238 -233
- package/README.pt-BR.md +4 -4
- package/README.zh-CN.md +6 -4
- package/build/config.js +12 -4
- package/build/deprecated-env.js +47 -0
- package/build/index.js +456 -237
- package/build/oauth-device-flow.js +80 -0
- package/build/oauth.js +135 -18
- package/build/schemas.js +1 -1
- package/build/server/remote-http-stack.js +39 -0
- package/build/tools/discover-activation.js +38 -0
- package/build/tools/exposed-tools.js +35 -0
- package/build/tools/listed-tools.js +76 -0
- package/build/tools/registry.js +226 -57
- package/build/tools/tool-profile.js +66 -0
- package/build/utils/compact-tool-allowlist.js +14 -0
- package/build/utils/logger.js +34 -12
- package/build/utils/url.js +15 -0
- package/package.json +3 -3
- package/build/scripts/check-skill-sync.js +0 -143
- package/build/scripts/generate-server-card.js +0 -39
- package/build/scripts/generate-tool-coverage.js +0 -58
- package/build/scripts/generate-tool-docs.js +0 -430
- package/build/scripts/tool-coverage/coverage.js +0 -341
- package/build/scripts/tool-coverage/coverage.test.js +0 -142
- package/build/test/callback-proxy-tests.js +0 -321
- package/build/test/cli/args.test.js +0 -175
- package/build/test/cli/boolean-flags.test.js +0 -106
- package/build/test/cli/compact-mcp-result.test.js +0 -93
- package/build/test/cli/exposure.test.js +0 -104
- package/build/test/cli/load-config-snapshot.js +0 -5
- package/build/test/cli/output.test.js +0 -74
- package/build/test/cli/replay-command.test.js +0 -37
- package/build/test/cli/router.test.js +0 -183
- package/build/test/client-pool-test.js +0 -109
- package/build/test/clients/client.js +0 -34
- package/build/test/clients/custom-header-client.js +0 -122
- package/build/test/clients/sse-client.js +0 -95
- package/build/test/clients/stdio-client.js +0 -113
- package/build/test/clients/streamable-http-client.js +0 -95
- package/build/test/config-allowed-groups.test.js +0 -97
- package/build/test/create-commit-actions.test.js +0 -102
- package/build/test/draft-note-schema.test.js +0 -44
- package/build/test/dynamic-api-url-allowlist.test.js +0 -121
- package/build/test/dynamic-api-url-test.js +0 -304
- package/build/test/dynamic-routing-tests.js +0 -495
- package/build/test/get-merge-request-discussion-schema.test.js +0 -30
- package/build/test/gitlab-artifact-entry-schema.test.js +0 -44
- package/build/test/gitlab-diff-schema.test.js +0 -36
- package/build/test/gitlab-pipeline-schedule-schema.test.js +0 -32
- package/build/test/gitlab-tag-schema.test.js +0 -51
- package/build/test/group-milestone-schema.test.js +0 -45
- package/build/test/mcp-oauth-tests.js +0 -905
- package/build/test/mcp-server-name.test.js +0 -87
- package/build/test/merge-merge-request-schema.test.js +0 -75
- package/build/test/multi-server-test.js +0 -190
- package/build/test/no-proxy-integration-test.js +0 -183
- package/build/test/no-proxy-test.js +0 -149
- package/build/test/nullable-gitlab-response-fields.test.js +0 -37
- package/build/test/nullish-tool-arguments-schema.test.js +0 -118
- package/build/test/oauth-device-flow-tests.js +0 -450
- package/build/test/oauth-startup.test.js +0 -204
- package/build/test/oauth-tests.js +0 -428
- package/build/test/path-segment-encoding.test.js +0 -98
- package/build/test/remote-auth-simple-test.js +0 -277
- package/build/test/remote-auth-tests.js +0 -314
- package/build/test/response-masking.test.js +0 -445
- package/build/test/schema-tests.js +0 -1679
- package/build/test/server/metrics.test.js +0 -47
- package/build/test/server/request-helpers.test.js +0 -48
- package/build/test/server/version.test.js +0 -13
- package/build/test/sse-auth-guard.test.js +0 -96
- package/build/test/sse-session-limits.test.js +0 -273
- package/build/test/stateless/callback-proxy.test.js +0 -510
- package/build/test/stateless/client-id.test.js +0 -177
- package/build/test/stateless/codec.test.js +0 -328
- package/build/test/stateless/config-ttl.test.js +0 -149
- package/build/test/stateless/consumed-proxy-code-cache.test.js +0 -150
- package/build/test/stateless/session-id-integration.test.js +0 -675
- package/build/test/stateless/session-id.test.js +0 -157
- package/build/test/streamable-http-concurrent-session.test.js +0 -171
- package/build/test/streamable-http-dns-rebinding.test.js +0 -185
- package/build/test/streamable-http-sse-stream.test.js +0 -350
- package/build/test/streamable-http-static-token-auth.test.js +0 -157
- package/build/test/streamable-http-unauthenticated-discovery.test.js +0 -207
- package/build/test/test-all-transport-server.js +0 -255
- package/build/test/test-auth-retry.js +0 -195
- package/build/test/test-blank-filters.js +0 -156
- package/build/test/test-ci-catalog.js +0 -177
- package/build/test/test-ci-lint.js +0 -191
- package/build/test/test-ci-variables.js +0 -309
- package/build/test/test-create-repository.js +0 -120
- package/build/test/test-dependency-proxy.js +0 -195
- package/build/test/test-deployment-tools.js +0 -463
- package/build/test/test-download-attachment.js +0 -190
- package/build/test/test-dynamic-project-scope.js +0 -524
- package/build/test/test-empty-toolsets.js +0 -535
- package/build/test/test-get-file-blame.js +0 -145
- package/build/test/test-get-file-contents.js +0 -128
- package/build/test/test-geteffectiveprojectid.js +0 -812
- package/build/test/test-health-check.js +0 -115
- package/build/test/test-issue-description-patch.js +0 -256
- package/build/test/test-job-artifacts.js +0 -275
- package/build/test/test-json-schema.js +0 -166
- package/build/test/test-list-group-members.js +0 -150
- package/build/test/test-list-group-merge-requests.js +0 -165
- package/build/test/test-list-issues.js +0 -170
- package/build/test/test-list-merge-requests.js +0 -201
- package/build/test/test-list-milestones.js +0 -145
- package/build/test/test-list-project-members.js +0 -132
- package/build/test/test-merge-request-approval-state-tools.js +0 -213
- package/build/test/test-merge-request-approvals.js +0 -187
- package/build/test/test-merge-request-pipelines.js +0 -106
- package/build/test/test-mr-diffs-filter.js +0 -132
- package/build/test/test-mr-file-diffs.js +0 -251
- package/build/test/test-oauth-proxy-rate-limit.js +0 -133
- package/build/test/test-orbit.js +0 -155
- package/build/test/test-permission-mode.js +0 -424
- package/build/test/test-pipeline-schedules.js +0 -482
- package/build/test/test-protected-branches.js +0 -174
- package/build/test/test-remote-downloads.js +0 -510
- package/build/test/test-search-code.js +0 -275
- package/build/test/test-snippets.js +0 -735
- package/build/test/test-tags.js +0 -220
- package/build/test/test-todos.js +0 -195
- package/build/test/test-token-optimizations.js +0 -691
- package/build/test/test-toolset-filtering.js +0 -499
- package/build/test/test-update-issue-slim.js +0 -141
- package/build/test/test-update-project.js +0 -112
- package/build/test/test-upload-markdown.js +0 -206
- package/build/test/test-vulnerabilities.js +0 -399
- package/build/test/test-webhooks.js +0 -218
- package/build/test/tool-description-quality.test.js +0 -50
- package/build/test/utils/bulk-publish-options.test.js +0 -32
- package/build/test/utils/download-token.test.js +0 -35
- package/build/test/utils/forwarded-public-base-url.test.js +0 -46
- package/build/test/utils/gitlab-instance-version.test.js +0 -32
- package/build/test/utils/gitlab-version-gate.test.js +0 -74
- package/build/test/utils/graphql-query.test.js +0 -168
- package/build/test/utils/helpers-path.test.js +0 -104
- package/build/test/utils/jmespath-tool-result.test.js +0 -91
- package/build/test/utils/masking.test.js +0 -384
- package/build/test/utils/merge-request-position.test.js +0 -57
- package/build/test/utils/mock-gitlab-server.js +0 -614
- package/build/test/utils/proxy-client-ip.test.js +0 -28
- package/build/test/utils/redact-sensitive.test.js +0 -32
- package/build/test/utils/safe-redirect-fetch.test.js +0 -463
- package/build/test/utils/server-launcher.js +0 -231
- package/build/test/utils/tool-args.test.js +0 -210
- package/build/test/utils/url.test.js +0 -128
- package/build/test/utils/version-check.test.js +0 -239
- package/build/test/utils/wiki-title.test.js +0 -21
- package/build/test-note.js +0 -54
- 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
|
-
});
|