@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.
- package/README.ko.md +7 -4
- package/README.md +11 -7
- package/README.pt-BR.md +7 -4
- package/README.zh-CN.md +7 -4
- package/build/cli/args.js +2 -0
- package/build/config.js +4 -0
- package/build/deprecated-env.js +3 -1
- package/build/index.js +370 -86
- package/build/oauth-consent.js +323 -0
- package/build/oauth-proxy.js +99 -12
- package/build/oauth.js +147 -3
- package/build/schemas.js +25 -1
- package/build/stateless/types.js +2 -0
- package/build/tools/exposed-tools.js +14 -0
- package/build/tools/registry.js +24 -4
- package/build/tools/tool-descriptions.js +59 -17
- package/build/utils/graphql-query.js +1 -2
- package/build/utils/helpers.js +63 -4
- package/build/utils/safe-redirect-fetch.js +1 -1
- package/build/utils/url.js +1 -0
- package/package.json +4 -3
|
@@ -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, "<")
|
|
132
|
+
.replace(/>/g, ">")
|
|
133
|
+
.replace(/"/g, """)
|
|
134
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/build/oauth-proxy.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
}
|