mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer: Critical: - C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.) - C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id) - C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay High: - H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format - H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min) - H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention) - H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks) - H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side Medium: - M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses - M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled - M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay) - M5: Union consent scopes on re-authorization instead of replacing existing grants - M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints - M8: Strict Bearer scheme check in MCP token verification Refactoring: - Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient) into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index - Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new requireCookieAuth middleware Tests: - Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032) - Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3 - Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
This commit is contained in:
+17
-31
@@ -9,19 +9,9 @@ import { isAddonEnabled } from '../services/adminService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||
|
||||
interface McpSession {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
userId: number;
|
||||
/** null = static trek_ token or JWT (full access); string[] = OAuth 2.1 scopes */
|
||||
scopes: string[] | null;
|
||||
/** true when authenticated via static trek_ token — triggers deprecation prompt */
|
||||
isStaticToken: boolean;
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, McpSession>();
|
||||
export { revokeUserSessions, revokeUserSessionsForClient };
|
||||
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
||||
@@ -83,31 +73,38 @@ interface VerifyTokenResult {
|
||||
user: User;
|
||||
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
|
||||
scopes: string[] | null;
|
||||
/** OAuth client_id when authenticated via OAuth 2.1; null otherwise */
|
||||
clientId: string | null;
|
||||
isStaticToken: boolean;
|
||||
}
|
||||
|
||||
function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token) return null;
|
||||
if (!authHeader) return null;
|
||||
// M8: strictly require "Bearer" scheme (RFC 6750)
|
||||
const spaceIdx = authHeader.indexOf(' ');
|
||||
if (spaceIdx === -1) return null;
|
||||
const scheme = authHeader.slice(0, spaceIdx);
|
||||
const token = authHeader.slice(spaceIdx + 1);
|
||||
if (scheme.toLowerCase() !== 'bearer' || !token) return null;
|
||||
|
||||
// OAuth 2.1 access token (trekoa_...)
|
||||
if (token.startsWith('trekoa_')) {
|
||||
const result = getUserByAccessToken(token);
|
||||
if (!result) return null;
|
||||
return { user: result.user, scopes: result.scopes, isStaticToken: false };
|
||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
||||
}
|
||||
|
||||
// Long-lived static MCP token (trek_...) — full access + deprecation notice
|
||||
if (token.startsWith('trek_')) {
|
||||
const user = verifyMcpToken(token);
|
||||
if (!user) return null;
|
||||
return { user, scopes: null, isStaticToken: true };
|
||||
return { user, scopes: null, clientId: null, isStaticToken: true };
|
||||
}
|
||||
|
||||
// Short-lived JWT (TREK web session used directly) — full access, no notice
|
||||
const user = verifyJwtToken(token);
|
||||
if (!user) return null;
|
||||
return { user, scopes: null, isStaticToken: false };
|
||||
return { user, scopes: null, clientId: null, isStaticToken: false };
|
||||
}
|
||||
|
||||
export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
@@ -121,7 +118,7 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
res.status(401).json({ error: 'Access token required' });
|
||||
return;
|
||||
}
|
||||
const { user, scopes, isStaticToken } = tokenResult;
|
||||
const { user, scopes, clientId, isStaticToken } = tokenResult;
|
||||
|
||||
if (isRateLimited(user.id)) {
|
||||
res.status(429).json({ error: 'Too many requests. Please slow down.' });
|
||||
@@ -174,13 +171,13 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
prompts: { listChanged: true },
|
||||
},
|
||||
});
|
||||
registerResources(server, user.id);
|
||||
registerResources(server, user.id, scopes);
|
||||
registerTools(server, user.id, scopes, isStaticToken);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sid) => {
|
||||
sessions.set(sid, { server, transport, userId: user.id, scopes, isStaticToken, lastActivity: Date.now() });
|
||||
sessions.set(sid, { server, transport, userId: user.id, scopes, clientId, isStaticToken, lastActivity: Date.now() });
|
||||
const authMethod = isStaticToken ? 'static-token' : scopes ? `oauth(${scopes.join(',')})` : 'jwt';
|
||||
console.log(`[MCP] Session ${sid} created for user ${user.id} [${authMethod}]. Active sessions: ${sessions.size}`);
|
||||
},
|
||||
@@ -200,17 +197,6 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
|
||||
export function revokeUserSessions(userId: number): void {
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId === userId) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close all active MCP sessions (call during graceful shutdown). */
|
||||
export function closeMcpSessions(): void {
|
||||
clearInterval(sessionSweepInterval);
|
||||
|
||||
+34
-23
@@ -15,6 +15,7 @@ import { getNotifications } from '../services/inAppNotifications';
|
||||
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { canRead, canReadTrips } from './scopes';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
@@ -31,6 +32,16 @@ function accessDenied(uri: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function scopeDenied(uri: string) {
|
||||
return {
|
||||
contents: [{
|
||||
uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify({ error: 'Insufficient OAuth scope to access this resource' }),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function jsonContent(uri: string, data: unknown) {
|
||||
return {
|
||||
contents: [{
|
||||
@@ -41,9 +52,9 @@ function jsonContent(uri: string, data: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
export function registerResources(server: McpServer, userId: number): void {
|
||||
export function registerResources(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
// List all accessible trips
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trips',
|
||||
'trek://trips',
|
||||
{ description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
|
||||
@@ -54,7 +65,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Single trip detail
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trip',
|
||||
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
|
||||
{ description: 'A single trip with metadata and member count', mimeType: 'application/json' },
|
||||
@@ -67,7 +78,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Days with assigned places
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trip-days',
|
||||
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
|
||||
{ description: 'Days of a trip with their assigned places', mimeType: 'application/json' },
|
||||
@@ -81,7 +92,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Places in a trip
|
||||
server.registerResource(
|
||||
if (canRead(scopes, 'places')) server.registerResource(
|
||||
'trip-places',
|
||||
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
|
||||
{ description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' },
|
||||
@@ -95,7 +106,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Budget items
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
|
||||
{ description: 'Budget and expense items for a trip', mimeType: 'application/json' },
|
||||
@@ -108,7 +119,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Packing checklist
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
|
||||
'trip-packing',
|
||||
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
|
||||
{ description: 'Packing checklist for a trip', mimeType: 'application/json' },
|
||||
@@ -121,7 +132,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Reservations (flights, hotels, restaurants)
|
||||
server.registerResource(
|
||||
if (canRead(scopes, 'reservations')) server.registerResource(
|
||||
'trip-reservations',
|
||||
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
|
||||
{ description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
|
||||
@@ -134,7 +145,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Day notes
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'day-notes',
|
||||
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
|
||||
{ description: 'Notes for a specific day in a trip', mimeType: 'application/json' },
|
||||
@@ -148,7 +159,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Accommodations (hotels, rentals) per trip
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trip-accommodations',
|
||||
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
|
||||
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' },
|
||||
@@ -161,7 +172,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Trip members (owner + collaborators)
|
||||
server.registerResource(
|
||||
if (canReadTrips(scopes)) server.registerResource(
|
||||
'trip-members',
|
||||
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
|
||||
{ description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
|
||||
@@ -176,7 +187,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Collab notes for a trip
|
||||
if (isAddonEnabled(ADDON_IDS.COLLAB)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.COLLAB) && 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' },
|
||||
@@ -189,7 +200,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Trip to-do list
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'collab')) server.registerResource(
|
||||
'trip-todos',
|
||||
new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }),
|
||||
{ description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' },
|
||||
@@ -201,7 +212,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
}
|
||||
);
|
||||
|
||||
// All place categories (global, no trip filter)
|
||||
// All place categories (global, no trip filter) — safe for any authenticated session
|
||||
server.registerResource(
|
||||
'categories',
|
||||
'trek://categories',
|
||||
@@ -213,7 +224,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// User's bucket list
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'places')) server.registerResource(
|
||||
'bucket-list',
|
||||
'trek://bucket-list',
|
||||
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
|
||||
@@ -224,7 +235,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// User's visited countries
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'places')) server.registerResource(
|
||||
'visited-countries',
|
||||
'trek://visited-countries',
|
||||
{ description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
|
||||
@@ -235,7 +246,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Budget per-person summary
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget-per-person',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }),
|
||||
{ description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' },
|
||||
@@ -248,7 +259,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Budget settlement
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET) && canRead(scopes, 'budget')) server.registerResource(
|
||||
'trip-budget-settlement',
|
||||
new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }),
|
||||
{ description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' },
|
||||
@@ -261,7 +272,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Packing bags
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerResource(
|
||||
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'packing')) server.registerResource(
|
||||
'trip-packing-bags',
|
||||
new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }),
|
||||
{ description: 'All packing bags for a trip with their members', mimeType: 'application/json' },
|
||||
@@ -274,7 +285,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// In-app notifications
|
||||
server.registerResource(
|
||||
if (canRead(scopes, 'notifications')) server.registerResource(
|
||||
'notifications-in-app',
|
||||
'trek://notifications/in-app',
|
||||
{ description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' },
|
||||
@@ -285,7 +296,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
);
|
||||
|
||||
// Atlas stats and regions (addon-gated)
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS)) {
|
||||
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'places')) {
|
||||
server.registerResource(
|
||||
'atlas-stats',
|
||||
'trek://atlas/stats',
|
||||
@@ -308,7 +319,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
}
|
||||
|
||||
// Collab polls & messages (addon-gated)
|
||||
if (isAddonEnabled(ADDON_IDS.COLLAB)) {
|
||||
if (isAddonEnabled(ADDON_IDS.COLLAB) && canRead(scopes, 'collab')) {
|
||||
server.registerResource(
|
||||
'trip-collab-polls',
|
||||
new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }),
|
||||
@@ -335,7 +346,7 @@ export function registerResources(server: McpServer, userId: number): void {
|
||||
}
|
||||
|
||||
// Vacay resources (addon-gated)
|
||||
if (isAddonEnabled(ADDON_IDS.VACAY)) {
|
||||
if (isAddonEnabled(ADDON_IDS.VACAY) && canRead(scopes, 'vacay')) {
|
||||
server.registerResource(
|
||||
'vacay-plan',
|
||||
'trek://vacay/plan',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
|
||||
|
||||
export interface McpSession {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
userId: number;
|
||||
/** null = static trek_ token or JWT (full access); string[] = OAuth 2.1 scopes */
|
||||
scopes: string[] | null;
|
||||
/** OAuth 2.1 client_id that owns this session; null for static-token / JWT sessions */
|
||||
clientId: string | null;
|
||||
/** true when authenticated via static trek_ token — triggers deprecation prompt */
|
||||
isStaticToken: boolean;
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
export const sessions = new Map<string, McpSession>();
|
||||
|
||||
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
|
||||
export function revokeUserSessions(userId: number): void {
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId === userId) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminate MCP sessions for a specific (user, OAuth client) pair.
|
||||
* Used when an OAuth token or session is revoked so only the affected client's
|
||||
* sessions are closed, not sessions from other clients for the same user. */
|
||||
export function revokeUserSessionsForClient(userId: number, clientId: string): void {
|
||||
for (const [sid, session] of sessions) {
|
||||
if (session.userId === userId && session.clientId === clientId) {
|
||||
try { session.server.close(); } catch { /* ignore */ }
|
||||
try { session.transport.close(); } catch { /* ignore */ }
|
||||
sessions.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user