mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
fix(mcp): MCP RFC compliant for more strict clients
This commit is contained in:
+1
-1
@@ -218,7 +218,7 @@ export default function App() {
|
|||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+63
-60
@@ -33,6 +33,7 @@ function translateRateLimit(): string {
|
|||||||
export const apiClient: AxiosInstance = axios.create({
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
timeout: 8000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -42,24 +43,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
|||||||
|
|
||||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const sid = getSocketId()
|
const sid = getSocketId()
|
||||||
if (sid) {
|
if (sid) {
|
||||||
config.headers['X-Socket-Id'] = sid
|
config.headers['X-Socket-Id'] = sid
|
||||||
}
|
}
|
||||||
// Attach a per-request idempotency key to all write operations so the
|
// Attach a per-request idempotency key to all write operations so the
|
||||||
// server can deduplicate retried requests (e.g. network blips).
|
// server can deduplicate retried requests (e.g. network blips).
|
||||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||||
const method = (config.method ?? '').toLowerCase()
|
const method = (config.method ?? '').toLowerCase()
|
||||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: Math.random().toString(36).slice(2)
|
: Math.random().toString(36).slice(2)
|
||||||
config.headers['X-Idempotency-Key'] = key
|
config.headers['X-Idempotency-Key'] = key
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
export function isAuthPublicPath(pathname: string): boolean {
|
export function isAuthPublicPath(pathname: string): boolean {
|
||||||
@@ -70,34 +71,34 @@ export function isAuthPublicPath(pathname: string): boolean {
|
|||||||
|
|
||||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||||
const { pathname } = window.location
|
const { pathname } = window.location
|
||||||
if (!isAuthPublicPath(pathname)) {
|
if (!isAuthPublicPath(pathname)) {
|
||||||
const currentPath = pathname + window.location.search + window.location.hash
|
const currentPath = pathname + window.location.search + window.location.hash
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (
|
||||||
if (
|
error.response?.status === 403 &&
|
||||||
error.response?.status === 403 &&
|
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
!window.location.pathname.startsWith('/settings')
|
||||||
!window.location.pathname.startsWith('/settings')
|
) {
|
||||||
) {
|
window.location.href = '/settings?mfa=required'
|
||||||
window.location.href = '/settings?mfa=required'
|
|
||||||
}
|
|
||||||
if (error.response?.status === 429) {
|
|
||||||
const translated = translateRateLimit()
|
|
||||||
const data = error.response.data as { error?: string } | undefined
|
|
||||||
if (data && typeof data === 'object') {
|
|
||||||
data.error = translated
|
|
||||||
} else {
|
|
||||||
error.response.data = { error: translated }
|
|
||||||
}
|
}
|
||||||
error.message = translated
|
if (error.response?.status === 429) {
|
||||||
|
const translated = translateRateLimit()
|
||||||
|
const data = error.response.data as { error?: string } | undefined
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
data.error = translated
|
||||||
|
} else {
|
||||||
|
error.response.data = { error: translated }
|
||||||
|
}
|
||||||
|
error.message = translated
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
@@ -142,6 +143,7 @@ export const oauthApi = {
|
|||||||
state?: string
|
state?: string
|
||||||
code_challenge: string
|
code_challenge: string
|
||||||
code_challenge_method: string
|
code_challenge_method: string
|
||||||
|
resource?: string
|
||||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||||
|
|
||||||
/** Submit user consent (approve or deny) */
|
/** Submit user consent (approve or deny) */
|
||||||
@@ -153,12 +155,13 @@ export const oauthApi = {
|
|||||||
code_challenge: string
|
code_challenge: string
|
||||||
code_challenge_method: string
|
code_challenge_method: string
|
||||||
approved: boolean
|
approved: boolean
|
||||||
|
resource?: string
|
||||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).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),
|
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||||
},
|
},
|
||||||
@@ -215,11 +218,11 @@ export const placesApi = {
|
|||||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
},
|
},
|
||||||
importGoogleList: (tripId: number | string, url: string) =>
|
importGoogleList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||||
importNaverList: (tripId: number | string, url: string) =>
|
importNaverList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
@@ -313,7 +316,7 @@ export const adminApi = {
|
|||||||
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||||
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||||
auditLog: (params?: { limit?: number; offset?: number }) =>
|
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||||
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||||
@@ -322,7 +325,7 @@ export const adminApi = {
|
|||||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||||
sendTestNotification: (data: Record<string, unknown>) =>
|
sendTestNotification: (data: Record<string, unknown>) =>
|
||||||
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
|
||||||
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
|
||||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||||
@@ -387,7 +390,7 @@ export const journeyApi = {
|
|||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||||
@@ -443,7 +446,7 @@ export const weatherApi = {
|
|||||||
|
|
||||||
export const configApi = {
|
export const configApi = {
|
||||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||||
apiClient.get('/config').then(r => r.data),
|
apiClient.get('/config').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
@@ -529,21 +532,21 @@ export const notificationsApi = {
|
|||||||
|
|
||||||
export const inAppNotificationsApi = {
|
export const inAppNotificationsApi = {
|
||||||
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
|
||||||
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
apiClient.get('/notifications/in-app', { params }).then(r => r.data),
|
||||||
unreadCount: () =>
|
unreadCount: () =>
|
||||||
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
|
||||||
markRead: (id: number) =>
|
markRead: (id: number) =>
|
||||||
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
|
||||||
markUnread: (id: number) =>
|
markUnread: (id: number) =>
|
||||||
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
|
||||||
markAllRead: () =>
|
markAllRead: () =>
|
||||||
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
apiClient.put('/notifications/in-app/read-all').then(r => r.data),
|
||||||
delete: (id: number) =>
|
delete: (id: number) =>
|
||||||
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
|
||||||
deleteAll: () =>
|
deleteAll: () =>
|
||||||
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
apiClient.delete('/notifications/in-app/all').then(r => r.data),
|
||||||
respond: (id: number, response: 'positive' | 'negative') =>
|
respond: (id: number, response: 'positive' | 'negative') =>
|
||||||
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
@@ -39,11 +39,11 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
||||||
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
||||||
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
|
setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo');
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
|
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,21 +60,21 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/auth/oidc/exchange', () =>
|
http.get('/api/auth/oidc/exchange', () =>
|
||||||
HttpResponse.json({ token: 'mock-oidc-token' })
|
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
||||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
|
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz');
|
||||||
setSearch('?oidc_code=testcode123');
|
setSearch('?oidc_code=testcode123');
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockNavigate).toHaveBeenCalledWith(
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
'/oauth/authorize?client_id=foo&state=xyz',
|
'/oauth/consent?client_id=foo&state=xyz',
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
||||||
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
||||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
|
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo');
|
||||||
setSearch('?oidc_error=token_failed');
|
setSearch('?oidc_error=token_failed');
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
@@ -102,4 +102,4 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage';
|
|||||||
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
||||||
|
|
||||||
function setSearchParams(search: string) {
|
function setSearchParams(search: string) {
|
||||||
window.history.pushState({}, '', '/oauth/authorize' + search);
|
window.history.pushState({}, '', '/oauth/consent' + search);
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALIDATE_OK = {
|
const VALIDATE_OK = {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
const state = params.get('state') || ''
|
const state = params.get('state') || ''
|
||||||
const codeChallenge = params.get('code_challenge') || ''
|
const codeChallenge = params.get('code_challenge') || ''
|
||||||
const ccMethod = params.get('code_challenge_method') || ''
|
const ccMethod = params.get('code_challenge_method') || ''
|
||||||
|
const resource = params.get('resource') || undefined
|
||||||
|
|
||||||
// Load auth state once, then validate
|
// Load auth state once, then validate
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +44,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return
|
if (authLoading) return
|
||||||
validateRequest()
|
validateRequest()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [authLoading, isAuthenticated])
|
}, [authLoading, isAuthenticated])
|
||||||
|
|
||||||
async function validateRequest() {
|
async function validateRequest() {
|
||||||
@@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: ccMethod,
|
code_challenge_method: ccMethod,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
|
resource,
|
||||||
})
|
})
|
||||||
setValidation(result)
|
setValidation(result)
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: ccMethod,
|
code_challenge_method: ccMethod,
|
||||||
approved,
|
approved,
|
||||||
|
resource,
|
||||||
})
|
})
|
||||||
setPageState('done')
|
setPageState('done')
|
||||||
window.location.href = result.redirect
|
window.location.href = result.redirect
|
||||||
@@ -111,20 +114,20 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
function toggleScope(s: string) {
|
function toggleScope(s: string) {
|
||||||
setSelectedScopes(prev =>
|
setSelectedScopes(prev =>
|
||||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
||||||
setSelectedScopes(prev =>
|
setSelectedScopes(prev =>
|
||||||
allSelected
|
allSelected
|
||||||
? prev.filter(s => !groupScopes.includes(s))
|
? prev.filter(s => !groupScopes.includes(s))
|
||||||
: [...new Set([...prev, ...groupScopes])]
|
: [...new Set([...prev, ...groupScopes])]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoginRedirect() {
|
function handleLoginRedirect() {
|
||||||
const next = '/oauth/authorize?' + params.toString() + window.location.hash
|
const next = '/oauth/consent?' + params.toString() + window.location.hash
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,212 +148,212 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
if (pageState === 'loading' || pageState === 'auto_approving') {
|
if (pageState === 'loading' || pageState === 'auto_approving') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === 'error') {
|
if (pageState === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === 'login_required') {
|
if (pageState === 'login_required') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLoginRedirect}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||||
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Sign in to TREK
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleLoginRedirect}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
|
||||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
Sign in to TREK
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageState === 'consent'
|
// pageState === 'consent'
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
|
||||||
{/* Left panel — app identity + actions */}
|
{/* Left panel — app identity + actions */}
|
||||||
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
|
||||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{validation?.client?.name || clientId}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
This application is requesting access to your TREK account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 space-y-2">
|
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
Only grant access to applications you trust. Your data stays on your server.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => submitConsent(true)}
|
|
||||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
|
||||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
|
||||||
{submitting
|
|
||||||
? 'Authorizing…'
|
|
||||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
|
||||||
? 'Select at least one scope'
|
|
||||||
: validation?.scopeSelectable
|
|
||||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
|
||||||
: 'Approve Access'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => submitConsent(false)}
|
|
||||||
disabled={submitting}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
|
||||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
|
||||||
Deny
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel — selectable scopes */}
|
|
||||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.keys(scopesByGroup).length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{validation?.client?.name || clientId}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
This application is requesting access to your TREK account.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{validation?.scopeSelectable ? (
|
<div className="mt-8 space-y-2">
|
||||||
/* DCR client — user selects which scopes to grant */
|
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<div className="space-y-3">
|
Only grant access to applications you trust. Your data stays on your server.
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
</p>
|
||||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
<button
|
||||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
onClick={() => submitConsent(true)}
|
||||||
return (
|
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
<input
|
{submitting
|
||||||
type="checkbox"
|
? 'Authorizing…'
|
||||||
checked={allGroupSelected}
|
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
? 'Select at least one scope'
|
||||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
: validation?.scopeSelectable
|
||||||
className="rounded flex-shrink-0"
|
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||||
/>
|
: 'Approve Access'}
|
||||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
</button>
|
||||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
<button
|
||||||
|
onClick={() => submitConsent(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel — selectable scopes */}
|
||||||
|
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.keys(scopesByGroup).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{validation?.scopeSelectable ? (
|
||||||
|
/* DCR client — user selects which scopes to grant */
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||||
|
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||||
|
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||||
|
return (
|
||||||
|
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
|
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allGroupSelected}
|
||||||
|
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||||
|
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||||
|
className="rounded flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||||
|
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
{groupScopes.map(s => {
|
{groupScopes.map(s => {
|
||||||
const keys = SCOPE_GROUPS[s]
|
const keys = SCOPE_GROUPS[s]
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={s}
|
key={s}
|
||||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedScopes.includes(s)}
|
checked={selectedScopes.includes(s)}
|
||||||
onChange={() => toggleScope(s)}
|
onChange={() => toggleScope(s)}
|
||||||
className="mt-0.5 rounded flex-shrink-0"
|
className="mt-0.5 rounded flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Settings-created client — scopes are fixed, show read-only */
|
|
||||||
<div className="space-y-5">
|
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
|
||||||
<div key={group}>
|
|
||||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{groupScopes.map(s => {
|
|
||||||
const keys = SCOPE_GROUPS[s]
|
|
||||||
return (
|
|
||||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
))}
|
/* Settings-created client — scopes are fixed, show read-only */
|
||||||
|
<div className="space-y-5">
|
||||||
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{groupScopes.map(s => {
|
||||||
|
const keys = SCOPE_GROUPS[s]
|
||||||
|
return (
|
||||||
|
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Always-available tools — granted regardless of scopes */}
|
{/* Always-available tools — granted regardless of scopes */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
Always included
|
Always included
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{[
|
{[
|
||||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||||
].map(({ name, desc }) => (
|
].map(({ name, desc }) => (
|
||||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+24
-1
@@ -110,7 +110,30 @@ export default defineConfig({
|
|||||||
'/mcp': {
|
'/mcp': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
},
|
||||||
|
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
|
||||||
|
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
|
||||||
|
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
|
||||||
|
'/oauth/authorize': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/oauth/token': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/oauth/register': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/oauth/revoke': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/.well-known': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+120
-9
@@ -43,11 +43,18 @@ import journeyPublicRoutes from './routes/journeyPublic';
|
|||||||
import publicConfigRoutes from './routes/publicConfig';
|
import publicConfigRoutes from './routes/publicConfig';
|
||||||
import systemNoticesRoutes from './routes/systemNotices';
|
import systemNoticesRoutes from './routes/systemNotices';
|
||||||
import { mcpHandler } from './mcp';
|
import { mcpHandler } from './mcp';
|
||||||
|
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
|
||||||
import { Addon } from './types';
|
import { Addon } from './types';
|
||||||
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||||
import { getCollabFeatures } from './services/adminService';
|
import { getCollabFeatures } from './services/adminService';
|
||||||
import { isAddonEnabled } from './services/adminService';
|
import { isAddonEnabled } from './services/adminService';
|
||||||
import { ADDON_IDS } from './addons';
|
import { ADDON_IDS } from './addons';
|
||||||
|
import { ALL_SCOPES } from './mcp/scopes';
|
||||||
|
import { getAppUrl } from './services/oidcService';
|
||||||
|
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
||||||
|
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
|
||||||
|
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
|
||||||
|
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
|
||||||
|
|
||||||
export function createApp(): express.Application {
|
export function createApp(): express.Application {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -58,8 +65,8 @@ export function createApp(): express.Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
||||||
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let corsOrigin: cors.CorsOptions['origin'];
|
let corsOrigin: cors.CorsOptions['origin'];
|
||||||
if (allowedOrigins) {
|
if (allowedOrigins) {
|
||||||
@@ -88,10 +95,27 @@ export function createApp(): express.Application {
|
|||||||
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
|
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
|
||||||
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
|
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
|
||||||
|
|
||||||
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
|
// RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable.
|
||||||
|
// /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it
|
||||||
|
// with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for
|
||||||
|
// browser-based DCR/authorization preflights — the global cors({ origin: false }) would
|
||||||
|
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
|
||||||
|
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
|
||||||
|
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
|
||||||
app.use(
|
app.use(
|
||||||
['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'],
|
(req: Request, _res: Response, next: NextFunction) => {
|
||||||
cors({ origin: '*', credentials: false }),
|
if (
|
||||||
|
req.path.startsWith('/.well-known/') ||
|
||||||
|
req.path === '/oauth/register' ||
|
||||||
|
req.path === '/oauth/authorize' ||
|
||||||
|
req.path === '/oauth/userinfo' ||
|
||||||
|
req.path === '/mcp'
|
||||||
|
) {
|
||||||
|
cors({ origin: '*', credentials: false })(req, _res, next);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
app.use(cors({ origin: corsOrigin, credentials: true }));
|
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
@@ -225,7 +249,7 @@ export function createApp(): express.Application {
|
|||||||
if (!photo) return res.status(401).send('Authentication required');
|
if (!photo) return res.status(401).send('Authentication required');
|
||||||
|
|
||||||
const share = db.prepare(
|
const share = db.prepare(
|
||||||
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||||
).get(rawToken) as { trip_id: number } | undefined;
|
).get(rawToken) as { trip_id: number } | undefined;
|
||||||
if (!share || share.trip_id !== photo.trip_id) {
|
if (!share || share.trip_id !== photo.trip_id) {
|
||||||
return res.status(401).send('Authentication required');
|
return res.status(401).send('Authentication required');
|
||||||
@@ -340,16 +364,103 @@ export function createApp(): express.Application {
|
|||||||
app.use('/api/notifications', notificationRoutes);
|
app.use('/api/notifications', notificationRoutes);
|
||||||
app.use('/api', shareRoutes);
|
app.use('/api', shareRoutes);
|
||||||
|
|
||||||
// OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke)
|
// OAuth 2.1 — public endpoints
|
||||||
app.use('/', oauthPublicRouter);
|
// Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting)
|
||||||
|
const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
|
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
|
||||||
|
// Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate
|
||||||
app.use('/api/oauth', oauthApiRouter);
|
app.use('/api/oauth', oauthApiRouter);
|
||||||
|
|
||||||
|
// SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB)
|
||||||
|
// is not called at createApp() time, before test tables have been created.
|
||||||
|
// mcpAuthMetadataRouter serves:
|
||||||
|
// /.well-known/oauth-authorization-server — RFC 8414 AS metadata
|
||||||
|
// /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1)
|
||||||
|
let _oauthMetadata: OAuthMetadata | null = null;
|
||||||
|
let _sdkMetaRouter: express.Router | null = null;
|
||||||
|
|
||||||
|
function getOAuthMetadata(): OAuthMetadata {
|
||||||
|
if (_oauthMetadata) return _oauthMetadata;
|
||||||
|
const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, '');
|
||||||
|
_oauthMetadata = {
|
||||||
|
issuer: base,
|
||||||
|
authorization_endpoint: `${base}/oauth/authorize`,
|
||||||
|
token_endpoint: `${base}/oauth/token`,
|
||||||
|
revocation_endpoint: `${base}/oauth/revoke`,
|
||||||
|
registration_endpoint: `${base}/oauth/register`,
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||||
|
scopes_supported: ALL_SCOPES,
|
||||||
|
};
|
||||||
|
return _oauthMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetaRouter(): express.Router {
|
||||||
|
if (_sdkMetaRouter) return _sdkMetaRouter;
|
||||||
|
const metadata = getOAuthMetadata();
|
||||||
|
_sdkMetaRouter = mcpAuthMetadataRouter({
|
||||||
|
oauthMetadata: metadata,
|
||||||
|
resourceServerUrl: new URL(`${metadata.issuer}/mcp`),
|
||||||
|
scopesSupported: ALL_SCOPES as string[],
|
||||||
|
resourceName: 'TREK MCP',
|
||||||
|
});
|
||||||
|
return _sdkMetaRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through
|
||||||
|
// so static files and SPA routes are unaffected when MCP is off.
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const isMetadataPath =
|
||||||
|
req.path === '/.well-known/oauth-authorization-server' ||
|
||||||
|
req.path === '/.well-known/openid-configuration' ||
|
||||||
|
req.path.startsWith('/.well-known/oauth-protected-resource');
|
||||||
|
if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||||
|
getMetaRouter()(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via
|
||||||
|
// /.well-known/openid-configuration. Serve the AS metadata plus the OIDC
|
||||||
|
// userinfo_endpoint so ChatGPT can fetch the authenticated user's email
|
||||||
|
// for authorization domain claiming.
|
||||||
|
app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => {
|
||||||
|
const meta = getOAuthMetadata();
|
||||||
|
res.json({
|
||||||
|
...meta,
|
||||||
|
userinfo_endpoint: `${meta.issuer}/oauth/userinfo`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
|
||||||
|
// to the SPA consent page at /oauth/consent
|
||||||
|
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
|
||||||
|
|
||||||
|
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
|
||||||
|
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
|
||||||
|
|
||||||
|
// Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth)
|
||||||
|
// oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here
|
||||||
|
app.use('/', oauthPublicRouter);
|
||||||
|
|
||||||
// MCP endpoint
|
// MCP endpoint
|
||||||
app.post('/mcp', mcpHandler);
|
app.post('/mcp', mcpHandler);
|
||||||
app.get('/mcp', mcpHandler);
|
app.get('/mcp', mcpHandler);
|
||||||
app.delete('/mcp', mcpHandler);
|
app.delete('/mcp', mcpHandler);
|
||||||
|
|
||||||
|
// Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle.
|
||||||
|
// Without this, the SPA catch-all serves HTML — clients probing
|
||||||
|
// /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL
|
||||||
|
// receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth".
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Production static file serving
|
// Production static file serving
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const publicPath = path.join(__dirname, '../public');
|
const publicPath = path.join(__dirname, '../public');
|
||||||
@@ -380,4 +491,4 @@ export function createApp(): express.Application {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
+18
-17
@@ -90,10 +90,10 @@ The following features are optional and may not be available on every TREK insta
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const STATIC_TOKEN_DEPRECATION_NOTICE =
|
const STATIC_TOKEN_DEPRECATION_NOTICE =
|
||||||
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
|
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
|
||||||
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
|
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
|
||||||
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
|
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
|
||||||
'The actual tool result follows — answer the user\'s question as well.';
|
'The actual tool result follows — answer the user\'s question as well.';
|
||||||
|
|
||||||
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
const sessionParsed = Number.parseInt(process.env.MCP_MAX_SESSION_PER_USER ?? "");
|
||||||
@@ -154,8 +154,9 @@ sessionSweepInterval.unref();
|
|||||||
|
|
||||||
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
||||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
||||||
|
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
||||||
res.set('WWW-Authenticate',
|
res.set('WWW-Authenticate',
|
||||||
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`);
|
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VerifyTokenResult {
|
interface VerifyTokenResult {
|
||||||
@@ -278,18 +279,18 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
|||||||
|
|
||||||
// Create a new per-user MCP server and session
|
// Create a new per-user MCP server and session
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{
|
{
|
||||||
name: 'TREK MCP',
|
name: 'TREK MCP',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
},
|
|
||||||
{
|
|
||||||
capabilities: {
|
|
||||||
resources: { listChanged: true },
|
|
||||||
tools: { listChanged: true },
|
|
||||||
prompts: { listChanged: true },
|
|
||||||
},
|
},
|
||||||
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
{
|
||||||
}
|
capabilities: {
|
||||||
|
resources: { listChanged: true },
|
||||||
|
tools: { listChanged: true },
|
||||||
|
prompts: { listChanged: true },
|
||||||
|
},
|
||||||
|
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
// Per-session closure: fires the deprecation notice once, on the first tool call.
|
// Per-session closure: fires the deprecation notice once, on the first tool call.
|
||||||
// Tool results are the only mechanism Claude reliably surfaces to the user;
|
// Tool results are the only mechanism Claude reliably surfaces to the user;
|
||||||
@@ -347,4 +348,4 @@ export function closeMcpSessions(): void {
|
|||||||
}
|
}
|
||||||
sessions.clear();
|
sessions.clear();
|
||||||
rateLimitMap.clear();
|
rateLimitMap.clear();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import type { Response } from 'express';
|
||||||
|
import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider';
|
||||||
|
import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
|
||||||
|
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
|
||||||
|
import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider';
|
||||||
|
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients';
|
||||||
|
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
|
||||||
|
import { db } from '../db/database';
|
||||||
|
import {
|
||||||
|
createOAuthClient,
|
||||||
|
consumeAuthCode,
|
||||||
|
issueTokens,
|
||||||
|
refreshTokens,
|
||||||
|
revokeToken as serviceRevokeToken,
|
||||||
|
verifyPKCE,
|
||||||
|
getUserByAccessToken,
|
||||||
|
} from '../services/oauthService';
|
||||||
|
import { ALL_SCOPES } from './scopes';
|
||||||
|
import { getAppUrl } from '../services/oidcService';
|
||||||
|
import { writeAudit } from '../services/auditLog';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB row type (mirrors oauthService.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface OAuthClientRow {
|
||||||
|
client_id: string;
|
||||||
|
name: string;
|
||||||
|
redirect_uris: string; // JSON array
|
||||||
|
allowed_scopes: string; // JSON array
|
||||||
|
is_public: number; // 0 | 1
|
||||||
|
created_via: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Redirect URI validation (mirrors oauth.ts DCR checks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DANGEROUS_SCHEMES = new Set([
|
||||||
|
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function assertValidRedirectUris(uris: string[]): void {
|
||||||
|
for (const u of uris) {
|
||||||
|
let url: URL;
|
||||||
|
try { url = new URL(u); } catch {
|
||||||
|
throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`);
|
||||||
|
}
|
||||||
|
if (DANGEROUS_SCHEMES.has(url.protocol))
|
||||||
|
throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`);
|
||||||
|
if (url.protocol === 'https:') continue;
|
||||||
|
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) continue;
|
||||||
|
const scheme = url.protocol.slice(0, -1);
|
||||||
|
if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue;
|
||||||
|
throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Row → SDK client info shape
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
|
||||||
|
return {
|
||||||
|
client_id: row.client_id,
|
||||||
|
client_name: row.name,
|
||||||
|
redirect_uris: JSON.parse(row.redirect_uris) as string[],
|
||||||
|
scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '),
|
||||||
|
token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
response_types: ['code'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clients store
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const trekClientsStore: OAuthRegisteredClientsStore = {
|
||||||
|
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?'
|
||||||
|
).get(clientId) as OAuthClientRow | undefined;
|
||||||
|
return row ? rowToInfo(row) : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerClient(
|
||||||
|
metadata: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
|
||||||
|
): Promise<OAuthClientInformationFull> {
|
||||||
|
const uris = metadata.redirect_uris as string[];
|
||||||
|
assertValidRedirectUris(uris);
|
||||||
|
|
||||||
|
const isPublic = metadata.token_endpoint_auth_method === 'none';
|
||||||
|
const name = (typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '').slice(0, 100) || 'MCP Client';
|
||||||
|
|
||||||
|
// When scope is absent (ChatGPT DCR), default to all scopes.
|
||||||
|
// The user still grants only what they approve at the consent screen.
|
||||||
|
const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES;
|
||||||
|
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
|
||||||
|
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
|
||||||
|
|
||||||
|
const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
|
||||||
|
if (result.error) throw new InvalidClientMetadataError(result.error);
|
||||||
|
|
||||||
|
const c = result.client!;
|
||||||
|
return {
|
||||||
|
client_id: c.client_id as string,
|
||||||
|
client_name: c.name as string,
|
||||||
|
redirect_uris: c.redirect_uris as string[],
|
||||||
|
scope: (c.allowed_scopes as string[]).join(' '),
|
||||||
|
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
response_types: ['code'],
|
||||||
|
...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OAuthServerProvider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const trekOAuthProvider: OAuthServerProvider = {
|
||||||
|
get clientsStore() { return trekClientsStore; },
|
||||||
|
|
||||||
|
// Redirects browser to the SPA consent page with OAuth params forwarded.
|
||||||
|
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||||
|
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||||
|
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
|
||||||
|
|
||||||
|
if (resource !== mcpResource) {
|
||||||
|
const url = new URL(params.redirectUri);
|
||||||
|
url.searchParams.set('error', 'invalid_target');
|
||||||
|
url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint');
|
||||||
|
if (params.state) url.searchParams.set('state', params.state);
|
||||||
|
res.redirect(302, url.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
client_id: client.client_id,
|
||||||
|
redirect_uri: params.redirectUri,
|
||||||
|
scope: params.scopes.join(' '),
|
||||||
|
code_challenge: params.codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
if (params.state) qs.set('state', params.state);
|
||||||
|
if (params.resource) qs.set('resource', params.resource.href);
|
||||||
|
|
||||||
|
res.redirect(302, `/oauth/consent?${qs.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Not called because skipLocalPkceValidation = true.
|
||||||
|
// PKCE verification is done inline in exchangeAuthorizationCode.
|
||||||
|
skipLocalPkceValidation: true,
|
||||||
|
|
||||||
|
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise<string> {
|
||||||
|
throw new ServerError('PKCE validation is handled by the provider directly');
|
||||||
|
},
|
||||||
|
|
||||||
|
async exchangeAuthorizationCode(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
code: string,
|
||||||
|
codeVerifier?: string,
|
||||||
|
redirectUri?: string,
|
||||||
|
resource?: URL,
|
||||||
|
): Promise<OAuthTokens> {
|
||||||
|
const pending = consumeAuthCode(code);
|
||||||
|
if (!pending || pending.clientId !== client.client_id)
|
||||||
|
throw new Error('Authorization grant is invalid.');
|
||||||
|
|
||||||
|
if (redirectUri && pending.redirectUri !== redirectUri)
|
||||||
|
throw new Error('Authorization grant is invalid.');
|
||||||
|
|
||||||
|
const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null;
|
||||||
|
if (pending.resource && resourceStr && pending.resource !== resourceStr)
|
||||||
|
throw new Error('Authorization grant is invalid.');
|
||||||
|
|
||||||
|
if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge))
|
||||||
|
throw new Error('Authorization grant is invalid.');
|
||||||
|
|
||||||
|
const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||||
|
writeAudit({
|
||||||
|
userId: pending.userId,
|
||||||
|
action: 'oauth.token.issue',
|
||||||
|
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
|
||||||
|
ip: null,
|
||||||
|
});
|
||||||
|
return tokens;
|
||||||
|
},
|
||||||
|
|
||||||
|
async exchangeRefreshToken(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
refreshToken: string,
|
||||||
|
_scopes?: string[],
|
||||||
|
_resource?: URL,
|
||||||
|
): Promise<OAuthTokens> {
|
||||||
|
const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null);
|
||||||
|
if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired');
|
||||||
|
return result.tokens!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||||
|
const info = getUserByAccessToken(token);
|
||||||
|
if (!info) throw new Error('Invalid or expired token');
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
clientId: info.clientId,
|
||||||
|
scopes: info.scopes,
|
||||||
|
extra: { user: info.user },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeToken(
|
||||||
|
client: OAuthClientInformationFull,
|
||||||
|
request: OAuthTokenRevocationRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
serviceRevokeToken(request.token, client.client_id, undefined, null);
|
||||||
|
},
|
||||||
|
};
|
||||||
+35
-139
@@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
|
|||||||
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
|
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
|
||||||
import { AuthRequest, OptionalAuthRequest } from '../types';
|
import { AuthRequest, OptionalAuthRequest } from '../types';
|
||||||
import { isAddonEnabled } from '../services/adminService';
|
import { isAddonEnabled } from '../services/adminService';
|
||||||
import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes';
|
import { ALL_SCOPES } from '../mcp/scopes';
|
||||||
import { ADDON_IDS } from '../addons';
|
import { ADDON_IDS } from '../addons';
|
||||||
import {
|
import {
|
||||||
validateAuthorizeRequest,
|
validateAuthorizeRequest,
|
||||||
@@ -14,16 +14,15 @@ import {
|
|||||||
revokeToken,
|
revokeToken,
|
||||||
verifyPKCE,
|
verifyPKCE,
|
||||||
authenticateClient,
|
authenticateClient,
|
||||||
isValidRedirectUri,
|
|
||||||
listOAuthClients,
|
listOAuthClients,
|
||||||
createOAuthClient,
|
createOAuthClient,
|
||||||
deleteOAuthClient,
|
deleteOAuthClient,
|
||||||
rotateOAuthClientSecret,
|
rotateOAuthClientSecret,
|
||||||
listOAuthSessions,
|
listOAuthSessions,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
|
getUserByAccessToken,
|
||||||
AuthorizeParams,
|
AuthorizeParams,
|
||||||
} from '../services/oauthService';
|
} from '../services/oauthService';
|
||||||
import { getAppUrl } from '../services/oidcService';
|
|
||||||
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
|
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -59,53 +58,18 @@ function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Req
|
|||||||
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
|
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
|
||||||
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
|
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
|
||||||
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
|
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
|
||||||
const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public router: /.well-known, /oauth/token, /oauth/revoke
|
// Public router: /oauth/token and /oauth/revoke
|
||||||
|
// (/.well-known and /oauth/register are now handled by SDK in app.ts)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const oauthPublicRouter = express.Router();
|
export const oauthPublicRouter = express.Router();
|
||||||
|
|
||||||
// RFC 8414 discovery document
|
|
||||||
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => {
|
|
||||||
// M2: return 404 (not 403) so feature presence isn't fingerprinted
|
|
||||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
|
||||||
|
|
||||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
|
||||||
res.json({
|
|
||||||
issuer: base,
|
|
||||||
authorization_endpoint: `${base}/oauth/authorize`,
|
|
||||||
token_endpoint: `${base}/oauth/token`,
|
|
||||||
revocation_endpoint: `${base}/oauth/revoke`,
|
|
||||||
registration_endpoint: `${base}/oauth/register`,
|
|
||||||
response_types_supported: ['code'],
|
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
||||||
code_challenge_methods_supported: ['S256'],
|
|
||||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
|
||||||
scopes_supported: ALL_SCOPES,
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Token endpoint — handles authorization_code and refresh_token grants
|
// Token endpoint — handles authorization_code and refresh_token grants
|
||||||
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
|
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
|
||||||
|
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||||
|
|
||||||
// M1: RFC 6749 §5.1 — token responses must not be cached
|
// M1: RFC 6749 §5.1 — token responses must not be cached
|
||||||
res.set('Cache-Control', 'no-store');
|
res.set('Cache-Control', 'no-store');
|
||||||
res.set('Pragma', 'no-cache');
|
res.set('Pragma', 'no-cache');
|
||||||
@@ -115,10 +79,6 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
|||||||
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
|
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
|
|
||||||
if (!isAddonEnabled(ADDON_IDS.MCP)) {
|
|
||||||
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client_id) {
|
if (!client_id) {
|
||||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
|
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||||
}
|
}
|
||||||
@@ -194,96 +154,32 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
|||||||
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
// RFC 7591 Dynamic Client Registration endpoint
|
// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3)
|
||||||
oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => {
|
// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming.
|
||||||
|
oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => {
|
||||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||||
|
const auth = req.headers['authorization'];
|
||||||
const body: Record<string, unknown> = typeof req.body === 'object' && req.body !== null ? req.body : {};
|
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
|
||||||
const ip = getClientIp(req);
|
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
|
||||||
|
return res.status(401).json({ error: 'invalid_token' });
|
||||||
const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : [];
|
|
||||||
if (redirectUris.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' });
|
|
||||||
}
|
}
|
||||||
// OAuth 2.1 + RFC 8252: confidential web apps need HTTPS; public
|
const token = auth.slice(7);
|
||||||
// clients (MCP, native) are limited to loopback or a reverse-DNS
|
const info = getUserByAccessToken(token);
|
||||||
// private-use scheme. This rejects `http://evil.example` DCR payloads
|
if (!info) {
|
||||||
// that today would otherwise be accepted since we previously only
|
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
|
||||||
// checked shape. Dangerous URL schemes (`javascript:`, `data:` etc.)
|
return res.status(401).json({ error: 'invalid_token' });
|
||||||
// are explicitly rejected — the authorize flow later 302s the
|
|
||||||
// browser to this URI, which with `javascript:` would execute
|
|
||||||
// attacker-controlled script under our redirect origin's context.
|
|
||||||
const DANGEROUS_SCHEMES = new Set([
|
|
||||||
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
|
|
||||||
]);
|
|
||||||
const allowed = redirectUris.every((u) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(u);
|
|
||||||
if (DANGEROUS_SCHEMES.has(url.protocol)) return false;
|
|
||||||
if (url.protocol === 'https:') return true;
|
|
||||||
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) return true;
|
|
||||||
// RFC 8252 §7.1 private-use scheme: must be a reverse-DNS name
|
|
||||||
// (e.g. `com.example.myapp:/callback`). Requiring a dot in the
|
|
||||||
// scheme is a cheap heuristic that rules out bare `myapp:` and
|
|
||||||
// `x:` one-off schemes the spec explicitly discourages.
|
|
||||||
const schemeBody = url.protocol.slice(0, -1);
|
|
||||||
if (/^[a-z][a-z0-9+.-]*$/i.test(schemeBody) && schemeBody.includes('.')) return true;
|
|
||||||
return false;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!allowed) {
|
|
||||||
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme' });
|
|
||||||
}
|
}
|
||||||
|
return res.json({
|
||||||
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
|
sub: String(info.user.id),
|
||||||
const clientName = rawName || 'MCP Client';
|
email: info.user.email,
|
||||||
|
email_verified: true,
|
||||||
// Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only
|
preferred_username: info.user.username,
|
||||||
const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post';
|
|
||||||
const isPublic = authMethod === 'none';
|
|
||||||
|
|
||||||
// Resolve requested scopes — scope is required; no implicit full-access grant
|
|
||||||
if (typeof body.scope !== 'string' || body.scope.trim() === '') {
|
|
||||||
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'scope is required' });
|
|
||||||
}
|
|
||||||
const rawScope = body.scope;
|
|
||||||
const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s));
|
|
||||||
if (requestedScopes.length === 0) {
|
|
||||||
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, {
|
|
||||||
isPublic,
|
|
||||||
createdVia: 'dcr',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error });
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = result.client!;
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
return res.status(201).json({
|
|
||||||
client_id: client.client_id,
|
|
||||||
...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}),
|
|
||||||
client_id_issued_at: now,
|
|
||||||
redirect_uris: client.redirect_uris,
|
|
||||||
grant_types: ['authorization_code', 'refresh_token'],
|
|
||||||
response_types: ['code'],
|
|
||||||
scope: (client.allowed_scopes as string[]).join(' '),
|
|
||||||
client_name: client.name,
|
|
||||||
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Token revocation endpoint (RFC 7009)
|
// Token revocation endpoint (RFC 7009)
|
||||||
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
|
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
|
||||||
// M2: return 404 when MCP is disabled
|
|
||||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||||
|
|
||||||
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
|
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
|
||||||
const { token, client_id, client_secret } = body;
|
const { token, client_id, client_secret } = body;
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
@@ -318,17 +214,17 @@ oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: R
|
|||||||
const userId = (req as OptionalAuthRequest).user?.id ?? null;
|
const userId = (req as OptionalAuthRequest).user?.id ?? null;
|
||||||
|
|
||||||
const result = validateAuthorizeRequest(
|
const result = validateAuthorizeRequest(
|
||||||
{
|
{
|
||||||
response_type: params.response_type || '',
|
response_type: params.response_type || '',
|
||||||
client_id: params.client_id || '',
|
client_id: params.client_id || '',
|
||||||
redirect_uri: params.redirect_uri || '',
|
redirect_uri: params.redirect_uri || '',
|
||||||
scope: params.scope || '',
|
scope: params.scope || '',
|
||||||
state: params.state,
|
state: params.state,
|
||||||
code_challenge: params.code_challenge || '',
|
code_challenge: params.code_challenge || '',
|
||||||
code_challenge_method: params.code_challenge_method || '',
|
code_challenge_method: params.code_challenge_method || '',
|
||||||
resource: typeof params.resource === 'string' ? params.resource : undefined,
|
resource: typeof params.resource === 'string' ? params.resource : undefined,
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
|
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
|
||||||
@@ -472,4 +368,4 @@ oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Re
|
|||||||
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
|
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
|
||||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||||
return res.json({ success: true });
|
return res.json({ success: true });
|
||||||
});
|
});
|
||||||
+1054
-1020
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -20,9 +20,17 @@
|
|||||||
// These paths manually redirect to the CJS dist until the SDK fixes its exports map.
|
// These paths manually redirect to the CJS dist until the SDK fixes its exports map.
|
||||||
"paths": {
|
"paths": {
|
||||||
"@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"],
|
"@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"],
|
||||||
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"]
|
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"],
|
||||||
|
"@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router"],
|
||||||
|
"@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize"],
|
||||||
|
"@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register"],
|
||||||
|
"@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider"],
|
||||||
|
"@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients"],
|
||||||
|
"@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors"],
|
||||||
|
"@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types"],
|
||||||
|
"@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user