Spaces:
Sleeping
Sleeping
File size: 13,468 Bytes
57a889c f2b20ec 57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | import express, { Request, Response, NextFunction } from 'express';
import path from 'node:path';
import fs from 'node:fs';
import { verifyJwtAndLoadUser } from '../../middleware/auth';
import { db } from '../../db/database';
import { mcpHandler } from '../../mcp';
import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { ALL_SCOPES } from '../../mcp/scopes';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
import { getMcpSafeUrl } from '../../services/notifications';
// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be
// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8).
//
// IMPORTANT β path resolution: the original blocks lived in src/app.ts, where __dirname
// resolves to the directory of app.js (one level above the uploads/public anchor), so they
// used '../uploads/...' and '../public'. This file lives three levels deeper
// (src/nest/platform/), so __dirname is three levels deeper too. The relative prefixes are
// therefore '../../../uploads/...' and '../../../public' β which resolve to the EXACT same
// absolute paths as before. This is the only intentional change; everything else is byte-for-byte
// identical. (rootDir/outDir preserve the tree, so the offset holds in both source/test and
// compiled/dist execution β matching the other nest controllers that use '../../../uploads/...'.)
const UPLOADS_DIR = path.join(__dirname, '../../../uploads');
export const PUBLIC_DIR = path.join(__dirname, '../../../public');
/**
* Static + guarded /uploads/* routes. Must be applied BEFORE the API route mounts
* (identical to its original position near the top of createApp).
*/
export function applyPlatformUploads(app: express.Application): void {
// Static: avatars, covers, and journey photos.
//
// Security model (audit SEC-M9): these paths are unauthenticated by
// design. All filenames are server-chosen UUID v4 (see `uuid()` in
// the multer storage config for avatars / covers / journey uploads),
// which gives each asset >122 bits of namespace entropy β not
// guessable via enumeration. An attacker would need to have already
// seen the URL (email, shared journey, etc.) to request the file.
//
// Moving these behind auth would also break:
// - Unauthenticated trip-card rendering on public share links
// - Journey public-share pages (/public/journey/:token)
// - Email-embedded avatars
//
// The `/uploads/photos/...` route below is DIFFERENT: photo URLs are
// not embedded in unauthenticated UI contexts, so that endpoint IS
// gated (session JWT with pv, or a share token scoped to the photo's
// trip).
app.use('/uploads/avatars', express.static(path.join(UPLOADS_DIR, 'avatars')));
app.use('/uploads/covers', express.static(path.join(UPLOADS_DIR, 'covers')));
app.use('/uploads/journey', express.static(path.join(UPLOADS_DIR, 'journey')));
// Photos require either a valid logged-in session (via JWT with the
// password_version gate) OR a share token that covers the SPECIFIC
// photo's trip. Previously any share token for any trip could request
// any photo filename by UUID β fine in practice because UUIDs are
// unguessable, but the auth model was wrong.
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
const safeName = path.basename(req.params.filename);
const filePath = path.join(UPLOADS_DIR, 'photos', safeName);
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(UPLOADS_DIR, 'photos'))) {
return res.status(403).send('Forbidden');
}
// existsSync here is cheap and avoids a sendFile error frame; kept
// sync because the handler is already short-lived.
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
const authHeader = req.headers.authorization;
const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
if (!rawToken) return res.status(401).send('Authentication required');
// JWT session path (with pv check).
const user = verifyJwtAndLoadUser(rawToken);
if (user) return res.sendFile(resolved);
// Share-token path: require the token to cover the exact trip the
// photo belongs to. Expired tokens fall through to 401.
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as { trip_id: number } | undefined;
if (!photo) return res.status(401).send('Authentication required');
const share = db.prepare(
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).get(rawToken) as { trip_id: number } | undefined;
if (!share || share.trip_id !== photo.trip_id) {
return res.status(401).send('Authentication required');
}
res.sendFile(resolved);
});
// Block direct access to /uploads/files
app.use('/uploads/files', (_req: Request, res: Response) => {
res.status(401).send('Authentication required');
});
}
/**
* Legacy /api/health handler, the OAuth/MCP SDK + transport wiring (well-known metadata,
* authorize/register SDK handlers, the COOP header, the /mcp routes), and the production
* SPA static + catch-all. Must be applied AFTER the API route mounts and BEFORE the global
* error handler (identical to its original position near the bottom of createApp).
*
* Note: the SDK metadata closures (getOAuthMetadata/getMetaRouter) and their lazy-init
* cache are kept module-local PER CALL so each app instance gets its own lazy state β the
* same as when they were function-local inside createApp.
*/
export function applyPlatformTransport(app: express.Application): void {
app.get('/health', (_req: Request, res: Response) => res.status(200).send('OK'));
app.get('/api/health', (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-store, must-revalidate')
res.json({ status: 'ok' })
});
// OAuth 2.1 β public endpoints
// Gate: 404 when MCP addon is disabled (M2 β prevents feature fingerprinting)
const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
next();
};
// SDK metadata router β built lazily on first request so getAppUrl() (which queries the DB)
// is not called at createApp() time, before test tables have been created.
// mcpAuthMetadataRouter serves:
// /.well-known/oauth-authorization-server β RFC 8414 AS metadata
// /.well-known/oauth-protected-resource/mcp β RFC 9728 path-based PRM (fixes issue #959 bug 1)
let _oauthMetadata: OAuthMetadata | null = null;
let _sdkMetaRouter: express.Router | null = null;
function getOAuthMetadata(): OAuthMetadata {
if (_oauthMetadata) return _oauthMetadata;
const base = getMcpSafeUrl().replace(/\/+$/, '');
_oauthMetadata = {
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
};
return _oauthMetadata;
}
function getMetaRouter(): express.Router {
if (_sdkMetaRouter) return _sdkMetaRouter;
const metadata = getOAuthMetadata();
_sdkMetaRouter = mcpAuthMetadataRouter({
oauthMetadata: metadata,
resourceServerUrl: new URL(`${metadata.issuer}/mcp`),
scopesSupported: ALL_SCOPES as string[],
resourceName: 'TREK MCP',
});
return _sdkMetaRouter;
}
// Only invoke the SDK metadata router for /.well-known/* paths.
// Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
// throws "Invalid URL" when APP_URL lacks a protocol β breaking all page loads.
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
getMetaRouter()(req, res, next);
});
// ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via
// /.well-known/openid-configuration. Serve the AS metadata plus the OIDC
// userinfo_endpoint so ChatGPT can fetch the authenticated user's email
// for authorization domain claiming.
app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => {
const meta = getOAuthMetadata();
res.json({
...meta,
userinfo_endpoint: `${meta.issuer}/oauth/userinfo`,
});
});
// RFC 9728 flat well-known URL β served alongside the path-based form the SDK already provides.
// Clients like ChatGPT probe /.well-known/oauth-protected-resource (no path suffix) on every
// fresh discovery. Without this, they get 404, fall back to the issuer URL as the resource
// parameter, and the authorize handler rejects them with invalid_target β showing the user
// the TREK home page instead of the consent form.
app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const meta = getOAuthMetadata();
res.json({
resource: `${meta.issuer}/mcp`,
authorization_servers: [meta.issuer],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
// to the SPA consent page at /oauth/consent
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
app.delete('/mcp', mcpHandler);
// Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle.
// Without this, the SPA catch-all serves HTML β clients probing
// /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL
// receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth".
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' });
next();
});
// Helmet's COOP: same-origin isolates the consent popup from its cross-origin opener (ChatGPT etc.), making window.opener null and breaking the OAuth flow.
app.use('/oauth/consent', (_req: Request, res: Response, next: NextFunction) => {
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
next();
});
}
/**
* Production SPA serving: the built client static assets + the index.html catch-all
* for client-side routes. This is the LEGACY (plain Express 4) form β a real
* `app.get(catch-all)` registered as the terminal handler. The NestJS bootstrap can
* NOT use this (its router terminates unmatched requests with a 404 before any
* post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves
* the SPA via the SpaFallbackFilter instead. Both produce the identical result:
* unmatched GET β index.html in production.
*/
export function applyPlatformSpa(app: express.Application): void {
applyPlatformStatic(app);
if (process.env.NODE_ENV !== 'production') return;
// /.*/ rather than '*' so the helper is Express-4 and Express-5 safe.
app.get(/.*/, (_req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
}
/**
* Production static serving of the built client (JS/CSS/assets). Split out from
* applyPlatformSpa because the NestJS bootstrap needs the static files served
* BEFORE its router (so a real asset request returns the file, not the SPA
* index.html), while the index.html catch-all is handled separately (legacy:
* app.get catch-all; Nest: SpaFallbackFilter). No-op outside production.
*/
export function applyPlatformStatic(app: express.Application): void {
if (process.env.NODE_ENV !== 'production') return;
app.use(
express.static(PUBLIC_DIR, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
}),
);
}
|