@netpad/mcp-server-remote 1.2.0 → 1.5.0
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/metrics.ts +208 -0
- package/api/lib/oauth.ts +475 -0
- package/api/mcp.ts +199 -43
- package/api/oauth-metadata.ts +75 -0
- package/api/oauth-protected-resource.ts +40 -0
- package/api/token.ts +256 -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/netpad-mcp-server-remote-1.5.0.tgz +0 -0
- package/package.json +2 -2
- package/vercel.json +20 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Metrics Collector
|
|
3
|
+
*
|
|
4
|
+
* Collects usage metrics from the MCP server and batches them for reporting
|
|
5
|
+
* to the NetPad API. Designed for Vercel serverless environment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================
|
|
9
|
+
// Types
|
|
10
|
+
// ============================================
|
|
11
|
+
|
|
12
|
+
export interface MCPRequestMetric {
|
|
13
|
+
timestamp: number;
|
|
14
|
+
organizationId?: string;
|
|
15
|
+
userId?: string;
|
|
16
|
+
method: 'GET' | 'POST';
|
|
17
|
+
tool?: string;
|
|
18
|
+
authMethod: 'oauth' | 'api_key' | 'none';
|
|
19
|
+
authSuccess: boolean;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
statusCode: number;
|
|
22
|
+
errorCode?: string;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MCPMetricsBatch {
|
|
27
|
+
serverVersion: string;
|
|
28
|
+
environment: string;
|
|
29
|
+
collectedAt: number;
|
|
30
|
+
metrics: MCPRequestMetric[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================
|
|
34
|
+
// Configuration
|
|
35
|
+
// ============================================
|
|
36
|
+
|
|
37
|
+
// In serverless environments, flush more aggressively since memory is ephemeral
|
|
38
|
+
const BATCH_SIZE = 10; // Reduced from 50 for serverless
|
|
39
|
+
const FLUSH_INTERVAL_MS = 10000; // 10 seconds (reduced from 30)
|
|
40
|
+
|
|
41
|
+
// In-memory buffer for metrics (serverless-friendly)
|
|
42
|
+
let metricsBuffer: MCPRequestMetric[] = [];
|
|
43
|
+
let lastFlushTime = Date.now();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get the NetPad API base URL
|
|
47
|
+
*/
|
|
48
|
+
function getNetPadApiUrl(): string {
|
|
49
|
+
return process.env.NETPAD_API_URL || 'https://www.netpad.io';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get MCP server version from package or environment
|
|
54
|
+
*/
|
|
55
|
+
function getServerVersion(): string {
|
|
56
|
+
return process.env.MCP_SERVER_VERSION || '1.4.2';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get environment (production, staging, development)
|
|
61
|
+
*/
|
|
62
|
+
function getEnvironment(): string {
|
|
63
|
+
return process.env.VERCEL_ENV || process.env.NODE_ENV || 'development';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================
|
|
67
|
+
// Metrics Collection
|
|
68
|
+
// ============================================
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Record a single MCP request metric
|
|
72
|
+
*/
|
|
73
|
+
export function recordMetric(metric: Omit<MCPRequestMetric, 'timestamp'>): void {
|
|
74
|
+
const fullMetric: MCPRequestMetric = {
|
|
75
|
+
...metric,
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
metricsBuffer.push(fullMetric);
|
|
80
|
+
console.log('[Metrics] Recorded:', {
|
|
81
|
+
method: metric.method,
|
|
82
|
+
tool: metric.tool,
|
|
83
|
+
authMethod: metric.authMethod,
|
|
84
|
+
durationMs: metric.durationMs,
|
|
85
|
+
statusCode: metric.statusCode,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Check if we should flush
|
|
89
|
+
if (metricsBuffer.length >= BATCH_SIZE) {
|
|
90
|
+
flushMetrics().catch((err) => {
|
|
91
|
+
console.error('[Metrics] Flush error:', err);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create a metrics context for timing a request
|
|
98
|
+
*/
|
|
99
|
+
export function startMetricsContext(): { startTime: number } {
|
|
100
|
+
return { startTime: Date.now() };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Calculate duration from metrics context
|
|
105
|
+
*/
|
|
106
|
+
export function getDuration(context: { startTime: number }): number {
|
|
107
|
+
return Date.now() - context.startTime;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ============================================
|
|
111
|
+
// Metrics Flushing
|
|
112
|
+
// ============================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Flush accumulated metrics to the NetPad API
|
|
116
|
+
*/
|
|
117
|
+
export async function flushMetrics(): Promise<void> {
|
|
118
|
+
if (metricsBuffer.length === 0) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Swap buffer to avoid race conditions
|
|
123
|
+
const metricsToFlush = metricsBuffer;
|
|
124
|
+
metricsBuffer = [];
|
|
125
|
+
lastFlushTime = Date.now();
|
|
126
|
+
|
|
127
|
+
const batch: MCPMetricsBatch = {
|
|
128
|
+
serverVersion: getServerVersion(),
|
|
129
|
+
environment: getEnvironment(),
|
|
130
|
+
collectedAt: Date.now(),
|
|
131
|
+
metrics: metricsToFlush,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const netpadUrl = getNetPadApiUrl();
|
|
136
|
+
const response = await fetch(`${netpadUrl}/api/v1/mcp-metrics`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
'X-MCP-Server-Version': getServerVersion(),
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify(batch),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
// Put metrics back on failure (at the front)
|
|
147
|
+
metricsBuffer = [...metricsToFlush, ...metricsBuffer];
|
|
148
|
+
console.error('[Metrics] Failed to flush:', response.status, await response.text());
|
|
149
|
+
} else {
|
|
150
|
+
console.log('[Metrics] Flushed', metricsToFlush.length, 'metrics');
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Put metrics back on network error
|
|
154
|
+
metricsBuffer = [...metricsToFlush, ...metricsBuffer];
|
|
155
|
+
console.error('[Metrics] Network error during flush:', error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Force flush all metrics (useful before process exit)
|
|
161
|
+
*/
|
|
162
|
+
export async function forceFlush(): Promise<void> {
|
|
163
|
+
await flushMetrics();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================
|
|
167
|
+
// Helper Functions
|
|
168
|
+
// ============================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Extract tool name from JSON-RPC request body
|
|
172
|
+
*/
|
|
173
|
+
export function extractToolFromBody(body: unknown): string | undefined {
|
|
174
|
+
if (!body || typeof body !== 'object') {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const request = body as Record<string, unknown>;
|
|
179
|
+
|
|
180
|
+
// JSON-RPC format: { method: 'tools/call', params: { name: 'tool_name' } }
|
|
181
|
+
if (request.method === 'tools/call' && request.params && typeof request.params === 'object') {
|
|
182
|
+
const params = request.params as Record<string, unknown>;
|
|
183
|
+
if (typeof params.name === 'string') {
|
|
184
|
+
return params.name;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Also check for direct method calls
|
|
189
|
+
if (typeof request.method === 'string' && request.method !== 'tools/call') {
|
|
190
|
+
return request.method;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get buffered metrics count (for testing/debugging)
|
|
198
|
+
*/
|
|
199
|
+
export function getBufferedMetricsCount(): number {
|
|
200
|
+
return metricsBuffer.length;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get time since last flush (for testing/debugging)
|
|
205
|
+
*/
|
|
206
|
+
export function getTimeSinceLastFlush(): number {
|
|
207
|
+
return Date.now() - lastFlushTime;
|
|
208
|
+
}
|
package/api/lib/oauth.ts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
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
|
+
* Check if a string is a valid HTTPS URL (for Client ID Metadata Documents)
|
|
381
|
+
*/
|
|
382
|
+
function isHttpsUrl(str: string): boolean {
|
|
383
|
+
try {
|
|
384
|
+
const url = new URL(str);
|
|
385
|
+
return url.protocol === 'https:';
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Validate OAuth client and redirect URI
|
|
393
|
+
*
|
|
394
|
+
* Supports:
|
|
395
|
+
* 1. Pre-registered clients (e.g., 'netpad')
|
|
396
|
+
* 2. Client ID Metadata Documents (HTTPS URLs as client_id per MCP spec)
|
|
397
|
+
*/
|
|
398
|
+
export function validateClient(
|
|
399
|
+
clientId: string,
|
|
400
|
+
redirectUri: string
|
|
401
|
+
): { valid: boolean; error?: string; isMetadataDocument?: boolean } {
|
|
402
|
+
// First check pre-registered clients
|
|
403
|
+
const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
|
|
404
|
+
|
|
405
|
+
if (client) {
|
|
406
|
+
if (!client.redirectUris.includes(redirectUri)) {
|
|
407
|
+
return { valid: false, error: 'invalid_redirect_uri' };
|
|
408
|
+
}
|
|
409
|
+
return { valid: true, isMetadataDocument: false };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Check if client_id is a URL (Client ID Metadata Document)
|
|
413
|
+
// Per MCP spec, we accept HTTPS URLs as client_id
|
|
414
|
+
if (isHttpsUrl(clientId)) {
|
|
415
|
+
// For Client ID Metadata Documents, we validate:
|
|
416
|
+
// 1. The redirect_uri should be localhost or HTTPS
|
|
417
|
+
// 2. The client_id URL must have a path component
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const clientUrl = new URL(clientId);
|
|
421
|
+
// Per spec: client_id URL MUST contain a path component
|
|
422
|
+
if (!clientUrl.pathname || clientUrl.pathname === '/') {
|
|
423
|
+
console.log('[OAuth] Client ID Metadata Document URL must have a path component');
|
|
424
|
+
return { valid: false, error: 'invalid_client' };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const redirectUrl = new URL(redirectUri);
|
|
428
|
+
const isLocalhost = redirectUrl.hostname === 'localhost' ||
|
|
429
|
+
redirectUrl.hostname === '127.0.0.1' ||
|
|
430
|
+
redirectUrl.hostname === '[::1]';
|
|
431
|
+
const isHttps = redirectUrl.protocol === 'https:';
|
|
432
|
+
|
|
433
|
+
if (!isLocalhost && !isHttps) {
|
|
434
|
+
console.log('[OAuth] Redirect URI must be localhost or HTTPS');
|
|
435
|
+
return { valid: false, error: 'invalid_redirect_uri' };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Accept the client - full validation of redirect_uri against the
|
|
439
|
+
// metadata document would require fetching it (async), which we'll do
|
|
440
|
+
// in the authorize endpoint if needed
|
|
441
|
+
console.log('[OAuth] Accepting Client ID Metadata Document:', clientId);
|
|
442
|
+
return { valid: true, isMetadataDocument: true };
|
|
443
|
+
} catch {
|
|
444
|
+
return { valid: false, error: 'invalid_redirect_uri' };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return { valid: false, error: 'invalid_client' };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Validate requested scopes
|
|
453
|
+
*
|
|
454
|
+
* For Client ID Metadata Documents, we accept all supported scopes
|
|
455
|
+
*/
|
|
456
|
+
export function validateScopes(requestedScopes: string, clientId: string): string[] {
|
|
457
|
+
const client = OAUTH_CONFIG.allowedClients[clientId as keyof typeof OAUTH_CONFIG.allowedClients];
|
|
458
|
+
|
|
459
|
+
// For pre-registered clients, filter to allowed scopes
|
|
460
|
+
if (client) {
|
|
461
|
+
const requested = requestedScopes.split(' ').filter(Boolean);
|
|
462
|
+
const allowed = requested.filter(scope => client.scopes.includes(scope));
|
|
463
|
+
return allowed.length > 0 ? allowed : ['mcp'];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// For Client ID Metadata Documents (URL-based client_id), accept all supported scopes
|
|
467
|
+
if (isHttpsUrl(clientId)) {
|
|
468
|
+
const requested = requestedScopes.split(' ').filter(Boolean);
|
|
469
|
+
const allScopes = Object.keys(OAUTH_CONFIG.scopes);
|
|
470
|
+
const allowed = requested.filter(scope => allScopes.includes(scope));
|
|
471
|
+
return allowed.length > 0 ? allowed : ['mcp'];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return ['mcp'];
|
|
475
|
+
}
|