@tradejs/app 3.1.29-beta.262 → 3.1.29-beta.264

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,460 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import OAuth2Server from '@node-oauth/oauth2-server';
3
+ import { mcpStorage, getDataStrict, redisKeys } from '@tradejs/infra/redis';
4
+ import { MCP_SCOPES, type McpPrincipal, type McpScope } from '@tradejs/types';
5
+
6
+ const TOKEN_TTL = 15 * 60;
7
+ const GRANT_TTL = 30 * 24 * 60 * 60;
8
+ export const fingerprint = (value: string) =>
9
+ createHash('sha256').update(value).digest('hex');
10
+ const secret = () => randomBytes(32).toString('base64url');
11
+
12
+ export const mcpOrigin = () => {
13
+ const url = new URL(
14
+ String(
15
+ process.env.MCP_PUBLIC_URL ||
16
+ process.env.APP_URL ||
17
+ 'http://localhost:3000',
18
+ ),
19
+ );
20
+ if (
21
+ url.username ||
22
+ url.password ||
23
+ url.search ||
24
+ url.hash ||
25
+ (url.protocol !== 'https:' &&
26
+ !(
27
+ url.protocol === 'http:' &&
28
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
29
+ ))
30
+ ) {
31
+ throw new Error(
32
+ 'MCP_PUBLIC_URL must use HTTPS (HTTP is allowed only on loopback)',
33
+ );
34
+ }
35
+ return url.origin;
36
+ };
37
+ export const mcpResource = () => `${mcpOrigin()}/mcp`;
38
+ export const mcpEnabled = () => process.env.MCP_ENABLED !== 'false';
39
+ const storageId = (kind: string, id: string) =>
40
+ `oauth:${fingerprint(mcpOrigin())}:${kind}:${fingerprint(id)}`;
41
+
42
+ export interface OAuthClient {
43
+ id: string;
44
+ name: string;
45
+ redirectUris: string[];
46
+ grants: string[];
47
+ }
48
+ interface Grant {
49
+ id: string;
50
+ userName: string;
51
+ clientId: string;
52
+ clientName: string;
53
+ scopes: McpScope[];
54
+ resource: string;
55
+ createdAt: number;
56
+ expiresAt: number;
57
+ }
58
+ export interface ConsentTicket {
59
+ userName: string;
60
+ client: OAuthClient;
61
+ query: Record<string, string>;
62
+ scopes: McpScope[];
63
+ }
64
+
65
+ export const validRedirectUri = (value: string) => {
66
+ try {
67
+ const url = new URL(value);
68
+ return (
69
+ !url.username &&
70
+ !url.password &&
71
+ !url.hash &&
72
+ (url.protocol === 'https:' ||
73
+ (url.protocol === 'http:' &&
74
+ ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)))
75
+ );
76
+ } catch {
77
+ return false;
78
+ }
79
+ };
80
+
81
+ // RFC 8252 allows a native client to choose its loopback port at login time.
82
+ export const redirectMatches = (registered: string, requested: string) => {
83
+ if (!validRedirectUri(requested)) return false;
84
+ const left = new URL(registered);
85
+ const right = new URL(requested);
86
+ if (left.protocol === 'http:' && !left.port) right.port = '';
87
+ return left.href === right.href;
88
+ };
89
+
90
+ export const registerOAuthClient = async (payload: unknown) => {
91
+ if (!payload || typeof payload !== 'object')
92
+ throw new Error('Invalid client metadata');
93
+ const data = payload as Record<string, unknown>;
94
+ const redirects = data.redirect_uris;
95
+ if (
96
+ !Array.isArray(redirects) ||
97
+ !redirects.length ||
98
+ redirects.length > 5 ||
99
+ !redirects.every(
100
+ (uri) =>
101
+ typeof uri === 'string' && uri.length <= 2048 && validRedirectUri(uri),
102
+ ) ||
103
+ (data.token_endpoint_auth_method !== undefined &&
104
+ data.token_endpoint_auth_method !== 'none') ||
105
+ (data.grant_types !== undefined &&
106
+ (!Array.isArray(data.grant_types) ||
107
+ !data.grant_types.every((grant) =>
108
+ ['authorization_code', 'refresh_token'].includes(grant),
109
+ ))) ||
110
+ (data.response_types !== undefined &&
111
+ (!Array.isArray(data.response_types) ||
112
+ data.response_types.length !== 1 ||
113
+ data.response_types[0] !== 'code'))
114
+ ) {
115
+ throw new Error(
116
+ 'Public authorization_code clients with HTTPS or loopback redirect URIs are required',
117
+ );
118
+ }
119
+ const client: OAuthClient = {
120
+ id: randomUUID(),
121
+ name:
122
+ typeof data.client_name === 'string'
123
+ ? data.client_name.slice(0, 100)
124
+ : 'MCP client',
125
+ redirectUris: redirects as string[],
126
+ grants: ['authorization_code', 'refresh_token'],
127
+ };
128
+ await mcpStorage.put(storageId('client', client.id), client, 90 * 86400);
129
+ return {
130
+ client_id: client.id,
131
+ client_id_issued_at: Math.floor(Date.now() / 1000),
132
+ client_name: client.name,
133
+ redirect_uris: client.redirectUris,
134
+ grant_types: client.grants,
135
+ response_types: ['code'],
136
+ token_endpoint_auth_method: 'none',
137
+ };
138
+ };
139
+
140
+ export const prepareConsent = async (
141
+ userName: string,
142
+ params: URLSearchParams,
143
+ ) => {
144
+ const query = Object.fromEntries(params);
145
+ if (
146
+ [...new Set(params.keys())].some((key) => params.getAll(key).length !== 1)
147
+ )
148
+ throw new Error('Duplicate OAuth parameter');
149
+ const stored = await mcpStorage.get<OAuthClient>(
150
+ storageId('client', query.client_id || ''),
151
+ );
152
+ if (
153
+ !stored ||
154
+ !query.redirect_uri ||
155
+ !stored.redirectUris.some((uri) => redirectMatches(uri, query.redirect_uri))
156
+ )
157
+ throw new Error('Invalid client or redirect URI');
158
+ if (
159
+ query.response_type !== 'code' ||
160
+ query.code_challenge_method !== 'S256' ||
161
+ !/^[A-Za-z0-9_-]{43}$/.test(query.code_challenge || '') ||
162
+ !query.state ||
163
+ query.state.length > 2048 ||
164
+ (query.resource && query.resource !== mcpResource())
165
+ )
166
+ throw new Error(
167
+ 'Authorization code, S256 PKCE, state and the correct resource are required',
168
+ );
169
+ const scopes = (query.scope || MCP_SCOPES.join(' '))
170
+ .split(' ')
171
+ .filter(Boolean);
172
+ if (
173
+ !scopes.length ||
174
+ scopes.some((scope) => !(MCP_SCOPES as readonly string[]).includes(scope))
175
+ )
176
+ throw new Error('Invalid scope');
177
+ const ticket = secret();
178
+ const consent: ConsentTicket = {
179
+ userName,
180
+ client: { ...stored, redirectUris: [query.redirect_uri] },
181
+ query,
182
+ scopes: [...new Set(scopes)] as McpScope[],
183
+ };
184
+ await mcpStorage.put(storageId('consent', ticket), consent, 600);
185
+ return ticket;
186
+ };
187
+
188
+ export const readConsent = (ticket: string) =>
189
+ mcpStorage.get<ConsentTicket>(storageId('consent', ticket));
190
+ export const consumeConsent = (ticket: string) =>
191
+ mcpStorage.take<ConsentTicket>(storageId('consent', ticket));
192
+
193
+ const getGrant = async (id: string) => {
194
+ const grant = await mcpStorage.get<Grant>(storageId('grant', id));
195
+ if (
196
+ !grant ||
197
+ grant.expiresAt <= Date.now() ||
198
+ grant.resource !== mcpResource()
199
+ )
200
+ return null;
201
+ // Account deletion immediately invalidates delegated access.
202
+ return (await getDataStrict(redisKeys.user(grant.userName))) ? grant : null;
203
+ };
204
+ type StoredToken = Pick<
205
+ OAuth2Server.Token,
206
+ 'client' | 'scope' | 'accessTokenExpiresAt' | 'refreshTokenExpiresAt'
207
+ > & { user: { id: string; grantId: string; resource: string } };
208
+ const restoreToken = (
209
+ stored: StoredToken,
210
+ token: string,
211
+ refresh: boolean,
212
+ ): OAuth2Server.Token => ({
213
+ ...stored,
214
+ accessToken: refresh ? '' : token,
215
+ ...(refresh ? { refreshToken: token } : {}),
216
+ accessTokenExpiresAt: new Date(stored.accessTokenExpiresAt!),
217
+ ...(stored.refreshTokenExpiresAt
218
+ ? { refreshTokenExpiresAt: new Date(stored.refreshTokenExpiresAt) }
219
+ : {}),
220
+ });
221
+
222
+ export const createOAuthServer = (consent?: ConsentTicket) => {
223
+ const model: OAuth2Server.AuthorizationCodeModel &
224
+ OAuth2Server.RefreshTokenModel = {
225
+ async getClient(id, clientSecret) {
226
+ if (clientSecret) return false;
227
+ return consent?.client.id === id
228
+ ? consent.client
229
+ : await mcpStorage.get<OAuthClient>(storageId('client', id));
230
+ },
231
+ async validateScope(_user, _client, scope) {
232
+ return scope?.length &&
233
+ scope.every((item) => (MCP_SCOPES as readonly string[]).includes(item))
234
+ ? scope
235
+ : false;
236
+ },
237
+ generateAccessToken: async () => secret(),
238
+ generateRefreshToken: async () => secret(),
239
+ generateAuthorizationCode: async () => secret(),
240
+ async saveAuthorizationCode(code, client, user) {
241
+ const value = { ...code, client, user } as OAuth2Server.AuthorizationCode;
242
+ const { authorizationCode, ...stored } = value;
243
+ await mcpStorage.put(storageId('code', authorizationCode), stored, 300);
244
+ return value;
245
+ },
246
+ async getAuthorizationCode(code) {
247
+ const stored = await mcpStorage.get<
248
+ Pick<
249
+ OAuth2Server.AuthorizationCode,
250
+ | 'client'
251
+ | 'user'
252
+ | 'expiresAt'
253
+ | 'redirectUri'
254
+ | 'scope'
255
+ | 'codeChallenge'
256
+ | 'codeChallengeMethod'
257
+ >
258
+ >(storageId('code', code));
259
+ if (!stored || !(await getGrant(stored.user.grantId))) return false;
260
+ return {
261
+ ...stored,
262
+ authorizationCode: code,
263
+ expiresAt: new Date(stored.expiresAt),
264
+ };
265
+ },
266
+ async revokeAuthorizationCode(code) {
267
+ return Boolean(
268
+ await mcpStorage.take(storageId('code', code.authorizationCode)),
269
+ );
270
+ },
271
+ async saveToken(token, client, user) {
272
+ const grant = await getGrant(user.grantId);
273
+ if (!grant || grant.clientId !== client.id)
274
+ throw new Error('Authorization revoked');
275
+ const { accessToken, refreshToken, ...rest } = token;
276
+ const stored = {
277
+ accessTokenExpiresAt: rest.accessTokenExpiresAt,
278
+ refreshTokenExpiresAt: rest.refreshTokenExpiresAt,
279
+ scope: rest.scope,
280
+ client,
281
+ user,
282
+ };
283
+ await mcpStorage.put(storageId('access', accessToken), stored, TOKEN_TTL);
284
+ if (refreshToken)
285
+ await mcpStorage.put(
286
+ storageId('refresh', refreshToken),
287
+ stored,
288
+ Math.max(1, Math.floor((grant.expiresAt - Date.now()) / 1000)),
289
+ );
290
+ return { ...token, client, user };
291
+ },
292
+ async getAccessToken(token) {
293
+ const stored = await mcpStorage.get<StoredToken>(
294
+ storageId('access', token),
295
+ );
296
+ return stored && (await getGrant(stored.user.grantId))
297
+ ? restoreToken(stored, token, false)
298
+ : false;
299
+ },
300
+ async getRefreshToken(token) {
301
+ const stored = await mcpStorage.get<StoredToken>(
302
+ storageId('refresh', token),
303
+ );
304
+ return stored && (await getGrant(stored.user.grantId))
305
+ ? (restoreToken(stored, token, true) as OAuth2Server.RefreshToken)
306
+ : false;
307
+ },
308
+ async revokeToken(token) {
309
+ return Boolean(
310
+ await mcpStorage.take(storageId('refresh', token.refreshToken)),
311
+ );
312
+ },
313
+ async verifyScope(token, scope) {
314
+ return scope.every((item) => token.scope?.includes(item));
315
+ },
316
+ };
317
+ // v5.3 enforces S256 PKCE; its bundled types lag the constructor options.
318
+ const options = {
319
+ model,
320
+ requirePKCE: true,
321
+ enablePlainPKCE: false,
322
+ accessTokenLifetime: TOKEN_TTL,
323
+ refreshTokenLifetime: GRANT_TTL,
324
+ authorizationCodeLifetime: 300,
325
+ requireClientAuthentication: {
326
+ authorization_code: false,
327
+ refresh_token: false,
328
+ },
329
+ alwaysIssueNewRefreshToken: true,
330
+ };
331
+ return new OAuth2Server(options);
332
+ };
333
+
334
+ export const authorizeConsent = async (consent: ConsentTicket) => {
335
+ const grant: Grant = {
336
+ id: randomUUID(),
337
+ userName: consent.userName,
338
+ clientId: consent.client.id,
339
+ clientName: consent.client.name,
340
+ scopes: consent.scopes,
341
+ resource: mcpResource(),
342
+ createdAt: Date.now(),
343
+ expiresAt: Date.now() + GRANT_TTL * 1000,
344
+ };
345
+ await mcpStorage.put(storageId('grant', grant.id), grant, GRANT_TTL);
346
+ await mcpStorage.put(
347
+ `connections:${fingerprint(consent.userName)}:${grant.id}`,
348
+ { ...grant, issuer: mcpOrigin() },
349
+ GRANT_TTL,
350
+ );
351
+ const server = createOAuthServer(consent);
352
+ const response = new OAuth2Server.Response();
353
+ try {
354
+ await server.authorize(
355
+ new OAuth2Server.Request({
356
+ method: 'GET',
357
+ headers: {},
358
+ query: { ...consent.query, scope: consent.scopes.join(' ') },
359
+ }),
360
+ response,
361
+ {
362
+ authenticateHandler: {
363
+ handle: async () => ({
364
+ id: consent.userName,
365
+ grantId: grant.id,
366
+ resource: mcpResource(),
367
+ }),
368
+ },
369
+ },
370
+ );
371
+ const location = new URL(response.get('location'));
372
+ location.searchParams.set('iss', mcpOrigin());
373
+ return location.href;
374
+ } catch (error) {
375
+ await revokeGrant(consent.userName, grant.id);
376
+ throw error;
377
+ }
378
+ };
379
+
380
+ export const authenticateMcp = async (
381
+ request: Request,
382
+ ): Promise<McpPrincipal | null> => {
383
+ const match = /^Bearer ([A-Za-z0-9_-]{43})$/i.exec(
384
+ request.headers.get('authorization') || '',
385
+ );
386
+ if (!match) return null;
387
+ const server = createOAuthServer();
388
+ const token = await server.options.model.getAccessToken!(match[1]);
389
+ if (
390
+ !token ||
391
+ !token.accessTokenExpiresAt ||
392
+ token.accessTokenExpiresAt.getTime() <= Date.now() ||
393
+ token.user.resource !== mcpResource()
394
+ )
395
+ return null;
396
+ return {
397
+ userName: token.user.id,
398
+ clientId: token.client.id,
399
+ grantId: token.user.grantId,
400
+ scopes: token.scope as McpScope[],
401
+ };
402
+ };
403
+
404
+ export const listConnections = async (userName: string) => {
405
+ const grants: Grant[] = [];
406
+ let cursor = '0';
407
+ do {
408
+ const page = await mcpStorage.scan(
409
+ `connections:${fingerprint(userName)}:`,
410
+ cursor,
411
+ );
412
+ cursor = page.cursor;
413
+ for (const id of page.ids) {
414
+ const grant = await mcpStorage.get<Grant & { issuer: string }>(id);
415
+ if (grant?.issuer === mcpOrigin() && (await getGrant(grant.id)))
416
+ grants.push(grant);
417
+ }
418
+ } while (cursor !== '0');
419
+ return grants.sort((a, b) => b.createdAt - a.createdAt);
420
+ };
421
+ export const revokeGrant = async (userName: string, grantId: string) => {
422
+ const grant = await mcpStorage.get<Grant>(storageId('grant', grantId));
423
+ if (!grant || grant.userName !== userName) return false;
424
+ await mcpStorage.remove(storageId('grant', grantId));
425
+ await mcpStorage.remove(`connections:${fingerprint(userName)}:${grantId}`);
426
+ return true;
427
+ };
428
+
429
+ export const oauthMetadata = () => ({
430
+ issuer: mcpOrigin(),
431
+ authorization_endpoint: `${mcpOrigin()}/oauth/authorize`,
432
+ token_endpoint: `${mcpOrigin()}/oauth/token`,
433
+ registration_endpoint: `${mcpOrigin()}/oauth/register`,
434
+ revocation_endpoint: `${mcpOrigin()}/oauth/revoke`,
435
+ response_types_supported: ['code'],
436
+ grant_types_supported: ['authorization_code', 'refresh_token'],
437
+ token_endpoint_auth_methods_supported: ['none'],
438
+ code_challenge_methods_supported: ['S256'],
439
+ authorization_response_iss_parameter_supported: true,
440
+ scopes_supported: MCP_SCOPES,
441
+ });
442
+
443
+ export const protectedResourceMetadata = () => ({
444
+ resource: mcpResource(),
445
+ authorization_servers: [mcpOrigin()],
446
+ scopes_supported: MCP_SCOPES,
447
+ bearer_methods_supported: ['header'],
448
+ });
449
+
450
+ export const oauthResponse = (
451
+ body: unknown,
452
+ status = 200,
453
+ headers: Record<string, string> = {},
454
+ ) =>
455
+ Response.json(body, {
456
+ status,
457
+ headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache', ...headers },
458
+ });
459
+ export const requireSameOrigin = (request: Request) =>
460
+ request.headers.get('origin') === mcpOrigin();