@netpad/mcp-server-remote 1.0.1 → 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/README.md +79 -17
- package/api/.well-known/oauth-authorization-server.ts +71 -0
- package/api/authorize.ts +538 -0
- package/api/index.ts +46 -3
- package/api/lib/oauth.ts +411 -0
- package/api/mcp.ts +129 -579
- 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.2.0.tgz +0 -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 +4 -3
- 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
|
+
}
|