mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): move health, discovery and consent-COOP behind the container
This commit is contained in:
@@ -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<INestApplication> {
|
||||
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<RequestHandler>(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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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('<concrete path>') — 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<typeof import('../../../src/app-config')>();
|
||||
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<string, string>,
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>,
|
||||
body: undefined as unknown,
|
||||
setHeader: vi.fn(function (this: { headers: Record<string, string> }, 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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof import('../../../src/app-config')>();
|
||||
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<typeof build>) {
|
||||
// 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', () => {
|
||||
|
||||
Reference in New Issue
Block a user