@zereight/mcp-gitlab 2.2.5 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Callback-proxy safeguards (GITLAB_OAUTH_CALLBACK_PROXY=true).
3
+ *
4
+ * In callback-proxy mode GitLab only sees the server's fixed /callback URL,
5
+ * so GitLab cannot check where the proxy authorization code goes next. Any
6
+ * caller can register a client through DCR, so two checks run on this server
7
+ * before the user is sent to GitLab:
8
+ *
9
+ * 1. The client's redirect_uri must match GITLAB_OAUTH_ALLOWED_REDIRECT_URIS
10
+ * (default: known MCP client callbacks plus loopback).
11
+ * 2. The user approves the client on a consent page that shows where the
12
+ * code will be sent. Approval is remembered per client_id + redirect_uri
13
+ * in a signed cookie.
14
+ */
15
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
16
+ import { redirectUriMatches } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
17
+ import { deriveSubkey, STATELESS_PURPOSES } from "./stateless/index.js";
18
+ // ---------------------------------------------------------------------------
19
+ // Redirect URI allowlist
20
+ // ---------------------------------------------------------------------------
21
+ /**
22
+ * Callbacks accepted when GITLAB_OAUTH_ALLOWED_REDIRECT_URIS is unset.
23
+ * Loopback entries accept any port (RFC 8252 §7.3) and any path.
24
+ */
25
+ export const DEFAULT_ALLOWED_REDIRECT_URIS = [
26
+ "https://claude.ai/api/mcp/auth_callback",
27
+ "https://claude.com/api/mcp/auth_callback",
28
+ "https://chatgpt.com/connector_platform_oauth_redirect",
29
+ "https://chatgpt.com/connector/oauth/*",
30
+ "cursor://anysphere.cursor-mcp/oauth/callback",
31
+ "http://localhost/*",
32
+ "http://127.0.0.1/*",
33
+ "http://[::1]/*",
34
+ ];
35
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
36
+ function parseRule(entry) {
37
+ if (entry === "*")
38
+ return { kind: "any" };
39
+ const isPrefix = entry.endsWith("*");
40
+ const raw = isPrefix ? entry.slice(0, -1) : entry;
41
+ if (raw.includes("*")) {
42
+ throw new Error(`GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: "${entry}" may only use * at the end`);
43
+ }
44
+ let url;
45
+ try {
46
+ url = new URL(raw);
47
+ }
48
+ catch {
49
+ throw new Error(`GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: "${entry}" is not a valid URL`);
50
+ }
51
+ if (url.hash || url.username || url.password) {
52
+ throw new Error(`GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: "${entry}" must not contain a fragment or credentials`);
53
+ }
54
+ if (!isPrefix)
55
+ return { kind: "exact", uri: entry };
56
+ // Require "/*" so "https://client.example/app*" cannot be read as also
57
+ // covering /application or /app-evil.
58
+ if (!raw.endsWith("/") || url.search) {
59
+ throw new Error(`GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: "${entry}" must end with "/*", e.g. https://client.example/oauth/*`);
60
+ }
61
+ return { kind: "prefix", base: url };
62
+ }
63
+ function ruleMatches(rule, requested, url) {
64
+ switch (rule.kind) {
65
+ case "any":
66
+ return true;
67
+ case "exact":
68
+ return redirectUriMatches(requested, rule.uri);
69
+ case "prefix": {
70
+ const { base } = rule;
71
+ if (url.protocol !== base.protocol || url.hostname !== base.hostname)
72
+ return false;
73
+ if (!LOOPBACK_HOSTS.has(base.hostname) && url.port !== base.port)
74
+ return false;
75
+ // URL parsing already resolved "." / ".." segments.
76
+ return url.pathname.startsWith(base.pathname);
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Split a GITLAB_OAUTH_ALLOWED_REDIRECT_URIS value into entries. Unset or
82
+ * empty means the defaults; the token "defaults" expands to them inside a
83
+ * list so operators can add entries without copying the built-in ones.
84
+ */
85
+ export function parseAllowedRedirectUris(raw) {
86
+ const parts = (raw ?? "")
87
+ .split(",")
88
+ .map(part => part.trim())
89
+ .filter(Boolean);
90
+ if (parts.length === 0)
91
+ return [...DEFAULT_ALLOWED_REDIRECT_URIS];
92
+ return parts.flatMap(part => (part === "defaults" ? [...DEFAULT_ALLOWED_REDIRECT_URIS] : [part]));
93
+ }
94
+ export class RedirectUriPolicy {
95
+ _rules;
96
+ entries;
97
+ constructor(entries) {
98
+ this.entries = [...entries];
99
+ this._rules = entries.map(parseRule);
100
+ }
101
+ /** True when the operator opted out with "*". */
102
+ get allowsAny() {
103
+ return this._rules.some(rule => rule.kind === "any");
104
+ }
105
+ allows(redirectUri) {
106
+ let url;
107
+ try {
108
+ url = new URL(redirectUri);
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ // OAuth redirect URIs must not carry a fragment (RFC 6749 §3.1.2).
114
+ if (url.hash || url.username || url.password)
115
+ return false;
116
+ return this._rules.some(rule => ruleMatches(rule, redirectUri, url));
117
+ }
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Consent page
121
+ // ---------------------------------------------------------------------------
122
+ const CSRF_COOKIE = "gitlab_mcp_oauth_csrf";
123
+ const CONSENT_COOKIE_PREFIX = "gitlab_mcp_oauth_consent_";
124
+ const CSRF_TTL_SECONDS = 10 * 60;
125
+ export const CONSENT_TTL_SECONDS = 30 * 24 * 60 * 60;
126
+ const CSRF_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
127
+ const MAX_CLIENT_NAME_LENGTH = 80;
128
+ function escapeHtml(value) {
129
+ return value
130
+ .replace(/&/g, "&")
131
+ .replace(/</g, "&lt;")
132
+ .replace(/>/g, "&gt;")
133
+ .replace(/"/g, "&quot;")
134
+ .replace(/'/g, "&#39;");
135
+ }
136
+ function parseCookies(header) {
137
+ const cookies = new Map();
138
+ if (!header)
139
+ return cookies;
140
+ for (const part of header.split(";")) {
141
+ const eq = part.indexOf("=");
142
+ if (eq <= 0)
143
+ continue;
144
+ const name = part.slice(0, eq).trim();
145
+ if (!cookies.has(name))
146
+ cookies.set(name, part.slice(eq + 1).trim());
147
+ }
148
+ return cookies;
149
+ }
150
+ function bodyField(body, name) {
151
+ if (!body || typeof body !== "object")
152
+ return undefined;
153
+ const value = body[name];
154
+ return typeof value === "string" ? value : undefined;
155
+ }
156
+ function safeEqual(a, b) {
157
+ const left = Buffer.from(a);
158
+ const right = Buffer.from(b);
159
+ return left.length === right.length && timingSafeEqual(left, right);
160
+ }
161
+ function describeRedirect(redirectUri) {
162
+ const url = new URL(redirectUri);
163
+ if (LOOPBACK_HOSTS.has(url.hostname)) {
164
+ return `an app on your own computer (${url.protocol}//${url.host})`;
165
+ }
166
+ return `${url.protocol}//${url.host}`;
167
+ }
168
+ export class ConsentGate {
169
+ _resourceName;
170
+ _secure;
171
+ _rememberKey;
172
+ constructor(options) {
173
+ this._resourceName = options.resourceName;
174
+ this._secure = options.secureCookies;
175
+ // Without stateless material the key is per process: approvals are asked
176
+ // again after a restart, which is the safe direction.
177
+ this._rememberKey = options.stateless
178
+ ? deriveSubkey(options.stateless, "current", STATELESS_PURPOSES.OAUTH_CONSENT)
179
+ : randomBytes(32);
180
+ }
181
+ /**
182
+ * Decide whether /authorize may continue to GitLab. Shows the consent page
183
+ * on first use, checks the submitted form, and honours a remembered approval.
184
+ */
185
+ check(client, params, res) {
186
+ const req = res.req;
187
+ const cookies = parseCookies(req?.headers?.cookie);
188
+ const action = req?.method === "POST" ? bodyField(req.body, "consent_action") : undefined;
189
+ if (action !== undefined) {
190
+ const submitted = bodyField(req.body, "consent_csrf");
191
+ const expected = cookies.get(this._cookieName(CSRF_COOKIE));
192
+ if (!submitted || !expected || !safeEqual(submitted, expected)) {
193
+ this._sendPage(res, 403, "Approval expired", "<p>This approval form expired or was not sent from this server. Start the sign-in again from your MCP client.</p>");
194
+ return "handled";
195
+ }
196
+ if (action !== "approve") {
197
+ const target = new URL(params.redirectUri);
198
+ target.searchParams.set("error", "access_denied");
199
+ target.searchParams.set("error_description", "The user denied the request");
200
+ if (params.state)
201
+ target.searchParams.set("state", params.state);
202
+ res.redirect(302, target.toString());
203
+ return "handled";
204
+ }
205
+ this._rememberApproval(res, client.client_id, params.redirectUri);
206
+ return "proceed";
207
+ }
208
+ if (this._isRemembered(cookies, client.client_id, params.redirectUri))
209
+ return "proceed";
210
+ this._renderConsent(res, cookies, client, params);
211
+ return "handled";
212
+ }
213
+ _cookieName(base) {
214
+ // __Host- cookies cannot be set or overwritten by sibling subdomains.
215
+ return this._secure ? `__Host-${base}` : base;
216
+ }
217
+ _cookieAttributes(maxAgeSeconds) {
218
+ return `Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; SameSite=Lax${this._secure ? "; Secure" : ""}`;
219
+ }
220
+ _approvalCookieName(clientId, redirectUri) {
221
+ const digest = createHash("sha256").update(`${clientId}\n${redirectUri}`).digest("hex");
222
+ return this._cookieName(`${CONSENT_COOKIE_PREFIX}${digest.slice(0, 16)}`);
223
+ }
224
+ _approvalSignature(clientId, redirectUri, expiresAt) {
225
+ return createHmac("sha256", this._rememberKey)
226
+ .update(`v1\n${clientId}\n${redirectUri}\n${expiresAt}`)
227
+ .digest("base64url");
228
+ }
229
+ _isRemembered(cookies, clientId, redirectUri) {
230
+ const value = cookies.get(this._approvalCookieName(clientId, redirectUri));
231
+ if (!value)
232
+ return false;
233
+ const [expiresRaw, signature] = value.split(".");
234
+ const expiresAt = Number(expiresRaw);
235
+ if (!signature || !Number.isInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
236
+ return false;
237
+ }
238
+ return safeEqual(signature, this._approvalSignature(clientId, redirectUri, expiresAt));
239
+ }
240
+ _rememberApproval(res, clientId, redirectUri) {
241
+ const expiresAt = Math.floor(Date.now() / 1000) + CONSENT_TTL_SECONDS;
242
+ const value = `${expiresAt}.${this._approvalSignature(clientId, redirectUri, expiresAt)}`;
243
+ res.setHeader("Set-Cookie", [
244
+ `${this._approvalCookieName(clientId, redirectUri)}=${value}; ${this._cookieAttributes(CONSENT_TTL_SECONDS)}`,
245
+ `${this._cookieName(CSRF_COOKIE)}=; ${this._cookieAttributes(0)}`,
246
+ ]);
247
+ }
248
+ _renderConsent(res, cookies, client, params) {
249
+ // Reuse a live token so two open sign-in tabs do not invalidate each other.
250
+ const existing = cookies.get(this._cookieName(CSRF_COOKIE));
251
+ const csrf = existing && CSRF_TOKEN_PATTERN.test(existing)
252
+ ? existing
253
+ : randomBytes(32).toString("base64url");
254
+ res.setHeader("Set-Cookie", `${this._cookieName(CSRF_COOKIE)}=${csrf}; ${this._cookieAttributes(CSRF_TTL_SECONDS)}`);
255
+ const rawName = client.client_name?.trim() || "Unnamed client";
256
+ const clientName = rawName.length > MAX_CLIENT_NAME_LENGTH
257
+ ? `${rawName.slice(0, MAX_CLIENT_NAME_LENGTH)}…`
258
+ : rawName;
259
+ const fields = {
260
+ client_id: client.client_id,
261
+ redirect_uri: params.redirectUri,
262
+ response_type: "code",
263
+ code_challenge: params.codeChallenge,
264
+ code_challenge_method: "S256",
265
+ scope: params.scopes && params.scopes.length > 0 ? params.scopes.join(" ") : undefined,
266
+ state: params.state,
267
+ resource: params.resource?.href,
268
+ consent_csrf: csrf,
269
+ };
270
+ const hidden = Object.entries(fields)
271
+ .filter((entry) => entry[1] !== undefined)
272
+ .map(([name, value]) => `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`)
273
+ .join("\n ");
274
+ const scopes = params.scopes && params.scopes.length > 0 ? params.scopes.join(", ") : "default";
275
+ this._sendPage(res, 200, "Approve MCP client", `<p>An MCP client wants to use ${escapeHtml(this._resourceName)} with your GitLab account.</p>
276
+ <p class="label">After you sign in to GitLab, access is sent to</p>
277
+ <p class="target">${escapeHtml(describeRedirect(params.redirectUri))}</p>
278
+ <p class="uri">${escapeHtml(params.redirectUri)}</p>
279
+ <p class="label">Client name (chosen by the client, not verified)</p>
280
+ <p>${escapeHtml(clientName)}</p>
281
+ <p class="label">Requested scopes</p>
282
+ <p>${escapeHtml(scopes)}</p>
283
+ <p class="warn">Approve only if you started this sign-in from your own MCP client and you recognize the address above.</p>
284
+ <form method="post">
285
+ ${hidden}
286
+ <button type="submit" name="consent_action" value="approve">Approve</button>
287
+ <button type="submit" name="consent_action" value="deny">Deny</button>
288
+ </form>`);
289
+ }
290
+ _sendPage(res, status, title, body) {
291
+ // No form-action directive: browsers apply it to the redirects that follow
292
+ // the POST (GitLab, then the client callback).
293
+ res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'");
294
+ res.setHeader("X-Frame-Options", "DENY");
295
+ res.setHeader("X-Content-Type-Options", "nosniff");
296
+ res.setHeader("Referrer-Policy", "no-referrer");
297
+ res.setHeader("Cache-Control", "no-store");
298
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
299
+ res.status(status).send(`<!doctype html>
300
+ <html lang="en">
301
+ <head>
302
+ <meta charset="utf-8">
303
+ <meta name="viewport" content="width=device-width, initial-scale=1">
304
+ <title>${escapeHtml(title)}</title>
305
+ <style>
306
+ body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 3rem auto; padding: 0 1rem; color: #1f1f1f; }
307
+ .label { margin-bottom: 0.2rem; color: #555; font-size: 0.9rem; }
308
+ .label + p { margin-top: 0; }
309
+ .target { font-size: 1.25rem; font-weight: 600; }
310
+ .uri { word-break: break-all; color: #555; font-family: ui-monospace, monospace; font-size: 0.85rem; }
311
+ .warn { margin: 1.5rem 0; }
312
+ button { font-size: 1rem; padding: 0.5rem 1.25rem; margin-right: 0.5rem; }
313
+ </style>
314
+ </head>
315
+ <body>
316
+ <main>
317
+ <h1>${escapeHtml(title)}</h1>
318
+ ${body}
319
+ </main>
320
+ </body>
321
+ </html>`);
322
+ }
323
+ }
@@ -44,12 +44,13 @@
44
44
  *
45
45
  * Activated when GITLAB_MCP_OAUTH=true. All other auth modes are unaffected.
46
46
  */
47
- import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
47
+ import { CustomOAuthError, InsufficientScopeError, InvalidGrantError, InvalidTokenError, ServerError, } from "@modelcontextprotocol/sdk/server/auth/errors.js";
48
48
  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
52
  import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, ConsumedProxyCodeCache, PROXY_CODE_CACHE_FULL, } from "./stateless/index.js";
53
+ import { ConsentGate, DEFAULT_ALLOWED_REDIRECT_URIS, RedirectUriPolicy } from "./oauth-consent.js";
53
54
  import { createLogger } from "./utils/logger.js";
54
55
  import { redactUrlSecretsInText } from "./utils/redact-url.js";
55
56
  const logger = createLogger("gitlab-mcp-oauth-proxy");
@@ -68,6 +69,9 @@ const REQUIRED_GITLAB_SCOPES_RO = ["read_api"];
68
69
  const PENDING_AUTH_MAX_SIZE = 1000;
69
70
  const PENDING_AUTH_TTL_MS = 10 * 60 * 1000; // 10 minutes
70
71
  const CLIENT_CACHE_MAX_SIZE = 1000;
72
+ const GROUP_MEMBERSHIP_CACHE_MAX_SIZE = 1000;
73
+ const GROUP_MEMBERSHIP_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
74
+ const GROUP_ACCESS_DENIED_MESSAGE = "Access denied: user is not a member of an allowed group";
71
75
  function singleQueryParam(value) {
72
76
  return typeof value === "string" ? value : undefined;
73
77
  }
@@ -143,9 +147,15 @@ class GitLabOAuthServerProvider {
143
147
  _stateless;
144
148
  // Group allowlist (optional). When set, tokens are rejected unless the
145
149
  // authenticated user is a direct or inherited member of at least one group.
146
- // Checked once at token issuance (exchangeAuthorizationCode), not per request.
150
+ // Checked at token issuance, on refresh and on every verified request; the
151
+ // result is cached per token (keyed by SHA-256) for GROUP_MEMBERSHIP_CACHE_TTL_MS.
147
152
  _allowedGroups;
148
- constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, allowedGroups, callbackProxyEnabled = false, callbackUrl = "", stateless = null) {
153
+ _groupMembershipCache = new BoundedLRUMap(GROUP_MEMBERSHIP_CACHE_MAX_SIZE);
154
+ // Callback proxy safeguards (see oauth-consent.ts). Null in passthrough
155
+ // mode, where GitLab checks redirect_uri against the app registration.
156
+ _redirectPolicy = null;
157
+ _consent = null;
158
+ constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, allowedGroups, callbackProxyEnabled = false, callbackUrl = "", stateless = null, allowedRedirectUris = DEFAULT_ALLOWED_REDIRECT_URIS) {
149
159
  this._gitlabBaseUrl = gitlabBaseUrl;
150
160
  this._gitlabAppId = gitlabAppId;
151
161
  this._resourceName = resourceName;
@@ -163,7 +173,17 @@ class GitLabOAuthServerProvider {
163
173
  throw new Error("callbackUrl is required when callbackProxyEnabled is true");
164
174
  }
165
175
  if (callbackProxyEnabled) {
176
+ this._redirectPolicy = new RedirectUriPolicy(allowedRedirectUris);
177
+ this._consent = new ConsentGate({
178
+ resourceName,
179
+ secureCookies: callbackUrl.startsWith("https:"),
180
+ stateless: stateless?.material ?? null,
181
+ });
166
182
  logger.info(`Callback proxy mode enabled — fixed callback URL: ${redactUrlSecretsInText(callbackUrl)}`);
183
+ if (this._redirectPolicy.allowsAny) {
184
+ logger.warn("GITLAB_OAUTH_ALLOWED_REDIRECT_URIS contains \"*\": any registered client may receive " +
185
+ "authorization codes; only the consent page stands between a phishing link and a user's GitLab token");
186
+ }
167
187
  }
168
188
  if (stateless) {
169
189
  logger.info(`Stateless mode enabled (client_id TTL: ${stateless.clientTtlSeconds}s, ` +
@@ -176,6 +196,7 @@ class GitLabOAuthServerProvider {
176
196
  const cache = this._clientCache;
177
197
  const resourceName = this._resourceName;
178
198
  const stateless = this._stateless;
199
+ const redirectPolicy = this._redirectPolicy;
179
200
  return {
180
201
  getClient: async (clientId) => {
181
202
  // Stateless path: a signed client_id carries the registration.
@@ -212,6 +233,18 @@ class GitLabOAuthServerProvider {
212
233
  registerClient: async (client) => {
213
234
  const grantTypes = client.grant_types ?? ["authorization_code"];
214
235
  const redirectUris = client.redirect_uris ?? [];
236
+ // Callback proxy mode: GitLab never sees the client's redirect_uri, so
237
+ // this server is the only place it can be checked.
238
+ const rejected = redirectPolicy
239
+ ? redirectUris.filter((uri) => !redirectPolicy.allows(uri))
240
+ : [];
241
+ if (rejected.length > 0) {
242
+ logger.warn(`DCR: rejected redirect_uris not allowed by GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: ${rejected
243
+ .map((uri) => JSON.stringify(redactUrlSecretsInText(uri)))
244
+ .join(", ")}`);
245
+ throw new CustomOAuthError("invalid_redirect_uri", `redirect_uri ${rejected[0]} is not allowed by this server. ` +
246
+ "The server operator can add it to GITLAB_OAUTH_ALLOWED_REDIRECT_URIS.");
247
+ }
215
248
  const clientName = client.client_name
216
249
  ? `${client.client_name} via ${resourceName}`
217
250
  : resourceName;
@@ -262,6 +295,24 @@ class GitLabOAuthServerProvider {
262
295
  const targetUrl = new URL(`${this._gitlabBaseUrl}/oauth/authorize`);
263
296
  if (this._callbackProxyEnabled) {
264
297
  // --- Callback proxy mode ---
298
+ // Re-check the policy here too: signed client_ids minted before an
299
+ // upgrade or allowlist change still carry their old redirect_uris.
300
+ // Answer directly; redirecting the error would send the browser to
301
+ // the URI we just refused.
302
+ if (this._redirectPolicy && !this._redirectPolicy.allows(params.redirectUri)) {
303
+ logger.warn(`authorize (callback proxy): refused redirect_uri ${JSON.stringify(redactUrlSecretsInText(params.redirectUri))} (not in GITLAB_OAUTH_ALLOWED_REDIRECT_URIS)`);
304
+ res.status(400).json({
305
+ error: "invalid_request",
306
+ error_description: "redirect_uri is not allowed by this server",
307
+ });
308
+ return;
309
+ }
310
+ // Ask the user before sending them to GitLab. Without this, any link
311
+ // to /authorize gets the operator's GitLab app consent screen and the
312
+ // resulting code goes to whichever client registered the link.
313
+ if (this._consent && this._consent.check(client, params, res) === "handled") {
314
+ return;
315
+ }
265
316
  // Generate a proxy PKCE pair (MCP server ↔ GitLab)
266
317
  const proxyCodeVerifier = randomBytes(32).toString("base64url");
267
318
  const proxyCodeChallenge = createHash("sha256")
@@ -453,12 +504,9 @@ class GitLabOAuthServerProvider {
453
504
  const data = await response.json();
454
505
  tokens = OAuthTokensSchema.parse(data);
455
506
  }
456
- if (this._allowedGroups) {
457
- const isMember = await this._checkGroupMembership(tokens.access_token);
458
- if (!isMember) {
459
- logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
460
- throw new ServerError("Access denied: user is not a member of an allowed group");
461
- }
507
+ if (!(await this.isAllowedGroupMember(tokens.access_token))) {
508
+ logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
509
+ throw new ServerError(GROUP_ACCESS_DENIED_MESSAGE);
462
510
  }
463
511
  // All checks passed — permanently consume the proxy code.
464
512
  legacyRestorable = null;
@@ -478,12 +526,33 @@ class GitLabOAuthServerProvider {
478
526
  throw err;
479
527
  }
480
528
  }
529
+ /**
530
+ * Returns true when no allowed groups are configured, or when the token
531
+ * owner belongs to one of them (see _checkGroupMembership). Results are
532
+ * cached per token for GROUP_MEMBERSHIP_CACHE_TTL_MS; failed lookups are not.
533
+ */
534
+ async isAllowedGroupMember(token) {
535
+ if (!this._allowedGroups)
536
+ return true;
537
+ const key = createHash("sha256").update(token).digest("hex");
538
+ const cached = this._groupMembershipCache.get(key);
539
+ if (cached && cached.expiresAt > Date.now())
540
+ return cached.isMember;
541
+ const isMember = await this._checkGroupMembership(token);
542
+ this._groupMembershipCache.set(key, {
543
+ isMember,
544
+ expiresAt: Date.now() + GROUP_MEMBERSHIP_CACHE_TTL_MS,
545
+ });
546
+ return isMember;
547
+ }
481
548
  /**
482
549
  * Returns true if the token owner belongs to at least one group whose
483
550
  * full_path equals or is a sub-path of any configured allowed group.
484
551
  *
485
552
  * Example: allowedGroups=["my-org"] allows members of "my-org",
486
553
  * "my-org/team-a", "my-org/team-a/squad-1", etc.
554
+ *
555
+ * Throws when GitLab is unavailable (5xx / 429) so the result is not cached.
487
556
  */
488
557
  async _checkGroupMembership(token) {
489
558
  const allowedPaths = this._allowedGroups.map((g) => g.toLowerCase());
@@ -492,6 +561,10 @@ class GitLabOAuthServerProvider {
492
561
  const res = await fetch(`${this._gitlabBaseUrl}/api/v4/groups?min_access_level=10&per_page=100&page=${page}`, {
493
562
  headers: { Authorization: `Bearer ${token}` }
494
563
  });
564
+ if (res.status >= 500 || res.status === 429) {
565
+ await res.body?.cancel();
566
+ throw new ServerError(`Group membership lookup failed: ${res.status}`);
567
+ }
495
568
  if (!res.ok)
496
569
  break;
497
570
  const groups = (await res.json());
@@ -532,7 +605,12 @@ class GitLabOAuthServerProvider {
532
605
  throw new ServerError(`Token refresh failed: ${response.status}`);
533
606
  }
534
607
  const data = await response.json();
535
- return OAuthTokensSchema.parse(data);
608
+ const tokens = OAuthTokensSchema.parse(data);
609
+ if (!(await this.isAllowedGroupMember(tokens.access_token))) {
610
+ logger.warn({ allowedGroups: this._allowedGroups }, "Token refresh denied: user is not a member of any allowed group");
611
+ throw new InvalidGrantError(GROUP_ACCESS_DENIED_MESSAGE);
612
+ }
613
+ return tokens;
536
614
  }
537
615
  // ---- Verify access token -----------------------------------------------
538
616
  async verifyAccessToken(token) {
@@ -552,6 +630,11 @@ class GitLabOAuthServerProvider {
552
630
  throw new InvalidTokenError("Invalid or expired GitLab OAuth token");
553
631
  }
554
632
  const info = (await res.json());
633
+ // 403 rather than 401: the token itself is valid, so asking the client to
634
+ // re-authenticate as the same user would not help.
635
+ if (!(await this.isAllowedGroupMember(token))) {
636
+ throw new InsufficientScopeError(GROUP_ACCESS_DENIED_MESSAGE);
637
+ }
555
638
  return {
556
639
  token,
557
640
  clientId: info.application?.uid ?? "dynamic",
@@ -559,6 +642,8 @@ class GitLabOAuthServerProvider {
559
642
  expiresAt: info.expires_in_seconds != null
560
643
  ? Math.floor(Date.now() / 1000) + info.expires_in_seconds
561
644
  : undefined,
645
+ // GitLab user id; stateful /mcp sessions are bound to it.
646
+ extra: { resourceOwnerId: info.resource_owner_id },
562
647
  };
563
648
  }
564
649
  // ---- Callback handler (callback proxy mode) ----------------------------
@@ -724,7 +809,9 @@ class GitLabOAuthServerProvider {
724
809
  * @param stateless Optional stateless-mode options. When set, DCR and later
725
810
  * callback-proxy state is encoded into opaque OAuth values
726
811
  * instead of an in-memory cache, enabling multi-pod deploys.
812
+ * @param allowedRedirectUris Callback proxy mode only: client redirect URIs that
813
+ * may register and receive codes (see oauth-consent.ts).
727
814
  */
728
- export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, allowedGroups, callbackProxyEnabled = false, callbackUrl = "", stateless = null) {
729
- return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, allowedGroups, callbackProxyEnabled, callbackUrl, stateless);
815
+ export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, allowedGroups, callbackProxyEnabled = false, callbackUrl = "", stateless = null, allowedRedirectUris = DEFAULT_ALLOWED_REDIRECT_URIS) {
816
+ return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, allowedGroups, callbackProxyEnabled, callbackUrl, stateless, allowedRedirectUris);
730
817
  }