Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a93ae2ffed | |||
| cf7a1bea4f | |||
| 69141fcacc | |||
| 0909abfa60 | |||
| a0c10e38f7 | |||
| 3ee4da9775 | |||
| 48c0f97ab9 | |||
| 7b2928a007 | |||
| f089c557e7 | |||
| 69432443b7 | |||
| cbaf744f0e |
@@ -3,6 +3,8 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
server/public/*
|
||||||
|
!server/public/.gitkeep
|
||||||
|
|
||||||
# Generated PWA icons (built from SVG via prebuild)
|
# Generated PWA icons (built from SVG via prebuild)
|
||||||
client/public/icons/*.png
|
client/public/icons/*.png
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
CLIENT_DIR="$REPO_ROOT/client"
|
||||||
|
SERVER_DIR="$REPO_ROOT/server"
|
||||||
|
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||||
|
|
||||||
|
echo "==> Installing client dependencies"
|
||||||
|
cd "$CLIENT_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Building client"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "==> Installing server dependencies"
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "==> Populating server/public"
|
||||||
|
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||||
|
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||||
|
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||||
|
|
||||||
|
echo "==> Done — server/public is ready"
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
import en from '../i18n/translations/en'
|
import en from '../i18n/translations/en'
|
||||||
import br from '../i18n/translations/br'
|
import br from '../i18n/translations/br'
|
||||||
import de from '../i18n/translations/de'
|
import de from '../i18n/translations/de'
|
||||||
@@ -33,6 +34,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',
|
||||||
},
|
},
|
||||||
@@ -68,10 +70,58 @@ export function isAuthPublicPath(pathname: string): boolean {
|
|||||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||||
|
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||||
|
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||||
|
async function unregisterSWAndReload(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker?.getRegistration()
|
||||||
|
if (reg) await reg.unregister()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
(error) => {
|
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async (error) => {
|
||||||
|
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||||
|
// as a CORS error with no response object. Probe the health endpoint to
|
||||||
|
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||||
|
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||||
|
if (!error.response && navigator.onLine) {
|
||||||
|
await probeNow()
|
||||||
|
// Both the original request and the health probe failed while the device
|
||||||
|
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||||
|
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||||
|
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||||
|
// down would also land here, but only reloads once).
|
||||||
|
if (!isReachable()) {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
|
await unregisterSWAndReload()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||||
|
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||||
|
// always application/json, so checking for text/html is unambiguous.
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||||
|
if (ct.includes('text/html')) {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
|
await unregisterSWAndReload()
|
||||||
|
return Promise.reject(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)) {
|
||||||
@@ -142,6 +192,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,6 +204,7 @@ 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: {
|
||||||
|
|||||||
@@ -719,8 +719,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
{t('budget.title')}
|
{t('budget.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||||
<div style={{ width: 150 }}>
|
<div className="max-md:!w-full" style={{ width: 150 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={setCurrency}
|
onChange={setCurrency}
|
||||||
@@ -730,7 +730,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ display: 'flex', gap: 6, width: 260 }}>
|
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}>
|
||||||
<input
|
<input
|
||||||
value={newCategoryName}
|
value={newCategoryName}
|
||||||
onChange={e => setNewCategoryName(e.target.value)}
|
onChange={e => setNewCategoryName(e.target.value)}
|
||||||
@@ -763,7 +763,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
>
|
>
|
||||||
<Download size={14} strokeWidth={2.5} /> CSV
|
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
|
||||||
|
startConnectivityProbe()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,13 +67,13 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 />);
|
||||||
|
|
||||||
|
|||||||
@@ -117,11 +117,25 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
|
||||||
|
authApi.getAppConfig?.()
|
||||||
|
.then((config: AppConfig) => {
|
||||||
|
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
|
||||||
|
return { config, fromCache: false }
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
|
||||||
|
return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false }
|
||||||
|
} catch { return { config: null as AppConfig | null, fromCache: false } }
|
||||||
|
})
|
||||||
|
.then(({ config, fromCache }) => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setAppConfig(config)
|
setAppConfig(config)
|
||||||
if (!config.has_users) setMode('register')
|
if (!config.has_users) setMode('register')
|
||||||
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
// Skip auto-redirect when config is from cache — network is unreliable
|
||||||
|
// and auto-redirecting to the IdP could loop if the proxy changed.
|
||||||
|
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||||
window.location.href = '/api/auth/oidc/login'
|
window.location.href = '/api/auth/oidc/login'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -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
|
||||||
@@ -124,7 +127,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1174,7 +1174,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'dateien' && (
|
{activeTab === 'dateien' && (
|
||||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files || []}
|
files={files || []}
|
||||||
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const PROBE_INTERVAL_MS = 30_000
|
||||||
|
const PROBE_TIMEOUT_MS = 1_500
|
||||||
|
|
||||||
|
let reachable = true
|
||||||
|
const listeners = new Set<(v: boolean) => void>()
|
||||||
|
|
||||||
|
function setReachable(v: boolean): void {
|
||||||
|
if (reachable === v) return
|
||||||
|
reachable = v
|
||||||
|
listeners.forEach(fn => fn(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(): Promise<void> {
|
||||||
|
if (!navigator.onLine) { setReachable(false); return }
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||||
|
const res = await fetch('/api/health', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(t)
|
||||||
|
// /api/health returns JSON. CF Access / Pangolin will either return HTML
|
||||||
|
// (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws
|
||||||
|
// below. Both proxy-auth scenarios resolve to reachable = false.
|
||||||
|
const ct = res.headers.get('content-type') || ''
|
||||||
|
setReachable(res.ok && ct.includes('application/json'))
|
||||||
|
} catch {
|
||||||
|
setReachable(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startConnectivityProbe(): void {
|
||||||
|
probe()
|
||||||
|
setInterval(probe, PROBE_INTERVAL_MS)
|
||||||
|
window.addEventListener('online', probe)
|
||||||
|
window.addEventListener('offline', () => setReachable(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReachable(): boolean { return reachable }
|
||||||
|
export function probeNow(): Promise<void> { return probe() }
|
||||||
|
export function onChange(fn: (v: boolean) => void): () => void {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => listeners.delete(fn)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
|
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/, /^\/oauth\//, /^\/.well-known\//],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
// Carto map tiles (default provider)
|
// Carto map tiles (default provider)
|
||||||
@@ -46,7 +46,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
// API calls — prefer network, fall back to cache
|
// API calls — prefer network, fall back to cache
|
||||||
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||||
urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i,
|
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||||
handler: 'NetworkFirst',
|
handler: 'NetworkFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'api-data',
|
cacheName: 'api-data',
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,15 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0%" stop-color="#1e293b"/>
|
|
||||||
<stop offset="100%" stop-color="#0f172a"/>
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id="icon">
|
|
||||||
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<rect width="512" height="512" fill="url(#bg)"/>
|
|
||||||
<g transform="translate(56,51) scale(0.267)">
|
|
||||||
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,33 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
||||||
<title>TREK</title>
|
|
||||||
|
|
||||||
<!-- PWA / iOS -->
|
|
||||||
<meta name="theme-color" content="#09090b" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="TREK" />
|
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
|
||||||
|
|
||||||
<!-- Fonts -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
|
||||||
|
|
||||||
<!-- Leaflet -->
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossorigin="" />
|
|
||||||
<script type="module" crossorigin src="/assets/index-BBkAKwut.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CR224PtB.css">
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1 +0,0 @@
|
|||||||
{"name":"TREK — Travel Planner","short_name":"TREK","description":"Travel Resource & Exploration Kit","start_url":"/","display":"standalone","background_color":"#0f172a","theme_color":"#111827","lang":"en","scope":"/","orientation":"any","categories":["travel","navigation"],"icons":[{"src":"icons/apple-touch-icon-180x180.png","sizes":"180x180","type":"image/png"},{"src":"icons/icon-192x192.png","sizes":"192x192","type":"image/png"},{"src":"icons/icon-512x512.png","sizes":"512x512","type":"image/png"},{"src":"icons/icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"},{"src":"icons/icon.svg","sizes":"any","type":"image/svg+xml"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,c)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let a={};const t=e=>i(e,o),r={module:{uri:o},exports:a,require:t};s[o]=Promise.all(n.map(e=>r[e]||t(e))).then(e=>(c(...e),a))}}define(["./workbox-58bd4dca"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"text-light.svg",revision:"8456421c45ccd1b881b1755949fb9891"},{url:"text-dark.svg",revision:"e86569d59169a1076a92a1d47cb94abf"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"logo-light.svg",revision:"e9a2e3363fed4298cb422332b8cb03e9"},{url:"logo-dark.svg",revision:"c7b85b3bdf9e73222bcd91f396b829b5"},{url:"index.html",revision:"9dc2d3ab2d0db984f9994195b762a404"},{url:"icons/icon.svg",revision:"8b49f04dc5ebfc2688777f548f6248a1"},{url:"icons/icon-white.svg",revision:"f437d171b083ee2463e3c44eb3785291"},{url:"icons/icon-dark.svg",revision:"cf48a00cd2b6393eb0c8ac67d821ec84"},{url:"icons/icon-512x512.png",revision:"e9813f28d172940286269b92c961bd9a"},{url:"icons/icon-192x192.png",revision:"4549ed2c430764d6eda6b12a326e6d58"},{url:"icons/apple-touch-icon-180x180.png",revision:"ba88094c86c61709a98adae54488508f"},{url:"fonts/Poppins-SemiBold.ttf",revision:"2c63e05091c7d89f6149c274971c7c23"},{url:"fonts/Poppins-Regular.ttf",revision:"09acac7457bdcf80af5cc3d1116208c5"},{url:"fonts/Poppins-Medium.ttf",revision:"20aaac2ef92cddeb0f12e67a443b0b9f"},{url:"fonts/Poppins-Italic.ttf",revision:"4a37e40ddcd3e0da0a1db26ce8704eff"},{url:"fonts/Poppins-Bold.ttf",revision:"92934d92f57e49fc6f61075c2aeb7689"},{url:"assets/index-CR224PtB.css",revision:null},{url:"assets/index-BBkAKwut.js",revision:null},{url:"icons/apple-touch-icon-180x180.png",revision:"ba88094c86c61709a98adae54488508f"},{url:"icons/icon-192x192.png",revision:"4549ed2c430764d6eda6b12a326e6d58"},{url:"icons/icon-512x512.png",revision:"e9813f28d172940286269b92c961bd9a"},{url:"icons/icon.svg",revision:"8b49f04dc5ebfc2688777f548f6248a1"},{url:"manifest.webmanifest",revision:"99e6d32e351da90e7659354c2dc39bfb"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html"),{denylist:[/^\/api/,/^\/uploads/,/^\/mcp/]})),e.registerRoute(/^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,new e.CacheFirst({cacheName:"map-tiles",plugins:[new e.ExpirationPlugin({maxEntries:1e3,maxAgeSeconds:2592e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,new e.CacheFirst({cacheName:"map-tiles",plugins:[new e.ExpirationPlugin({maxEntries:1e3,maxAgeSeconds:2592e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/unpkg\.com\/.*/i,new e.CacheFirst({cacheName:"cdn-libs",plugins:[new e.ExpirationPlugin({maxEntries:30,maxAgeSeconds:31536e3}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/\/api\/(?!auth|admin|backup|settings).*/i,new e.NetworkFirst({cacheName:"api-data",networkTimeoutSeconds:5,plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:86400}),new e.CacheableResponsePlugin({statuses:[200]})]}),"GET"),e.registerRoute(/\/uploads\/(?:covers|avatars)\/.*/i,new e.CacheFirst({cacheName:"user-uploads",plugins:[new e.ExpirationPlugin({maxEntries:300,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[200]})]}),"GET")});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="c5c1a398e1"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="9b226024c5"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#c5c1a398e1)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#9b226024c5)"><g fill="#000000" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="7fc4e3f80b"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="086ce69399"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#7fc4e3f80b)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#086ce69399)"><g fill="#ffffff" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -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();
|
||||||
@@ -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({
|
||||||
@@ -252,7 +276,10 @@ export function createApp(): express.Application {
|
|||||||
app.use('/api/trips/:tripId/collab', collabRoutes);
|
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||||
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
|
app.get('/api/health', (_req: Request, res: Response) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store, must-revalidate')
|
||||||
|
res.json({ status: 'ok' })
|
||||||
|
});
|
||||||
app.use('/api/config', publicConfigRoutes);
|
app.use('/api/config', publicConfigRoutes);
|
||||||
app.use('/api', assignmentsRoutes);
|
app.use('/api', assignmentsRoutes);
|
||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
@@ -340,16 +367,126 @@ 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`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// RFC 9728 flat well-known URL — served alongside the path-based form the SDK already provides.
|
||||||
|
// Clients like ChatGPT probe /.well-known/oauth-protected-resource (no path suffix) on every
|
||||||
|
// fresh discovery. Without this, they get 404, fall back to the issuer URL as the resource
|
||||||
|
// parameter, and the authorize handler rejects them with invalid_target — showing the user
|
||||||
|
// the TREK home page instead of the consent form.
|
||||||
|
app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
|
||||||
|
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||||
|
const meta = getOAuthMetadata();
|
||||||
|
res.json({
|
||||||
|
resource: `${meta.issuer}/mcp`,
|
||||||
|
authorization_servers: [meta.issuer],
|
||||||
|
bearer_methods_supported: ['header'],
|
||||||
|
scopes_supported: ALL_SCOPES,
|
||||||
|
resource_name: 'TREK MCP',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helmet's COOP: same-origin isolates the consent popup from its cross-origin opener (ChatGPT etc.), making window.opener null and breaking the OAuth flow.
|
||||||
|
app.use('/oauth/consent', (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none');
|
||||||
|
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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
});
|
return res.json({
|
||||||
if (!allowed) {
|
sub: String(info.user.id),
|
||||||
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme' });
|
email: info.user.email,
|
||||||
}
|
email_verified: true,
|
||||||
|
preferred_username: info.user.username,
|
||||||
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
|
|
||||||
const clientName = rawName || 'MCP Client';
|
|
||||||
|
|
||||||
// Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only
|
|
||||||
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);
|
||||||
|
|||||||
@@ -621,6 +621,15 @@ export function isNtfyConfiguredAdmin(): boolean {
|
|||||||
return !!(getAppSetting('admin_ntfy_topic'));
|
return !!(getAppSetting('admin_ntfy_topic'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeHeaderValue(value: string): string {
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
if (value.charCodeAt(i) > 0xFF) {
|
||||||
|
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendNtfy(
|
export async function sendNtfy(
|
||||||
url: string,
|
url: string,
|
||||||
token: string | null,
|
token: string | null,
|
||||||
@@ -638,11 +647,11 @@ export async function sendNtfy(
|
|||||||
|
|
||||||
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
|
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Title': payload.title,
|
'Title': encodeHeaderValue(payload.title),
|
||||||
'Priority': String(meta.priority),
|
'Priority': String(meta.priority),
|
||||||
};
|
};
|
||||||
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
|
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
|
||||||
if (payload.link) headers['Click'] = payload.link;
|
if (payload.link) headers['Click'] = encodeHeaderValue(payload.link);
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -103,12 +103,48 @@ describe('GET /.well-known/oauth-authorization-server', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Issue #959 regression tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('RFC 9728 — path-based protected resource metadata (issue #959 bug 1)', () => {
|
||||||
|
it('OAUTH-959A — /.well-known/oauth-protected-resource/mcp returns JSON (not SPA HTML)', async () => {
|
||||||
|
const res = await request(app).get('/.well-known/oauth-protected-resource/mcp');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toMatch(/json/);
|
||||||
|
expect(res.body.resource).toContain('/mcp');
|
||||||
|
expect(Array.isArray(res.body.authorization_servers)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () => {
|
||||||
|
it('OAUTH-959B — POST /oauth/register without scope field returns 201 with default scopes', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/oauth/register')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ redirect_uris: ['https://chatgpt.example.com/cb'], token_endpoint_auth_method: 'none' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.client_id).toBeDefined();
|
||||||
|
expect(typeof res.body.scope).toBe('string');
|
||||||
|
expect(res.body.scope.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OAUTH-959C — POST /oauth/register with explicit scope registers only requested scopes', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/oauth/register')
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send({ redirect_uris: ['https://example.com/cb'], token_endpoint_auth_method: 'none', scope: 'trips:read' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.scope).toBe('trips:read');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// POST /oauth/token — authorization_code grant
|
// POST /oauth/token — authorization_code grant
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('POST /oauth/token — authorization_code grant', () => {
|
describe('POST /oauth/token — authorization_code grant', () => {
|
||||||
it('OAUTH-002 — missing client_id/client_secret returns 401 invalid_client', async () => {
|
it('OAUTH-002 — missing client_id returns 401 invalid_client', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/oauth/token')
|
.post('/oauth/token')
|
||||||
.send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' });
|
.send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' });
|
||||||
@@ -116,13 +152,12 @@ describe('POST /oauth/token — authorization_code grant', () => {
|
|||||||
expect(res.body.error).toBe('invalid_client');
|
expect(res.body.error).toBe('invalid_client');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('OAUTH-003 — MCP addon disabled returns 403 mcp_disabled', async () => {
|
it('OAUTH-003 — MCP addon disabled returns 404', async () => {
|
||||||
isAddonEnabledMock.mockReturnValue(false);
|
isAddonEnabledMock.mockReturnValue(false);
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/oauth/token')
|
.post('/oauth/token')
|
||||||
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
|
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(404);
|
||||||
expect(res.body.error).toBe('mcp_disabled');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => {
|
it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => {
|
||||||
@@ -211,7 +246,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
|
|||||||
expect(res.body.error).toBe('invalid_grant');
|
expect(res.body.error).toBe('invalid_grant');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('OAUTH-008 — wrong client_secret returns 401 invalid_client', async () => {
|
it('OAUTH-008 — wrong client_secret returns 401 invalid_client (timing-safe check)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
|
||||||
const { verifier, challenge } = makePkce();
|
const { verifier, challenge } = makePkce();
|
||||||
@@ -909,7 +944,6 @@ describe('M1 — Cache-Control headers on /oauth/token', () => {
|
|||||||
.post('/oauth/token')
|
.post('/oauth/token')
|
||||||
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
|
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
|
||||||
expect(res.headers['cache-control']).toBe('no-store');
|
expect(res.headers['cache-control']).toBe('no-store');
|
||||||
expect(res.headers['pragma']).toBe('no-cache');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -455,4 +455,27 @@ describe('sendNtfy', () => {
|
|||||||
expect(calledOpts.headers['Priority']).toBe('3');
|
expect(calledOpts.headers['Priority']).toBe('3');
|
||||||
expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header
|
expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('NTFY-009 — title with non-Latin-1 chars is RFC 2047 encoded', async () => {
|
||||||
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||||
|
|
||||||
|
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Buy →€ ticket' });
|
||||||
|
|
||||||
|
const [, calledOpts] = mockFetch.mock.calls[0];
|
||||||
|
const encoded = calledOpts.headers['Title'] as string;
|
||||||
|
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
|
||||||
|
const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, '');
|
||||||
|
expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Buy →€ ticket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NTFY-010 — ASCII-only title is passed through verbatim', async () => {
|
||||||
|
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
|
||||||
|
|
||||||
|
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Simple ASCII title' });
|
||||||
|
|
||||||
|
const [, calledOpts] = mockFetch.mock.calls[0];
|
||||||
|
expect(calledOpts.headers['Title']).toBe('Simple ASCII title');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,15 @@
|
|||||||
// 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"],
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ This page explains how to connect an AI assistant to your TREK instance. TREK su
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
> **Cloudflare users:** If your TREK instance is proxied through Cloudflare, Bot Fight Mode and Super Bot Fight Mode will block MCP requests from ChatGPT. Claude.ai is not affected. See [Troubleshooting → MCP requests blocked by Cloudflare WAF](#mcp-requests-blocked-by-cloudflare-waf-bot-fight-mode) for the fix.
|
||||||
|
|
||||||
## Option A: OAuth 2.1 (recommended)
|
## Option A: OAuth 2.1 (recommended)
|
||||||
|
|
||||||
OAuth 2.1 is the preferred connection method. You grant specific scopes during the consent step and no token management is required afterward — TREK issues short-lived access tokens and automatically rotates refresh tokens.
|
OAuth 2.1 is the preferred connection method. You grant specific scopes during the consent step and no token management is required afterward — TREK issues short-lived access tokens and automatically rotates refresh tokens.
|
||||||
|
|||||||
@@ -251,3 +251,55 @@ environment:
|
|||||||
- MCP_RATE_LIMIT=600 # requests per minute per user (default: 300)
|
- MCP_RATE_LIMIT=600 # requests per minute per user (default: 300)
|
||||||
- MCP_MAX_SESSION_PER_USER=50 # concurrent sessions per user (default: 20)
|
- MCP_MAX_SESSION_PER_USER=50 # concurrent sessions per user (default: 20)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP requests blocked by Cloudflare WAF (Bot Fight Mode)
|
||||||
|
|
||||||
|
**Cause:** When TREK is proxied through Cloudflare, **Bot Fight Mode** and **Super Bot Fight Mode** classify requests from ChatGPT as bots and block them at the WAF level — before the request ever reaches TREK. This is specific to ChatGPT; Claude.ai is not affected. ChatGPT's exit node IPs have low reputation scores in Cloudflare's threat intelligence and the User-Agent matches Cloudflare's automated-traffic heuristics. TREK itself never receives the request, so there is nothing in TREK's logs; the block is silent from TREK's perspective.
|
||||||
|
|
||||||
|
Symptoms:
|
||||||
|
- ChatGPT shows a connection error or times out immediately after OAuth completes.
|
||||||
|
- Cloudflare's Security → Events log shows blocked requests to `/mcp` with action `block` and source `bfm` (Bot Fight Mode) or `managed_rule`.
|
||||||
|
|
||||||
|
**Fix — Option 1: Disable Bot Fight Mode (free plan and paid plan)**
|
||||||
|
|
||||||
|
In the Cloudflare dashboard for your zone: **Security → Bots → Bot Fight Mode → Off** (or Super Bot Fight Mode → Off).
|
||||||
|
|
||||||
|
This is the only option available on the **free plan**. It disables bot blocking for the entire zone — all probe bots, scrapers, and crawlers that Cloudflare would otherwise block will reach your server. Only use this if you have no alternative.
|
||||||
|
|
||||||
|
**Fix — Option 2: WAF skip rule for MCP paths (paid plan only)**
|
||||||
|
|
||||||
|
> WAF custom rules require a **paid Cloudflare plan** (Pro or above). This option is not available on the free plan.
|
||||||
|
|
||||||
|
Create a WAF skip rule that bypasses bot management only for the MCP and OAuth paths, leaving protection in place for the rest of the site:
|
||||||
|
|
||||||
|
1. Go to **Security → WAF → Custom rules** and click **Create rule**.
|
||||||
|
2. Enter the following expression (replace `trek.example.com` with your domain):
|
||||||
|
|
||||||
|
```
|
||||||
|
(http.host eq "trek.example.com") and (
|
||||||
|
http.request.uri.path eq "/mcp" or
|
||||||
|
http.request.uri.path starts_with "/oauth/" or
|
||||||
|
http.request.uri.path starts_with "/.well-known/"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
This covers all paths that ChatGPT's servers hit during discovery, OAuth, and MCP calls:
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `/mcp` | MCP endpoint (GET, POST, DELETE) |
|
||||||
|
| `/oauth/authorize` | OAuth authorization handler |
|
||||||
|
| `/oauth/register` | Dynamic client registration |
|
||||||
|
| `/oauth/token` | Token issuance |
|
||||||
|
| `/oauth/userinfo` | User info (for domain claiming) |
|
||||||
|
| `/oauth/revoke` | Token revocation |
|
||||||
|
| `/.well-known/oauth-authorization-server` | RFC 8414 AS metadata |
|
||||||
|
| `/.well-known/oauth-protected-resource` | RFC 9728 flat resource metadata |
|
||||||
|
| `/.well-known/openid-configuration` | OIDC discovery |
|
||||||
|
|
||||||
|
3. Set the action to **Skip** and check **Bot Fight Mode** (and/or **Super Bot Fight Mode**) under the skip options.
|
||||||
|
4. Save and deploy.
|
||||||
|
|
||||||
|
This allows MCP and OAuth traffic through while keeping Cloudflare bot protection active for all other paths.
|
||||||
|
|||||||