From 2041181dc2d09a9384b3bddbdfbc79022dfe5feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 21 Jul 2026 13:51:03 +0200 Subject: [PATCH] fix(mcp): serve OAuth authorize on a dedicated host so the PWA can't capture it (#709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Connecting the MCP connector from **ChatGPT on Android** fails. The installed Whisper Money PWA is a Chrome **WebAPK** that auto-verifies as an Android **App Link handler** for the whole app origin (manifest `scope: "/"`), so `https://whisper.money/oauth/authorize` gets routed **into the app**. Once inside the standalone app, the redirect back to the OAuth client can't complete → the connection fails. (Claude works because it opens OAuth in a Custom Tab.) Confirmed on an Android emulator: the WebAPK shows `AutoVerify=true`, `whisper.money: verified`. DB evidence: ChatGPT registers + reaches consent (auth codes issued) but never exchanges a token. ## Why not `handle_links` `handle_links: "not-preferred"` (tried in #707, reverted in #708) is **origin-wide** — it would push *every* `whisper.money` link (bank-auth callback, email verification, shared deep links) to the browser, not just `/oauth`. We want links to keep opening the installed app. ## Fix (surgical) Move the OAuth **authorization server** to a dedicated host outside the PWA scope. `config('mcp.authorization_server')` becomes env-driven (`MCP_AUTHORIZATION_SERVER`); in prod → `https://oauth.whisper.money` (DNS already points at the same app). Every endpoint derives from the request host (no forced root URL), so pointing the auth server at the subdomain makes `issuer` + `authorize`/`token`/`register` all resolve to `oauth.whisper.money` — **same origin as each other**, no cross-origin metadata mismatch. The protected resource (`/mcp/oauth`) and **all other app links stay on `whisper.money`**, so deep-linking into the app is fully preserved. Only the OAuth flow leaves the app — into the browser, where the round-trip completes. ## Activation (after merge + deploy) 1. Set `MCP_AUTHORIZATION_SERVER=https://oauth.whisper.money` in prod env, redeploy. 2. I'll curl the discovery chain to confirm it resolves to the subdomain. 3. Test the ChatGPT connect on a real phone. Safe until step 1: env unset → `authorization_server` stays `null` → current behavior. No effect on local/dev. ## Tests Added a Pest test: with `mcp.authorization_server` configured, protected-resource metadata advertises the dedicated host and auth-server metadata (fetched from that host) keeps `issuer` + all endpoints on it. App has no `TrustHosts` restriction (already serves the subdomain) and `SESSION_DOMAIN=null` (host-only cookies — subdomain gets its own session, no security downgrade). --- .env.example | 6 ++++++ config/mcp.php | 9 ++++++++- tests/Feature/Mcp/McpOAuthTest.php | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index b372bc48..c8646b9d 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,12 @@ APP_FAKER_LOCALE=en_US PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= +# OAuth authorization server host for the MCP OAuth server (RFC 8414 issuer). +# Set to a dedicated host OUTSIDE the PWA's manifest scope so mobile OAuth links +# (e.g. ChatGPT on Android) open in the browser instead of being captured by the +# installed PWA. Leave blank to use the app URL. Production: https://oauth.whisper.money +MCP_AUTHORIZATION_SERVER= + APP_MAINTENANCE_DRIVER=file # APP_MAINTENANCE_STORE=database diff --git a/config/mcp.php b/config/mcp.php index ca0052a0..c01770db 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -48,8 +48,15 @@ return [ | per RFC 8414. This value appears in your protected resource and auth | server metadata endpoints. When null, this defaults to `url('/')`. | + | Point this at a dedicated host (e.g. https://oauth.whisper.money) so the + | authorize/token/register endpoints live outside the PWA's manifest scope. + | The installed PWA is a verified App Link handler for the app origin, so an + | on-origin `/oauth/authorize` link (e.g. from ChatGPT on Android) gets + | captured into the app, where the redirect back to the client can't + | complete. A separate host keeps the OAuth flow in the browser instead. + | */ - 'authorization_server' => null, + 'authorization_server' => env('MCP_AUTHORIZATION_SERVER'), ]; diff --git a/tests/Feature/Mcp/McpOAuthTest.php b/tests/Feature/Mcp/McpOAuthTest.php index 4d632dc7..394db955 100644 --- a/tests/Feature/Mcp/McpOAuthTest.php +++ b/tests/Feature/Mcp/McpOAuthTest.php @@ -110,6 +110,26 @@ it('serves authorization server metadata advertising PKCE and the mcp:use scope' expect($json['registration_endpoint'])->toContain('oauth/register'); }); +it('runs the whole authorization server on the configured dedicated host', function () { + config()->set('mcp.authorization_server', 'https://oauth.whisper.money'); + + // Protected-resource metadata (served from the app origin) points clients at the + // dedicated host, while the protected resource itself stays on the app origin. + $resource = get('/.well-known/oauth-protected-resource/mcp/oauth')->assertOk()->json(); + expect($resource['authorization_servers'])->toBe(['https://oauth.whisper.money']); + expect($resource['resource'])->toEndWith('/mcp/oauth'); + + // Fetched from that host, every auth-server endpoint stays on it (same origin as + // the issuer), so the OAuth authorize link lives off the PWA origin and the + // installed app can't capture it. + $server = get('https://oauth.whisper.money/.well-known/oauth-authorization-server') + ->assertOk()->json(); + expect($server['issuer'])->toBe('https://oauth.whisper.money'); + expect($server['authorization_endpoint'])->toStartWith('https://oauth.whisper.money/'); + expect($server['token_endpoint'])->toStartWith('https://oauth.whisper.money/'); + expect($server['registration_endpoint'])->toStartWith('https://oauth.whisper.money/'); +}); + /* |-------------------------------------------------------------------------- | 401 bootstrap challenge (mandatory for Claude / ChatGPT)