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:
parent
13360ce0b8
commit
2041181dc2
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue