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:
jubnl
2026-04-10 02:03:12 +02:00
parent e91ee04d93
commit 7c0a0d5f39
9 changed files with 1024 additions and 155 deletions
+34 -23
View File
@@ -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',