diff --git a/.env.example b/.env.example index 6291949f..b372bc48 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US +# Passport OAuth signing keys for the MCP OAuth server. Leave blank to use the +# keys generated by `php artisan passport:keys` under storage/. Set both (PEM +# contents) to share one key pair across multiple app instances. +PASSPORT_PRIVATE_KEY= +PASSPORT_PUBLIC_KEY= + APP_MAINTENANCE_DRIVER=file # APP_MAINTENANCE_STORE=database diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ecb5c39..398d0f9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,9 @@ jobs: - name: Generate Application Key run: php artisan key:generate + - name: Generate Passport Keys + run: php artisan passport:keys --force + - name: Tests # --parallel splits the suite across workers via paratest. Each # worker creates its own "testing_test_" database; the mysql @@ -144,6 +147,9 @@ jobs: - name: Generate Application Key run: php artisan key:generate + - name: Generate Passport Keys + run: php artisan passport:keys --force + - name: Browser Tests run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/6 env: diff --git a/app/Http/Controllers/Settings/McpTokenController.php b/app/Http/Controllers/Settings/McpTokenController.php index 642632a1..c3edd26b 100644 --- a/app/Http/Controllers/Settings/McpTokenController.php +++ b/app/Http/Controllers/Settings/McpTokenController.php @@ -41,6 +41,7 @@ class McpTokenController extends Controller implements HasMiddleware return Inertia::render('settings/mcp', [ 'tokens' => $this->tokensFor($request), 'serverUrl' => url('/mcp'), + 'oauthUrl' => url('/mcp/oauth'), 'subscribeUrl' => route('subscribe'), 'newToken' => $request->session()->get('mcp_token'), ]); diff --git a/app/Mcp/Servers/WhisperMoneyServer.php b/app/Mcp/Servers/WhisperMoneyServer.php index 44f038bb..27f594ab 100644 --- a/app/Mcp/Servers/WhisperMoneyServer.php +++ b/app/Mcp/Servers/WhisperMoneyServer.php @@ -35,8 +35,8 @@ use Laravel\Mcp\Server\Tool; #[Version('1.0.0')] #[Instructions(<<<'MARKDOWN' Access to the authenticated user's Whisper Money finance data, for analysing -spending, cashflow and net worth — and, with a read & write token, for editing -that data. +spending, cashflow and net worth — and, with write access, for editing that +data. - All amounts are integers in minor units (cents). Divide by 100 for a display value. - Data is organised into "spaces" (the personal space and any shared spaces). diff --git a/app/Mcp/Tools/WriteTool.php b/app/Mcp/Tools/WriteTool.php index ab7b5057..b9bddee6 100644 --- a/app/Mcp/Tools/WriteTool.php +++ b/app/Mcp/Tools/WriteTool.php @@ -10,14 +10,16 @@ use App\Models\Space; use App\Models\Transaction; use App\Models\User; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Auth; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; /** * Base for every Whisper Money write tool. On top of the McpTool Pro-plan gate - * it requires the calling token to carry the `mcp:write` ability, so a - * read-only token can analyse data but never change it. + * it gates write access: OAuth connections (Claude Desktop / ChatGPT) get + * read+write, and Sanctum personal access tokens must carry the `mcp:write` + * ability, so a read-only PAT can analyse data but never change it. * * Each concrete write tool must additionally carry the #[IsDestructive] * annotation. PHP attributes are not inherited, so the framework only reports @@ -27,7 +29,13 @@ abstract class WriteTool extends McpTool { protected function respond(Request $request, User $user): Response { - if (! $user->tokenCan('mcp:write')) { + // Write access is granted to OAuth connections (Claude Desktop / + // ChatGPT, resolved via the `api` guard — the user approves the + // connection on the consent screen) and to Sanctum personal access + // tokens carrying the mcp:write ability. A read-only Sanctum token is + // rejected. Bank-connected data stays protected for both (see the + // writableAccount / transaction helpers below). + if (Auth::getDefaultDriver() !== 'api' && ! $user->tokenCan('mcp:write')) { return Response::error('This token is read-only. Create a read & write token to make changes.'); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9de64c83..a454f315 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Laravel\Cashier\Cashier; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; +use Laravel\Passport\Passport; class AppServiceProvider extends ServiceProvider { @@ -55,5 +56,9 @@ class AppServiceProvider extends ServiceProvider RateLimiter::for('emails', function (object $job): Limit { return Limit::perSecond(30); }); + + // Render the OAuth consent screen (Claude Desktop / ChatGPT connecting + // to the MCP server) with our own on-brand Blade view. + Passport::authorizationView(fn (array $parameters) => response()->view('mcp.authorize', $parameters)); } } diff --git a/composer.json b/composer.json index 75341194..f11a1ce9 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "laravel/fortify": "^1.30", "laravel/framework": "^13.0", "laravel/mcp": "^0.6.7", + "laravel/passport": "^13.0", "laravel/pennant": "^1.18", "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0", diff --git a/composer.lock b/composer.lock index c6fb4071..9d938126 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "196f117744cd19cbd40b8b0fb6d2cdc2", + "content-hash": "effcd211b8b23d89abfefa7550a43d7d", "packages": [ { "name": "aws/aws-crt-php", @@ -391,6 +391,73 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -2208,6 +2275,81 @@ }, "time": "2026-04-15T08:30:42+00:00" }, + { + "name": "laravel/passport", + "version": "v13.7.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/passport.git", + "reference": "90053dc4ba681c076855779250109bb624f961f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passport/zipball/90053dc4ba681c076855779250109bb624f961f6", + "reference": "90053dc4ba681c076855779250109bb624f961f6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "firebase/php-jwt": "^6.4|^7.0", + "illuminate/auth": "^11.35|^12.0|^13.0", + "illuminate/console": "^11.35|^12.0|^13.0", + "illuminate/container": "^11.35|^12.0|^13.0", + "illuminate/contracts": "^11.35|^12.0|^13.0", + "illuminate/cookie": "^11.35|^12.0|^13.0", + "illuminate/database": "^11.35|^12.0|^13.0", + "illuminate/encryption": "^11.35|^12.0|^13.0", + "illuminate/http": "^11.35|^12.0|^13.0", + "illuminate/support": "^11.35|^12.0|^13.0", + "league/oauth2-server": "^9.2", + "php": "^8.2", + "php-http/discovery": "^1.20", + "phpseclib/phpseclib": "^3.0", + "psr/http-factory-implementation": "*", + "symfony/console": "^7.1|^8.0", + "symfony/psr-http-message-bridge": "^7.1|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passport\\PassportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passport\\": "src/", + "Laravel\\Passport\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Passport provides OAuth2 server support to Laravel.", + "keywords": [ + "laravel", + "oauth", + "passport" + ], + "support": { + "issues": "https://github.com/laravel/passport/issues", + "source": "https://github.com/laravel/passport" + }, + "time": "2026-04-16T14:00:29+00:00" + }, { "name": "laravel/pennant", "version": "v1.22.0", @@ -2599,6 +2741,79 @@ }, "time": "2026-02-24T12:51:07+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", "version": "2.8.2", @@ -2788,6 +3003,65 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, { "name": "league/flysystem", "version": "3.32.0", @@ -2976,6 +3250,103 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth2-server", + "version": "9.4.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c", + "reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.4", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/jwt": "^5.6", + "league/event": "^3.0", + "league/uri": "^7.8", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.8", + "paragonie/random_compat": "^9.99.100", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.50", + "roave/security-advisories": "dev-master", + "slevomat/coding-standard": "^8.27.1", + "squizlabs/php_codesniffer": "^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.4.1" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2026-06-25T15:24:07+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -3972,6 +4343,135 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -4047,6 +4547,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.55", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", + "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-06-14T23:24:10+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.2", @@ -4457,6 +5067,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -11939,85 +12662,6 @@ }, "time": "2025-11-29T19:12:34+00:00" }, - { - "name": "php-http/discovery", - "version": "1.20.0", - "source": { - "type": "git", - "url": "https://github.com/php-http/discovery.git", - "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", - "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.0|^2.0", - "php": "^7.1 || ^8.0" - }, - "conflict": { - "nyholm/psr7": "<1.0", - "zendframework/zend-diactoros": "*" - }, - "provide": { - "php-http/async-client-implementation": "*", - "php-http/client-implementation": "*", - "psr/http-client-implementation": "*", - "psr/http-factory-implementation": "*", - "psr/http-message-implementation": "*" - }, - "require-dev": { - "composer/composer": "^1.0.2|^2.0", - "graham-campbell/phpspec-skip-example-extension": "^5.0", - "php-http/httplug": "^1.0 || ^2.0", - "php-http/message-factory": "^1.0", - "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", - "sebastian/comparator": "^3.0.5 || ^4.0.8", - "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" - }, - "type": "composer-plugin", - "extra": { - "class": "Http\\Discovery\\Composer\\Plugin", - "plugin-optional": true - }, - "autoload": { - "psr-4": { - "Http\\Discovery\\": "src/" - }, - "exclude-from-classmap": [ - "src/Composer/Plugin.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com" - } - ], - "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", - "homepage": "http://php-http.org", - "keywords": [ - "adapter", - "client", - "discovery", - "factory", - "http", - "message", - "psr17", - "psr7" - ], - "support": { - "issues": "https://github.com/php-http/discovery/issues", - "source": "https://github.com/php-http/discovery/tree/1.20.0" - }, - "time": "2024-10-02T11:20:13+00:00" - }, { "name": "php-http/httplug", "version": "2.4.1", diff --git a/config/auth.php b/config/auth.php index d7568ff1..24d83118 100644 --- a/config/auth.php +++ b/config/auth.php @@ -42,6 +42,11 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + + 'api' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], ], /* diff --git a/config/mcp.php b/config/mcp.php new file mode 100644 index 00000000..ca0052a0 --- /dev/null +++ b/config/mcp.php @@ -0,0 +1,55 @@ + [ + // Anthropic's hosted callback for Claude Desktop / Claude web connectors. + 'https://claude.ai', + // OpenAI's hosted callback for ChatGPT connectors. + 'https://chatgpt.com', + ], + + /* + |-------------------------------------------------------------------------- + | Allowed Custom Schemes + |-------------------------------------------------------------------------- + | + | Native desktop OAuth clients like Cursor and VS Code use private-use URI + | schemes (RFC 8252) for redirect callbacks instead of standard schemes + | like HTTPS. Here, you may list which custom schemes you will allow. + | + */ + + 'custom_schemes' => [ + // 'claude', + // 'cursor', + // 'vscode', + ], + + /* + |-------------------------------------------------------------------------- + | Authorization Server + |-------------------------------------------------------------------------- + | + | Here you may configure the OAuth authorization server issuer identifier + | per RFC 8414. This value appears in your protected resource and auth + | server metadata endpoints. When null, this defaults to `url('/')`. + | + */ + + 'authorization_server' => null, + +]; diff --git a/config/passport.php b/config/passport.php new file mode 100644 index 00000000..aed43589 --- /dev/null +++ b/config/passport.php @@ -0,0 +1,48 @@ + 'web', + + 'middleware' => [], + + /* + |-------------------------------------------------------------------------- + | Encryption Keys + |-------------------------------------------------------------------------- + | + | Passport uses encryption keys while generating secure access tokens for + | your application. By default, the keys are stored as local files but + | can be set via environment variables when that is more convenient. + | + */ + + 'private_key' => env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passport Database Connection + |-------------------------------------------------------------------------- + | + | By default, Passport's models will utilize your application's default + | database connection. If you wish to use a different connection you + | may specify the configured name of the database connection here. + | + */ + + 'connection' => env('PASSPORT_CONNECTION'), + +]; diff --git a/database/migrations/2026_07_17_154027_create_oauth_auth_codes_table.php b/database/migrations/2026_07_17_154027_create_oauth_auth_codes_table.php new file mode 100644 index 00000000..8eefa44d --- /dev/null +++ b/database/migrations/2026_07_17_154027_create_oauth_auth_codes_table.php @@ -0,0 +1,39 @@ +char('id', 80)->primary(); + $table->foreignUuid('user_id')->index(); + $table->foreignUuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_07_17_154028_create_oauth_access_tokens_table.php b/database/migrations/2026_07_17_154028_create_oauth_access_tokens_table.php new file mode 100644 index 00000000..38cdd405 --- /dev/null +++ b/database/migrations/2026_07_17_154028_create_oauth_access_tokens_table.php @@ -0,0 +1,41 @@ +char('id', 80)->primary(); + $table->foreignUuid('user_id')->nullable()->index(); + $table->foreignUuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_07_17_154029_create_oauth_refresh_tokens_table.php b/database/migrations/2026_07_17_154029_create_oauth_refresh_tokens_table.php new file mode 100644 index 00000000..afb3c55c --- /dev/null +++ b/database/migrations/2026_07_17_154029_create_oauth_refresh_tokens_table.php @@ -0,0 +1,37 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_07_17_154030_create_oauth_clients_table.php b/database/migrations/2026_07_17_154030_create_oauth_clients_table.php new file mode 100644 index 00000000..e07d7910 --- /dev/null +++ b/database/migrations/2026_07_17_154030_create_oauth_clients_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->nullableUuidMorphs('owner'); + $table->string('name'); + $table->string('secret')->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect_uris'); + $table->text('grant_types'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2026_07_17_154031_create_oauth_device_codes_table.php b/database/migrations/2026_07_17_154031_create_oauth_device_codes_table.php new file mode 100644 index 00000000..236c4694 --- /dev/null +++ b/database/migrations/2026_07_17_154031_create_oauth_device_codes_table.php @@ -0,0 +1,42 @@ +char('id', 80)->primary(); + $table->foreignUuid('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e1914625..25f234c6 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -82,6 +82,16 @@ echo "MySQL is ready!" echo "Running migrations..." php artisan migrate --force +# Ensure Passport signing keys exist (OAuth for MCP connectors). Keys provided +# via PASSPORT_PRIVATE_KEY/PASSPORT_PUBLIC_KEY env take precedence; otherwise +# generate them into storage/ (a persisted volume) so issued tokens survive +# restarts. NOTE: for a multi-instance deployment, provide the keys via env so +# every instance validates tokens with the same key. +if [ -z "$PASSPORT_PRIVATE_KEY" ] && [ ! -f /app/storage/oauth-private.key ]; then + echo "Generating Passport keys..." + php artisan passport:keys --force +fi + # Cache configuration echo "Caching configuration..." php artisan config:cache diff --git a/lang/es.json b/lang/es.json index a61252f1..63c2215f 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2270,8 +2270,19 @@ "Rotate a token if it leaks, or revoke it to cut off access.": "Rota un token si se filtra, o revócalo para cortar el acceso.", "Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.": "Al rotar obtienes un secreto nuevo y se cancela el anterior. Lo que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo secreto.", "Anything using this token loses access right away, and you cannot undo it.": "Cualquier cosa que use este token pierde el acceso al instante, y no se puede deshacer.", - "Here is your connection URL. Use it with a token you created above.": "Esta es tu URL de conexión. Úsala con un token que hayas creado arriba.", + "Connect your AI assistant using the details for it below.": "Conecta tu asistente de IA usando los datos correspondientes de abajo.", "Run this, using one of your tokens in place of .": "Ejecuta esto, usando uno de tus tokens en lugar de .", "Claude Desktop & ChatGPT": "Claude Desktop y ChatGPT", - "Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.": "Muy pronto. Estas apps inician sesión con OAuth, que todavía estamos construyendo, así que de momento un token no funciona con ellas. Usa Claude Code por ahora." + "These apps sign in instead of using a token. Add a custom connector pointing at the URL below, then approve the connection on the Whisper Money screen that opens. No token needed.": "Estas apps inician sesión en lugar de usar un token. Añade un conector personalizado que apunte a la URL de abajo y luego aprueba la conexión en la pantalla de Whisper Money que se abre. No hace falta ningún token.", + "Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only). You approve the connection on the Whisper Money screen.": "Las apps conectadas pueden leer, analizar y modificar tus datos (las cuentas conectadas a un banco siguen siendo de solo lectura). Tú apruebas la conexión en la pantalla de Whisper Money.", + "Connect to :app": "Conectar con :app", + "Connect :client to :app": "Conectar :client con :app", + ":client is asking to read and make changes to your finance data.": ":client solicita leer y modificar tus datos financieros.", + "Signed in as": "Sesión iniciada como", + "Sends you back to": "Te devuelve a", + "This connection can:": "Esta conexión puede:", + "Read and analyse your transactions, balances, categories and spending.": "Leer y analizar tus transacciones, saldos, categorías y gastos.", + "Create, edit and delete transactions, categories, labels and automation rules.": "Crear, editar y eliminar transacciones, categorías, etiquetas y reglas de automatización.", + "Bank-connected accounts and their transactions stay read-only. You can disconnect it at any time from the connected app.": "Las cuentas conectadas a un banco y sus transacciones siguen siendo de solo lectura. Puedes desconectarla cuando quieras desde la app conectada.", + "Connecting…": "Conectando…" } diff --git a/resources/js/pages/settings/mcp.tsx b/resources/js/pages/settings/mcp.tsx index fe7f786e..7b91dfa7 100644 --- a/resources/js/pages/settings/mcp.tsx +++ b/resources/js/pages/settings/mcp.tsx @@ -56,6 +56,7 @@ interface TokenRow { interface McpPageProps { tokens: TokenRow[]; serverUrl: string; + oauthUrl: string; subscribeUrl: string; newToken: string | null; } @@ -69,9 +70,8 @@ function formatDate(value: string | null): string { } export default function Mcp() { - const { tokens, serverUrl, subscribeUrl, newToken, auth } = usePage< - SharedData & McpPageProps - >().props; + const { tokens, serverUrl, oauthUrl, subscribeUrl, newToken, auth } = + usePage().props; const [, copy] = useClipboard(); const form = useForm<{ name: string; scope: 'read' | 'read_write' }>({ @@ -402,24 +402,39 @@ export default function Mcp() { {__('How to connect')} {__( - 'Here is your connection URL. Use it with a token you created above.', + 'Connect your AI assistant using the details for it below.', )} -
- - {serverUrl} - - +
+

+ {__('Claude Desktop & ChatGPT')} +

+

+ {__( + 'These apps sign in instead of using a token. Add a custom connector pointing at the URL below, then approve the connection on the Whisper Money screen that opens. No token needed.', + )} +

+
+ + {oauthUrl} + + +
+

+ {__( + 'Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only). You approve the connection on the Whisper Money screen.', + )} +

@@ -435,17 +450,6 @@ export default function Mcp() { {`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer "`}
- -
-

- {__('Claude Desktop & ChatGPT')} -

-

- {__( - 'Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.', - )} -

-
diff --git a/resources/views/mcp/authorize.blade.php b/resources/views/mcp/authorize.blade.php new file mode 100644 index 00000000..fa8f6cfe --- /dev/null +++ b/resources/views/mcp/authorize.blade.php @@ -0,0 +1,165 @@ + + ($appearance ?? 'system') == 'dark'])> + + + + + {{-- Inline script to detect system dark mode preference and apply it immediately --}} + + + + + {{ __('Connect to :app', ['app' => config('app.name', 'Whisper Money')]) }} + + + + + + + + + + + @vite(['resources/css/app.css']) + + +@php + $redirectHost = parse_url($request->get('redirect_uri', ''), PHP_URL_HOST); +@endphp +
+
+ +
+ +
+
+ + +
+ +

+ {{ __('Connect :client to :app', ['client' => $client->name, 'app' => config('app.name', 'Whisper Money')]) }} +

+ +

+ {{ __(':client is asking to read and make changes to your finance data.', ['client' => $client->name]) }} +

+
+ + +
+ +
+

{{ __('Signed in as') }}

+

{{ $user->email }}

+ @if($redirectHost) +

{{ __('Sends you back to') }}

+

{{ $redirectHost }}

+ @endif +
+ + +
+

{{ __('This connection can:') }}

+ +
    +
  • +
    +
    +
    + + {{ __('Read and analyse your transactions, balances, categories and spending.') }} + +
  • +
  • +
    +
    +
    + + {{ __('Create, edit and delete transactions, categories, labels and automation rules.') }} + +
  • +
+ +

+ {{ __('Bank-connected accounts and their transactions stay read-only. You can disconnect it at any time from the connected app.') }} +

+
+
+ + +
+ +
+ @csrf + @method('DELETE') + + + + +
+ + +
+ @csrf + + + + +
+
+
+
+
+ + + + diff --git a/routes/ai.php b/routes/ai.php index a86b053a..64055af6 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -8,12 +8,29 @@ use Laravel\Mcp\Facades\Mcp; | MCP Routes |-------------------------------------------------------------------------- | -| Remote (streamable HTTP) MCP server. Authenticated with Sanctum personal -| access tokens carrying an `mcp:read` ability, throttled per user. The -| Pro-plan gate is enforced inside each tool so a lapsed subscription stops -| working without the user having to revoke their token. +| Remote (streamable HTTP) MCP server, exposed on two endpoints: +| +| - `/mcp` is authenticated with Sanctum personal access tokens carrying an +| `mcp:read` ability (Claude Code, static bearer token, read/read_write via +| abilities). Left completely unchanged. +| - `/mcp/oauth` is authenticated with OAuth 2.1 (Authorization Code + PKCE) +| for clients that sign in rather than paste a token — Claude Desktop / web +| and ChatGPT connectors. `Mcp::oauthRoutes()` registers the RFC 8414 / 9728 +| discovery endpoints, the RFC 7591 DCR endpoint and the `mcp:use` Passport +| scope; the package's AddWwwAuthenticateHeader middleware then turns an +| unauthenticated request into the mandatory 401 bootstrap challenge. +| +| OAuth connections carry only the single `mcp:use` scope, so `tokenCan` +| reports no `mcp:write` ability and the write tools stay read-only for them +| (see WriteTool). The Pro-plan gate is enforced inside each tool under either +| guard, so a lapsed subscription stops working without revoking access. | */ +Mcp::oauthRoutes(); + Mcp::web('/mcp', WhisperMoneyServer::class) ->middleware(['auth:sanctum', 'abilities:mcp:read', 'throttle:60,1']); + +Mcp::web('/mcp/oauth', WhisperMoneyServer::class) + ->middleware(['auth:api', 'throttle:60,1']); diff --git a/tests/Feature/Mcp/McpOAuthTest.php b/tests/Feature/Mcp/McpOAuthTest.php new file mode 100644 index 00000000..4d632dc7 --- /dev/null +++ b/tests/Feature/Mcp/McpOAuthTest.php @@ -0,0 +1,205 @@ + $scopes + */ +function issueOAuthToken(User $user, array $scopes = ['mcp:use']): string +{ + $client = app(ClientRepository::class)->createAuthorizationCodeGrantClient( + name: 'Claude', + redirectUris: [CLAUDE_CALLBACK], + confidential: false, + ); + + [$verifier, $challenge] = pkcePair(); + + // 1. Authorize: renders the consent screen and primes the session. + $authorize = test()->actingAs($user)->get('/oauth/authorize?'.http_build_query([ + 'client_id' => $client->id, + 'redirect_uri' => CLAUDE_CALLBACK, + 'response_type' => 'code', + 'scope' => implode(' ', $scopes), + 'state' => 'state-token', + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ]))->assertOk(); + + // The consent form carries the single-use auth token we must echo back. + preg_match('/name="auth_token" value="([^"]+)"/', $authorize->getContent(), $matches); + $authToken = $matches[1] ?? null; + expect($authToken)->not->toBeNull(); + + // 2. Approve: redirects back to the client with the authorization code. + $approve = test()->actingAs($user)->post('/oauth/authorize', [ + 'auth_token' => $authToken, + 'client_id' => $client->id, + 'state' => 'state-token', + ])->assertRedirect(); + + parse_str((string) parse_url($approve->headers->get('Location'), PHP_URL_QUERY), $redirect); + expect($redirect['code'] ?? null)->not->toBeNull(); + + // 3. Exchange the code + verifier for a token (public client, no secret). + $token = test()->post('/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => $client->id, + 'redirect_uri' => CLAUDE_CALLBACK, + 'code_verifier' => $verifier, + 'code' => $redirect['code'], + ])->assertOk(); + + return $token->json('access_token'); +} + +/* +|-------------------------------------------------------------------------- +| Discovery (RFC 9728 / RFC 8414) +|-------------------------------------------------------------------------- +*/ + +it('serves protected resource metadata for the OAuth MCP endpoint', function () { + $response = get('/.well-known/oauth-protected-resource/mcp/oauth')->assertOk(); + + expect($response->json('resource'))->toEndWith('/mcp/oauth'); + expect($response->json('authorization_servers'))->not->toBeEmpty(); + expect($response->json('scopes_supported'))->toBe(['mcp:use']); +}); + +it('serves authorization server metadata advertising PKCE and the mcp:use scope', function () { + $json = get('/.well-known/oauth-authorization-server')->assertOk()->json(); + + expect($json['response_types_supported'])->toBe(['code']); + expect($json['code_challenge_methods_supported'])->toBe(['S256']); + expect($json['scopes_supported'])->toBe(['mcp:use']); + expect($json['grant_types_supported'])->toBe(['authorization_code', 'refresh_token']); + expect($json['authorization_endpoint'])->toContain('oauth/authorize'); + expect($json['token_endpoint'])->toContain('oauth/token'); + expect($json['registration_endpoint'])->toContain('oauth/register'); +}); + +/* +|-------------------------------------------------------------------------- +| 401 bootstrap challenge (mandatory for Claude / ChatGPT) +|-------------------------------------------------------------------------- +*/ + +it('returns a 401 bootstrap challenge for an unauthenticated OAuth MCP request', function () { + $response = postJson('/mcp/oauth', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']); + + $response->assertUnauthorized(); + + $header = $response->headers->get('WWW-Authenticate'); + expect($header)->toContain('Bearer'); + expect($header)->toContain('resource_metadata='); + expect($header)->toContain('/.well-known/oauth-protected-resource/mcp/oauth'); +}); + +/* +|-------------------------------------------------------------------------- +| Dynamic Client Registration (RFC 7591) +|-------------------------------------------------------------------------- +*/ + +it('registers a public PKCE client via dynamic client registration', function () { + $response = postJson('/oauth/register', [ + 'client_name' => 'Claude', + 'redirect_uris' => [CLAUDE_CALLBACK], + ])->assertOk()->assertJson([ + 'token_endpoint_auth_method' => 'none', + 'scope' => 'mcp:use', + ]); + + expect($response->json('client_id'))->not->toBeEmpty(); + expect($response->json('redirect_uris'))->toContain(CLAUDE_CALLBACK); +}); + +it('rejects a DCR redirect URI outside the allowlist', function () { + postJson('/oauth/register', [ + 'client_name' => 'Evil', + 'redirect_uris' => ['https://evil.example.com/callback'], + ])->assertStatus(400)->assertJson(['error' => 'invalid_redirect_uri']); +}); + +/* +|-------------------------------------------------------------------------- +| End-to-end Authorization Code + PKCE flow +|-------------------------------------------------------------------------- +*/ + +it('authenticates a full Authorization Code + PKCE flow and serves read tools', function () { + $user = User::factory()->create(); + $token = issueOAuthToken($user); + + expect($token)->not->toBeEmpty(); + + withHeaders(['Authorization' => "Bearer {$token}"]) + ->postJson('/mcp/oauth', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => ['name' => 'list_spaces', 'arguments' => (object) []], + ]) + ->assertOk() + ->assertSee('Personal', false); +}); + +/* +|-------------------------------------------------------------------------- +| Write access over OAuth (Claude Desktop / ChatGPT connections can write) +|-------------------------------------------------------------------------- +*/ + +it('lets an OAuth connection use write tools', function () { + $user = User::factory()->create(); + $token = issueOAuthToken($user); + + withHeaders(['Authorization' => "Bearer {$token}"]) + ->postJson('/mcp/oauth', [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'create_label', + 'arguments' => ['name' => 'Groceries', 'color' => 'blue'], + ], + ]) + ->assertOk() + ->assertSee('Groceries', false); + + $label = Label::query()->where('user_id', $user->id)->first(); + expect($label)->not->toBeNull(); + expect($label->name)->toBe('Groceries'); +}); diff --git a/worktree.sh b/worktree.sh index 4d84ad88..2e935102 100755 --- a/worktree.sh +++ b/worktree.sh @@ -5,5 +5,11 @@ ROOT_PATH=$1 cp "$ROOT_PATH/.env" .env cp -r "$ROOT_PATH/storage/keys" ./storage/keys +# Passport OAuth signing keys (for the MCP OAuth server). Reuse the main repo's +# keys if present, otherwise generate a fresh pair after deps are installed. +cp "$ROOT_PATH"/storage/oauth-*.key ./storage/ 2>/dev/null || true + bun i composer install + +[ -f ./storage/oauth-private.key ] || php artisan passport:keys