feat(mcp): introduce OAuth 2.1 auth and enforce addon gating

OAuth 2.1 authentication for MCP:
- Add OAuth 2.1 authorization server with PKCE support (routes/oauth.ts)
- Add OAuth service for client CRUD, auth-code flow, and token management (services/oauthService.ts)
- Add typed scope definitions and enforcement helpers (mcp/scopes.ts)
- Add OAuth consent UI page (OAuthAuthorizePage.tsx)
- Add client-side scope labels and descriptions (api/oauthScopes.ts)
- Integrate OAuth token auth into MCP handler alongside existing static tokens
- All OAuth endpoints gated on `mcp` addon

Addon gating across MCP tools, resources, and prompts:
- Add typed ADDON_IDS constant (server/src/addons.ts) replacing all string literals
- Gate budget tools and resources (trip-budget, per-person, settlement) on `budget` addon
- Gate packing tools and resources (trip-packing, trip-packing-bags, trip-todos) on `packing` addon
- Gate todos tools on `packing` addon (mirrors web UI Lists tab behavior)
- Expand atlas gate to cover full tool body (bucket-list + country tools no longer leak)
- Expand collab gate to cover full tool body (collab notes no longer leak)
- Gate packing-list and budget-overview MCP prompts on their respective addons
- Gate get_trip_summary sections per addon; blank packing/budget/collab_notes/todos when disabled
- Remove trip-files resource and files field from get_trip_summary
- Replace all isAddonEnabled('literal') calls with ADDON_IDS constants

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jubnl
2026-04-09 22:25:58 +02:00
parent 5c0d819fc1
commit 830f6c0706
32 changed files with 2589 additions and 669 deletions
+37
View File
@@ -72,6 +72,43 @@ export const authApi = {
},
}
export const oauthApi = {
/** Validate OAuth authorize params — called by consent page on load */
validate: (params: {
response_type: string
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */
authorize: (body: {
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
approved: boolean
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
},
sessions: {
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
},
}
export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
+43
View File
@@ -0,0 +1,43 @@
// Human-readable scope definitions for the OAuth consent page.
// Must stay in sync with server/src/mcp/scopes.ts
export interface ScopeInfo {
label: string
description: string
group: string
}
export const SCOPE_GROUPS: Record<string, ScopeInfo> = {
'trips:read': { label: 'View trips & itineraries', description: 'Read trips, days, day notes, members, and share links', group: 'Trips' },
'trips:write': { label: 'Edit trips & itineraries', description: 'Create and update trips, days, notes, and manage members', group: 'Trips' },
'trips:delete': { label: 'Delete trips', description: 'Permanently delete entire trips — this action is irreversible', group: 'Trips' },
'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, categories, and visited countries', group: 'Places' },
'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, tags, and atlas entries', group: 'Places' },
'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' },
'packing:write': { label: 'Manage packing lists', description: 'Add, update, delete, toggle, and reorder packing items and bags', group: 'Packing' },
'budget:read': { label: 'View budget', description: 'Read budget items and expense breakdown', group: 'Budget' },
'budget:write': { label: 'Manage budget', description: 'Create, update, and delete budget items', group: 'Budget' },
'reservations:read': { label: 'View reservations', description: 'Read reservations and accommodation details', group: 'Reservations' },
'reservations:write': { label: 'Manage reservations', description: 'Create, update, delete, and reorder reservations', group: 'Reservations' },
'collab:read': { label: 'View collaboration', description: 'Read collab notes, polls, messages, and to-do items', group: 'Collaboration' },
'collab:write': { label: 'Manage collaboration', description: 'Create, update, and delete collab notes, todos, polls, and messages', group: 'Collaboration' },
'notifications:read': { label: 'View notifications', description: 'Read in-app notifications and unread counts', group: 'Notifications' },
'notifications:write': { label: 'Manage notifications', description: 'Mark notifications as read and respond to them', group: 'Notifications' },
'vacay:read': { label: 'View vacation plans', description: 'Read vacation planning data, entries, and stats', group: 'Vacation' },
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
'media:read': { label: 'Maps & weather data', description: 'Search locations, resolve map URLs, and fetch weather forecasts', group: 'Media' },
}
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
// Group all scopes for the client registration form
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.group))]
export function getScopesByGroup(): Record<string, Array<{ scope: string } & ScopeInfo>> {
const groups: Record<string, Array<{ scope: string } & ScopeInfo>> = {}
for (const [scope, info] of Object.entries(SCOPE_GROUPS)) {
if (!groups[info.group]) groups[info.group] = []
groups[info.group].push({ scope, ...info })
}
return groups
}