Skip to content

v3.0.16 — bug fixes#964

Open
jubnl wants to merge 11 commits intomainfrom
v3-0-16
Open

v3.0.16 — bug fixes#964
jubnl wants to merge 11 commits intomainfrom
v3-0-16

Conversation

@jubnl
Copy link
Copy Markdown
Collaborator

@jubnl jubnl commented May 6, 2026

Description

A series of fixes making TREK's MCP OAuth 2.1 server fully compatible with ChatGPT and other strict MCP clients, plus a crash fix for ntfy notifications with non-Latin-1 characters, documentation for Cloudflare bot blocking, PWA resilience behind upstream auth proxies (Cloudflare Zero Trust, Pangolin), mobile layout fixes for the Files tab and the Budget tab.

MCP OAuth 2.1 overhaul (cbaf744)

Comprehensive refactor of the OAuth layer to comply with strict client implementations:

  • Extracted OAuth provider logic into a dedicated oauthProvider.ts module
  • Rewrote the consent page (OAuthAuthorizePage.tsx) and public OAuth routes (oauth.ts)
  • Renamed the SPA consent route from /oauth/authorize to /oauth/consent so the SDK's own authorization handler can own /oauth/authorize without the service worker intercepting it
  • Tightened PKCE validation, scope handling, and token exchange edge cases
  • Rewrote the OAuth integration test suite (oauth.test.ts) to cover the full RFC-compliant flows

Flat /.well-known/oauth-protected-resource for ChatGPT (6943244)

ChatGPT probes the flat well-known URL (/.well-known/oauth-protected-resource) on every fresh discovery cycle. The SDK only served the path-suffixed form (/.well-known/oauth-protected-resource/mcp), returning 404 on the flat probe. Without the resource metadata, ChatGPT fell back to the issuer URL as the resource parameter; the authorize handler rejected it with invalid_target, redirecting back to ChatGPT's callback with an error and showing the user the TREK home page instead of the consent form.

Added an explicit GET handler for the flat URL that returns the same protected resource metadata.

OAuth consent popup — SW denylist and COOP header (f089c55)

Two bugs were preventing the OAuth consent popup from working for cross-origin clients:

  • The service worker was intercepting /oauth/authorize navigation requests (not in its denylist), serving index.html, and React Router's catch-all redirected to / instead of the SDK's authorization handler
  • Helmet's default Cross-Origin-Opener-Policy: same-origin isolated the consent popup from its cross-origin opener (ChatGPT), making window.opener null and breaking the popup-based OAuth completion signal

Fixed by adding /oauth/ to the service worker denylist and setting COOP: unsafe-none on the /oauth/consent route.

ntfy RFC 2047 header encoding (7b2928a)

ntfy notification headers are transmitted as ByteString; non-Latin-1 characters (emoji, accented characters, non-ASCII trip names) caused a crash when building the notification request. Non-Latin-1 header values are now encoded with RFC 2047 (=?utf-8?B?…?=).

Cloudflare bot detection docs (48c0f97)

Added a troubleshooting entry and MCP-Setup callout documenting that Cloudflare's Bot Fight Mode blocks ChatGPT's MCP requests due to IP reputation and UA heuristics. Covers the free-plan workaround (disable Bot Fight Mode entirely, with an explicit warning about the security tradeoff) and a paid-plan WAF skip rule with the full Cloudflare expression syntax and a table of all affected paths (/mcp, /oauth/*, /.well-known/*).

PWA resilience behind upstream auth proxies — CF Zero Trust & Pangolin (3ee4da9)

When TREK is deployed behind Cloudflare Zero Trust or Pangolin, the PWA was silently getting stuck after the upstream auth cookie expired: XHR calls to /api/* were intercepted and returned a cross-origin redirect, which the browser CORS-blocked — axios surfaced this as a generic network error that no interceptor caught, so the app just showed "network error" toasts without ever triggering re-authentication.

  • New client/src/sync/connectivity.ts module — probes /api/health every 30 s using fetch with cache: no-store. Inspects the response's Content-Type to distinguish a reachable TREK server (JSON) from a proxy auth wall (HTML or CORS error). Exposes isReachable() / probeNow() / onChange().
  • axios interceptor — two new branches:
    • !error.response + navigator.onLine: runs probeNow(); if the health probe also fails (!isReachable()), both requests were blocked by the proxy → window.location.reload() so the edge proxy can intercept the top-level navigation and run its auth flow. Covers CF Access and standard Pangolin (302 mode).
    • error.response.status === 401 with Content-Type: text/html: covers Pangolin's header-auth extended compatibility mode, which returns a 401 + HTML redirect page instead of a 302. TREK's own 401 responses are always application/json, so there is no collision with the existing AUTH_REQUIRED branch.
    • A sessionStorage flag prevents reload loops; it is cleared on any successful API response so the guard resets after re-auth.
  • /api/health excluded from SW cache (vite.config.js NetworkFirst regex) and Cache-Control: no-store added server-side — ensures probes always hit the network and are never served stale from the 24 h api-data cache.
  • LoginPage appConfig localStorage cache — the SSO button was gated on a successful GET /api/auth/app-config. When the proxy intercepted that fetch, appConfig stayed null and the SSO button never rendered in OIDC + UN/PW dual mode (reported edge case: users had to uninstall and reinstall the PWA to get the button back). The response is now cached in localStorage and used as fallback; auto-redirect to the IdP is skipped when config comes from cache to avoid redirect loops.

Mobile Files tab — last file hidden under bottom navbar (a0c10e3)

The Files (dateien) tab wrapper in TripPlannerPage was missing the paddingBottom: 'var(--bottom-nav-h)' reservation that every other scrollable tab already applies. On mobile the 84 px sticky BottomNav was occluding the last file in the list. Added the padding to match the established pattern.

Mobile Budget tab — no way to add a category (0909abf)

The Budget toolbar (currency selector, category-name input + + button, CSV export) was wrapped in hidden md:flex, so on mobile only the "Budget" title rendered — users could not create budget categories on phones once at least one category already existed (the empty-state form was not gated, creating an asymmetry).

Removed the desktop-only gate and added max-md:!w-full overrides on the currency wrapper and category input/button wrapper so controls stack as full-width rows on narrow viewports. The CSV button collapses to icon-only below sm to avoid crowding.

Related Issue or Discussion

Closes #959
Addresses discussion #836

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have read the Contributing Guidelines
  • My branch is up to date with dev
  • This PR targets the dev branch, not main (wiki-only PRs are exempt)
  • I have tested my changes locally
  • I have added/updated tests that prove my fix is effective or that my feature works
  • I have updated documentation if needed

jubnl added 5 commits May 6, 2026 09:59
…T reconnect

Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.

Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.

Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.
Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.

Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.
…ByteString crash

Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
…uests

Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.
@jubnl jubnl changed the title fix(mcp): MCP RFC compliant for more strict clients fix(mcp): full OAuth 2.1 compliance for ChatGPT and strict MCP clients May 6, 2026
@jubnl jubnl changed the title fix(mcp): full OAuth 2.1 compliance for ChatGPT and strict MCP clients v3.0.16 — bug fixes May 6, 2026
jubnl added 6 commits May 6, 2026 12:16
Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.

New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.

axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
  probe also fails (proxy is intercepting all requests), trigger a guarded
  window.location.reload() so the edge proxy can intercept the top-level
  navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
  covering Pangolin header-auth extended compatibility mode which returns
  401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
  there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
  response so the guard resets after re-auth.

/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.

LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.

Fixes discussion #836.
…allenge

WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.

Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).
Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] ChatGPT MCP OAuth fails with path-based resource metadata and DCR scope requirement

1 participant