@netpad/mcp-server-remote 1.2.0 → 1.4.2
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/api/.well-known/oauth-authorization-server.ts +71 -0
- package/api/authorize.ts +538 -0
- package/api/index.ts +27 -4
- package/api/lib/oauth.ts +411 -0
- package/api/mcp.ts +120 -41
- package/api/oauth-metadata.ts +71 -0
- package/api/oauth-protected-resource.ts +40 -0
- package/api/token.ts +213 -0
- package/netpad-mcp-server-remote-1.3.0.tgz +0 -0
- package/netpad-mcp-server-remote-1.4.1.tgz +0 -0
- package/netpad-mcp-server-remote-1.4.2.tgz +0 -0
- package/package.json +2 -2
- package/vercel.json +20 -0
package/api/lib/oauth.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 + PKCE utilities for NetPad MCP Remote Server
|
|
3
|
+
*
|
|
4
|
+
* This implements the OAuth 2.0 Authorization Code flow with PKCE
|
|
5
|
+
* for Claude.ai's Remote MCP connector integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// CONFIGURATION
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export const OAUTH_CONFIG = {
|
|
15
|
+
// Issuer URL (the MCP server itself)
|
|
16
|
+
issuer: process.env.OAUTH_ISSUER || 'https://mcp.netpad.io',
|
|
17
|
+
|
|
18
|
+
// NetPad API URL for user authentication
|
|
19
|
+
// Note: Using www.netpad.io because netpad.io redirects to www with 307
|
|
20
|
+
netpadApiUrl: process.env.NETPAD_API_URL || 'https://www.netpad.io',
|
|
21
|
+
|
|
22
|
+
// Token settings
|
|
23
|
+
accessTokenTtlSeconds: 3600, // 1 hour
|
|
24
|
+
refreshTokenTtlSeconds: 86400 * 30, // 30 days
|
|
25
|
+
authCodeTtlSeconds: 600, // 10 minutes
|
|
26
|
+
|
|
27
|
+
// Allowed OAuth clients
|
|
28
|
+
allowedClients: {
|
|
29
|
+
'netpad': {
|
|
30
|
+
name: 'NetPad MCP Connector',
|
|
31
|
+
redirectUris: [
|
|
32
|
+
'https://claude.ai/api/mcp/auth_callback',
|
|
33
|
+
'https://www.claude.ai/api/mcp/auth_callback',
|
|
34
|
+
// Add localhost for testing
|
|
35
|
+
'http://localhost:3000/api/mcp/auth_callback',
|
|
36
|
+
],
|
|
37
|
+
scopes: ['claudeai', 'mcp', 'read', 'write'],
|
|
38
|
+
pkceRequired: true,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Supported scopes
|
|
43
|
+
scopes: {
|
|
44
|
+
'claudeai': 'Access NetPad via Claude.ai',
|
|
45
|
+
'mcp': 'Use MCP tools',
|
|
46
|
+
'read': 'Read forms, workflows, and data',
|
|
47
|
+
'write': 'Create and modify forms, workflows, and data',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// TYPES
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
export interface AuthorizationCode {
|
|
56
|
+
code: string;
|
|
57
|
+
clientId: string;
|
|
58
|
+
redirectUri: string;
|
|
59
|
+
scope: string;
|
|
60
|
+
codeChallenge: string;
|
|
61
|
+
codeChallengeMethod: string;
|
|
62
|
+
userId: string;
|
|
63
|
+
organizationId: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface OAuthToken {
|
|
68
|
+
accessToken: string;
|
|
69
|
+
refreshToken: string;
|
|
70
|
+
tokenType: 'Bearer';
|
|
71
|
+
expiresIn: number;
|
|
72
|
+
scope: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TokenPayload {
|
|
76
|
+
sub: string; // User ID
|
|
77
|
+
org: string; // Organization ID
|
|
78
|
+
scope: string;
|
|
79
|
+
iat: number;
|
|
80
|
+
exp: number;
|
|
81
|
+
iss: string;
|
|
82
|
+
aud: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// IN-MEMORY STORES (Replace with database in production)
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
// Store authorization codes (short-lived, should use Redis in production)
|
|
90
|
+
const authorizationCodes = new Map<string, AuthorizationCode>();
|
|
91
|
+
|
|
92
|
+
// Store refresh tokens (should use database in production)
|
|
93
|
+
const refreshTokens = new Map<string, { userId: string; organizationId: string; scope: string; expiresAt: number }>();
|
|
94
|
+
|
|
95
|
+
// Cleanup expired codes periodically
|
|
96
|
+
setInterval(() => {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
for (const [code, data] of authorizationCodes) {
|
|
99
|
+
if (data.expiresAt < now) {
|
|
100
|
+
authorizationCodes.delete(code);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const [token, data] of refreshTokens) {
|
|
104
|
+
if (data.expiresAt < now) {
|
|
105
|
+
refreshTokens.delete(token);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}, 60000); // Every minute
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// PKCE UTILITIES
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Verify PKCE code_verifier against code_challenge
|
|
116
|
+
*
|
|
117
|
+
* For S256: code_challenge = BASE64URL(SHA256(code_verifier))
|
|
118
|
+
*/
|
|
119
|
+
export function verifyPkceChallenge(
|
|
120
|
+
codeVerifier: string,
|
|
121
|
+
codeChallenge: string,
|
|
122
|
+
method: string
|
|
123
|
+
): boolean {
|
|
124
|
+
if (method === 'S256') {
|
|
125
|
+
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
|
|
126
|
+
const computed = base64UrlEncode(hash);
|
|
127
|
+
return computed === codeChallenge;
|
|
128
|
+
} else if (method === 'plain') {
|
|
129
|
+
return codeVerifier === codeChallenge;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Base64 URL encode (RFC 4648)
|
|
136
|
+
*/
|
|
137
|
+
function base64UrlEncode(buffer: Buffer): string {
|
|
138
|
+
return buffer
|
|
139
|
+
.toString('base64')
|
|
140
|
+
.replace(/\+/g, '-')
|
|
141
|
+
.replace(/\//g, '_')
|
|
142
|
+
.replace(/=+$/, '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// AUTHORIZATION CODE MANAGEMENT
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Generate a self-contained authorization code (stateless)
|
|
151
|
+
*
|
|
152
|
+
* Since Vercel serverless functions don't share memory between invocations,
|
|
153
|
+
* we encode all the authorization data directly into the code itself.
|
|
154
|
+
* The code is signed with HMAC to prevent tampering.
|
|
155
|
+
*/
|
|
156
|
+
export function generateAuthorizationCode(params: {
|
|
157
|
+
clientId: string;
|
|
158
|
+
redirectUri: string;
|
|
159
|
+
scope: string;
|
|
160
|
+
codeChallenge: string;
|
|
161
|
+
codeChallengeMethod: string;
|
|
162
|
+
userId: string;
|
|
163
|
+
organizationId: string;
|
|
164
|
+
}): string {
|
|
165
|
+
const payload = {
|
|
166
|
+
cid: params.clientId,
|
|
167
|
+
ruri: params.redirectUri,
|
|
168
|
+
scope: params.scope,
|
|
169
|
+
cc: params.codeChallenge,
|
|
170
|
+
ccm: params.codeChallengeMethod,
|
|
171
|
+
uid: params.userId,
|
|
172
|
+
oid: params.organizationId,
|
|
173
|
+
exp: Date.now() + OAUTH_CONFIG.authCodeTtlSeconds * 1000,
|
|
174
|
+
nonce: crypto.randomBytes(8).toString('hex'), // Prevent replay
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const payloadStr = JSON.stringify(payload);
|
|
178
|
+
const payloadB64 = Buffer.from(payloadStr).toString('base64url');
|
|
179
|
+
|
|
180
|
+
// Sign the payload
|
|
181
|
+
const secret = process.env.OAUTH_SECRET || 'netpad-mcp-oauth-secret-change-in-production';
|
|
182
|
+
const signature = crypto
|
|
183
|
+
.createHmac('sha256', secret)
|
|
184
|
+
.update(payloadB64)
|
|
185
|
+
.digest('base64url');
|
|
186
|
+
|
|
187
|
+
return `${payloadB64}.${signature}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Consume an authorization code (stateless verification)
|
|
192
|
+
*/
|
|
193
|
+
export function consumeAuthorizationCode(code: string): AuthorizationCode | null {
|
|
194
|
+
try {
|
|
195
|
+
const parts = code.split('.');
|
|
196
|
+
if (parts.length !== 2) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const [payloadB64, signature] = parts;
|
|
201
|
+
|
|
202
|
+
// Verify signature
|
|
203
|
+
const secret = process.env.OAUTH_SECRET || 'netpad-mcp-oauth-secret-change-in-production';
|
|
204
|
+
const expectedSig = crypto
|
|
205
|
+
.createHmac('sha256', secret)
|
|
206
|
+
.update(payloadB64)
|
|
207
|
+
.digest('base64url');
|
|
208
|
+
|
|
209
|
+
if (signature !== expectedSig) {
|
|
210
|
+
console.log('[OAuth] Authorization code signature mismatch');
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Decode payload
|
|
215
|
+
const payloadStr = Buffer.from(payloadB64, 'base64url').toString();
|
|
216
|
+
const payload = JSON.parse(payloadStr);
|
|
217
|
+
|
|
218
|
+
// Check expiration
|
|
219
|
+
if (payload.exp < Date.now()) {
|
|
220
|
+
console.log('[OAuth] Authorization code expired');
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Note: In a stateless system, we can't truly enforce one-time use
|
|
225
|
+
// without external storage. The short expiration (10 min) mitigates this.
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
code,
|
|
229
|
+
clientId: payload.cid,
|
|
230
|
+
redirectUri: payload.ruri,
|
|
231
|
+
scope: payload.scope,
|
|
232
|
+
codeChallenge: payload.cc,
|
|
233
|
+
codeChallengeMethod: payload.ccm,
|
|
234
|
+
userId: payload.uid,
|
|
235
|
+
organizationId: payload.oid,
|
|
236
|
+
expiresAt: payload.exp,
|
|
237
|
+
};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error('[OAuth] Failed to decode authorization code:', error);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ============================================================================
|
|
245
|
+
// TOKEN GENERATION
|
|
246
|
+
// ============================================================================
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Generate access token (JWT-like but simpler for now)
|
|
250
|
+
*
|
|
251
|
+
* In production, use proper JWT with RS256 signing
|
|
252
|
+
*/
|
|
253
|
+
export function generateAccessToken(params: {
|
|
254
|
+
userId: string;
|
|
255
|
+
organizationId: string;
|
|
256
|
+
scope: string;
|
|
257
|
+
}): string {
|
|
258
|
+
const payload: TokenPayload = {
|
|
259
|
+
sub: params.userId,
|
|
260
|
+
org: params.organizationId,
|
|
261
|
+
scope: params.scope,
|
|
262
|
+
iat: Math.floor(Date.now() / 1000),
|
|
263
|
+
exp: Math.floor(Date.now() / 1000) + OAUTH_CONFIG.accessTokenTtlSeconds,
|
|
264
|
+
iss: OAUTH_CONFIG.issuer,
|
|
265
|
+
aud: 'mcp.netpad.io',
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Simple encoding (in production, use proper JWT signing)
|
|
269
|
+
const header = base64UrlEncode(Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
|
|
270
|
+
const body = base64UrlEncode(Buffer.from(JSON.stringify(payload)));
|
|
271
|
+
const secret = process.env.OAUTH_SECRET || 'netpad-mcp-oauth-secret-change-in-production';
|
|
272
|
+
const signature = crypto
|
|
273
|
+
.createHmac('sha256', secret)
|
|
274
|
+
.update(`${header}.${body}`)
|
|
275
|
+
.digest();
|
|
276
|
+
const sig = base64UrlEncode(signature);
|
|
277
|
+
|
|
278
|
+
return `${header}.${body}.${sig}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Generate refresh token
|
|
283
|
+
*/
|
|
284
|
+
export function generateRefreshToken(params: {
|
|
285
|
+
userId: string;
|
|
286
|
+
organizationId: string;
|
|
287
|
+
scope: string;
|
|
288
|
+
}): string {
|
|
289
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
290
|
+
|
|
291
|
+
refreshTokens.set(token, {
|
|
292
|
+
userId: params.userId,
|
|
293
|
+
organizationId: params.organizationId,
|
|
294
|
+
scope: params.scope,
|
|
295
|
+
expiresAt: Date.now() + OAUTH_CONFIG.refreshTokenTtlSeconds * 1000,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return token;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Validate and decode an access token
|
|
303
|
+
*/
|
|
304
|
+
export function validateAccessToken(token: string): TokenPayload | null {
|
|
305
|
+
try {
|
|
306
|
+
const parts = token.split('.');
|
|
307
|
+
if (parts.length !== 3) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const [header, body, sig] = parts;
|
|
312
|
+
|
|
313
|
+
// Verify signature
|
|
314
|
+
const secret = process.env.OAUTH_SECRET || 'netpad-mcp-oauth-secret-change-in-production';
|
|
315
|
+
const expectedSig = base64UrlEncode(
|
|
316
|
+
crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest()
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
if (sig !== expectedSig) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Decode payload
|
|
324
|
+
const payload = JSON.parse(Buffer.from(body, 'base64').toString()) as TokenPayload;
|
|
325
|
+
|
|
326
|
+
// Check expiration
|
|
327
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return payload;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Exchange refresh token for new access token
|
|
339
|
+
*/
|
|
340
|
+
export function exchangeRefreshToken(refreshToken: string): {
|
|
341
|
+
accessToken: string;
|
|
342
|
+
refreshToken: string;
|
|
343
|
+
expiresIn: number;
|
|
344
|
+
scope: string;
|
|
345
|
+
} | null {
|
|
346
|
+
const data = refreshTokens.get(refreshToken);
|
|
347
|
+
|
|
348
|
+
if (!data || data.expiresAt < Date.now()) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Generate new tokens
|
|
353
|
+
const newAccessToken = generateAccessToken({
|
|
354
|
+
userId: data.userId,
|
|
355
|
+
organizationId: data.organizationId,
|
|
356
|
+
scope: data.scope,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Optionally rotate refresh token
|
|
360
|
+
refreshTokens.delete(refreshToken);
|
|
361
|
+
const newRefreshToken = generateRefreshToken({
|
|
362
|
+
userId: data.userId,
|
|
363
|
+
organizationId: data.organizationId,
|
|
364
|
+
scope: data.scope,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
accessToken: newAccessToken,
|
|
369
|
+
refreshToken: newRefreshToken,
|
|
370
|
+
expiresIn: OAUTH_CONFIG.accessTokenTtlSeconds,
|
|
371
|
+
scope: data.scope,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ============================================================================
|
|
376
|
+
// CLIENT VALIDATION
|
|
377
|
+
// ============================================================================
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Validate OAuth client and redirect URI
|
|
381
|
+
*/
|
|
382
|
+
export function validateClient(
|
|
383
|
+
clientId: string,
|
|
384
|
+
redirectUri: string
|
|
385
|
+
): { valid: boolean; error?: string } {
|
|
386
|
+
const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
|
|
387
|
+
|
|
388
|
+
if (!client) {
|
|
389
|
+
return { valid: false, error: 'invalid_client' };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!client.redirectUris.includes(redirectUri)) {
|
|
393
|
+
return { valid: false, error: 'invalid_redirect_uri' };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { valid: true };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Validate requested scopes
|
|
401
|
+
*/
|
|
402
|
+
export function validateScopes(requestedScopes: string, clientId: string): string[] {
|
|
403
|
+
const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
|
|
404
|
+
if (!client) return [];
|
|
405
|
+
|
|
406
|
+
const requested = requestedScopes.split(' ').filter(Boolean);
|
|
407
|
+
const allowed = requested.filter(scope => client.scopes.includes(scope));
|
|
408
|
+
|
|
409
|
+
// Default to 'mcp' if no valid scopes
|
|
410
|
+
return allowed.length > 0 ? allowed : ['mcp'];
|
|
411
|
+
}
|
package/api/mcp.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
|
2
2
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
3
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
4
|
import { createNetPadMcpServer } from '@netpad/mcp-server';
|
|
5
|
+
import { validateAccessToken } from './lib/oauth.js';
|
|
5
6
|
|
|
6
7
|
// Store transports by session ID for reconnection
|
|
7
8
|
const transports = new Map<string, StreamableHTTPServerTransport>();
|
|
8
9
|
|
|
9
10
|
// ============================================================================
|
|
10
|
-
// API
|
|
11
|
+
// AUTHENTICATION (Supports both OAuth tokens and API keys)
|
|
11
12
|
// ============================================================================
|
|
12
13
|
|
|
13
14
|
// Cache validated API keys for 5 minutes to reduce API calls
|
|
@@ -16,28 +17,46 @@ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Get the NetPad API base URL from environment or default
|
|
20
|
+
* Note: Using www.netpad.io because netpad.io redirects to www with 307
|
|
19
21
|
*/
|
|
20
22
|
function getNetPadApiUrl(): string {
|
|
21
|
-
return process.env.NETPAD_API_URL || 'https://netpad.io';
|
|
23
|
+
return process.env.NETPAD_API_URL || 'https://www.netpad.io';
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
* Authentication result
|
|
28
|
+
*/
|
|
29
|
+
interface AuthResult {
|
|
30
|
+
valid: boolean;
|
|
31
|
+
userId?: string;
|
|
32
|
+
organizationId?: string;
|
|
33
|
+
scope?: string;
|
|
34
|
+
error?: {
|
|
35
|
+
status: number;
|
|
36
|
+
error: string;
|
|
37
|
+
code: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate the request using either OAuth token or API key
|
|
29
43
|
*
|
|
30
|
-
*
|
|
44
|
+
* Supports:
|
|
45
|
+
* 1. OAuth access tokens (JWT-like tokens from /token endpoint)
|
|
46
|
+
* 2. NetPad API keys (np_live_xxx or np_test_xxx)
|
|
31
47
|
*/
|
|
32
|
-
async function
|
|
48
|
+
async function validateRequest(req: VercelRequest): Promise<AuthResult> {
|
|
33
49
|
const authHeader = req.headers['authorization'];
|
|
34
50
|
|
|
35
51
|
// Check if Authorization header is present
|
|
36
52
|
if (!authHeader) {
|
|
37
53
|
return {
|
|
38
|
-
|
|
39
|
-
error:
|
|
40
|
-
|
|
54
|
+
valid: false,
|
|
55
|
+
error: {
|
|
56
|
+
status: 401,
|
|
57
|
+
error: 'Missing Authorization header. Use: Authorization: Bearer <token>',
|
|
58
|
+
code: 'MISSING_AUTH',
|
|
59
|
+
},
|
|
41
60
|
};
|
|
42
61
|
}
|
|
43
62
|
|
|
@@ -45,41 +64,94 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
|
|
|
45
64
|
const parts = authHeader.split(' ');
|
|
46
65
|
if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
|
|
47
66
|
return {
|
|
48
|
-
|
|
49
|
-
error:
|
|
50
|
-
|
|
67
|
+
valid: false,
|
|
68
|
+
error: {
|
|
69
|
+
status: 401,
|
|
70
|
+
error: 'Invalid Authorization header format. Use: Authorization: Bearer <token>',
|
|
71
|
+
code: 'INVALID_AUTH_FORMAT',
|
|
72
|
+
},
|
|
51
73
|
};
|
|
52
74
|
}
|
|
53
75
|
|
|
54
|
-
const
|
|
76
|
+
const token = parts[1].trim();
|
|
55
77
|
|
|
56
|
-
if (!
|
|
78
|
+
if (!token) {
|
|
57
79
|
return {
|
|
58
|
-
|
|
59
|
-
error:
|
|
60
|
-
|
|
80
|
+
valid: false,
|
|
81
|
+
error: {
|
|
82
|
+
status: 401,
|
|
83
|
+
error: 'Token is empty',
|
|
84
|
+
code: 'EMPTY_TOKEN',
|
|
85
|
+
},
|
|
61
86
|
};
|
|
62
87
|
}
|
|
63
88
|
|
|
64
|
-
//
|
|
65
|
-
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// Try OAuth token first (JWT-like format with dots)
|
|
91
|
+
// ============================================================================
|
|
92
|
+
if (token.includes('.')) {
|
|
93
|
+
console.log('[MCP] Attempting OAuth token validation...');
|
|
94
|
+
const payload = validateAccessToken(token);
|
|
95
|
+
if (payload) {
|
|
96
|
+
console.log('[MCP] OAuth token valid:', { userId: payload.sub, org: payload.org });
|
|
97
|
+
return {
|
|
98
|
+
valid: true,
|
|
99
|
+
userId: payload.sub,
|
|
100
|
+
organizationId: payload.org,
|
|
101
|
+
scope: payload.scope,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// If it looks like a JWT but failed validation, return error
|
|
105
|
+
// (don't fall through to API key validation)
|
|
106
|
+
console.log('[MCP] OAuth token validation failed');
|
|
66
107
|
return {
|
|
67
|
-
|
|
68
|
-
error:
|
|
69
|
-
|
|
108
|
+
valid: false,
|
|
109
|
+
error: {
|
|
110
|
+
status: 401,
|
|
111
|
+
error: 'Invalid or expired OAuth token',
|
|
112
|
+
code: 'INVALID_OAUTH_TOKEN',
|
|
113
|
+
},
|
|
70
114
|
};
|
|
71
115
|
}
|
|
72
116
|
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Try API key (np_live_xxx or np_test_xxx format)
|
|
119
|
+
// ============================================================================
|
|
120
|
+
if (token.startsWith('np_live_') || token.startsWith('np_test_')) {
|
|
121
|
+
return validateApiKey(token);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Unknown token format
|
|
125
|
+
return {
|
|
126
|
+
valid: false,
|
|
127
|
+
error: {
|
|
128
|
+
status: 401,
|
|
129
|
+
error: 'Invalid token format. Use an OAuth token or NetPad API key (np_live_xxx)',
|
|
130
|
+
code: 'INVALID_TOKEN_FORMAT',
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate a NetPad API key
|
|
137
|
+
*/
|
|
138
|
+
async function validateApiKey(apiKey: string): Promise<AuthResult> {
|
|
73
139
|
// Check cache first
|
|
74
140
|
const cached = apiKeyCache.get(apiKey);
|
|
75
141
|
if (cached && cached.expiresAt > Date.now()) {
|
|
76
142
|
if (cached.valid) {
|
|
77
|
-
return
|
|
143
|
+
return {
|
|
144
|
+
valid: true,
|
|
145
|
+
organizationId: cached.organizationId,
|
|
146
|
+
};
|
|
78
147
|
}
|
|
79
148
|
return {
|
|
80
|
-
|
|
81
|
-
error:
|
|
82
|
-
|
|
149
|
+
valid: false,
|
|
150
|
+
error: {
|
|
151
|
+
status: 401,
|
|
152
|
+
error: 'Invalid or expired API key',
|
|
153
|
+
code: 'INVALID_API_KEY',
|
|
154
|
+
},
|
|
83
155
|
};
|
|
84
156
|
}
|
|
85
157
|
|
|
@@ -103,7 +175,11 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
|
|
|
103
175
|
organizationId: data.organizationId,
|
|
104
176
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
|
105
177
|
});
|
|
106
|
-
return
|
|
178
|
+
return {
|
|
179
|
+
valid: true,
|
|
180
|
+
userId: data.userId,
|
|
181
|
+
organizationId: data.organizationId,
|
|
182
|
+
};
|
|
107
183
|
}
|
|
108
184
|
|
|
109
185
|
// Key is invalid - cache the negative result too (shorter TTL)
|
|
@@ -115,9 +191,12 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
|
|
|
115
191
|
// Parse error response
|
|
116
192
|
const errorData = await response.json().catch(() => ({}));
|
|
117
193
|
return {
|
|
118
|
-
|
|
119
|
-
error:
|
|
120
|
-
|
|
194
|
+
valid: false,
|
|
195
|
+
error: {
|
|
196
|
+
status: response.status,
|
|
197
|
+
error: errorData.error?.message || 'Invalid or expired API key',
|
|
198
|
+
code: errorData.error?.code || 'INVALID_API_KEY',
|
|
199
|
+
},
|
|
121
200
|
};
|
|
122
201
|
} catch (error) {
|
|
123
202
|
console.error('Error validating API key against NetPad:', error);
|
|
@@ -126,7 +205,7 @@ async function validateApiKey(req: VercelRequest): Promise<{ status: number; err
|
|
|
126
205
|
// This allows the MCP server to work even if NetPad API is temporarily unavailable
|
|
127
206
|
// but only accepts properly formatted keys
|
|
128
207
|
console.warn('NetPad API unavailable, accepting key based on format validation only');
|
|
129
|
-
return
|
|
208
|
+
return { valid: true };
|
|
130
209
|
}
|
|
131
210
|
}
|
|
132
211
|
|
|
@@ -141,12 +220,12 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
141
220
|
// ============================================================================
|
|
142
221
|
// AUTHENTICATE REQUEST
|
|
143
222
|
// ============================================================================
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
res.status(
|
|
147
|
-
error:
|
|
148
|
-
code:
|
|
149
|
-
hint: '
|
|
223
|
+
const authResult = await validateRequest(req);
|
|
224
|
+
if (!authResult.valid) {
|
|
225
|
+
res.status(authResult.error?.status || 401).json({
|
|
226
|
+
error: authResult.error?.error || 'Authentication failed',
|
|
227
|
+
code: authResult.error?.code || 'AUTH_FAILED',
|
|
228
|
+
hint: 'Connect via Claude.ai Settings > Connectors, or use an API key from netpad.io/settings',
|
|
150
229
|
});
|
|
151
230
|
return;
|
|
152
231
|
}
|
|
@@ -164,7 +243,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
164
243
|
// Create the full NetPad MCP server with all 80+ tools
|
|
165
244
|
const server = createNetPadMcpServer({
|
|
166
245
|
name: '@netpad/mcp-server-remote',
|
|
167
|
-
version: '1.
|
|
246
|
+
version: '1.2.0',
|
|
168
247
|
});
|
|
169
248
|
await server.connect(transport);
|
|
170
249
|
|
|
@@ -194,7 +273,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
|
194
273
|
// Create the full NetPad MCP server with all 80+ tools
|
|
195
274
|
const server = createNetPadMcpServer({
|
|
196
275
|
name: '@netpad/mcp-server-remote',
|
|
197
|
-
version: '1.
|
|
276
|
+
version: '1.2.0',
|
|
198
277
|
});
|
|
199
278
|
await server.connect(transport);
|
|
200
279
|
|