diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index b0c8269c0..b52c6c5a0 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -11,6 +11,8 @@ import { apiDocsEnabled } from './nest/common/api-docs.kill-switch'; import { setupApiDocs } from './nest/platform/api-docs'; import { McpRegistryService } from '@trek/nest-mcp'; import { setMcpRegistry } from './mcp/registry-handoff'; +import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware'; +import type { RequestHandler } from 'express'; import { validateBodyContracts } from './nest/common/validate-body-contracts'; import { validateRouteGuards } from './nest/common/validate-route-guards'; import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter'; @@ -82,6 +84,13 @@ export async function buildApp(): Promise { applyGlobalMiddleware(instance, { http }); applyPlatformUploads(instance); applyPlatformTransport(instance); + // The SDK discovery router (+ its addon gate). Container-built so its deps + // are injected (same pre-init consumption bridge as httpConfig above), but + // applied here as a PATHLESS app.use: the SDK router matches absolute + // /.well-known/* paths against req.url, and any Nest wildcard forRoutes() + // mount is an Express pattern mount that strips the matched prefix from + // req.url before the middleware runs. + instance.use(app.get(MCP_METADATA_MIDDLEWARE)); applyPlatformStatic(instance); // Pin the request-body ceiling explicitly. The Express shell used to set // '100kb' and stopped doing it when the Nest instance took over parsing, which diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 733965d4d..50e1b7dab 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -5,6 +5,7 @@ import { AppConfigModule } from './app-config/app-config.module'; import { DatabaseModule } from './database/database.module'; import { RealtimeModule } from './realtime/realtime.module'; import { HealthModule } from './health/health.module'; +import { PlatformModule } from './platform/platform.module'; import { GlobalAuthGuard } from './auth/global-auth.guard'; import { MfaPolicyGuard } from './auth/mfa-policy.guard'; import { WeatherModule } from './weather/weather.module'; @@ -69,7 +70,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/common/validate-route-guards.ts b/server/src/nest/common/validate-route-guards.ts index 7c71f1aaf..92530e396 100644 --- a/server/src/nest/common/validate-route-guards.ts +++ b/server/src/nest/common/validate-route-guards.ts @@ -80,7 +80,15 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [ 'AuthPublicController.resetPassword', 'AuthPublicController.verifyMfaLogin', 'ConfigController.getConfig', + // OAuth/OIDC discovery documents + the JSON 404 catchalls that keep + // /.well-known probes from ever seeing SPA HTML. + 'DiscoveryController.openidConfiguration', + 'DiscoveryController.protectedResource', + 'DiscoveryController.wellKnownFallback', + 'DiscoveryController.wellKnownRoot', 'FeaturesController.features', + // The container/uptime probe. + 'FeaturesController.health', // The download link carries its own short-lived token. 'FilesDownloadController.download', 'HelpController.asset', diff --git a/server/src/nest/health/features.controller.ts b/server/src/nest/health/features.controller.ts index 61a706017..9df6b7041 100644 --- a/server/src/nest/health/features.controller.ts +++ b/server/src/nest/health/features.controller.ts @@ -1,10 +1,12 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Res } from '@nestjs/common'; +import type { Response } from 'express'; import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service'; import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; import { Public } from '../auth/public.decorator'; -/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */ +/** Exposes the container probe and the server feature flags consumed by the + * frontend to show/hide optional UI. */ @Public('server capability flags the login screen reads to decide what to offer') @Controller('api/health') export class FeaturesController { @@ -13,6 +15,15 @@ export class FeaturesController { private readonly addons: AddonsService, ) {} + /** The container/uptime probe. The forced-HTTPS redirect and HSTS exempt this + * path inside globalMiddleware, so probes work regardless of proxy setup; + * @Res() keeps the exact legacy header casing and body bytes. */ + @Get() + health(@Res() res: Response): void { + res.setHeader('Cache-Control', 'no-store, must-revalidate'); + res.json({ status: 'ok' }); + } + @Get('features') features() { return { diff --git a/server/src/nest/health/health.module.ts b/server/src/nest/health/health.module.ts index 5693c1bbe..e362b8d4a 100644 --- a/server/src/nest/health/health.module.ts +++ b/server/src/nest/health/health.module.ts @@ -5,9 +5,9 @@ import { AddonsModule } from '../addons/addons.module'; /** Server capability reporting. `GET /api/health/features` tells the client which * optional server-side features are usable, so it can hide the affordances it - * cannot back. `GET /api/health` itself is the container probe and stays on the - * raw express layer (`platform.routes.ts`) — it must answer before Nest routing - * and before the HTTPS redirect. */ + * cannot back. `GET /api/health` is the container probe — the forced-HTTPS + * redirect and HSTS exempt it by path inside globalMiddleware, so it answers + * plain-HTTP probes no matter where the route itself is registered. */ @Module({ imports: [KitineraryExtractorModule, AddonsModule], controllers: [FeaturesController], diff --git a/server/src/nest/platform/consent-coop.middleware.ts b/server/src/nest/platform/consent-coop.middleware.ts new file mode 100644 index 000000000..7e83a7174 --- /dev/null +++ b/server/src/nest/platform/consent-coop.middleware.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import type { NestMiddleware } from '@nestjs/common'; +import type { Request, Response, NextFunction } from 'express'; + +/** + * 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 — + * so /oauth/consent relaxes it. Runs after helmet (Nest middleware registers + * during init, helmet is pre-init) and simply overwrites the header, exactly + * like the pre-init override it replaces. + */ +@Injectable() +export class ConsentCoopMiddleware implements NestMiddleware { + use(_req: Request, res: Response, next: NextFunction): void { + res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none'); + next(); + } +} diff --git a/server/src/nest/platform/discovery-metadata.service.ts b/server/src/nest/platform/discovery-metadata.service.ts new file mode 100644 index 000000000..760b36eed --- /dev/null +++ b/server/src/nest/platform/discovery-metadata.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import type express from 'express'; +import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; +import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth'; +import { ALL_SCOPES } from '../../mcp/scopes'; +import { getMcpSafeUrl } from '../../app-config'; + +/** + * OAuth/MCP discovery metadata, built lazily on first request so the issuer URL + * is resolved from the live env, not frozen at buildApp() time. One instance per + * app (DI singleton) replaces the per-applyPlatformTransport-call closures the + * pre-init Express mount used — same laziness, same per-app cache scope. + * + * 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) + */ +@Injectable() +export class DiscoveryMetadataService { + private oauthMetadata: OAuthMetadata | null = null; + private sdkMetaRouter: express.Router | null = null; + + getOAuthMetadata(): OAuthMetadata { + if (this.oauthMetadata) return this.oauthMetadata; + const base = getMcpSafeUrl().replace(/\/+$/, ''); + this.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 this.oauthMetadata; + } + + getMetaRouter(): express.Router { + if (this.sdkMetaRouter) return this.sdkMetaRouter; + const metadata = this.getOAuthMetadata(); + this.sdkMetaRouter = mcpAuthMetadataRouter({ + oauthMetadata: metadata, + resourceServerUrl: new URL(`${metadata.issuer}/mcp`), + scopesSupported: ALL_SCOPES as string[], + resourceName: 'TREK MCP', + }); + return this.sdkMetaRouter; + } +} diff --git a/server/src/nest/platform/discovery.controller.ts b/server/src/nest/platform/discovery.controller.ts new file mode 100644 index 000000000..b3d32fa8d --- /dev/null +++ b/server/src/nest/platform/discovery.controller.ts @@ -0,0 +1,85 @@ +import { All, Controller, Get, NotFoundException, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { Public } from '../auth/public.decorator'; +import { DiscoveryMetadataService } from './discovery-metadata.service'; +import { AddonsService } from '../addons/addons.service'; +import { ADDON_IDS } from '../../addons'; +import { ALL_SCOPES } from '../../mcp/scopes'; + +/** + * The hand-rolled halves of OAuth discovery (the SDK metadata router itself is + * the pre-init McpMetadataMiddleware). Every handler writes through @Res() and + * never throws for its own paths: a thrown NotFoundException would reach + * SpaFallbackFilter, which serves index.html for any unmatched production GET — + * the exact "does not implement OAuth" bug the JSON catchall exists to prevent. + * + * Route declaration order is load-bearing (Nest registers in method order): + * the two concrete documents come before the catchalls. + */ +@Public('OAuth/OIDC discovery metadata read by MCP clients before any session exists') +@Controller('.well-known') +export class DiscoveryController { + constructor( + private readonly meta: DiscoveryMetadataService, + private readonly addons: AddonsService, + ) {} + + // 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. + @Get('openid-configuration') + openidConfiguration(@Res() res: Response): void { + const meta = this.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. + @Get('oauth-protected-resource') + protectedResource(@Res() res: Response): void { + if (!this.addons.isAddonEnabled(ADDON_IDS.MCP)) { + res.status(404).end(); + return; + } + const meta = this.meta.getOAuthMetadata(); + res.json({ + resource: `${meta.issuer}/mcp`, + authorization_servers: [meta.issuer], + bearer_methods_supported: ['header'], + scopes_supported: ALL_SCOPES, + resource_name: 'TREK MCP', + }); + } + + // '/.well-known' and '/.well-known/' both land here (Express strict:false). + // The trailing-slash form was inside the legacy catchall's startsWith match → + // JSON 404. The bare form was NOT — it fell through to the router 404 + // (SpaFallbackFilter: index.html on production GETs, JSON envelope otherwise), + // so the throw below deliberately reproduces the framework message and route. + @All('/') + wellKnownRoot(@Req() req: Request, @Res() res: Response): void { + if (req.path === '/.well-known/') { + res.status(404).json({ error: 'not_found' }); + return; + } + throw new NotFoundException(`Cannot ${req.method} ${req.originalUrl}`); + } + + // 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". + @All('*path') + wellKnownFallback(@Res() res: Response): void { + res.status(404).json({ error: 'not_found' }); + } +} diff --git a/server/src/nest/platform/mcp-metadata.middleware.ts b/server/src/nest/platform/mcp-metadata.middleware.ts new file mode 100644 index 000000000..a62894e3e --- /dev/null +++ b/server/src/nest/platform/mcp-metadata.middleware.ts @@ -0,0 +1,39 @@ +import type { RequestHandler } from 'express'; +import { DiscoveryMetadataService } from './discovery-metadata.service'; +import { AddonsService } from '../addons/addons.service'; +import { ADDON_IDS } from '../../addons'; + +/** + * The SDK discovery router plus its addon gate: 404 (empty body) on every + * /.well-known/* path while the MCP addon is off (M2 — prevents feature + * fingerprinting), otherwise delegate to the lazily built metadata router. + * + * NOT registered through MiddlewareConsumer: the SDK router matches absolute + * /.well-known/* paths against req.url, and a Nest wildcard forRoutes() mount + * is an Express pattern mount that strips the matched prefix from req.url + * before the middleware runs. bootstrap.ts applies this pre-init as a pathless + * app.use — the only mount shape that leaves req.url untouched — resolving it + * from the container by token (the httpConfig.KEY precedent). A factory + * provider rather than a NestMiddleware class because nothing consumer-mounts + * it: the deliverable is the bare Express RequestHandler itself. + */ +export const MCP_METADATA_MIDDLEWARE = Symbol('MCP_METADATA_MIDDLEWARE'); + +export function createMcpMetadataMiddleware( + meta: DiscoveryMetadataService, + addons: AddonsService, +): RequestHandler { + return (req, res, next) => { + if (req.path.startsWith('/.well-known/') && !addons.isAddonEnabled(ADDON_IDS.MCP)) { + res.status(404).end(); + return; + } + meta.getMetaRouter()(req, res, next); + }; +} + +export const mcpMetadataMiddlewareProvider = { + provide: MCP_METADATA_MIDDLEWARE, + useFactory: createMcpMetadataMiddleware, + inject: [DiscoveryMetadataService, AddonsService], +}; diff --git a/server/src/nest/platform/platform.module.ts b/server/src/nest/platform/platform.module.ts new file mode 100644 index 000000000..6df100c0e --- /dev/null +++ b/server/src/nest/platform/platform.module.ts @@ -0,0 +1,35 @@ +import { Module, type MiddlewareConsumer, type NestModule } from '@nestjs/common'; +import { AddonsModule } from '../addons/addons.module'; +import { DiscoveryController } from './discovery.controller'; +import { DiscoveryMetadataService } from './discovery-metadata.service'; +import { mcpMetadataMiddlewareProvider } from './mcp-metadata.middleware'; +import { ConsentCoopMiddleware } from './consent-coop.middleware'; + +/** + * The platform surface that lives behind the container: OAuth/OIDC discovery + * (controller + SDK metadata middleware) and the /oauth/consent COOP override. + * What remains pre-init in platform.routes.ts is path-prefixed static serving + * (uploads, built client) plus — until the /mcp migration lands — the OAuth SDK + * and /mcp transport mounts. + * + * This module is the repo's first MiddlewareConsumer. Express middleware that + * needs DI has exactly two honest mounts: + * - consumer.apply(...).forRoutes('') — an Express prefix + * mount, identical semantics to the legacy app.use('/path', fn) calls + * (ConsentCoopMiddleware below); + * - a pathless app.use in bootstrap for middleware that must see the + * original req.url (MCP_METADATA_MIDDLEWARE — a wildcard forRoutes() + * would strip the matched prefix and break the SDK router's matching). + */ +@Module({ + imports: [AddonsModule], + controllers: [DiscoveryController], + providers: [DiscoveryMetadataService, mcpMetadataMiddlewareProvider, ConsentCoopMiddleware], +}) +export class PlatformModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + // Prefix mount: covers /oauth/consent and any sub-path, like the legacy + // app.use('/oauth/consent', ...) it replaces. + consumer.apply(ConsentCoopMiddleware).forRoutes('oauth/consent'); + } +} diff --git a/server/src/nest/platform/platform.routes.ts b/server/src/nest/platform/platform.routes.ts index 16b78fa7e..51807e4ec 100644 --- a/server/src/nest/platform/platform.routes.ts +++ b/server/src/nest/platform/platform.routes.ts @@ -9,12 +9,8 @@ import { mcpHandler } from '../../mcp'; import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider'; import { isAddonEnabled } from '../addons/addons.bridge'; 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 '../../app-config'; // 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). @@ -104,21 +100,13 @@ export function applyPlatformUploads(app: express.Application): void { } /** - * 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. + * The OAuth SDK + /mcp transport mounts still living on the pre-init Express + * layer. The rest of the former transport surface is behind the container now: + * /api/health (FeaturesController), OAuth discovery (DiscoveryController + the + * McpMetadataMiddleware bootstrap applies pre-init), and the /oauth/consent + * COOP override (ConsentCoopMiddleware via PlatformModule.configure). */ export function applyPlatformTransport(app: express.Application): void { - 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) => { @@ -126,81 +114,6 @@ export function applyPlatformTransport(app: express.Application): void { next(); }; - // SDK metadata router — built lazily on first request so the issuer URL is - // resolved from the live env, not frozen at createApp() time. - // 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 })); @@ -212,21 +125,6 @@ export function applyPlatformTransport(app: express.Application): void { 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(); - }); } /** diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index 3b9e956e9..caf77224c 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -59,6 +59,7 @@ import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { createOAuthClient, getUserByAccessToken } from '../../src/nest/oauth/oauth.bridge'; +import { ALL_SCOPES } from '../../src/mcp/scopes'; import { OauthService } from '../../src/nest/oauth/oauth.service'; import { DatabaseService } from '../../src/nest/database/database.service'; import { AddonsService } from '../../src/nest/addons/addons.service'; @@ -164,6 +165,87 @@ describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () = }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Platform/discovery parity pins — byte-level oracles for the MCP/OAuth mount +// migration (these must pass identically before AND after the routes move +// behind the Nest container). +// ───────────────────────────────────────────────────────────────────────────── + +describe('platform/discovery parity pins', () => { + it('PLAT-PIN-001 — GET /api/health returns the exact probe body + Cache-Control', async () => { + const res = await request(app).get('/api/health'); + expect(res.status).toBe(200); + expect(res.headers['cache-control']).toBe('no-store, must-revalidate'); + expect(res.body).toEqual({ status: 'ok' }); + }); + + it('PLAT-PIN-002 — openid-configuration is the AS metadata plus userinfo_endpoint, exactly', async () => { + const res = await request(app).get('/.well-known/openid-configuration'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + issuer: 'https://trek.example.com', + authorization_endpoint: 'https://trek.example.com/oauth/authorize', + token_endpoint: 'https://trek.example.com/oauth/token', + revocation_endpoint: 'https://trek.example.com/oauth/revoke', + registration_endpoint: 'https://trek.example.com/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, + userinfo_endpoint: 'https://trek.example.com/oauth/userinfo', + }); + }); + + it('PLAT-PIN-003 — flat oauth-protected-resource is the exact 5-key RFC 9728 document', async () => { + const res = await request(app).get('/.well-known/oauth-protected-resource'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + resource: 'https://trek.example.com/mcp', + authorization_servers: ['https://trek.example.com'], + bearer_methods_supported: ['header'], + scopes_supported: ALL_SCOPES, + resource_name: 'TREK MCP', + }); + }); + + it('PLAT-PIN-004 — every /.well-known/* path 404s with an EMPTY body when MCP is disabled', async () => { + setMcpEnabled(false); + for (const path of [ + '/.well-known/openid-configuration', + '/.well-known/oauth-protected-resource', + '/.well-known/oauth-authorization-server', + ]) { + const res = await request(app).get(path); + expect(res.status, path).toBe(404); + expect(res.text, path).toBe(''); + } + }); + + it('PLAT-PIN-005 — an unhandled /.well-known path gets the JSON 404, never SPA HTML', async () => { + const res = await request(app).get('/.well-known/does-not-exist'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + }); + + it('PLAT-PIN-006 — /.well-known/ (trailing slash) also gets the JSON 404', async () => { + const res = await request(app).get('/.well-known/'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + }); + + it('PLAT-PIN-007 — bare /.well-known falls through to the router 404 envelope', async () => { + const res = await request(app).get('/.well-known'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Cannot GET /.well-known' }); + }); + + it('PLAT-PIN-008 — /oauth/consent responses carry the relaxed COOP header', async () => { + const res = await request(app).get('/oauth/consent'); + expect(res.headers['cross-origin-opener-policy']).toBe('unsafe-none'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // POST /oauth/token — authorization_code grant // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/discovery.controller.test.ts b/server/tests/unit/nest/discovery.controller.test.ts new file mode 100644 index 000000000..0a08b55e1 --- /dev/null +++ b/server/tests/unit/nest/discovery.controller.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NotFoundException } from '@nestjs/common'; + +const h = vi.hoisted(() => ({ + getMcpSafeUrl: vi.fn(() => 'https://trek.example.test'), + // The SDK factory returns a tagged middleware so tests can identify delegation + // without running real router wiring. + metaRouter: vi.fn(), +})); + +vi.mock('../../../src/app-config', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getMcpSafeUrl: h.getMcpSafeUrl }; +}); +vi.mock('@modelcontextprotocol/sdk/server/auth/router', () => ({ + mcpAuthMetadataRouter: vi.fn(() => h.metaRouter), +})); + +import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; +import { DiscoveryMetadataService } from '../../../src/nest/platform/discovery-metadata.service'; +import { + MCP_METADATA_MIDDLEWARE, + createMcpMetadataMiddleware, + mcpMetadataMiddlewareProvider, +} from '../../../src/nest/platform/mcp-metadata.middleware'; +import { ConsentCoopMiddleware } from '../../../src/nest/platform/consent-coop.middleware'; +import { DiscoveryController } from '../../../src/nest/platform/discovery.controller'; +import { PlatformModule } from '../../../src/nest/platform/platform.module'; +import { ALL_SCOPES } from '../../../src/mcp/scopes'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; + +function makeRes() { + const res = { + statusCode: 200, + body: undefined as unknown, + headers: {} as Record, + ended: false, + status: vi.fn(function (this: typeof res, c: number) { this.statusCode = c; return this; }), + json: vi.fn(function (this: typeof res, b: unknown) { this.body = b; return this; }), + end: vi.fn(function (this: typeof res) { this.ended = true; return this; }), + setHeader: vi.fn(function (this: typeof res, k: string, v: string) { this.headers[k] = v; return this; }), + }; + return res; +} + +function addons(enabled: boolean) { + return { isAddonEnabled: vi.fn(() => enabled) } as unknown as AddonsService; +} + +beforeEach(() => { + vi.clearAllMocks(); + h.getMcpSafeUrl.mockReturnValue('https://trek.example.test'); +}); + +describe('DiscoveryMetadataService', () => { + it('DISC-001: builds the RFC 8414 AS metadata from the live safe URL', () => { + const meta = new DiscoveryMetadataService().getOAuthMetadata(); + expect(meta).toEqual({ + issuer: 'https://trek.example.test', + authorization_endpoint: 'https://trek.example.test/oauth/authorize', + token_endpoint: 'https://trek.example.test/oauth/token', + revocation_endpoint: 'https://trek.example.test/oauth/revoke', + registration_endpoint: 'https://trek.example.test/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, + }); + }); + + it('DISC-002: trims trailing slashes off the configured base URL', () => { + h.getMcpSafeUrl.mockReturnValue('https://trek.example.test///'); + expect(new DiscoveryMetadataService().getOAuthMetadata().issuer).toBe('https://trek.example.test'); + }); + + it('DISC-003: caches the metadata after the first lazy build', () => { + const svc = new DiscoveryMetadataService(); + const first = svc.getOAuthMetadata(); + expect(svc.getOAuthMetadata()).toBe(first); + expect(h.getMcpSafeUrl).toHaveBeenCalledTimes(1); + }); + + it('DISC-004: builds the SDK metadata router once, pointing at the /mcp resource', () => { + const svc = new DiscoveryMetadataService(); + expect(svc.getMetaRouter()).toBe(h.metaRouter); + expect(svc.getMetaRouter()).toBe(h.metaRouter); + expect(mcpAuthMetadataRouter).toHaveBeenCalledTimes(1); + const opts = vi.mocked(mcpAuthMetadataRouter).mock.calls[0][0]; + expect(opts.resourceServerUrl.href).toBe('https://trek.example.test/mcp'); + expect(opts.resourceName).toBe('TREK MCP'); + expect(opts.scopesSupported).toEqual(ALL_SCOPES); + }); +}); + +describe('createMcpMetadataMiddleware', () => { + it('DISC-010: 404s a /.well-known path with an empty body when MCP is disabled', () => { + const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(false)); + const res = makeRes(); + const next = vi.fn(); + mw({ path: '/.well-known/oauth-authorization-server' } as never, res as never, next); + expect(res.statusCode).toBe(404); + expect(res.ended).toBe(true); + expect(next).not.toHaveBeenCalled(); + expect(h.metaRouter).not.toHaveBeenCalled(); + }); + + it('DISC-011: delegates a /.well-known path to the SDK router when MCP is enabled', () => { + const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), addons(true)); + const next = vi.fn(); + mw({ path: '/.well-known/oauth-authorization-server' } as never, makeRes() as never, next); + expect(h.metaRouter).toHaveBeenCalled(); + }); + + it('DISC-012: delegates non-well-known paths without consulting the addon gate', () => { + const gate = addons(false); + const mw = createMcpMetadataMiddleware(new DiscoveryMetadataService(), gate); + mw({ path: '/api/trips' } as never, makeRes() as never, vi.fn()); + expect(gate.isAddonEnabled).not.toHaveBeenCalled(); + expect(h.metaRouter).toHaveBeenCalled(); + }); + + it('DISC-013: the factory provider wires the token to the factory with its two deps', () => { + expect(mcpMetadataMiddlewareProvider.provide).toBe(MCP_METADATA_MIDDLEWARE); + expect(mcpMetadataMiddlewareProvider.useFactory).toBe(createMcpMetadataMiddleware); + expect(mcpMetadataMiddlewareProvider.inject.length).toBe(2); + }); +}); + +describe('ConsentCoopMiddleware', () => { + it('DISC-020: relaxes COOP then continues', () => { + const res = makeRes(); + const next = vi.fn(); + new ConsentCoopMiddleware().use({} as never, res as never, next); + expect(res.headers['Cross-Origin-Opener-Policy']).toBe('unsafe-none'); + expect(next).toHaveBeenCalled(); + }); +}); + +describe('DiscoveryController', () => { + function controller(enabled = true) { + return new DiscoveryController(new DiscoveryMetadataService(), addons(enabled)); + } + + it('DISC-030: openid-configuration is the AS metadata plus userinfo_endpoint', () => { + const res = makeRes(); + controller().openidConfiguration(res as never); + const body = res.body as { issuer: string; userinfo_endpoint: string }; + expect(body.issuer).toBe('https://trek.example.test'); + expect(body.userinfo_endpoint).toBe('https://trek.example.test/oauth/userinfo'); + }); + + it('DISC-031: flat oauth-protected-resource 404s empty when MCP is disabled', () => { + const res = makeRes(); + controller(false).protectedResource(res as never); + expect(res.statusCode).toBe(404); + expect(res.ended).toBe(true); + }); + + it('DISC-032: flat oauth-protected-resource serves the PRM document when enabled', () => { + const res = makeRes(); + controller().protectedResource(res as never); + expect(res.body).toEqual({ + resource: 'https://trek.example.test/mcp', + authorization_servers: ['https://trek.example.test'], + bearer_methods_supported: ['header'], + scopes_supported: ALL_SCOPES, + resource_name: 'TREK MCP', + }); + }); + + it('DISC-033: the catchall answers 404 JSON, never throwing into the SPA fallback', () => { + const res = makeRes(); + controller().wellKnownFallback(res as never); + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + }); + + it('DISC-034: /.well-known/ (trailing slash) gets the same 404 JSON', () => { + const res = makeRes(); + controller().wellKnownRoot({ path: '/.well-known/' } as never, res as never); + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: 'not_found' }); + }); + + it('DISC-035: bare /.well-known reproduces the framework 404 (SpaFallback route)', () => { + const req = { path: '/.well-known', method: 'GET', originalUrl: '/.well-known' }; + expect(() => controller().wellKnownRoot(req as never, makeRes() as never)) + .toThrowError(new NotFoundException('Cannot GET /.well-known')); + }); + + it('DISC-036: route declaration order keeps the concrete documents ahead of the catchalls', () => { + // Nest registers routes in method-declaration order; the catchall must not + // shadow the concrete documents. + const names = Object.getOwnPropertyNames(DiscoveryController.prototype).filter((n) => n !== 'constructor'); + expect(names.indexOf('openidConfiguration')).toBeLessThan(names.indexOf('wellKnownRoot')); + expect(names.indexOf('protectedResource')).toBeLessThan(names.indexOf('wellKnownRoot')); + expect(names.indexOf('wellKnownRoot')).toBeLessThan(names.indexOf('wellKnownFallback')); + }); +}); + +describe('PlatformModule', () => { + it('DISC-040: applies the consent COOP middleware to the /oauth/consent prefix', () => { + const forRoutes = vi.fn(); + const consumer = { apply: vi.fn(() => ({ forRoutes })) }; + new PlatformModule().configure(consumer as never); + expect(consumer.apply).toHaveBeenCalledWith(ConsentCoopMiddleware); + expect(forRoutes).toHaveBeenCalledWith('oauth/consent'); + }); +}); diff --git a/server/tests/unit/nest/features.controller.test.ts b/server/tests/unit/nest/features.controller.test.ts index b3dc0d226..14c8c0029 100644 --- a/server/tests/unit/nest/features.controller.test.ts +++ b/server/tests/unit/nest/features.controller.test.ts @@ -70,4 +70,17 @@ describe('FeaturesController (GET /api/health/features)', () => { expect(Reflect.getMetadata('path', FeaturesController)).toBe('api/health'); expect(Reflect.getMetadata('path', FeaturesController.prototype.features)).toBe('features'); }); + + it('FEAT-009: GET /api/health writes the exact probe bytes (legacy parity)', () => { + const { controller } = make(true, true); + const res = { + headers: {} as Record, + body: undefined as unknown, + setHeader: vi.fn(function (this: { headers: Record }, k: string, v: string) { this.headers[k] = v; }), + json: vi.fn(function (this: { body: unknown }, b: unknown) { this.body = b; }), + }; + controller.health(res as never); + expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate'); + expect(res.body).toEqual({ status: 'ok' }); + }); }); diff --git a/server/tests/unit/nest/platform.controller.test.ts b/server/tests/unit/nest/platform.controller.test.ts index 8eb16b854..44684a0aa 100644 --- a/server/tests/unit/nest/platform.controller.test.ts +++ b/server/tests/unit/nest/platform.controller.test.ts @@ -5,12 +5,10 @@ import { NotFoundException } from '@nestjs/common'; const h = vi.hoisted(() => ({ verifyJwtAndLoadUser: vi.fn(), isAddonEnabled: vi.fn(), - getMcpSafeUrl: vi.fn(() => 'https://trek.example.test'), dbPrepare: vi.fn(), existsSync: vi.fn(), // SDK middleware spies — each returns a tagged handler so we can identify which // app.use call received it. - metaRouter: vi.fn(), authorizeHandler: vi.fn(), registerHandler: vi.fn(), mcpHandler: vi.fn(), @@ -21,16 +19,9 @@ vi.mock('../../../src/db/database', () => ({ db: { prepare: h.dbPrepare } })); vi.mock('../../../src/mcp', () => ({ mcpHandler: h.mcpHandler })); vi.mock('../../../src/mcp/oauthProvider', () => ({ trekOAuthProvider: {}, trekClientsStore: {} })); vi.mock('../../../src/nest/addons/addons.bridge', () => ({ isAddonEnabled: h.isAddonEnabled })); -vi.mock('../../../src/app-config', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, getMcpSafeUrl: h.getMcpSafeUrl }; -}); -// SDK router/handler factories return distinct tagged middleware so we never hit -// real new URL(...) wiring during registration. -vi.mock('@modelcontextprotocol/sdk/server/auth/router', () => ({ - mcpAuthMetadataRouter: vi.fn(() => h.metaRouter), -})); +// SDK handler factories return distinct tagged middleware so we never hit real +// wiring during registration. vi.mock('@modelcontextprotocol/sdk/server/auth/handlers/authorize', () => ({ authorizationHandler: vi.fn(() => h.authorizeHandler), })); @@ -101,7 +92,6 @@ function makeRes() { beforeEach(() => { vi.clearAllMocks(); - h.getMcpSafeUrl.mockReturnValue('https://trek.example.test'); }); describe('applyPlatformUploads', () => { @@ -234,91 +224,6 @@ describe('applyPlatformTransport', () => { return calls; } - it('GET /api/health sets no-store and returns ok', () => { - const calls = build(); - const health = calls.find((c) => c.method === 'get' && c.path === '/api/health')!.handlers[0]; - const res = makeRes(); - health({}, res); - expect(res.headers['Cache-Control']).toBe('no-store, must-revalidate'); - expect(res.body).toEqual({ status: 'ok' }); - }); - - describe('the /.well-known metadata middleware', () => { - function wellKnownMw(calls: ReturnType) { - // first app.use with no path, registered right after /api/health - return calls.find((c) => c.method === 'use' && c.path === undefined)!.handlers[0]; - } - - it('404s a /.well-known path when MCP is disabled', () => { - h.isAddonEnabled.mockReturnValue(false); - const mw = wellKnownMw(build()); - const res = makeRes(); - const next = vi.fn(); - mw({ path: '/.well-known/oauth-authorization-server' }, res, next); - expect(res.statusCode).toBe(404); - expect(next).not.toHaveBeenCalled(); - }); - - it('delegates to the SDK meta router for a non-well-known path', () => { - h.isAddonEnabled.mockReturnValue(true); - const mw = wellKnownMw(build()); - const res = makeRes(); - const next = vi.fn(); - mw({ path: '/anything' }, res, next); - expect(h.metaRouter).toHaveBeenCalled(); - }); - - it('delegates to the SDK meta router for a well-known path when MCP is enabled', () => { - h.isAddonEnabled.mockReturnValue(true); - const mw = wellKnownMw(build()); - const res = makeRes(); - const next = vi.fn(); - mw({ path: '/.well-known/oauth-authorization-server' }, res, next); - expect(h.metaRouter).toHaveBeenCalled(); - }); - }); - - it('GET /.well-known/openid-configuration returns AS metadata + userinfo_endpoint', () => { - const calls = build(); - const handler = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0]; - const res = makeRes(); - handler({}, res); - const body = res.body as { issuer: string; userinfo_endpoint: string }; - expect(body.issuer).toBe('https://trek.example.test'); - expect(body.userinfo_endpoint).toBe('https://trek.example.test/oauth/userinfo'); - }); - - it('trims trailing slashes off the configured base URL', () => { - h.getMcpSafeUrl.mockReturnValue('https://trek.example.test///'); - const calls = build(); - const handler = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0]; - const res = makeRes(); - handler({}, res); - expect((res.body as { issuer: string }).issuer).toBe('https://trek.example.test'); - }); - - describe('GET /.well-known/oauth-protected-resource (flat)', () => { - function handler() { - return build().find((c) => c.method === 'get' && c.path === '/.well-known/oauth-protected-resource')!.handlers[0]; - } - - it('404 when MCP is disabled', () => { - h.isAddonEnabled.mockReturnValue(false); - const res = makeRes(); - handler()({}, res); - expect(res.statusCode).toBe(404); - }); - - it('returns the PRM document when MCP is enabled', () => { - h.isAddonEnabled.mockReturnValue(true); - const res = makeRes(); - handler()({}, res); - const body = res.body as { resource: string; authorization_servers: string[] }; - expect(body.resource).toBe('https://trek.example.test/mcp'); - expect(body.authorization_servers).toEqual(['https://trek.example.test']); - }); - }); - describe('mcpAddonGate (used on /oauth/authorize + /oauth/register)', () => { function gate() { // The gate is the first handler on the /oauth/authorize use registration. @@ -357,60 +262,6 @@ describe('applyPlatformTransport', () => { expect(calls.find((c) => c.method === 'get' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler); expect(calls.find((c) => c.method === 'delete' && c.path === '/mcp')!.handlers[0]).toBe(h.mcpHandler); }); - - describe('the terminal /.well-known JSON-404 middleware', () => { - function mw() { - // The pathless app.use registered after the /mcp routes. - const calls = build(); - const pathless = calls.filter((c) => c.method === 'use' && c.path === undefined); - // first pathless = meta router; second = the JSON 404. - return pathless[1].handlers[0]; - } - - it('404 JSON for an unhandled /.well-known path', () => { - const res = makeRes(); - const next = vi.fn(); - mw()({ path: '/.well-known/unknown' }, res, next); - expect(res.statusCode).toBe(404); - expect(res.body).toEqual({ error: 'not_found' }); - expect(next).not.toHaveBeenCalled(); - }); - - it('calls next() for any non-well-known path', () => { - const res = makeRes(); - const next = vi.fn(); - mw()({ path: '/dashboard' }, res, next); - expect(next).toHaveBeenCalled(); - }); - }); - - it('the /oauth/consent middleware relaxes COOP then continues', () => { - const calls = build(); - const mw = calls.find((c) => c.method === 'use' && c.path === '/oauth/consent')!.handlers[0]; - const res = makeRes(); - const next = vi.fn(); - mw({}, res, next); - expect(res.headers['Cross-Origin-Opener-Policy']).toBe('unsafe-none'); - expect(next).toHaveBeenCalled(); - }); - - it('caches the OAuth metadata + SDK router across requests (lazy init runs once)', async () => { - const router = await import('@modelcontextprotocol/sdk/server/auth/router'); - const calls = build(); - const openid = calls.find((c) => c.path === '/.well-known/openid-configuration')!.handlers[0]; - h.getMcpSafeUrl.mockClear(); - openid({}, makeRes()); - openid({}, makeRes()); - // getMcpSafeUrl is only consulted on the first lazy build of the metadata. - expect(h.getMcpSafeUrl).toHaveBeenCalledTimes(1); - - // Trigger the meta router lazy build twice; the SDK factory runs once. - const metaMw = calls.find((c) => c.method === 'use' && c.path === undefined)!.handlers[0]; - h.isAddonEnabled.mockReturnValue(true); - metaMw({ path: '/x' }, makeRes(), vi.fn()); - metaMw({ path: '/y' }, makeRes(), vi.fn()); - expect(router.mcpAuthMetadataRouter).toHaveBeenCalledTimes(1); - }); }); describe('applyPlatformStatic', () => {