@zereight/mcp-gitlab 2.1.4 → 2.1.6
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 +442 -0
- package/README.md +12 -0
- package/README.zh-CN.md +442 -0
- package/build/config.js +65 -2
- package/build/index.js +348 -5
- package/build/oauth-proxy.js +182 -47
- package/build/stateless/client-id.js +68 -0
- package/build/stateless/codec.js +205 -0
- package/build/stateless/errors.js +24 -0
- package/build/stateless/index.js +14 -0
- package/build/stateless/pending-auth.js +65 -0
- package/build/stateless/secret.js +98 -0
- package/build/stateless/session-id.js +68 -0
- package/build/stateless/stored-tokens.js +66 -0
- package/build/stateless/types.js +18 -0
- package/build/test/schema-tests.js +81 -3
- package/build/test/stateless/callback-proxy.test.js +393 -0
- package/build/test/stateless/client-id.test.js +176 -0
- package/build/test/stateless/codec.test.js +328 -0
- package/build/test/stateless/config-ttl.test.js +149 -0
- package/build/test/stateless/session-id-integration.test.js +675 -0
- package/build/test/stateless/session-id.test.js +131 -0
- package/build/test/test-json-schema.js +148 -0
- package/build/utils/schema.js +40 -6
- package/package.json +4 -3
|
@@ -0,0 +1,393 @@
|
|
|
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, 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("exchangeAuthorizationCode rejects wrong PKCE verifier", async () => {
|
|
260
|
+
const s = secret();
|
|
261
|
+
const pod = makeProvider(loadMaterial(s));
|
|
262
|
+
const registered = await pod.clientsStore.registerClient({
|
|
263
|
+
redirect_uris: [CLIENT_REDIRECT],
|
|
264
|
+
token_endpoint_auth_method: "none",
|
|
265
|
+
});
|
|
266
|
+
const correctVerifier = randomBytes(32).toString("base64url");
|
|
267
|
+
const correctChallenge = createHash("sha256")
|
|
268
|
+
.update(correctVerifier)
|
|
269
|
+
.digest("base64url");
|
|
270
|
+
const authorizeRes = makeAuthorizeRes();
|
|
271
|
+
await pod.authorize(registered, {
|
|
272
|
+
state: "s",
|
|
273
|
+
scopes: ["api"],
|
|
274
|
+
redirectUri: CLIENT_REDIRECT,
|
|
275
|
+
codeChallenge: correctChallenge,
|
|
276
|
+
}, authorizeRes);
|
|
277
|
+
const proxyState = new URL(authorizeRes.redirectedTo).searchParams.get("state");
|
|
278
|
+
const cbRes = makeFakeRes();
|
|
279
|
+
await pod.handleCallback(fakeReq({ code: "gitlab-code", state: proxyState }), cbRes);
|
|
280
|
+
const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
|
|
281
|
+
// Redeem with wrong code_verifier
|
|
282
|
+
const wrongVerifier = randomBytes(32).toString("base64url");
|
|
283
|
+
await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, wrongVerifier, CLIENT_REDIRECT), /PKCE verification failed/);
|
|
284
|
+
});
|
|
285
|
+
test("exchangeAuthorizationCode rejects wrong client_id", async () => {
|
|
286
|
+
const s = secret();
|
|
287
|
+
const pod = makeProvider(loadMaterial(s));
|
|
288
|
+
const r1 = await pod.clientsStore.registerClient({
|
|
289
|
+
redirect_uris: [CLIENT_REDIRECT],
|
|
290
|
+
token_endpoint_auth_method: "none",
|
|
291
|
+
});
|
|
292
|
+
const r2 = await pod.clientsStore.registerClient({
|
|
293
|
+
redirect_uris: [CLIENT_REDIRECT],
|
|
294
|
+
token_endpoint_auth_method: "none",
|
|
295
|
+
});
|
|
296
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
297
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
298
|
+
const aRes = makeAuthorizeRes();
|
|
299
|
+
await pod.authorize(r1, {
|
|
300
|
+
state: "s",
|
|
301
|
+
scopes: ["api"],
|
|
302
|
+
redirectUri: CLIENT_REDIRECT,
|
|
303
|
+
codeChallenge: challenge,
|
|
304
|
+
}, aRes);
|
|
305
|
+
const proxyState = new URL(aRes.redirectedTo).searchParams.get("state");
|
|
306
|
+
const cbRes = makeFakeRes();
|
|
307
|
+
await pod.handleCallback(fakeReq({ code: "g", state: proxyState }), cbRes);
|
|
308
|
+
const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
|
|
309
|
+
// Try to redeem as r2 (different client_id)
|
|
310
|
+
await assert.rejects(() => pod.exchangeAuthorizationCode(r2, proxyCode, verifier, CLIENT_REDIRECT), /Invalid client for authorization code/);
|
|
311
|
+
});
|
|
312
|
+
test("exchangeAuthorizationCode rejects wrong redirect_uri", async () => {
|
|
313
|
+
const s = secret();
|
|
314
|
+
const pod = makeProvider(loadMaterial(s));
|
|
315
|
+
const registered = await pod.clientsStore.registerClient({
|
|
316
|
+
redirect_uris: [CLIENT_REDIRECT],
|
|
317
|
+
token_endpoint_auth_method: "none",
|
|
318
|
+
});
|
|
319
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
320
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
321
|
+
const aRes = makeAuthorizeRes();
|
|
322
|
+
await pod.authorize(registered, {
|
|
323
|
+
state: "s",
|
|
324
|
+
scopes: ["api"],
|
|
325
|
+
redirectUri: CLIENT_REDIRECT,
|
|
326
|
+
codeChallenge: challenge,
|
|
327
|
+
}, aRes);
|
|
328
|
+
const proxyState = new URL(aRes.redirectedTo).searchParams.get("state");
|
|
329
|
+
const cbRes = makeFakeRes();
|
|
330
|
+
await pod.handleCallback(fakeReq({ code: "g", state: proxyState }), cbRes);
|
|
331
|
+
const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
|
|
332
|
+
await assert.rejects(() => pod.exchangeAuthorizationCode(registered, proxyCode, verifier, "https://attacker.example/cb"), /Invalid redirect_uri for authorization code/);
|
|
333
|
+
});
|
|
334
|
+
test("stale state rejected at /callback", async () => {
|
|
335
|
+
const s = secret();
|
|
336
|
+
const pod = makeProvider(loadMaterial(s));
|
|
337
|
+
// Bogus state that wasn't minted by this provider (or wasn't minted at all)
|
|
338
|
+
const cbRes = makeFakeRes();
|
|
339
|
+
await pod.handleCallback(fakeReq({ code: "g", state: "v1.ps.totally-bogus" }), cbRes);
|
|
340
|
+
assert.equal(cbRes.statusCode, 400);
|
|
341
|
+
assert.match(cbRes.body ?? "", /Unknown or expired state/i);
|
|
342
|
+
});
|
|
343
|
+
test("legacy non-stateless provider still works (no regression)", async () => {
|
|
344
|
+
const pod = makeProvider(null);
|
|
345
|
+
const registered = await pod.clientsStore.registerClient({
|
|
346
|
+
redirect_uris: [CLIENT_REDIRECT],
|
|
347
|
+
token_endpoint_auth_method: "none",
|
|
348
|
+
});
|
|
349
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
350
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
351
|
+
const aRes = makeAuthorizeRes();
|
|
352
|
+
await pod.authorize(registered, {
|
|
353
|
+
state: "s",
|
|
354
|
+
scopes: ["api"],
|
|
355
|
+
redirectUri: CLIENT_REDIRECT,
|
|
356
|
+
codeChallenge: challenge,
|
|
357
|
+
}, aRes);
|
|
358
|
+
const legacyState = new URL(aRes.redirectedTo).searchParams.get("state");
|
|
359
|
+
assert.ok(!looksLikeStatelessState(legacyState), "legacy state is a UUID, not sealed");
|
|
360
|
+
const cbRes = makeFakeRes();
|
|
361
|
+
await pod.handleCallback(fakeReq({ code: "g", state: legacyState }), cbRes);
|
|
362
|
+
assert.equal(cbRes.statusCode, 302);
|
|
363
|
+
const proxyCode = new URL(cbRes.redirectedTo).searchParams.get("code");
|
|
364
|
+
assert.ok(!looksLikeStatelessStoredTokensCode(proxyCode), "legacy code is a UUID");
|
|
365
|
+
const tokens = await pod.exchangeAuthorizationCode(registered, proxyCode, verifier, CLIENT_REDIRECT);
|
|
366
|
+
assert.equal(tokens.access_token, "gl-access-123");
|
|
367
|
+
});
|
|
368
|
+
test("different secret on pod B rejects the sealed state", async () => {
|
|
369
|
+
const podA = makeProvider(loadMaterial(secret()));
|
|
370
|
+
const podB = makeProvider(loadMaterial(secret())); // different secret
|
|
371
|
+
const registered = await podA.clientsStore.registerClient({
|
|
372
|
+
redirect_uris: [CLIENT_REDIRECT],
|
|
373
|
+
token_endpoint_auth_method: "none",
|
|
374
|
+
});
|
|
375
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
376
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
377
|
+
const aRes = makeAuthorizeRes();
|
|
378
|
+
await podA.authorize(registered, {
|
|
379
|
+
state: "s",
|
|
380
|
+
scopes: ["api"],
|
|
381
|
+
redirectUri: CLIENT_REDIRECT,
|
|
382
|
+
codeChallenge: challenge,
|
|
383
|
+
}, aRes);
|
|
384
|
+
const proxyState = new URL(aRes.redirectedTo).searchParams.get("state");
|
|
385
|
+
const cbRes = makeFakeRes();
|
|
386
|
+
await podB.handleCallback(fakeReq({ code: "g", state: proxyState }), cbRes);
|
|
387
|
+
assert.equal(cbRes.statusCode, 400);
|
|
388
|
+
assert.match(cbRes.body ?? "", /Unknown or expired state/i);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
after(() => {
|
|
392
|
+
// no global resources
|
|
393
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the stateless `client_id` DCR path.
|
|
3
|
+
*
|
|
4
|
+
* The key cross-pod claim is that two GitLabOAuthServerProvider instances,
|
|
5
|
+
* sharing only OAUTH_STATELESS_SECRET, can successfully issue a client_id on
|
|
6
|
+
* one and validate it on the other — matching the scenario where a Kubernetes
|
|
7
|
+
* load balancer routes POST /register to pod A and GET /authorize to pod B.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { describe, test } from "node:test";
|
|
12
|
+
import { createGitLabOAuthProvider } from "../../oauth-proxy.js";
|
|
13
|
+
import { mintClientId, openClientId, looksLikeStatelessClientId, } from "../../stateless/client-id.js";
|
|
14
|
+
import { loadKeyMaterialFromEnv } from "../../stateless/index.js";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
function secret() {
|
|
19
|
+
return randomBytes(32).toString("base64url");
|
|
20
|
+
}
|
|
21
|
+
function loadMaterial(current, previous) {
|
|
22
|
+
const env = {
|
|
23
|
+
OAUTH_STATELESS_SECRET: current,
|
|
24
|
+
};
|
|
25
|
+
if (previous)
|
|
26
|
+
env.OAUTH_STATELESS_SECRET_PREVIOUS = previous;
|
|
27
|
+
const m = loadKeyMaterialFromEnv(true, env);
|
|
28
|
+
assert.ok(m, "expected material to load");
|
|
29
|
+
return m;
|
|
30
|
+
}
|
|
31
|
+
function makeProvider(material, { callbackProxy = false } = {}) {
|
|
32
|
+
return createGitLabOAuthProvider("https://gitlab.example.com", "real-gitlab-app-id", "GitLab MCP Server (test)", false, // readOnly
|
|
33
|
+
undefined, // customScopes
|
|
34
|
+
callbackProxy, callbackProxy ? "https://mcp.example.com/callback" : "", material
|
|
35
|
+
? {
|
|
36
|
+
material,
|
|
37
|
+
clientTtlSeconds: 86400,
|
|
38
|
+
pendingTtlSeconds: 600,
|
|
39
|
+
storedTtlSeconds: 600,
|
|
40
|
+
}
|
|
41
|
+
: null);
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// mint / open helpers (direct)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe("mintClientId / openClientId", () => {
|
|
47
|
+
test("roundtrips redirect_uris", () => {
|
|
48
|
+
const m = loadMaterial(secret());
|
|
49
|
+
const cid = mintClientId(m, {
|
|
50
|
+
redirectUris: ["https://client.example.com/cb"],
|
|
51
|
+
grantTypes: ["authorization_code"],
|
|
52
|
+
clientName: "Test Client",
|
|
53
|
+
});
|
|
54
|
+
assert.ok(looksLikeStatelessClientId(cid));
|
|
55
|
+
const p = openClientId(m, cid, 86400);
|
|
56
|
+
assert.ok(p);
|
|
57
|
+
assert.deepEqual(p.ruris, ["https://client.example.com/cb"]);
|
|
58
|
+
assert.deepEqual(p.gt, ["authorization_code"]);
|
|
59
|
+
assert.equal(p.cn, "Test Client");
|
|
60
|
+
});
|
|
61
|
+
test("openClientId returns null on signature error", () => {
|
|
62
|
+
const m1 = loadMaterial(secret());
|
|
63
|
+
const m2 = loadMaterial(secret()); // different secret entirely
|
|
64
|
+
const cid = mintClientId(m1, {
|
|
65
|
+
redirectUris: ["https://client.example.com/cb"],
|
|
66
|
+
});
|
|
67
|
+
assert.equal(openClientId(m2, cid, 86400), null);
|
|
68
|
+
});
|
|
69
|
+
test("openClientId returns null when expired", () => {
|
|
70
|
+
const m = loadMaterial(secret());
|
|
71
|
+
// Mint at t-3600
|
|
72
|
+
const past = Math.floor(Date.now() / 1000) - 3600;
|
|
73
|
+
const cid = mintClientId(m, {
|
|
74
|
+
redirectUris: ["https://client.example.com/cb"],
|
|
75
|
+
now: () => past,
|
|
76
|
+
});
|
|
77
|
+
// TTL = 60s ⇒ expired
|
|
78
|
+
assert.equal(openClientId(m, cid, 60), null);
|
|
79
|
+
});
|
|
80
|
+
test("rotation: cid minted under previous secret still opens", () => {
|
|
81
|
+
const s1 = secret();
|
|
82
|
+
const s2 = secret();
|
|
83
|
+
const mOld = loadMaterial(s1);
|
|
84
|
+
const cid = mintClientId(mOld, {
|
|
85
|
+
redirectUris: ["https://client.example.com/cb"],
|
|
86
|
+
});
|
|
87
|
+
// Operator rotated: new is current, old is previous.
|
|
88
|
+
const mRotated = loadMaterial(s2, s1);
|
|
89
|
+
assert.ok(openClientId(mRotated, cid, 86400));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// clientsStore end-to-end across two provider instances (cross-pod)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
describe("clientsStore cross-pod (stateless)", () => {
|
|
96
|
+
test("register on pod A ⇒ getClient succeeds on pod B", async () => {
|
|
97
|
+
const sharedSecret = secret();
|
|
98
|
+
const podA = makeProvider(loadMaterial(sharedSecret));
|
|
99
|
+
const podB = makeProvider(loadMaterial(sharedSecret));
|
|
100
|
+
// Register on A
|
|
101
|
+
const registered = await podA.clientsStore.registerClient({
|
|
102
|
+
redirect_uris: ["https://client.example.com/cb"],
|
|
103
|
+
token_endpoint_auth_method: "none",
|
|
104
|
+
});
|
|
105
|
+
assert.ok(looksLikeStatelessClientId(registered.client_id));
|
|
106
|
+
assert.deepEqual(registered.redirect_uris, ["https://client.example.com/cb"]);
|
|
107
|
+
// Lookup on B — no shared memory, only shared secret
|
|
108
|
+
const looked = await podB.clientsStore.getClient(registered.client_id);
|
|
109
|
+
assert.ok(looked, "B should resolve the signed client_id");
|
|
110
|
+
assert.equal(looked.client_id, registered.client_id);
|
|
111
|
+
assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
|
|
112
|
+
// token_endpoint_auth_method defaults to "none" for DCR
|
|
113
|
+
assert.equal(looked.token_endpoint_auth_method, "none");
|
|
114
|
+
});
|
|
115
|
+
test("getClient on pod B with different secret rejects the client_id", async () => {
|
|
116
|
+
const podA = makeProvider(loadMaterial(secret()));
|
|
117
|
+
const podB = makeProvider(loadMaterial(secret())); // different secret
|
|
118
|
+
const registered = await podA.clientsStore.registerClient({
|
|
119
|
+
redirect_uris: ["https://client.example.com/cb"],
|
|
120
|
+
token_endpoint_auth_method: "none",
|
|
121
|
+
});
|
|
122
|
+
const looked = await podB.clientsStore.getClient(registered.client_id);
|
|
123
|
+
assert.equal(looked, undefined);
|
|
124
|
+
});
|
|
125
|
+
test("non-stateless provider falls back to legacy cache path", async () => {
|
|
126
|
+
// Simulating a pod with stateless mode OFF — it should keep working
|
|
127
|
+
// against its own in-memory cache.
|
|
128
|
+
const provider = makeProvider(null);
|
|
129
|
+
const registered = await provider.clientsStore.registerClient({
|
|
130
|
+
redirect_uris: ["https://client.example.com/cb"],
|
|
131
|
+
token_endpoint_auth_method: "none",
|
|
132
|
+
});
|
|
133
|
+
// Legacy mode uses UUID client_ids, not stateless v1.cid.*
|
|
134
|
+
assert.ok(!looksLikeStatelessClientId(registered.client_id));
|
|
135
|
+
const looked = await provider.clientsStore.getClient(registered.client_id);
|
|
136
|
+
assert.ok(looked);
|
|
137
|
+
assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
|
|
138
|
+
});
|
|
139
|
+
test("legacy client_id is unchanged in stateless mode (e.g. pre-existing GitLab app uid)", async () => {
|
|
140
|
+
// A caller that passes a non-stateless client_id should receive the
|
|
141
|
+
// legacy stub so unrelated flows (like token exchange with a pre-registered
|
|
142
|
+
// GitLab app) continue to work.
|
|
143
|
+
const provider = makeProvider(loadMaterial(secret()));
|
|
144
|
+
const looked = await provider.clientsStore.getClient("legacy-app-id");
|
|
145
|
+
assert.ok(looked);
|
|
146
|
+
assert.equal(looked.client_id, "legacy-app-id");
|
|
147
|
+
// Stub has empty redirect_uris — the caller's code path must tolerate this.
|
|
148
|
+
assert.deepEqual(looked.redirect_uris, []);
|
|
149
|
+
});
|
|
150
|
+
test("rotation: client_id minted on pod-old verifies on pod-rotated", async () => {
|
|
151
|
+
const s1 = secret();
|
|
152
|
+
const s2 = secret();
|
|
153
|
+
const podOld = makeProvider(loadMaterial(s1));
|
|
154
|
+
const podRotated = makeProvider(loadMaterial(s2, s1));
|
|
155
|
+
const registered = await podOld.clientsStore.registerClient({
|
|
156
|
+
redirect_uris: ["https://client.example.com/cb"],
|
|
157
|
+
token_endpoint_auth_method: "none",
|
|
158
|
+
});
|
|
159
|
+
const looked = await podRotated.clientsStore.getClient(registered.client_id);
|
|
160
|
+
assert.ok(looked);
|
|
161
|
+
assert.deepEqual(looked.redirect_uris, ["https://client.example.com/cb"]);
|
|
162
|
+
});
|
|
163
|
+
test("long redirect_uri list roundtrips", async () => {
|
|
164
|
+
const sharedSecret = secret();
|
|
165
|
+
const podA = makeProvider(loadMaterial(sharedSecret));
|
|
166
|
+
const podB = makeProvider(loadMaterial(sharedSecret));
|
|
167
|
+
const ruris = Array.from({ length: 5 }, (_, i) => `https://client.example.com/cb-${i}`);
|
|
168
|
+
const registered = await podA.clientsStore.registerClient({
|
|
169
|
+
redirect_uris: ruris,
|
|
170
|
+
token_endpoint_auth_method: "none",
|
|
171
|
+
});
|
|
172
|
+
const looked = await podB.clientsStore.getClient(registered.client_id);
|
|
173
|
+
assert.ok(looked);
|
|
174
|
+
assert.deepEqual(looked.redirect_uris, ruris);
|
|
175
|
+
});
|
|
176
|
+
});
|