@zereight/mcp-gitlab 2.0.2 → 2.0.4

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.
@@ -1,389 +0,0 @@
1
- import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
2
- import { config } from './config.js';
3
- import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
4
- import { logger } from './logger.js';
5
- import argon2 from './argon2wrapper.js';
6
- import { randomBytes } from 'crypto';
7
- // Custom provider that handles dynamic registration and maps to GitLab OAuth
8
- class GitLabProxyProvider extends ProxyOAuthServerProvider {
9
- // Static async factory method
10
- static async New(options) {
11
- // we put this here so we dont initialize this unless we are using the oauth provider
12
- const Database = (await import('better-sqlite3')).default;
13
- const db = new Database(config.GITLAB_OAUTH2_DB_PATH);
14
- // Create tables if they don't exist
15
- db.exec(`
16
- CREATE TABLE IF NOT EXISTS oauth_clients (
17
- client_id TEXT PRIMARY KEY,
18
- client_secret TEXT NOT NULL,
19
- redirect_uris TEXT NOT NULL,
20
- grant_types TEXT NOT NULL,
21
- response_types TEXT NOT NULL,
22
- token_endpoint_auth_method TEXT NOT NULL,
23
- client_id_issued_at INTEGER NOT NULL,
24
- metadata TEXT NOT NULL
25
- );
26
-
27
- CREATE TABLE IF NOT EXISTS client_redirect_uris (
28
- client_id TEXT PRIMARY KEY,
29
- redirect_uris TEXT NOT NULL
30
- );
31
-
32
- CREATE TABLE IF NOT EXISTS state_mappings (
33
- state TEXT PRIMARY KEY,
34
- redirect_uri TEXT NOT NULL,
35
- timestamp INTEGER NOT NULL
36
- );
37
-
38
- CREATE TABLE IF NOT EXISTS access_tokens (
39
- token_hash TEXT PRIMARY KEY,
40
- client_id TEXT NOT NULL,
41
- scopes TEXT NOT NULL,
42
- expires_at INTEGER,
43
- timestamp INTEGER NOT NULL
44
- );
45
- `);
46
- const provider = new GitLabProxyProvider(options, db);
47
- return provider;
48
- }
49
- // State expiry time in milliseconds (15 minutes)
50
- STATE_EXPIRY_MS = 15 * 60 * 1000;
51
- // Token expiry time in milliseconds (7 days)
52
- TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
53
- // Cleanup interval
54
- cleanupInterval;
55
- db;
56
- constructor(options, db) {
57
- super(options);
58
- this.db = db;
59
- // Start cleanup interval
60
- this.cleanupInterval = setInterval(() => {
61
- const now = Date.now();
62
- try {
63
- // Clean up expired state mappings
64
- db.prepare('DELETE FROM state_mappings WHERE timestamp < ?').run(now - this.STATE_EXPIRY_MS);
65
- // Clean up expired tokens
66
- db.prepare('DELETE FROM access_tokens WHERE timestamp < ? OR (expires_at IS NOT NULL AND expires_at < ?)')
67
- .run(now - this.TOKEN_EXPIRY_MS, Math.floor(now / 1000));
68
- }
69
- catch (err) {
70
- logger.error('Error during cleanup:', err);
71
- }
72
- }, 5 * 60 * 1000);
73
- }
74
- get clientsStore() {
75
- return {
76
- getClient: async (clientId) => {
77
- // Check if this is the actual GitLab client
78
- if (clientId === config.GITLAB_OAUTH2_CLIENT_ID) {
79
- return {
80
- client_id: config.GITLAB_OAUTH2_CLIENT_ID,
81
- client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET,
82
- redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL],
83
- grant_types: ['authorization_code', 'refresh_token'],
84
- response_types: ['code'],
85
- token_endpoint_auth_method: 'client_secret_post'
86
- };
87
- }
88
- // Check if this is a registered dynamic client
89
- const row = this.db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId);
90
- if (!row) {
91
- return undefined;
92
- }
93
- const client = {
94
- ...JSON.parse(row.metadata),
95
- client_id: row.client_id,
96
- client_secret: row.client_secret,
97
- redirect_uris: JSON.parse(row.redirect_uris),
98
- grant_types: JSON.parse(row.grant_types),
99
- response_types: JSON.parse(row.response_types),
100
- token_endpoint_auth_method: row.token_endpoint_auth_method,
101
- client_id_issued_at: row.client_id_issued_at
102
- };
103
- return client;
104
- },
105
- registerClient: async (clientMetadata) => {
106
- // Generate a unique client ID for this MCP client using crypto-safe random
107
- const randomId = randomBytes(16).toString('hex');
108
- const clientId = `mcp_${Date.now()}_${randomId}`;
109
- // Generate a secure client secret
110
- const randomSecret = randomBytes(32).toString('hex');
111
- // Create the client registration
112
- const client = {
113
- ...clientMetadata,
114
- client_id: clientId,
115
- client_secret: `secret_${randomSecret}`,
116
- client_id_issued_at: Math.floor(Date.now() / 1000),
117
- grant_types: clientMetadata.grant_types || ['authorization_code', 'refresh_token'],
118
- response_types: clientMetadata.response_types || ['code'],
119
- token_endpoint_auth_method: clientMetadata.token_endpoint_auth_method || 'client_secret_post'
120
- };
121
- // Store the client in database
122
- try {
123
- // Store client
124
- this.db.prepare(`
125
- INSERT INTO oauth_clients
126
- (client_id, client_secret, redirect_uris, grant_types, response_types,
127
- token_endpoint_auth_method, client_id_issued_at, metadata)
128
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
129
- `).run(client.client_id, client.client_secret, JSON.stringify(client.redirect_uris), JSON.stringify(client.grant_types), JSON.stringify(client.response_types), client.token_endpoint_auth_method, client.client_id_issued_at, JSON.stringify(clientMetadata));
130
- // Store redirect URIs
131
- this.db.prepare('INSERT INTO client_redirect_uris (client_id, redirect_uris) VALUES (?, ?)')
132
- .run(clientId, JSON.stringify(clientMetadata.redirect_uris || []));
133
- return client;
134
- }
135
- catch (err) {
136
- throw err;
137
- }
138
- }
139
- };
140
- }
141
- // Override authorization to use GitLab OAuth credentials
142
- async authorize(client, params, res) {
143
- // Store the mapping between state and client's actual redirect URI with timestamp
144
- if (params.state && params.redirectUri) {
145
- try {
146
- this.db.prepare('INSERT OR REPLACE INTO state_mappings (state, redirect_uri, timestamp) VALUES (?, ?, ?)')
147
- .run(params.state, params.redirectUri, Date.now());
148
- logger.debug(`Stored state mapping: ${params.state} -> ${params.redirectUri} at ${new Date().toISOString()}`);
149
- }
150
- catch (err) {
151
- logger.error('Error storing state mapping:', err);
152
- throw err;
153
- }
154
- }
155
- // Construct the authorization URL directly to ensure proper formatting
156
- const authUrl = new URL(this._endpoints.authorizationUrl);
157
- // Add required OAuth parameters
158
- authUrl.searchParams.set('client_id', config.GITLAB_OAUTH2_CLIENT_ID);
159
- authUrl.searchParams.set('response_type', 'code');
160
- authUrl.searchParams.set('redirect_uri', config.GITLAB_OAUTH2_REDIRECT_URL.trim());
161
- authUrl.searchParams.set('code_challenge', params.codeChallenge);
162
- authUrl.searchParams.set('code_challenge_method', 'S256');
163
- // Add optional parameters
164
- if (params.state) {
165
- authUrl.searchParams.set('state', params.state);
166
- }
167
- const gitlabScopes = ['api', 'openid', 'profile', 'email'];
168
- authUrl.searchParams.set('scope', gitlabScopes.join(' '));
169
- // GitLab doesn't support the 'resource' parameter, so we skip it
170
- logger.debug({
171
- url: authUrl.toString(),
172
- scopes: gitlabScopes,
173
- requested_scopes: params.scopes
174
- }, `Redirecting to GitLab OAuth`);
175
- // Redirect to GitLab
176
- res.redirect(authUrl.toString());
177
- }
178
- // Method to get redirect URI from state
179
- async getRedirectUriFromState(state) {
180
- const row = this.db.prepare('SELECT redirect_uri, timestamp FROM state_mappings WHERE state = ?').get(state);
181
- if (!row) {
182
- return undefined;
183
- }
184
- return {
185
- redirectUri: row.redirect_uri,
186
- timestamp: row.timestamp
187
- };
188
- }
189
- // Method to delete state mapping
190
- async deleteStateMapping(state) {
191
- this.db.prepare('DELETE FROM state_mappings WHERE state = ?').run(state);
192
- }
193
- // Method to verify token
194
- async verifyToken(token) {
195
- // Get all token hashes to check against
196
- const rows = this.db.prepare('SELECT * FROM access_tokens').all();
197
- // Find the matching token by verifying against each hash
198
- let matchingRow = null;
199
- for (const row of rows) {
200
- try {
201
- if (await argon2.verify(row.token_hash, token)) {
202
- matchingRow = row;
203
- break;
204
- }
205
- }
206
- catch (err) {
207
- // Skip invalid hashes
208
- continue;
209
- }
210
- }
211
- if (!matchingRow) {
212
- return undefined;
213
- }
214
- const now = Date.now();
215
- // Check if token has expired by timestamp
216
- if (now - matchingRow.timestamp > this.TOKEN_EXPIRY_MS) {
217
- await this.deleteAccessTokenByHash(matchingRow.token_hash);
218
- return undefined;
219
- }
220
- // Check if token has an explicit expiry time
221
- if (matchingRow.expires_at && matchingRow.expires_at < Math.floor(now / 1000)) {
222
- await this.deleteAccessTokenByHash(matchingRow.token_hash);
223
- return undefined;
224
- }
225
- const authInfo = {
226
- token: token, // Return the original token
227
- clientId: matchingRow.client_id,
228
- scopes: JSON.parse(matchingRow.scopes),
229
- expiresAt: matchingRow.expires_at ?? undefined
230
- };
231
- return {
232
- authInfo,
233
- gitlabToken: token, // Return the original token since we don't store gitlab_token anymore
234
- timestamp: matchingRow.timestamp
235
- };
236
- }
237
- // Helper method to delete access token by hash
238
- async deleteAccessTokenByHash(tokenHash) {
239
- this.db.prepare('DELETE FROM access_tokens WHERE token_hash = ?').run(tokenHash);
240
- }
241
- // Override token exchange to use GitLab OAuth credentials
242
- async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
243
- // Use GitLab OAuth credentials for token exchange
244
- const gitlabClient = {
245
- ...client,
246
- client_id: config.GITLAB_OAUTH2_CLIENT_ID,
247
- client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET,
248
- redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL]
249
- };
250
- // Use GitLab's redirect URI for the token exchange
251
- const tokens = await super.exchangeAuthorizationCode(gitlabClient, authorizationCode, codeVerifier, config.GITLAB_OAUTH2_REDIRECT_URL, resource);
252
- // Store the token mapping for our own verification
253
- if (tokens.access_token) {
254
- const expiresAt = tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null;
255
- const scopes = tokens.scope ? tokens.scope.split(' ') : [];
256
- try {
257
- // Hash the token before storing
258
- const tokenHash = await argon2.hash(tokens.access_token);
259
- this.db.prepare(`
260
- INSERT OR REPLACE INTO access_tokens
261
- (token_hash, client_id, scopes, expires_at, timestamp)
262
- VALUES (?, ?, ?, ?, ?)
263
- `).run(tokenHash, client.client_id, JSON.stringify(scopes), expiresAt, Date.now());
264
- }
265
- catch (err) {
266
- logger.error('Error storing access token:', err);
267
- throw err;
268
- }
269
- }
270
- return tokens;
271
- }
272
- // Override verifyAccessToken to use our internal token store
273
- async verifyAccessToken(token) {
274
- const tokenInfo = await this.verifyToken(token);
275
- if (!tokenInfo) {
276
- throw new Error('Invalid or expired token');
277
- }
278
- return tokenInfo.authInfo;
279
- }
280
- // Handle OAuth callback and redirect to client's actual callback URL
281
- handleOAuthCallback = async (req, res) => {
282
- const { code, state, error, error_description } = req.query;
283
- logger.debug({ code: !!code, state, error }, 'OAuth callback received');
284
- if (!state) {
285
- res.status(400).send('Missing state parameter');
286
- return;
287
- }
288
- try {
289
- // Get the client's actual redirect URI with timestamp
290
- const stateMapping = await this.getRedirectUriFromState(state);
291
- if (!stateMapping) {
292
- logger.error(`No redirect URI found for state: ${state}`);
293
- res.status(400).send('Invalid state parameter');
294
- return;
295
- }
296
- // Check if the state mapping has expired
297
- if (Date.now() - stateMapping.timestamp > this.STATE_EXPIRY_MS) {
298
- logger.error(`State mapping expired for state: ${state}`);
299
- await this.deleteStateMapping(state);
300
- res.status(400).send('State parameter expired');
301
- return;
302
- }
303
- const clientRedirectUri = stateMapping.redirectUri;
304
- // Clean up the state mapping
305
- await this.deleteStateMapping(state);
306
- // Build the redirect URL with all parameters
307
- const redirectUrl = new URL(clientRedirectUri);
308
- // Pass through all query parameters
309
- if (code)
310
- redirectUrl.searchParams.set('code', code);
311
- if (state)
312
- redirectUrl.searchParams.set('state', state);
313
- if (error)
314
- redirectUrl.searchParams.set('error', error);
315
- if (error_description)
316
- redirectUrl.searchParams.set('error_description', error_description);
317
- if (error) {
318
- logger.debug({ error }, "oauth callback error");
319
- }
320
- logger.debug(`sending redirecting to client callback ${state}`);
321
- // Redirect to the client's actual callback URL
322
- res.redirect(redirectUrl.toString());
323
- }
324
- catch (err) {
325
- logger.error('Error handling OAuth callback:', err);
326
- res.status(500).send('Internal server error');
327
- }
328
- };
329
- // Create OAuth2 router
330
- createOAuth2Router() {
331
- if (!config.GITLAB_OAUTH2_BASE_URL) {
332
- throw new Error("GITLAB_OAUTH2_BASE_URL is not set");
333
- }
334
- return mcpAuthRouter({
335
- issuerUrl: new URL(config.GITLAB_OAUTH2_BASE_URL),
336
- baseUrl: new URL(config.GITLAB_OAUTH2_BASE_URL),
337
- authorizationOptions: {},
338
- provider: this,
339
- });
340
- }
341
- // Create token verifier
342
- createTokenVerifier() {
343
- const tokenVerifier = {
344
- verifyAccessToken: async (token) => {
345
- return this.verifyAccessToken(token);
346
- }
347
- };
348
- return tokenVerifier;
349
- }
350
- }
351
- // Export the provider class
352
- export { GitLabProxyProvider };
353
- // Create the GitLab OAuth provider
354
- export const createGitLabOAuthProvider = async () => {
355
- if (!config.GITLAB_OAUTH2_AUTHORIZATION_URL) {
356
- throw new Error("GITLAB_OAUTH2_AUTHORIZATION_URL is not set");
357
- }
358
- if (!config.GITLAB_OAUTH2_CLIENT_ID) {
359
- throw new Error("GITLAB_OAUTH2_CLIENT_ID is not set");
360
- }
361
- if (!config.GITLAB_OAUTH2_REDIRECT_URL) {
362
- throw new Error("GITLAB_OAUTH2_REDIRECT_URIS is not set");
363
- }
364
- if (!config.GITLAB_OAUTH2_TOKEN_URL) {
365
- throw new Error("GITLAB_OAUTH2_TOKEN_URL is not set");
366
- }
367
- if (!config.GITLAB_OAUTH2_ISSUER_URL) {
368
- throw new Error("GITLAB_OAUTH2_ISSUER_URL is not set");
369
- }
370
- if (!config.GITLAB_OAUTH2_BASE_URL) {
371
- throw new Error("GITLAB_OAUTH2_BASE_URL is not set");
372
- }
373
- const provider = await GitLabProxyProvider.New({
374
- endpoints: {
375
- authorizationUrl: config.GITLAB_OAUTH2_AUTHORIZATION_URL,
376
- tokenUrl: config.GITLAB_OAUTH2_TOKEN_URL,
377
- revocationUrl: config.GITLAB_OAUTH2_REVOCATION_URL,
378
- },
379
- verifyAccessToken: async () => {
380
- // This will be overridden by the class method
381
- throw new Error('Should not be called');
382
- },
383
- getClient: async (client_id) => {
384
- // This is handled by our custom provider's clientsStore
385
- return undefined;
386
- }
387
- });
388
- return provider;
389
- };