diff --git a/MCP.md b/MCP.md index a89bfe5d..01de46f4 100644 --- a/MCP.md +++ b/MCP.md @@ -53,10 +53,11 @@ management required — just provide the server URL: > The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows). **What happens automatically:** -1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server. -2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591). -3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant. -4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed. +1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint. +2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata. +3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591). +4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant. +5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed. > **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth > discovery to work correctly. diff --git a/server/src/app.ts b/server/src/app.ts index 24661ad7..c9e39f5d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -77,6 +77,11 @@ export function createApp(): express.Application { const shouldForceHttps = process.env.FORCE_HTTPS === 'true'; + // RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config + app.use( + ['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'], + cors({ origin: '*', credentials: false }), + ); app.use(cors({ origin: corsOrigin, credentials: true })); app.use(helmet({ contentSecurityPolicy: { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 8b1d15f9..f2b476ab 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1767,6 +1767,11 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err; } }, + // Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp + () => { + try { db.exec('ALTER TABLE oauth_tokens ADD COLUMN audience TEXT'); } + catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index 710d1061..4d215260 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -11,6 +11,7 @@ import { registerResources } from './resources'; import { registerTools } from './tools'; import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager'; import { writeAudit, getClientIp } from '../services/auditLog'; +import { getAppUrl } from '../services/oidcService'; export { revokeUserSessions, revokeUserSessionsForClient }; @@ -151,6 +152,12 @@ const sessionSweepInterval = setInterval(() => { // Prevent the interval from keeping the process alive if nothing else is running sessionSweepInterval.unref(); +function setAuthChallenge(res: Response, error = 'invalid_token'): void { + const base = (getAppUrl() || '').replace(/\/+$/, ''); + res.set('WWW-Authenticate', + `Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`); +} + interface VerifyTokenResult { user: User; /** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */ @@ -173,6 +180,11 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null { if (token.startsWith('trekoa_')) { const result = getUserByAccessToken(token); if (!result) return null; + // RFC 8707: if the token carries an audience, it must match this resource endpoint + if (result.audience !== null) { + const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`; + if (result.audience !== expected) return null; + } return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false }; } @@ -211,6 +223,7 @@ export async function mcpHandler(req: Request, res: Response): Promise { const tokenResult = verifyToken(req.headers['authorization']); if (!tokenResult) { + setAuthChallenge(res); res.status(401).json({ error: 'Access token required' }); return; } @@ -231,10 +244,12 @@ export async function mcpHandler(req: Request, res: Response): Promise { return; } if (session.userId !== user.id) { + setAuthChallenge(res); res.status(403).json({ error: 'Session belongs to a different user' }); return; } if (session.clientId !== clientId) { + setAuthChallenge(res); res.status(403).json({ error: 'Session was created with a different OAuth client' }); return; } diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 443413cf..87f4771c 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -13,7 +13,7 @@ import { listCategories } from '../services/categoryService'; import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService'; import { getNotifications } from '../services/inAppNotifications'; import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService'; -import { isAddonEnabled } from '../services/adminService'; +import { isAddonEnabled, getCollabFeatures } from '../services/adminService'; import { ADDON_IDS } from '../addons'; import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService'; import { canRead, canReadTrips } from './scopes'; @@ -188,7 +188,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str ); // Collab notes for a trip - if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) server.registerResource( + const collabFeatures = isAddonEnabled(ADDON_IDS.COLLAB) ? getCollabFeatures() : null; + if (collabFeatures?.notes && canRead(scopes, 'collab')) server.registerResource( 'trip-collab-notes', new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }), { description: 'Shared collaborative notes for a trip', mimeType: 'application/json' }, @@ -319,8 +320,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str ); } - // Collab polls & messages (addon-gated) - if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) { + // Collab polls (addon + sub-feature gated) + if (collabFeatures?.polls && canRead(scopes, 'collab')) { server.registerResource( 'trip-collab-polls', new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }), @@ -332,7 +333,10 @@ export function registerResources(server: McpServer, userId: number, scopes: str return jsonContent(uri.href, polls); } ); + } + // Collab messages (addon + sub-feature gated) + if (collabFeatures?.chat && canRead(scopes, 'collab')) { server.registerResource( 'trip-collab-messages', new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }), diff --git a/server/src/mcp/tools/collab.ts b/server/src/mcp/tools/collab.ts index de92e969..0a827b48 100644 --- a/server/src/mcp/tools/collab.ts +++ b/server/src/mcp/tools/collab.ts @@ -7,7 +7,7 @@ import { listPolls, createPoll, votePoll, closePoll, deletePoll, listMessages, createMessage, deleteMessage, addOrRemoveReaction, } from '../../services/collabService'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, @@ -22,9 +22,11 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s if (!isAddonEnabled(ADDON_IDS.COLLAB)) return; + const features = getCollabFeatures(); + // --- COLLAB NOTES --- - if (W) server.registerTool( + if (features.notes && W) server.registerTool( 'create_collab_note', { description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).', @@ -47,7 +49,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.notes && W) server.registerTool( 'update_collab_note', { description: 'Edit an existing collaborative note on a trip.', @@ -72,7 +74,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.notes && W) server.registerTool( 'delete_collab_note', { description: 'Delete a collaborative note from a trip.', @@ -94,7 +96,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s // --- COLLAB POLLS & CHAT --- - if (R) server.registerTool( + if (features.polls && R) server.registerTool( 'list_collab_polls', { description: 'List all polls for a trip.', @@ -110,7 +112,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.polls && W) server.registerTool( 'create_collab_poll', { description: 'Create a new poll in the collab panel.', @@ -132,7 +134,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.polls && W) server.registerTool( 'vote_collab_poll', { description: 'Vote on a poll option (or remove vote if already voted for that option).', @@ -152,7 +154,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.polls && W) server.registerTool( 'close_collab_poll', { description: 'Close a poll so no more votes can be cast.', @@ -172,7 +174,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.polls && W) server.registerTool( 'delete_collab_poll', { description: 'Delete a poll and all its votes.', @@ -192,7 +194,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (R) server.registerTool( + if (features.chat && R) server.registerTool( 'list_collab_messages', { description: 'List chat messages for a trip (most recent 100, oldest-first).', @@ -209,7 +211,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.chat && W) server.registerTool( 'send_collab_message', { description: "Send a chat message to a trip's collab channel.", @@ -230,7 +232,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.chat && W) server.registerTool( 'delete_collab_message', { description: 'Delete a chat message (only the message owner can delete their own messages).', @@ -250,7 +252,7 @@ export function registerCollabTools(server: McpServer, userId: number, scopes: s } ); - if (W) server.registerTool( + if (features.chat && W) server.registerTool( 'react_collab_message', { description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).', diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts index c6e44812..535ab7bc 100644 --- a/server/src/mcp/tools/transports.ts +++ b/server/src/mcp/tools/transports.ts @@ -44,7 +44,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'), confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), - metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), + metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), endpoints: endpointSchema, needs_review: z.boolean().optional(), }, @@ -95,7 +95,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'), confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), - metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), + metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), endpoints: endpointSchema, needs_review: z.boolean().optional(), }, diff --git a/server/src/mcp/tools/trips.ts b/server/src/mcp/tools/trips.ts index 7712e61b..e2d8aaca 100644 --- a/server/src/mcp/tools/trips.ts +++ b/server/src/mcp/tools/trips.ts @@ -12,7 +12,7 @@ import { import { createOrUpdateShareLink, getShareLink, deleteShareLink, } from '../../services/shareService'; -import { isAddonEnabled } from '../../services/adminService'; +import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; import { countMessages, listPolls } from '../../services/collabService'; import { @@ -161,6 +161,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING); const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET); const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB); + const collabFeatures = collabEnabled ? getCollabFeatures() : null; // Scope gates — sections not covered by the client's OAuth scopes are omitted. // Core trip data (metadata, days, members, accommodations) is always included // because this tool is always registered and needed for navigation. @@ -173,16 +174,16 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str let pollCount = 0; let messageCount = 0; if (canReadCollab) { - pollCount = listPolls(tripId).length; - messageCount = countMessages(tripId); + if (collabFeatures?.polls) pollCount = listPolls(tripId).length; + if (collabFeatures?.chat) messageCount = countMessages(tripId); } const notice = getDeprecationNotice(); - const data = { + const summaryData = { ...summary, - reservations: canReadRes ? summary.reservations : undefined, - packing: canReadPacking ? summary.packing : undefined, - budget: canReadBudget ? summary.budget : undefined, - collab_notes: canReadCollab ? summary.collab_notes : [], + reservations: canReadRes ? summary.reservations : undefined, + packing: canReadPacking ? summary.packing : undefined, + budget: canReadBudget ? summary.budget : undefined, + collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [], todos, pollCount, messageCount, @@ -191,19 +192,10 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str isError: true as const, content: [ { type: 'text' as const, text: notice }, - { type: 'text' as const, text: JSON.stringify(data, null, 2) }, + { type: 'text' as const, text: JSON.stringify(summaryData, null, 2) }, ], }; - return ok({ - ...summary, - reservations: canReadRes ? summary.reservations : undefined, - packing: canReadPacking ? summary.packing : undefined, - budget: canReadBudget ? summary.budget : undefined, - collab_notes: canReadCollab ? summary.collab_notes : [], - todos, - pollCount, - messageCount, - }); + return ok(summaryData); } ); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 1c9c2aca..7cd32712 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -266,6 +266,7 @@ router.get('/collab-features', (_req: Request, res: Response) => { router.put('/collab-features', (req: Request, res: Response) => { const result = svc.updateCollabFeatures(req.body); + invalidateMcpSessions(); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts index 1dbe4899..1c79366f 100644 --- a/server/src/routes/oauth.ts +++ b/server/src/routes/oauth.ts @@ -87,6 +87,20 @@ oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, scope_descriptions: Object.fromEntries( ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label]) ), + resource_parameter_supported: true, + }); +}); + +// RFC 9728 Protected Resource Metadata +oauthPublicRouter.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => { + if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); + const base = (getAppUrl() || '').replace(/\/+$/, ''); + res.json({ + resource: `${base}/mcp`, + authorization_servers: [base], + bearer_methods_supported: ['header'], + scopes_supported: ALL_SCOPES, + resource_name: 'TREK MCP', }); }); @@ -98,7 +112,7 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons // Accept both JSON and application/x-www-form-urlencoded const body: Record = typeof req.body === 'object' ? req.body : {}; - const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body; + const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body; const ip = getClientIp(req); if (!isAddonEnabled(ADDON_IDS.MCP)) { @@ -133,6 +147,12 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); } + // RFC 8707: if the auth code was bound to a resource, the token request must present the same value + if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) { + writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'resource_mismatch' }, ip }); + return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); + } + // Verify client secret if (!authenticateClient(client_id, client_secret)) { logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); @@ -146,8 +166,8 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); } - const tokens = issueTokens(client_id, pending.userId, pending.scopes); - writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes }, ip }); + const tokens = issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null); + writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip }); return res.json(tokens); } @@ -275,6 +295,7 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R state: params.state, code_challenge: params.code_challenge || '', code_challenge_method: params.code_challenge_method || '', + resource: typeof params.resource === 'string' ? params.resource : undefined, }, userId, ); @@ -298,7 +319,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons const { user } = req as AuthRequest; const { client_id, redirect_uri, scope, state, - code_challenge, code_challenge_method, approved, + code_challenge, code_challenge_method, approved, resource, } = req.body as { client_id: string; redirect_uri: string; @@ -307,6 +328,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons code_challenge: string; code_challenge_method: string; approved: boolean; + resource?: string; }; const ip = getClientIp(req); @@ -332,6 +354,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons state, code_challenge, code_challenge_method, + resource, }; const validation = validateAuthorizeRequest(params, user.id); @@ -350,6 +373,7 @@ oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Respons userId: user.id, redirectUri: redirect_uri, scopes, + resource: validation.resource ?? null, codeChallenge: code_challenge, codeChallengeMethod: 'S256', }); diff --git a/server/src/services/oauthService.ts b/server/src/services/oauthService.ts index 1552aaa8..f4a3a34f 100644 --- a/server/src/services/oauthService.ts +++ b/server/src/services/oauthService.ts @@ -6,6 +6,7 @@ import { ADDON_IDS } from '../addons'; import { User } from '../types'; import { writeAudit, logWarn } from './auditLog'; import { revokeUserSessionsForClient } from '../mcp/sessionManager'; +import { getAppUrl } from './oidcService'; // --------------------------------------------------------------------------- // Constants @@ -28,6 +29,7 @@ interface PendingCode { userId: number; redirectUri: string; scopes: string[]; + resource: string | null; codeChallenge: string; codeChallengeMethod: 'S256'; expiresAt: number; @@ -67,6 +69,7 @@ interface OAuthTokenRow { access_token_hash: string; refresh_token_hash: string; scopes: string; // JSON array + audience: string | null; access_token_expires_at: string; refresh_token_expires_at: string; revoked_at: string | null; @@ -243,6 +246,7 @@ export function createAuthCode(params: { userId: number; redirectUri: string; scopes: string[]; + resource: string | null; codeChallenge: string; codeChallengeMethod: 'S256'; }): string | null { @@ -294,6 +298,7 @@ export function issueTokens( userId: number, scopes: string[], parentTokenId: number | null = null, + audience: string | null = null, ): { access_token: string; refresh_token: string; @@ -312,9 +317,9 @@ export function issueTokens( db.prepare(` INSERT INTO oauth_tokens - (client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId); + (client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId); return { access_token: rawAccess, @@ -333,12 +338,13 @@ export interface OAuthTokenInfo { user: User; scopes: string[]; clientId: string; + audience: string | null; } export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null { const hash = hashToken(rawToken); const row = db.prepare(` - SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at, + SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at, ot.user_id, ot.client_id, u.username, u.email, u.role FROM oauth_tokens ot JOIN users u ON ot.user_id = u.id @@ -353,6 +359,7 @@ export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null { user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' }, scopes: JSON.parse(row.scopes), clientId: row.client_id, + audience: row.audience ?? null, }; } @@ -406,7 +413,7 @@ export function refreshTokens( const hash = hashToken(rawRefreshToken); const row = db.prepare(` - SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id + SELECT id, client_id, user_id, scopes, audience, refresh_token_expires_at, revoked_at, parent_token_id FROM oauth_tokens WHERE refresh_token_hash = ? `).get(hash) as OAuthTokenRow | undefined; @@ -442,7 +449,7 @@ export function refreshTokens( revokeUserSessionsForClient(row.user_id, clientId); - const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id); + const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null); writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip }); return { tokens }; @@ -522,6 +529,7 @@ export interface AuthorizeParams { state?: string; code_challenge: string; code_challenge_method: string; + resource?: string; } export interface ValidateAuthorizeResult { @@ -530,6 +538,7 @@ export interface ValidateAuthorizeResult { error_description?: string; client?: { name: string; allowed_scopes: string[] }; scopes?: string[]; + resource?: string | null; /** true when user is logged in but consent UI must be shown */ consentRequired?: boolean; /** true when the request is valid but user is not authenticated */ @@ -573,6 +582,13 @@ export function validateAuthorizeRequest( return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' }; } + // RFC 8707 resource indicator: if provided, must identify the TREK MCP endpoint exactly + const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`; + const resource = params.resource ? params.resource.replace(/\/+$/, '') : null; + if (resource !== null && resource !== mcpResource) { + return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' }; + } + const requestedScopes = (params.scope || '').split(' ').filter(Boolean); if (requestedScopes.length === 0) { return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' }; @@ -599,6 +615,7 @@ export function validateAuthorizeRequest( valid: true, client: { name: client.name, allowed_scopes: allowedScopes }, scopes: grantedScopes, + resource: resource ?? mcpResource, consentRequired, scopeSelectable: client.created_via === 'dcr', }; diff --git a/server/tests/unit/mcp/tools-addon-gating.test.ts b/server/tests/unit/mcp/tools-addon-gating.test.ts index a1733e0e..bff2e474 100644 --- a/server/tests/unit/mcp/tools-addon-gating.test.ts +++ b/server/tests/unit/mcp/tools-addon-gating.test.ts @@ -38,6 +38,7 @@ const { isAddonEnabledMock } = vi.hoisted(() => { }); vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: isAddonEnabledMock, + getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); import { createTables } from '../../../src/db/schema'; diff --git a/server/tests/unit/mcp/tools-atlas-expanded.test.ts b/server/tests/unit/mcp/tools-atlas-expanded.test.ts index 84b44eb9..b82cc02d 100644 --- a/server/tests/unit/mcp/tools-atlas-expanded.test.ts +++ b/server/tests/unit/mcp/tools-atlas-expanded.test.ts @@ -37,6 +37,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn().mockReturnValue(true), + getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); import { createTables } from '../../../src/db/schema'; diff --git a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts index 3d0f3651..ed6ba088 100644 --- a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts +++ b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts @@ -38,6 +38,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn().mockReturnValue(true), + getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); import { createTables } from '../../../src/db/schema'; diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 38b37df3..9d831cff 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -42,7 +42,10 @@ const { isAddonEnabledMock } = vi.hoisted(() => { const isAddonEnabledMock = vi.fn().mockReturnValue(true); return { isAddonEnabledMock }; }); -vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: isAddonEnabledMock })); +vi.mock('../../../src/services/adminService', () => ({ + isAddonEnabled: isAddonEnabledMock, + getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), +})); const { mockGetTripSummary } = vi.hoisted(() => ({ mockGetTripSummary: vi.fn(), diff --git a/server/tests/unit/mcp/tools-vacay.test.ts b/server/tests/unit/mcp/tools-vacay.test.ts index 74dae706..9f7fae16 100644 --- a/server/tests/unit/mcp/tools-vacay.test.ts +++ b/server/tests/unit/mcp/tools-vacay.test.ts @@ -41,6 +41,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn().mockReturnValue(true), + getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); // Mock async service functions that make external calls diff --git a/server/tests/unit/services/oauthService.test.ts b/server/tests/unit/services/oauthService.test.ts index 5222b609..5fc109c6 100644 --- a/server/tests/unit/services/oauthService.test.ts +++ b/server/tests/unit/services/oauthService.test.ts @@ -38,6 +38,7 @@ vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() })); vi.mock('../../../src/services/adminService', () => ({ isAddonEnabled: vi.fn().mockReturnValue(true), + getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }), })); import { createTables } from '../../../src/db/schema';