@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.
@@ -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/stored-tokens.js";
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
- if (this._callbackProxyEnabled) {
329
- // --- Callback proxy mode ---
330
- // The authorizationCode is a proxy code we generated in handleCallback().
331
- // It is either a sealed token (stateless mode) or a random UUID that
332
- // keys into the _storedTokens LRU (legacy mode).
333
- const stateless = this._stateless;
334
- let entry = null;
335
- if (stateless && looksLikeStatelessStoredTokensCode(authorizationCode)) {
336
- const payload = openStoredTokensCode(stateless.material, authorizationCode, stateless.storedTtlSeconds);
337
- if (!payload) {
338
- throw new ServerError("Invalid or expired authorization code");
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
- entry = {
341
- tokens: payload.t,
342
- clientId: payload.cid,
343
- clientCodeChallenge: payload.ccc,
344
- clientRedirectUri: payload.cru,
345
- };
346
- // NOTE: Stateless mode cannot enforce one-time use without a shared
347
- // store. Replay is mitigated by short TTL + client PKCE verification
348
- // below (attacker needs the code_verifier). Documented in
349
- // stateless/stored-tokens.ts.
350
- }
351
- else {
352
- const lru = this._storedTokens.get(authorizationCode);
353
- if (!lru) {
354
- throw new ServerError("Invalid or expired authorization code");
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
- if (Date.now() - lru.createdAt > PENDING_AUTH_TTL_MS) {
357
- this._storedTokens.delete(authorizationCode);
358
- throw new ServerError("Authorization code expired — please restart the OAuth flow");
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
- // One-time use: delete after validation
361
- this._storedTokens.delete(authorizationCode);
362
- entry = {
363
- tokens: lru.tokens,
364
- clientId: lru.clientId,
365
- clientCodeChallenge: lru.clientCodeChallenge,
366
- clientRedirectUri: lru.clientRedirectUri,
367
- };
368
- }
369
- // Bind the proxy code to the client and redirect_uri that initiated
370
- // /authorize, preserving the normal OAuth authorization-code invariant.
371
- if (client.client_id !== entry.clientId) {
372
- throw new ServerError("Invalid client for authorization code");
373
- }
374
- if (redirectUri !== entry.clientRedirectUri) {
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
- // Verify client PKCE: the client's code_verifier must match the
378
- // code_challenge stored during /authorize.
379
- if (entry.clientCodeChallenge) {
380
- if (!codeVerifier) {
381
- throw new ServerError("PKCE code_verifier is required");
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 computed = createHash("sha256").update(codeVerifier).digest("base64url");
384
- if (computed !== entry.clientCodeChallenge) {
385
- throw new ServerError("PKCE verification failed");
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
- tokens = entry.tokens;
389
- }
390
- else {
391
- // --- Passthrough mode (original behavior) ---
392
- const params = new URLSearchParams({
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
- const data = await response.json();
414
- tokens = OAuthTokensSchema.parse(data);
468
+ return tokens;
415
469
  }
416
- if (this._allowedGroups) {
417
- const isMember = await this._checkGroupMembership(tokens.access_token);
418
- if (!isMember) {
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
- const res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, {
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
- merged_yaml: z.string().optional(),
313
- includes: z.array(z.unknown()).optional(),
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
+ }
@@ -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 two mitigations:
19
- * - short TTL (OAUTH_STATELESS_STORED_TTL_SECONDS, default 600s)
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
+ });