Repository navigation
Expand file tree
/
Copy pathoauth-consent.ts
More file actions
402 lines (364 loc) · 15.1 KB
/
Copy pathoauth-consent.ts
File metadata and controls
402 lines (364 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
/**
* Callback-proxy safeguards (GITLAB_OAUTH_CALLBACK_PROXY=true).
*
* In callback-proxy mode GitLab only sees the server's fixed /callback URL,
* so GitLab cannot check where the proxy authorization code goes next. Any
* caller can register a client through DCR, so two checks run on this server
* before the user is sent to GitLab:
*
* 1. The client's redirect_uri must match GITLAB_OAUTH_ALLOWED_REDIRECT_URIS
* (default: known MCP client callbacks plus loopback).
* 2. The user approves the client on a consent page that shows where the
* code will be sent. Approval is remembered per client_id + redirect_uri
* in a signed cookie.
*/
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import type { Request, Response } from "express";
import { redirectUriMatches } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
import type { AuthorizationParams } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import type { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
import { deriveSubkey, STATELESS_PURPOSES } from "./stateless/index.js";
import type { StatelessKeyMaterial } from "./stateless/index.js";
// ---------------------------------------------------------------------------
// Redirect URI allowlist
// ---------------------------------------------------------------------------
/**
* Callbacks accepted when GITLAB_OAUTH_ALLOWED_REDIRECT_URIS is unset.
* Loopback entries accept any port (RFC 8252 §7.3) and any path.
*/
export const DEFAULT_ALLOWED_REDIRECT_URIS: readonly string[] = [
"https://claude.ai/api/mcp/auth_callback",
"https://claude.com/api/mcp/auth_callback",
"https://chatgpt.com/connector_platform_oauth_redirect",
"https://chatgpt.com/connector/oauth/*",
"cursor://anysphere.cursor-mcp/oauth/callback",
"http://localhost/*",
"http://127.0.0.1/*",
"http://[::1]/*",
];
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
type RedirectRule =
| { kind: "any" }
| { kind: "exact"; uri: string }
| { kind: "prefix"; base: URL };
function parseRule(entry: string): RedirectRule {
if (entry === "*") return { kind: "any" };
const isPrefix = entry.endsWith("*");
const raw = isPrefix ? entry.slice(0, -1) : entry;
if (raw.includes("*")) {
throw new Error(`GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: "${entry}" may only use * at the end`);
}
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error(`GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: "${entry}" is not a valid URL`);
}
if (url.hash || url.username || url.password) {
throw new Error(
`GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: "${entry}" must not contain a fragment or credentials`
);
}
if (!isPrefix) return { kind: "exact", uri: entry };
// Require "/*" so "https://client.example/app*" cannot be read as also
// covering /application or /app-evil.
if (!raw.endsWith("/") || url.search) {
throw new Error(
`GITLAB_OAUTH_ALLOWED_REDIRECT_URIS: "${entry}" must end with "/*", e.g. https://client.example/oauth/*`
);
}
return { kind: "prefix", base: url };
}
function ruleMatches(rule: RedirectRule, requested: string, url: URL): boolean {
switch (rule.kind) {
case "any":
return true;
case "exact":
return redirectUriMatches(requested, rule.uri);
case "prefix": {
const { base } = rule;
if (url.protocol !== base.protocol || url.hostname !== base.hostname) return false;
if (!LOOPBACK_HOSTS.has(base.hostname) && url.port !== base.port) return false;
// URL parsing already resolved "." / ".." segments.
return url.pathname.startsWith(base.pathname);
}
}
}
/**
* Split a GITLAB_OAUTH_ALLOWED_REDIRECT_URIS value into entries. Unset or
* empty means the defaults; the token "defaults" expands to them inside a
* list so operators can add entries without copying the built-in ones.
*/
export function parseAllowedRedirectUris(raw: string | undefined): string[] {
const parts = (raw ?? "")
.split(",")
.map(part => part.trim())
.filter(Boolean);
if (parts.length === 0) return [...DEFAULT_ALLOWED_REDIRECT_URIS];
return parts.flatMap(part => (part === "defaults" ? [...DEFAULT_ALLOWED_REDIRECT_URIS] : [part]));
}
export class RedirectUriPolicy {
private readonly _rules: RedirectRule[];
readonly entries: readonly string[];
constructor(entries: readonly string[]) {
this.entries = [...entries];
this._rules = entries.map(parseRule);
}
/** True when the operator opted out with "*". */
get allowsAny(): boolean {
return this._rules.some(rule => rule.kind === "any");
}
allows(redirectUri: string): boolean {
let url: URL;
try {
url = new URL(redirectUri);
} catch {
return false;
}
// OAuth redirect URIs must not carry a fragment (RFC 6749 §3.1.2).
if (url.hash || url.username || url.password) return false;
return this._rules.some(rule => ruleMatches(rule, redirectUri, url));
}
}
// ---------------------------------------------------------------------------
// Consent page
// ---------------------------------------------------------------------------
const CSRF_COOKIE = "gitlab_mcp_oauth_csrf";
const CONSENT_COOKIE_PREFIX = "gitlab_mcp_oauth_consent_";
const CSRF_TTL_SECONDS = 10 * 60;
export const CONSENT_TTL_SECONDS = 30 * 24 * 60 * 60;
const CSRF_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const MAX_CLIENT_NAME_LENGTH = 80;
export interface ConsentGateOptions {
/** Shown as the service name on the page. */
resourceName: string;
/** Set for https deployments: adds Secure and the __Host- cookie prefix. */
secureCookies: boolean;
/**
* Stateless key material. When set, remembered approvals are signed with a
* key derived from OAUTH_STATELESS_SECRET so every pod accepts them.
*/
stateless?: StatelessKeyMaterial | null;
}
/** "proceed" = continue to GitLab; "handled" = a response was already sent. */
export type ConsentOutcome = "proceed" | "handled";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function parseCookies(header: string | undefined): Map<string, string> {
const cookies = new Map<string, string>();
if (!header) return cookies;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
const name = part.slice(0, eq).trim();
if (!cookies.has(name)) cookies.set(name, part.slice(eq + 1).trim());
}
return cookies;
}
function bodyField(body: unknown, name: string): string | undefined {
if (!body || typeof body !== "object") return undefined;
const value = (body as Record<string, unknown>)[name];
return typeof value === "string" ? value : undefined;
}
function safeEqual(a: string, b: string): boolean {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
function describeRedirect(redirectUri: string): string {
const url = new URL(redirectUri);
if (LOOPBACK_HOSTS.has(url.hostname)) {
return `an app on your own computer (${url.protocol}//${url.host})`;
}
return `${url.protocol}//${url.host}`;
}
export class ConsentGate {
private readonly _resourceName: string;
private readonly _secure: boolean;
private readonly _rememberKey: Buffer;
constructor(options: ConsentGateOptions) {
this._resourceName = options.resourceName;
this._secure = options.secureCookies;
// Without stateless material the key is per process: approvals are asked
// again after a restart, which is the safe direction.
this._rememberKey = options.stateless
? deriveSubkey(options.stateless, "current", STATELESS_PURPOSES.OAUTH_CONSENT)
: randomBytes(32);
}
/**
* Decide whether /authorize may continue to GitLab. Shows the consent page
* on first use, checks the submitted form, and honours a remembered approval.
*/
check(
client: OAuthClientInformationFull,
params: AuthorizationParams,
res: Response
): ConsentOutcome {
const req = res.req as Request | undefined;
const cookies = parseCookies(req?.headers?.cookie);
const action = req?.method === "POST" ? bodyField(req.body, "consent_action") : undefined;
if (action !== undefined) {
const submitted = bodyField(req!.body, "consent_csrf");
const expected = cookies.get(this._cookieName(CSRF_COOKIE));
if (!submitted || !expected || !safeEqual(submitted, expected)) {
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>"
);
return "handled";
}
if (action !== "approve") {
const target = new URL(params.redirectUri);
target.searchParams.set("error", "access_denied");
target.searchParams.set("error_description", "The user denied the request");
if (params.state) target.searchParams.set("state", params.state);
res.redirect(302, target.toString());
return "handled";
}
this._rememberApproval(res, client.client_id, params.redirectUri);
return "proceed";
}
if (this._isRemembered(cookies, client.client_id, params.redirectUri)) return "proceed";
this._renderConsent(res, cookies, client, params);
return "handled";
}
private _cookieName(base: string): string {
// __Host- cookies cannot be set or overwritten by sibling subdomains.
return this._secure ? `__Host-${base}` : base;
}
private _cookieAttributes(maxAgeSeconds: number): string {
return `Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; SameSite=Lax${this._secure ? "; Secure" : ""}`;
}
private _approvalCookieName(clientId: string, redirectUri: string): string {
const digest = createHash("sha256").update(`${clientId}\n${redirectUri}`).digest("hex");
return this._cookieName(`${CONSENT_COOKIE_PREFIX}${digest.slice(0, 16)}`);
}
private _approvalSignature(clientId: string, redirectUri: string, expiresAt: number): string {
return createHmac("sha256", this._rememberKey)
.update(`v1\n${clientId}\n${redirectUri}\n${expiresAt}`)
.digest("base64url");
}
private _isRemembered(
cookies: Map<string, string>,
clientId: string,
redirectUri: string
): boolean {
const value = cookies.get(this._approvalCookieName(clientId, redirectUri));
if (!value) return false;
const [expiresRaw, signature] = value.split(".");
const expiresAt = Number(expiresRaw);
if (!signature || !Number.isInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
return false;
}
return safeEqual(signature, this._approvalSignature(clientId, redirectUri, expiresAt));
}
private _rememberApproval(res: Response, clientId: string, redirectUri: string): void {
const expiresAt = Math.floor(Date.now() / 1000) + CONSENT_TTL_SECONDS;
const value = `${expiresAt}.${this._approvalSignature(clientId, redirectUri, expiresAt)}`;
res.setHeader("Set-Cookie", [
`${this._approvalCookieName(clientId, redirectUri)}=${value}; ${this._cookieAttributes(CONSENT_TTL_SECONDS)}`,
`${this._cookieName(CSRF_COOKIE)}=; ${this._cookieAttributes(0)}`,
]);
}
private _renderConsent(
res: Response,
cookies: Map<string, string>,
client: OAuthClientInformationFull,
params: AuthorizationParams
): void {
// Reuse a live token so two open sign-in tabs do not invalidate each other.
const existing = cookies.get(this._cookieName(CSRF_COOKIE));
const csrf =
existing && CSRF_TOKEN_PATTERN.test(existing)
? existing
: randomBytes(32).toString("base64url");
res.setHeader(
"Set-Cookie",
`${this._cookieName(CSRF_COOKIE)}=${csrf}; ${this._cookieAttributes(CSRF_TTL_SECONDS)}`
);
const rawName = client.client_name?.trim() || "Unnamed client";
const clientName =
rawName.length > MAX_CLIENT_NAME_LENGTH
? `${rawName.slice(0, MAX_CLIENT_NAME_LENGTH)}…`
: rawName;
const fields: Record<string, string | undefined> = {
client_id: client.client_id,
redirect_uri: params.redirectUri,
response_type: "code",
code_challenge: params.codeChallenge,
code_challenge_method: "S256",
scope: params.scopes && params.scopes.length > 0 ? params.scopes.join(" ") : undefined,
state: params.state,
resource: params.resource?.href,
consent_csrf: csrf,
};
const hidden = Object.entries(fields)
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(([name, value]) => `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`)
.join("\n ");
const scopes = params.scopes && params.scopes.length > 0 ? params.scopes.join(", ") : "default";
this._sendPage(
res,
200,
"Approve MCP client",
`<p>An MCP client wants to use ${escapeHtml(this._resourceName)} with your GitLab account.</p>
<p class="label">After you sign in to GitLab, access is sent to</p>
<p class="target">${escapeHtml(describeRedirect(params.redirectUri))}</p>
<p class="uri">${escapeHtml(params.redirectUri)}</p>
<p class="label">Client name (chosen by the client, not verified)</p>
<p>${escapeHtml(clientName)}</p>
<p class="label">Requested scopes</p>
<p>${escapeHtml(scopes)}</p>
<p class="warn">Approve only if you started this sign-in from your own MCP client and you recognize the address above.</p>
<form method="post">
${hidden}
<button type="submit" name="consent_action" value="approve">Approve</button>
<button type="submit" name="consent_action" value="deny">Deny</button>
</form>`
);
}
private _sendPage(res: Response, status: number, title: string, body: string): void {
// No form-action directive: browsers apply it to the redirects that follow
// the POST (GitLab, then the client callback).
res.setHeader(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"
);
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.status(status).send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 3rem auto; padding: 0 1rem; color: #1f1f1f; }
.label { margin-bottom: 0.2rem; color: #555; font-size: 0.9rem; }
.label + p { margin-top: 0; }
.target { font-size: 1.25rem; font-weight: 600; }
.uri { word-break: break-all; color: #555; font-family: ui-monospace, monospace; font-size: 0.85rem; }
.warn { margin: 1.5rem 0; }
button { font-size: 1rem; padding: 0.5rem 1.25rem; margin-right: 0.5rem; }
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
${body}
</main>
</body>
</html>`);
}
}