@zereight/mcp-gitlab 2.1.46 → 2.1.48
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 +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/build/config.js +1 -1
- package/build/index.js +138 -111
- package/build/oauth-proxy.js +151 -87
- package/build/schemas.js +4 -2
- package/build/scripts/generate-server-card.js +39 -0
- package/build/server/request-helpers.js +75 -0
- package/build/stateless/consumed-proxy-code-cache.js +82 -0
- package/build/stateless/index.js +1 -0
- package/build/stateless/stored-tokens.js +4 -2
- package/build/test/mcp-oauth-tests.js +96 -0
- package/build/test/nullable-gitlab-response-fields.test.js +16 -1
- package/build/test/server/request-helpers.test.js +38 -0
- package/build/test/stateless/callback-proxy.test.js +109 -0
- package/build/test/stateless/consumed-proxy-code-cache.test.js +150 -0
- package/build/test/streamable-http-sse-stream.test.js +350 -0
- package/build/test/test-all-transport-server.js +9 -3
- package/build/test/test-download-attachment.js +27 -0
- package/build/test/test-get-file-contents.js +128 -0
- package/build/test/test-job-artifacts.js +27 -2
- package/build/test/test-merge-request-approval-state-tools.js +42 -0
- package/build/test/test-upload-markdown.js +38 -7
- package/build/test/utils/helpers-path.test.js +104 -0
- package/build/utils/helpers.js +205 -0
- package/build/utils/logger.js +4 -0
- package/package.json +2 -2
package/build/oauth-proxy.js
CHANGED
|
@@ -49,7 +49,7 @@ import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
|
49
49
|
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
50
50
|
import { looksLikeStatelessClientId, mintClientId, openClientId, } from "./stateless/client-id.js";
|
|
51
51
|
import { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./stateless/pending-auth.js";
|
|
52
|
-
import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stateless/
|
|
52
|
+
import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, ConsumedProxyCodeCache, PROXY_CODE_CACHE_FULL, } from "./stateless/index.js";
|
|
53
53
|
import { createLogger } from "./utils/logger.js";
|
|
54
54
|
const logger = createLogger("gitlab-mcp-oauth-proxy");
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
@@ -129,6 +129,13 @@ class GitLabOAuthServerProvider {
|
|
|
129
129
|
_callbackUrl;
|
|
130
130
|
_pendingAuth = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
|
|
131
131
|
_storedTokens = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
|
|
132
|
+
/**
|
|
133
|
+
* Per-pod replay-prevention cache for sealed (stateless) proxy authorization
|
|
134
|
+
* codes. Keys are SHA-256 hashes of the code; entries are TTL-bound (never
|
|
135
|
+
* LRU-evicted early) and hold pending→consumed state. Cross-pod replay
|
|
136
|
+
* remains mitigated by the short stored-code TTL + PKCE.
|
|
137
|
+
*/
|
|
138
|
+
_usedProxyCodes = new ConsumedProxyCodeCache(PENDING_AUTH_MAX_SIZE);
|
|
132
139
|
// Stateless mode (optional). When set, DCR and callback-proxy state are
|
|
133
140
|
// serialised into opaque OAuth values and the in-memory caches above are
|
|
134
141
|
// bypassed. Enabled independently of callback-proxy mode.
|
|
@@ -325,102 +332,150 @@ class GitLabOAuthServerProvider {
|
|
|
325
332
|
// ---- Token exchange ----------------------------------------------------
|
|
326
333
|
async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
|
|
327
334
|
let tokens;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
335
|
+
/** Reserved sealed-code hash + id; released on failure, committed on success. */
|
|
336
|
+
let reservedProxyCode = null;
|
|
337
|
+
/**
|
|
338
|
+
* Legacy (non-stateless) proxy code taken from `_storedTokens` before
|
|
339
|
+
* binding checks. Restored on binding/PKCE failure so a wrong verifier
|
|
340
|
+
* cannot burn the code for the legitimate client.
|
|
341
|
+
*/
|
|
342
|
+
let legacyRestorable = null;
|
|
343
|
+
try {
|
|
344
|
+
if (this._callbackProxyEnabled) {
|
|
345
|
+
// --- Callback proxy mode ---
|
|
346
|
+
// The authorizationCode is a proxy code we generated in handleCallback().
|
|
347
|
+
// It is either a sealed token (stateless mode) or a random UUID that
|
|
348
|
+
// keys into the _storedTokens LRU (legacy mode).
|
|
349
|
+
const stateless = this._stateless;
|
|
350
|
+
let entry = null;
|
|
351
|
+
if (stateless && looksLikeStatelessStoredTokensCode(authorizationCode)) {
|
|
352
|
+
const codeHash = createHash("sha256").update(authorizationCode).digest("hex");
|
|
353
|
+
let reserved;
|
|
354
|
+
try {
|
|
355
|
+
reserved = this._usedProxyCodes.tryReserve(codeHash, stateless.storedTtlSeconds);
|
|
356
|
+
if (!reserved.ok) {
|
|
357
|
+
if (reserved.reason === "pending") {
|
|
358
|
+
throw new ServerError("Authorization code exchange in progress — please retry");
|
|
359
|
+
}
|
|
360
|
+
throw new ServerError("Authorization code already used");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
if (err instanceof Error && err.message === PROXY_CODE_CACHE_FULL) {
|
|
365
|
+
logger.warn({
|
|
366
|
+
cacheSize: this._usedProxyCodes.size,
|
|
367
|
+
cacheMaxSize: this._usedProxyCodes.maxSize,
|
|
368
|
+
}, "Proxy code replay cache at capacity");
|
|
369
|
+
throw new ServerError("Authorization server busy — please retry the OAuth flow");
|
|
370
|
+
}
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
reservedProxyCode = { hash: codeHash, id: reserved.reservationId };
|
|
374
|
+
const payload = openStoredTokensCode(stateless.material, authorizationCode, stateless.storedTtlSeconds);
|
|
375
|
+
if (!payload) {
|
|
376
|
+
throw new ServerError("Invalid or expired authorization code");
|
|
377
|
+
}
|
|
378
|
+
entry = {
|
|
379
|
+
tokens: payload.t,
|
|
380
|
+
clientId: payload.cid,
|
|
381
|
+
clientCodeChallenge: payload.ccc,
|
|
382
|
+
clientRedirectUri: payload.cru,
|
|
383
|
+
};
|
|
384
|
+
// NOTE: Cross-pod one-time use still requires a shared store. Replay
|
|
385
|
+
// across pods is mitigated by short TTL (default 120s) + client PKCE.
|
|
386
|
+
// Documented in stateless/stored-tokens.ts.
|
|
339
387
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
388
|
+
else {
|
|
389
|
+
// Atomic take: serialize concurrent exchanges. Restored below if
|
|
390
|
+
// client/redirect/PKCE binding checks fail (DoS prevention).
|
|
391
|
+
const lru = this._storedTokens.getAndDelete(authorizationCode);
|
|
392
|
+
if (!lru) {
|
|
393
|
+
throw new ServerError("Invalid or expired authorization code");
|
|
394
|
+
}
|
|
395
|
+
if (Date.now() - lru.createdAt > PENDING_AUTH_TTL_MS) {
|
|
396
|
+
throw new ServerError("Authorization code expired — please restart the OAuth flow");
|
|
397
|
+
}
|
|
398
|
+
legacyRestorable = { code: authorizationCode, entry: lru };
|
|
399
|
+
entry = {
|
|
400
|
+
tokens: lru.tokens,
|
|
401
|
+
clientId: lru.clientId,
|
|
402
|
+
clientCodeChallenge: lru.clientCodeChallenge,
|
|
403
|
+
clientRedirectUri: lru.clientRedirectUri,
|
|
404
|
+
};
|
|
355
405
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
406
|
+
// Bind the proxy code to the client and redirect_uri that initiated
|
|
407
|
+
// /authorize, preserving the normal OAuth authorization-code invariant.
|
|
408
|
+
// Binding + PKCE run while consumption is only *reserved* (pending /
|
|
409
|
+
// restorable); failed checks release so the legitimate client can retry.
|
|
410
|
+
if (client.client_id !== entry.clientId) {
|
|
411
|
+
throw new ServerError("Invalid client for authorization code");
|
|
359
412
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
throw new ServerError("Invalid redirect_uri for authorization code");
|
|
413
|
+
if (redirectUri !== entry.clientRedirectUri) {
|
|
414
|
+
throw new ServerError("Invalid redirect_uri for authorization code");
|
|
415
|
+
}
|
|
416
|
+
// Verify client PKCE: the client's code_verifier must match the
|
|
417
|
+
// code_challenge stored during /authorize.
|
|
418
|
+
if (entry.clientCodeChallenge) {
|
|
419
|
+
if (!codeVerifier) {
|
|
420
|
+
throw new ServerError("PKCE code_verifier is required");
|
|
421
|
+
}
|
|
422
|
+
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
423
|
+
if (computed !== entry.clientCodeChallenge) {
|
|
424
|
+
throw new ServerError("PKCE verification failed");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
tokens = entry.tokens;
|
|
376
428
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
429
|
+
else {
|
|
430
|
+
// --- Passthrough mode (original behavior) ---
|
|
431
|
+
const params = new URLSearchParams({
|
|
432
|
+
grant_type: "authorization_code",
|
|
433
|
+
client_id: this._gitlabAppId,
|
|
434
|
+
code: authorizationCode,
|
|
435
|
+
});
|
|
436
|
+
if (codeVerifier)
|
|
437
|
+
params.append("code_verifier", codeVerifier);
|
|
438
|
+
if (redirectUri)
|
|
439
|
+
params.append("redirect_uri", redirectUri);
|
|
440
|
+
if (resource)
|
|
441
|
+
params.append("resource", resource.href);
|
|
442
|
+
const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
445
|
+
body: params.toString(),
|
|
446
|
+
});
|
|
447
|
+
if (!response.ok) {
|
|
448
|
+
const body = await response.text();
|
|
449
|
+
logger.error(`Token exchange failed (${response.status}): ${body}`);
|
|
450
|
+
throw new ServerError(`Token exchange failed: ${response.status}`);
|
|
382
451
|
}
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
452
|
+
const data = await response.json();
|
|
453
|
+
tokens = OAuthTokensSchema.parse(data);
|
|
454
|
+
}
|
|
455
|
+
if (this._allowedGroups) {
|
|
456
|
+
const isMember = await this._checkGroupMembership(tokens.access_token);
|
|
457
|
+
if (!isMember) {
|
|
458
|
+
logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
|
|
459
|
+
throw new ServerError("Access denied: user is not a member of an allowed group");
|
|
386
460
|
}
|
|
387
461
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
grant_type: "authorization_code",
|
|
394
|
-
client_id: this._gitlabAppId,
|
|
395
|
-
code: authorizationCode,
|
|
396
|
-
});
|
|
397
|
-
if (codeVerifier)
|
|
398
|
-
params.append("code_verifier", codeVerifier);
|
|
399
|
-
if (redirectUri)
|
|
400
|
-
params.append("redirect_uri", redirectUri);
|
|
401
|
-
if (resource)
|
|
402
|
-
params.append("resource", resource.href);
|
|
403
|
-
const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
404
|
-
method: "POST",
|
|
405
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
406
|
-
body: params.toString(),
|
|
407
|
-
});
|
|
408
|
-
if (!response.ok) {
|
|
409
|
-
const body = await response.text();
|
|
410
|
-
logger.error(`Token exchange failed (${response.status}): ${body}`);
|
|
411
|
-
throw new ServerError(`Token exchange failed: ${response.status}`);
|
|
462
|
+
// All checks passed — permanently consume the proxy code.
|
|
463
|
+
legacyRestorable = null;
|
|
464
|
+
if (reservedProxyCode) {
|
|
465
|
+
this._usedProxyCodes.commit(reservedProxyCode.hash, reservedProxyCode.id);
|
|
466
|
+
reservedProxyCode = null;
|
|
412
467
|
}
|
|
413
|
-
|
|
414
|
-
tokens = OAuthTokensSchema.parse(data);
|
|
468
|
+
return tokens;
|
|
415
469
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
|
|
420
|
-
throw new ServerError("Access denied: user is not a member of an allowed group");
|
|
470
|
+
catch (err) {
|
|
471
|
+
if (legacyRestorable) {
|
|
472
|
+
this._storedTokens.set(legacyRestorable.code, legacyRestorable.entry);
|
|
421
473
|
}
|
|
474
|
+
if (reservedProxyCode) {
|
|
475
|
+
this._usedProxyCodes.release(reservedProxyCode.hash, reservedProxyCode.id);
|
|
476
|
+
}
|
|
477
|
+
throw err;
|
|
422
478
|
}
|
|
423
|
-
return tokens;
|
|
424
479
|
}
|
|
425
480
|
/**
|
|
426
481
|
* Returns true if the token owner belongs to at least one group whose
|
|
@@ -480,9 +535,18 @@ class GitLabOAuthServerProvider {
|
|
|
480
535
|
}
|
|
481
536
|
// ---- Verify access token -----------------------------------------------
|
|
482
537
|
async verifyAccessToken(token) {
|
|
483
|
-
|
|
538
|
+
let res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, {
|
|
484
539
|
headers: { Authorization: `Bearer ${token}` },
|
|
485
540
|
});
|
|
541
|
+
if (res.status === 401) {
|
|
542
|
+
// Some GitLab instances sit behind an edge cache that strips the
|
|
543
|
+
// Authorization header on /oauth/* paths (observed on
|
|
544
|
+
// git.drupalcode.org, fronted by Varnish), so a valid token 401s
|
|
545
|
+
// here while working fine against /api/v4. Doorkeeper also accepts
|
|
546
|
+
// the RFC 6750 access_token query parameter — retry with that form
|
|
547
|
+
// before rejecting the token.
|
|
548
|
+
res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info?access_token=${encodeURIComponent(token)}`);
|
|
549
|
+
}
|
|
486
550
|
if (!res.ok) {
|
|
487
551
|
throw new InvalidTokenError("Invalid or expired GitLab OAuth token");
|
|
488
552
|
}
|
package/build/schemas.js
CHANGED
|
@@ -309,8 +309,10 @@ export const GitLabCiLintResultSchema = z.object({
|
|
|
309
309
|
valid: z.coerce.boolean(),
|
|
310
310
|
errors: z.array(z.string()),
|
|
311
311
|
warnings: z.array(z.string()).optional(),
|
|
312
|
-
|
|
313
|
-
|
|
312
|
+
// GitLab sends null (not an omitted field) for both when the config is
|
|
313
|
+
// invalid, which is exactly when this tool is called. See #638.
|
|
314
|
+
merged_yaml: z.string().nullish(),
|
|
315
|
+
includes: z.array(z.unknown()).nullish(),
|
|
314
316
|
jobs: z.array(z.unknown()).optional(),
|
|
315
317
|
});
|
|
316
318
|
export const ValidateCiLintSchema = z.object({
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { allTools } from "../tools/registry.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const pkgPath = path.resolve(process.cwd(), "package.json");
|
|
6
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
7
|
+
const serverCard = {
|
|
8
|
+
serverInfo: {
|
|
9
|
+
name: pkg.mcpName || pkg.name,
|
|
10
|
+
version: pkg.version,
|
|
11
|
+
},
|
|
12
|
+
authentication: {
|
|
13
|
+
required: true,
|
|
14
|
+
schemes: ["bearer", "oauth2"],
|
|
15
|
+
},
|
|
16
|
+
tools: allTools.map(t => {
|
|
17
|
+
const tool = { ...t };
|
|
18
|
+
if (tool.inputSchema &&
|
|
19
|
+
typeof tool.inputSchema === "object" &&
|
|
20
|
+
"$schema" in tool.inputSchema) {
|
|
21
|
+
const schema = { ...tool.inputSchema };
|
|
22
|
+
delete schema["$schema"];
|
|
23
|
+
tool.inputSchema = schema;
|
|
24
|
+
}
|
|
25
|
+
return tool;
|
|
26
|
+
}),
|
|
27
|
+
resources: [],
|
|
28
|
+
prompts: [],
|
|
29
|
+
};
|
|
30
|
+
const outDir = path.resolve(process.cwd(), ".well-known", "mcp");
|
|
31
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
32
|
+
const outFile = path.join(outDir, "server-card.json");
|
|
33
|
+
fs.writeFileSync(outFile, JSON.stringify(serverCard, null, 2), "utf8");
|
|
34
|
+
console.log(`Successfully generated ${outFile} (${allTools.length} tools)`);
|
|
35
|
+
}
|
|
36
|
+
main().catch(err => {
|
|
37
|
+
console.error("Failed to generate server card:", err);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Produce a safe short form of a session id for logs. Legacy UUIDs pass
|
|
3
|
+
* through; stateless sealed sids are truncated to the "v1.sid." prefix
|
|
4
|
+
* plus a handful of bytes of the ciphertext so operators can correlate
|
|
5
|
+
* flows without the log line carrying the sealed bearer token.
|
|
6
|
+
*/
|
|
7
|
+
export function redactSessionIdForLog(sid) {
|
|
8
|
+
if (!sid)
|
|
9
|
+
return "<none>";
|
|
10
|
+
if (sid.startsWith("v1.sid."))
|
|
11
|
+
return "v1.sid.<redacted>";
|
|
12
|
+
// UUIDs / other formats are low-sensitivity; show first 8 chars.
|
|
13
|
+
return sid.length > 8 ? `${sid.slice(0, 8)}…` : sid;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Detect whether an MCP JSON-RPC request body represents an "initialize"
|
|
17
|
+
* request. Accepts both single-message and batch forms. Returns false on any
|
|
18
|
+
* unexpected shape — callers treat an ambiguous body as non-init, which is
|
|
19
|
+
* the safer default (it means the SDK will fail loudly rather than silently
|
|
20
|
+
* spawning a new session).
|
|
21
|
+
*/
|
|
22
|
+
export function isInitializationRequestBody(body) {
|
|
23
|
+
if (!body)
|
|
24
|
+
return false;
|
|
25
|
+
const isInitObj = (m) => typeof m === "object" && m !== null && m.method === "initialize";
|
|
26
|
+
if (Array.isArray(body))
|
|
27
|
+
return body.some(isInitObj);
|
|
28
|
+
return isInitObj(body);
|
|
29
|
+
}
|
|
30
|
+
export function isUnauthenticatedDiscoveryRequestBody(body) {
|
|
31
|
+
if (!body)
|
|
32
|
+
return false;
|
|
33
|
+
const isDiscoveryMethod = (m) => {
|
|
34
|
+
if (typeof m !== "object" || m === null)
|
|
35
|
+
return false;
|
|
36
|
+
const method = m.method;
|
|
37
|
+
return (method === "initialize" || method === "notifications/initialized" || method === "tools/list");
|
|
38
|
+
};
|
|
39
|
+
if (Array.isArray(body))
|
|
40
|
+
return body.every(isDiscoveryMethod);
|
|
41
|
+
return isDiscoveryMethod(body);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Normalize an `Mcp-Session-Id` header value.
|
|
45
|
+
*
|
|
46
|
+
* Node's HTTP types allow any request header to surface as `string[]` when
|
|
47
|
+
* the client sends it more than once. Casting to `string` and calling
|
|
48
|
+
* `.startsWith()` on an array throws `TypeError: startsWith is not a
|
|
49
|
+
* function`, which Express converts to a 500 — silently turning malformed
|
|
50
|
+
* requests into server errors and breaking the 401/404 semantics we
|
|
51
|
+
* carefully distinguish in stateless mode. A duplicated `Mcp-Session-Id` is
|
|
52
|
+
* also ill-formed at the protocol level: there is no well-defined way to
|
|
53
|
+
* pick between two values, so we reject arrays rather than guess.
|
|
54
|
+
*
|
|
55
|
+
* Empty-string is normalized to `undefined` so call sites can use truthy
|
|
56
|
+
* checks and `?? undefined`-style fallbacks uniformly.
|
|
57
|
+
*/
|
|
58
|
+
export function readMcpSessionIdHeader(req) {
|
|
59
|
+
const raw = req.headers["mcp-session-id"];
|
|
60
|
+
if (typeof raw !== "string")
|
|
61
|
+
return undefined;
|
|
62
|
+
return raw.length > 0 ? raw : undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Normalize an `Accept` header. Duplicate Accept values surface as `string[]`
|
|
66
|
+
* in Node; joining them preserves `text/event-stream` if any value listed it.
|
|
67
|
+
*/
|
|
68
|
+
export function readAcceptHeader(req) {
|
|
69
|
+
const raw = req.headers.accept;
|
|
70
|
+
if (typeof raw === "string")
|
|
71
|
+
return raw;
|
|
72
|
+
if (Array.isArray(raw))
|
|
73
|
+
return raw.filter(value => typeof value === "string").join(", ");
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-pod replay-prevention cache for sealed proxy authorization codes.
|
|
3
|
+
*
|
|
4
|
+
* Unlike an LRU map, entries are retained until their TTL expires — never
|
|
5
|
+
* evicted early. Inserts fail closed when the cache is full of still-valid
|
|
6
|
+
* entries, so a burst of traffic cannot reopen a capture-replay window by
|
|
7
|
+
* pushing a consumed hash out before `storedTtlSeconds` elapses.
|
|
8
|
+
*/
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
export const PROXY_CODE_CACHE_FULL = "PROXY_CODE_CACHE_FULL";
|
|
11
|
+
/** Largest TTL (seconds) that keeps `now + ttl * 1000` finite. */
|
|
12
|
+
const MAX_TTL_SECONDS = Math.floor(Number.MAX_SAFE_INTEGER / 1000);
|
|
13
|
+
function ttlToExpiryMs(now, ttlSeconds) {
|
|
14
|
+
// Non-finite / non-positive TTLs must still create a usable expiry so a
|
|
15
|
+
// reservation blocks concurrent replay instead of leaving expiresAt as NaN.
|
|
16
|
+
let safeSeconds = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : 1;
|
|
17
|
+
if (safeSeconds > MAX_TTL_SECONDS) {
|
|
18
|
+
safeSeconds = MAX_TTL_SECONDS;
|
|
19
|
+
}
|
|
20
|
+
const expiresAt = now + safeSeconds * 1000;
|
|
21
|
+
return Number.isFinite(expiresAt) ? expiresAt : Number.MAX_SAFE_INTEGER;
|
|
22
|
+
}
|
|
23
|
+
export class ConsumedProxyCodeCache {
|
|
24
|
+
_map = new Map();
|
|
25
|
+
_maxSize;
|
|
26
|
+
_now;
|
|
27
|
+
constructor(maxSize, now = Date.now) {
|
|
28
|
+
this._maxSize = maxSize;
|
|
29
|
+
this._now = now;
|
|
30
|
+
}
|
|
31
|
+
purgeExpired(now) {
|
|
32
|
+
for (const [key, entry] of this._map) {
|
|
33
|
+
if (entry.expiresAt <= now)
|
|
34
|
+
this._map.delete(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Atomically reserve a code hash for an in-flight exchange.
|
|
39
|
+
* @returns `{ ok: true }` if reserved, or `{ ok: false, reason }` when an
|
|
40
|
+
* in-TTL pending/consumed entry already exists.
|
|
41
|
+
* @throws Error with message {@link PROXY_CODE_CACHE_FULL} when the cache
|
|
42
|
+
* is full of non-expired entries (fail closed).
|
|
43
|
+
*/
|
|
44
|
+
tryReserve(key, ttlSeconds) {
|
|
45
|
+
const now = this._now();
|
|
46
|
+
this.purgeExpired(now);
|
|
47
|
+
const existing = this._map.get(key);
|
|
48
|
+
if (existing && existing.expiresAt > now) {
|
|
49
|
+
return { ok: false, reason: existing.state };
|
|
50
|
+
}
|
|
51
|
+
if (this._map.size >= this._maxSize) {
|
|
52
|
+
throw new Error(PROXY_CODE_CACHE_FULL);
|
|
53
|
+
}
|
|
54
|
+
this._map.set(key, {
|
|
55
|
+
expiresAt: ttlToExpiryMs(now, ttlSeconds),
|
|
56
|
+
state: "pending",
|
|
57
|
+
reservationId: randomBytes(16).toString("hex"),
|
|
58
|
+
});
|
|
59
|
+
const entry = this._map.get(key);
|
|
60
|
+
return { ok: true, reservationId: entry.reservationId };
|
|
61
|
+
}
|
|
62
|
+
/** Mark a reserved code as fully consumed (replay rejected until TTL). */
|
|
63
|
+
commit(key, reservationId) {
|
|
64
|
+
const entry = this._map.get(key);
|
|
65
|
+
if (entry && entry.reservationId === reservationId) {
|
|
66
|
+
entry.state = "consumed";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Drop an in-flight reservation so a later legitimate exchange can proceed. */
|
|
70
|
+
release(key, reservationId) {
|
|
71
|
+
const entry = this._map.get(key);
|
|
72
|
+
if (entry?.state === "pending" && entry.reservationId === reservationId) {
|
|
73
|
+
this._map.delete(key);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
get size() {
|
|
77
|
+
return this._map.size;
|
|
78
|
+
}
|
|
79
|
+
get maxSize() {
|
|
80
|
+
return this._maxSize;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/build/stateless/index.js
CHANGED
|
@@ -11,4 +11,5 @@ export { open, seal, sign, verify, } from "./codec.js";
|
|
|
11
11
|
export { looksLikeStatelessClientId, mintClientId, openClientId, } from "./client-id.js";
|
|
12
12
|
export { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./pending-auth.js";
|
|
13
13
|
export { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stored-tokens.js";
|
|
14
|
+
export { ConsumedProxyCodeCache, PROXY_CODE_CACHE_FULL, } from "./consumed-proxy-code-cache.js";
|
|
14
15
|
export { looksLikeStatelessSessionId, mintSessionId, openSessionId, } from "./session-id.js";
|
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
* Uses AEAD (AES-256-GCM) because the payload contains the GitLab access
|
|
16
16
|
* token and refresh token.
|
|
17
17
|
*
|
|
18
|
-
* Replay: proxyCode replay is defeated by
|
|
19
|
-
* - short TTL (OAUTH_STATELESS_STORED_TTL_SECONDS, default
|
|
18
|
+
* Replay: proxyCode replay is defeated by three mitigations:
|
|
19
|
+
* - short TTL (OAUTH_STATELESS_STORED_TTL_SECONDS, default 120s)
|
|
20
20
|
* - PKCE binding (the client must still present a matching code_verifier)
|
|
21
|
+
* - per-pod TTL-bound replay cache (pending reservation → commit on success;
|
|
22
|
+
* failed binding/PKCE releases the reservation; never LRU-evicts early)
|
|
21
23
|
*/
|
|
22
24
|
import { open, seal } from "./codec.js";
|
|
23
25
|
import { StatelessCodecError } from "./errors.js";
|
|
@@ -400,6 +400,102 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => {
|
|
|
400
400
|
stub.close();
|
|
401
401
|
}
|
|
402
402
|
});
|
|
403
|
+
test("verifyAccessToken falls back to access_token query param when Bearer gets 401", async () => {
|
|
404
|
+
// Simulates a GitLab behind an edge cache that strips the Authorization
|
|
405
|
+
// header on /oauth/* (e.g. git.drupalcode.org behind Varnish): the
|
|
406
|
+
// Bearer-header request 401s, the RFC 6750 query-param retry succeeds.
|
|
407
|
+
const TOKEN = "tok+en/with?special=chars&more"; // exercises URL encoding
|
|
408
|
+
const requests = [];
|
|
409
|
+
const { createServer } = await import("node:http");
|
|
410
|
+
const stub = createServer((req, res) => {
|
|
411
|
+
requests.push({ url: req.url, authHeader: req.headers["authorization"] });
|
|
412
|
+
const url = new URL(req.url, "http://localhost");
|
|
413
|
+
if (url.searchParams.get("access_token") === TOKEN) {
|
|
414
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
415
|
+
res.end(JSON.stringify({
|
|
416
|
+
resource_owner_id: 7,
|
|
417
|
+
scopes: ["api"],
|
|
418
|
+
expires_in_seconds: 3600,
|
|
419
|
+
application: { uid: "app-uid-abc" },
|
|
420
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
// Header-stripping edge: reject anything not using the query param
|
|
425
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
426
|
+
res.end(JSON.stringify({ error: "invalid_token" }));
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
|
|
430
|
+
const addr = stub.address();
|
|
431
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
432
|
+
try {
|
|
433
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
434
|
+
const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
|
|
435
|
+
const authInfo = await provider.verifyAccessToken(TOKEN);
|
|
436
|
+
assert.strictEqual(requests.length, 2, "exactly one retry after the 401");
|
|
437
|
+
assert.strictEqual(requests[0].authHeader, `Bearer ${TOKEN}`, "first attempt sends the token as a Bearer Authorization header");
|
|
438
|
+
assert.ok(!requests[0].url.includes("access_token="), "first attempt does not use the query param");
|
|
439
|
+
assert.strictEqual(requests[1].authHeader, undefined, "retry omits the Authorization header");
|
|
440
|
+
assert.ok(requests[1].url.includes(`access_token=${encodeURIComponent(TOKEN)}`), "retry carries the URL-encoded access_token query param");
|
|
441
|
+
assert.strictEqual(authInfo.token, TOKEN, "token preserved through the fallback path");
|
|
442
|
+
assert.strictEqual(authInfo.clientId, "app-uid-abc", "AuthInfo built from retry response");
|
|
443
|
+
console.log(" ✓ verifyAccessToken falls back to query param after Bearer 401");
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
stub.close();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
test("verifyAccessToken does not retry when the Bearer request succeeds", async () => {
|
|
450
|
+
let requestCount = 0;
|
|
451
|
+
const { createServer } = await import("node:http");
|
|
452
|
+
const stub = createServer((_req, res) => {
|
|
453
|
+
requestCount++;
|
|
454
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
455
|
+
res.end(JSON.stringify({
|
|
456
|
+
resource_owner_id: 7,
|
|
457
|
+
scopes: ["api"],
|
|
458
|
+
expires_in_seconds: 3600,
|
|
459
|
+
application: { uid: "app-uid-abc" },
|
|
460
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
461
|
+
}));
|
|
462
|
+
});
|
|
463
|
+
await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
|
|
464
|
+
const addr = stub.address();
|
|
465
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
466
|
+
try {
|
|
467
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
468
|
+
const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
|
|
469
|
+
await provider.verifyAccessToken("good-token");
|
|
470
|
+
assert.strictEqual(requestCount, 1, "no retry on a 200 response");
|
|
471
|
+
console.log(" ✓ verifyAccessToken makes a single request on success");
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
stub.close();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
test("verifyAccessToken does not retry on non-401 errors", async () => {
|
|
478
|
+
let requestCount = 0;
|
|
479
|
+
const { createServer } = await import("node:http");
|
|
480
|
+
const stub = createServer((_req, res) => {
|
|
481
|
+
requestCount++;
|
|
482
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
483
|
+
res.end(JSON.stringify({ error: "service_unavailable" }));
|
|
484
|
+
});
|
|
485
|
+
await new Promise(resolve => stub.listen(0, "127.0.0.1", resolve));
|
|
486
|
+
const addr = stub.address();
|
|
487
|
+
const baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
488
|
+
try {
|
|
489
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
490
|
+
const provider = createGitLabOAuthProvider(baseUrl, "test-app-id");
|
|
491
|
+
await assert.rejects(() => provider.verifyAccessToken("tok"), /invalid or expired/i, "non-401 errors still reject");
|
|
492
|
+
assert.strictEqual(requestCount, 1, "the query-param fallback is 401-only");
|
|
493
|
+
console.log(" ✓ verifyAccessToken does not retry on non-401 errors");
|
|
494
|
+
}
|
|
495
|
+
finally {
|
|
496
|
+
stub.close();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
403
499
|
test("verifyAccessToken maps GitLab token info to AuthInfo", async () => {
|
|
404
500
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
405
501
|
const { createServer } = await import("node:http");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import { GitLabCompareResultSchema, GitLabProtectedBranchAccessLevelSchema, } from "../schemas.js";
|
|
3
|
+
import { GitLabCiLintResultSchema, GitLabCompareResultSchema, GitLabProtectedBranchAccessLevelSchema, } from "../schemas.js";
|
|
4
4
|
test("role-based protected-branch access levels accept null user_id/group_id", () => {
|
|
5
5
|
// #575: GitLab returns null for role-based levels, not omitted fields
|
|
6
6
|
const parsed = GitLabProtectedBranchAccessLevelSchema.parse({
|
|
@@ -20,3 +20,18 @@ test("branch compare accepts null commit", () => {
|
|
|
20
20
|
});
|
|
21
21
|
assert.equal(parsed.commit, null);
|
|
22
22
|
});
|
|
23
|
+
test("ci lint result accepts null merged_yaml/includes on an invalid config", () => {
|
|
24
|
+
// #638: GitLab nulls both fields whenever the config is invalid — i.e. in
|
|
25
|
+
// exactly the case validate_ci_lint exists to report on.
|
|
26
|
+
const parsed = GitLabCiLintResultSchema.parse({
|
|
27
|
+
valid: false,
|
|
28
|
+
errors: ["image is defined in top-level and `default:` entry"],
|
|
29
|
+
warnings: [],
|
|
30
|
+
merged_yaml: null,
|
|
31
|
+
includes: null,
|
|
32
|
+
});
|
|
33
|
+
assert.equal(parsed.valid, false);
|
|
34
|
+
assert.equal(parsed.merged_yaml, null);
|
|
35
|
+
assert.equal(parsed.includes, null);
|
|
36
|
+
assert.deepEqual(parsed.errors, ["image is defined in top-level and `default:` entry"]);
|
|
37
|
+
});
|