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)