fix(mcp): add RFC 9728 PRM, RFC 8707 audience binding, and collab sub-feature gating

Root cause: claude.ai's MCP connector (spec 2025-06-18) requires the resource server
to publish Protected Resource Metadata and return WWW-Authenticate on 401s to bind
the /mcp endpoint to its AS. Without these, it silently shows no tools after OAuth.

- Add /.well-known/oauth-protected-resource (RFC 9728) with addon gating
- Emit WWW-Authenticate: Bearer resource_metadata=... on 401/auth-failure 403s
- Open CORS (origin: *) on both .well-known/* endpoints per RFC 8414/9728
- Accept resource parameter at authorize + token endpoints (RFC 8707)
- Store audience on oauth_tokens; validate on every MCP request
- Refresh tokens inherit audience; add resource_parameter_supported to AS metadata
- DB migration: ADD COLUMN audience TEXT to oauth_tokens
- Gate collab MCP tools/resources by chat/notes/polls sub-features individually
- Invalidate MCP sessions when collab sub-features are toggled in admin
- Update test mocks and MCP.md
This commit is contained in:
jubnl
2026-04-20 07:34:38 +02:00
parent 0d534f13cf
commit dd90c6d424
16 changed files with 125 additions and 51 deletions
+5
View File
@@ -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: {
+5
View File
@@ -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) {
+15
View File
@@ -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<void> {
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<void> {
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;
}
+8 -4
View File
@@ -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 }),
+15 -13
View File
@@ -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).',
+11 -19
View File
@@ -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);
}
);
+1
View File
@@ -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,
+28 -4
View File
@@ -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<string, string> = 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',
});
+23 -6
View File
@@ -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',
};