fix(mcp): serve OAuth authorize on a dedicated host so the PWA can't capture it (#709)

## 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).
This commit is contained in:
Víctor Falcón 2026-07-21 13:51:03 +02:00 committed by GitHub
parent 13360ce0b8
commit 2041181dc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 34 additions and 1 deletions

View File

@ -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

View File

@ -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'),
];

View File

@ -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)