@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.
@@ -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
+ }