diff --git a/app/Actions/AccessToken/ListConnectedMcpClients.php b/app/Actions/AccessToken/ListConnectedMcpClients.php index ef00eac9..0e2922a1 100644 --- a/app/Actions/AccessToken/ListConnectedMcpClients.php +++ b/app/Actions/AccessToken/ListConnectedMcpClients.php @@ -6,26 +6,29 @@ use App\Models\AccessToken; use App\Models\User; +use App\Models\Workspace; use Illuminate\Support\Collection; class ListConnectedMcpClients { /** - * The viewer's own MCP OAuth connections, keyed by OAuth client. - * Matches API keys: each person only sees what they connected. + * The viewer's own MCP OAuth connections for the given workspace, keyed by + * OAuth client. Matches API keys: each person only sees what they connected + * for the workspace they are viewing. * * @return list */ - public static function forUser(User $user): array + public static function forUser(User $user, Workspace $workspace): array { $tokens = AccessToken::query() ->where('user_id', $user->id) + ->where('workspace_id', $workspace->id) ->connectedMcpOAuth() ->with(['client', 'user.currentWorkspace', 'workspace', 'refreshToken']) ->get(); return $tokens - ->filter(fn (AccessToken $token): bool => $token->isListedMcpConnection($user)) + ->filter(fn (AccessToken $token): bool => $token->isListedMcpConnection($user, $workspace)) ->groupBy('client_id') ->map(function (Collection $group): array { /** @var AccessToken $token */ @@ -38,6 +41,7 @@ public static function forUser(User $user): array 'last_used_at' => $group->max('last_used_at'), ]; }) + ->sortByDesc(fn (array $client): mixed => $client['last_used_at'] ?? 0) ->values() ->all(); } diff --git a/app/Actions/AccessToken/RevokeAccessTokens.php b/app/Actions/AccessToken/RevokeAccessTokens.php new file mode 100644 index 00000000..7d304ef9 --- /dev/null +++ b/app/Actions/AccessToken/RevokeAccessTokens.php @@ -0,0 +1,40 @@ +|list|AccessToken $tokens + */ + public static function execute(Collection|array|AccessToken $tokens): void + { + $tokens = Collection::wrap($tokens) + ->filter(fn (mixed $token): bool => $token instanceof AccessToken) + ->values(); + + if ($tokens->isEmpty()) { + return; + } + + $tokenIds = $tokens->pluck('id'); + + DB::table('oauth_refresh_tokens') + ->whereIn('access_token_id', $tokenIds) + ->update(['revoked' => true]); + + $tokens->each(function (AccessToken $token): void { + if ($token->revoked) { + return; + } + + $token->revoke(); + }); + } +} diff --git a/app/Actions/AccessToken/RevokeMcpOAuthGrants.php b/app/Actions/AccessToken/RevokeMcpOAuthGrants.php index 0ce56435..edd209d7 100644 --- a/app/Actions/AccessToken/RevokeMcpOAuthGrants.php +++ b/app/Actions/AccessToken/RevokeMcpOAuthGrants.php @@ -8,7 +8,6 @@ use App\Models\User; use App\Models\Workspace; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; class RevokeMcpOAuthGrants { @@ -46,15 +45,37 @@ public static function forUser(User $user): bool /** * Revoke active MCP OAuth grants for one OAuth client owned by the user. + * When a workspace is provided, only grants bound to that workspace are revoked. * * @return bool True when at least one grant was revoked. */ - public static function forUserClient(User $user, string $clientId): bool + public static function forUserClient(User $user, string $clientId, ?Workspace $workspace = null): bool + { + $query = AccessToken::query() + ->where('user_id', $user->id) + ->where('client_id', $clientId) + ->mcpOAuth() + ->where('revoked', false); + + if ($workspace !== null) { + $query->where('workspace_id', $workspace->id); + } + + return self::revoke($query->get()); + } + + /** + * Revoke MCP OAuth grants bound to a specific workspace for a user + * (e.g. when the member is removed from that workspace). + * + * @return bool True when at least one grant was revoked. + */ + public static function forUserOnWorkspace(string $userId, Workspace $workspace): bool { return self::revoke( AccessToken::query() - ->where('user_id', $user->id) - ->where('client_id', $clientId) + ->where('user_id', $userId) + ->where('workspace_id', $workspace->id) ->mcpOAuth() ->where('revoked', false) ->get(), @@ -77,17 +98,7 @@ private static function revoke(Collection $tokens): bool return false; } - DB::transaction(function () use ($tokens): void { - $tokenIds = $tokens->pluck('id'); - - DB::table('oauth_refresh_tokens') - ->whereIn('access_token_id', $tokenIds) - ->update(['revoked' => true]); - - $tokens->each(function (AccessToken $token): void { - $token->forceFill(['revoked' => true])->saveQuietly(); - }); - }); + RevokeAccessTokens::execute($tokens); return true; } diff --git a/app/Actions/Invite/RemoveMember.php b/app/Actions/Invite/RemoveMember.php index d231a213..cef04749 100644 --- a/app/Actions/Invite/RemoveMember.php +++ b/app/Actions/Invite/RemoveMember.php @@ -33,6 +33,7 @@ public static function execute(Workspace $workspace, string $userId): void $workspace->members()->detach($userId); RevokeWorkspaceApiKeys::forUserOnWorkspace($userId, $workspace); + RevokeMcpOAuthGrants::forUserOnWorkspace($userId, $workspace); if (! $user) { return; @@ -54,8 +55,7 @@ public static function execute(Workspace $workspace, string $userId): void $settlement = SettleStrandedMember::execute($user, $account); } - // If the member still exists but can no longer view any workspace, - // drop their MCP OAuth grants (refresh tokens included). + // Safety net for any leftover unbound MCP grants after full removal. $remaining = User::query()->find($userId); if ($remaining instanceof User) { diff --git a/app/Http/Controllers/Api/ApiKeyController.php b/app/Http/Controllers/Api/ApiKeyController.php index 48fe36c2..0e3ab124 100644 --- a/app/Http/Controllers/Api/ApiKeyController.php +++ b/app/Http/Controllers/Api/ApiKeyController.php @@ -22,6 +22,7 @@ public function index(Request $request): AnonymousResourceCollection $tokens = AccessToken::where('user_id', $request->user()->id) ->where('workspace_id', $request->user()->currentWorkspace->id) ->where('revoked', false) + ->personalAccessApiKey() ->latest() ->get(); @@ -52,6 +53,7 @@ public function destroy(Request $request, string $tokenId): JsonResponse $token = AccessToken::where('id', $tokenId) ->where('user_id', $request->user()->id) ->where('workspace_id', $request->user()->currentWorkspace->id) + ->personalAccessApiKey() ->first(); if (! $token) { diff --git a/app/Http/Controllers/App/ApiKeyController.php b/app/Http/Controllers/App/ApiKeyController.php index 01f4f807..33cd4d1e 100644 --- a/app/Http/Controllers/App/ApiKeyController.php +++ b/app/Http/Controllers/App/ApiKeyController.php @@ -28,6 +28,7 @@ public function index(Request $request): InertiaResponse|RedirectResponse $tokens = AccessToken::where('user_id', $request->user()->id) ->where('workspace_id', $workspace->id) ->where('revoked', false) + ->personalAccessApiKey() ->latest() ->get() ->map(fn (AccessToken $token) => [ @@ -78,6 +79,7 @@ public function destroy(Request $request, string $tokenId): RedirectResponse $token = AccessToken::where('id', $tokenId) ->where('user_id', $request->user()->id) ->where('workspace_id', $workspace->id) + ->personalAccessApiKey() ->first(); if (! $token) { diff --git a/app/Http/Controllers/App/McpSettingsController.php b/app/Http/Controllers/App/McpSettingsController.php index a519e24c..a624414f 100644 --- a/app/Http/Controllers/App/McpSettingsController.php +++ b/app/Http/Controllers/App/McpSettingsController.php @@ -22,17 +22,18 @@ public function index(Request $request): Response return Inertia::render('settings/workspace/Mcp', [ 'mcpUrl' => route('mcp.trypost'), - 'connectedClients' => ListConnectedMcpClients::forUser($user), + 'connectedClients' => ListConnectedMcpClients::forUser($user, $workspace), ]); } public function disconnect(Request $request, string $client): RedirectResponse { $user = $request->user(); + $workspace = $user->currentWorkspace; - $this->authorize('view', $user->currentWorkspace); + $this->authorize('view', $workspace); - if (! RevokeMcpOAuthGrants::forUserClient($user, $client)) { + if (! RevokeMcpOAuthGrants::forUserClient($user, $client, $workspace)) { return back(); } diff --git a/app/Http/Middleware/Api/LoadWorkspaceFromToken.php b/app/Http/Middleware/Api/LoadWorkspaceFromToken.php index bd333457..87e5ddb5 100644 --- a/app/Http/Middleware/Api/LoadWorkspaceFromToken.php +++ b/app/Http/Middleware/Api/LoadWorkspaceFromToken.php @@ -32,12 +32,13 @@ public function handle(Request $request, Closure $next, ?string $context = null) return response()->json(['message' => 'Token expired.'], Response::HTTP_UNAUTHORIZED); } - // Personal API tokens (created from settings) bind to a specific - // workspace at creation. OAuth tokens (e.g. ChatGPT MCP) don't — - // they follow the user's current workspace. + // Personal API keys and MCP OAuth grants both bind to a workspace at + // issue time. Resolve from the token — never from the user's current + // workspace switcher (that would let a multi-workspace agent silently + // act on the wrong tenant). $workspace = $token->workspace_id - ? Workspace::find($token->workspace_id) - : $user->currentWorkspace; + ? Workspace::query()->find($token->workspace_id) + : null; if (! $workspace) { return response()->json(['message' => 'No workspace selected.'], Response::HTTP_UNAUTHORIZED); diff --git a/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php b/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php index 1bf3bf7b..23af7812 100644 --- a/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php +++ b/app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php @@ -35,12 +35,12 @@ public function handle(Request $request): Response|ResponseFactory $validated = $request->validate(['api_key_id' => ['required', 'string']]); - // workspace_id filter excludes OAuth-flow tokens (which have null - // workspace_id), so the caller can't accidentally revoke their own - // ChatGPT/MCP session token through this tool. + // Personal access API keys only — cannot revoke the caller's own MCP + // OAuth session (even when it shares this workspace_id). $token = AccessToken::where('user_id', $request->user()->id) ->where('workspace_id', $workspace->id) ->where('revoked', false) + ->personalAccessApiKey() ->find(data_get($validated, 'api_key_id')); if (! $token) { diff --git a/app/Mcp/Tools/ApiKey/ListApiKeysTool.php b/app/Mcp/Tools/ApiKey/ListApiKeysTool.php index 2e867ec1..99ad7f04 100644 --- a/app/Mcp/Tools/ApiKey/ListApiKeysTool.php +++ b/app/Mcp/Tools/ApiKey/ListApiKeysTool.php @@ -33,12 +33,12 @@ public function handle(Request $request): Response|ResponseFactory return $workspace; } - // Filtering by workspace_id excludes OAuth-flow tokens (whose - // workspace_id is null and resolved at request time via - // LoadWorkspaceFromToken middleware). + // Personal access API keys only — workspace-bound MCP OAuth grants must + // not appear here or be revocable via this tool. $tokens = AccessToken::where('user_id', $request->user()->id) ->where('workspace_id', $workspace->id) ->where('revoked', false) + ->personalAccessApiKey() ->latest() ->get(); diff --git a/app/Models/AccessToken.php b/app/Models/AccessToken.php index b1243ab0..d686fedd 100644 --- a/app/Models/AccessToken.php +++ b/app/Models/AccessToken.php @@ -232,7 +232,8 @@ private function ownerCanViewWorkspace(?User $user = null, ?Workspace $workspace return false; } - $workspace ??= $this->workspace ?? $user->currentWorkspace; + // Bound grants resolve only from the token — never the switcher. + $workspace ??= $this->workspace; return $workspace instanceof Workspace && $user->can('view', $workspace); diff --git a/app/Passport/AccessTokenRepository.php b/app/Passport/AccessTokenRepository.php new file mode 100644 index 00000000..892ff718 --- /dev/null +++ b/app/Passport/AccessTokenRepository.php @@ -0,0 +1,114 @@ +getIdentifier(); + $userId = $accessTokenEntity->getUserIdentifier(); + $clientId = $accessTokenEntity->getClient()->getIdentifier(); + $workspaceId = null; + + if ($this->clientRequiresWorkspace($clientId)) { + $workspaceId = $this->resolveWorkspaceId($userId); + + if ($workspaceId === null) { + throw OAuthServerException::invalidGrant( + 'Unable to bind this connection to a workspace. Reconnect from a workspace you belong to.', + ); + } + } + + Passport::token()->forceFill([ + 'id' => $id, + 'user_id' => $userId, + 'client_id' => $clientId, + 'workspace_id' => $workspaceId, + 'scopes' => $accessTokenEntity->getScopes(), + 'revoked' => false, + 'expires_at' => $accessTokenEntity->getExpiryDateTime(), + ])->save(); + + $this->events->dispatch(new AccessTokenCreated($id, $userId, $clientId)); + } + + private function clientRequiresWorkspace(string $clientId): bool + { + $client = Passport::client()->newQuery()->find($clientId); + + return $client !== null && ! $client->hasGrantType('personal_access'); + } + + private function resolveWorkspaceId(?string $userId): ?string + { + $user = $userId ? User::query()->find($userId) : null; + + return match (request('grant_type')) { + 'refresh_token' => $this->ownedWorkspace( + $user, + AccessToken::query() + ->find($this->payloadId('refresh_token', 'access_token_id')) + ?->workspace_id, + ), + 'authorization_code' => $this->ownedWorkspace( + $user, + AuthCode::query()->find($this->payloadId('code', 'auth_code_id'))?->workspace_id, + ), + default => null, + }; + } + + private function ownedWorkspace(?User $user, mixed $workspaceId): ?string + { + if ($user === null || blank($workspaceId)) { + return null; + } + + $workspace = Workspace::query()->find($workspaceId); + + return $workspace && $user->belongsToWorkspace($workspace) + ? $workspace->id + : null; + } + + private function payloadId(string $input, string $key): mixed + { + $encrypted = request($input); + + if (! is_string($encrypted) || $encrypted === '') { + return null; + } + + return data_get($this->decryptor->decrypt($encrypted), $key) ?: null; + } +} diff --git a/app/Passport/AuthCode.php b/app/Passport/AuthCode.php new file mode 100644 index 00000000..bf802a16 --- /dev/null +++ b/app/Passport/AuthCode.php @@ -0,0 +1,28 @@ + + */ + protected function casts(): array + { + return [ + 'revoked' => 'bool', + 'expires_at' => 'datetime', + ]; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } +} diff --git a/app/Passport/AuthCodeRepository.php b/app/Passport/AuthCodeRepository.php new file mode 100644 index 00000000..f8c6e8fc --- /dev/null +++ b/app/Passport/AuthCodeRepository.php @@ -0,0 +1,63 @@ +forceFill([ + 'id' => $authCodeEntity->getIdentifier(), + 'user_id' => $authCodeEntity->getUserIdentifier(), + 'client_id' => $authCodeEntity->getClient()->getIdentifier(), + 'workspace_id' => $this->resolveWorkspaceId($authCodeEntity->getUserIdentifier()), + 'scopes' => json_encode($authCodeEntity->getScopes()), + 'revoked' => false, + 'expires_at' => $authCodeEntity->getExpiryDateTime(), + ])->save(); + } + + private function resolveWorkspaceId(?string $userId): ?string + { + $user = Auth::user(); + + if (! $user instanceof User && $userId) { + $user = User::query()->find($userId); + } + + if (! $user instanceof User) { + return null; + } + + $requestedId = request()->string('workspace_id'); + + if ($requestedId->isEmpty()) { + return null; + } + + $workspace = Workspace::query()->find($requestedId->value()); + + if ($workspace === null || ! $user->belongsToWorkspace($workspace)) { + return null; + } + + return $workspace->id; + } +} diff --git a/app/Passport/AuthorizationController.php b/app/Passport/AuthorizationController.php new file mode 100644 index 00000000..434d3ccb --- /dev/null +++ b/app/Passport/AuthorizationController.php @@ -0,0 +1,28 @@ + $parameters + */ + public function __invoke(array $parameters): Response + { + $user = data_get($parameters, 'user'); + $workspaces = $user instanceof User + ? $user->accountWorkspaces()->orderBy('name')->get() + : collect(); + + return Inertia::render('mcp/Authorize', [ + 'client' => [ + 'id' => data_get($parameters, 'client.id'), + 'name' => data_get($parameters, 'client.name'), + ], + 'user' => [ + 'email' => data_get($parameters, 'user.email'), + ], + 'workspaces' => $workspaces->map->only(['id', 'name'])->values(), + 'selectedWorkspaceId' => $workspaces->firstWhere( + 'id', + $user instanceof User ? $user->current_workspace_id : null, + )?->id ?? $workspaces->first()?->id ?? '', + 'scopes' => collect(data_get($parameters, 'scopes', []))->map->toArray()->values(), + 'authToken' => data_get($parameters, 'authToken'), + 'state' => data_get($parameters, 'request.state', ''), + ]); + } +} diff --git a/app/Passport/OAuthPayloadDecryptor.php b/app/Passport/OAuthPayloadDecryptor.php new file mode 100644 index 00000000..adef05a1 --- /dev/null +++ b/app/Passport/OAuthPayloadDecryptor.php @@ -0,0 +1,51 @@ +|null + */ + public function decrypt(string $encrypted): ?array + { + try { + $json = Crypto::decryptWithPassword( + $encrypted, + Passport::tokenEncryptionKey($this->encrypter), + ); + } catch (Throwable) { + return null; + } + + $payload = json_decode($json, true); + + return is_array($payload) ? $payload : null; + } + + /** + * Encrypt a payload the same way League's CryptTrait does (for tests). + * + * @param array $payload + */ + public function encrypt(array $payload): string + { + return Crypto::encryptWithPassword( + json_encode($payload, JSON_THROW_ON_ERROR), + Passport::tokenEncryptionKey($this->encrypter), + ); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 55b360d0..2f18e906 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -53,7 +53,6 @@ use Laravel\Cashier\Events\WebhookReceived; use Laravel\Nightwatch\Facades\Nightwatch; use Laravel\Nightwatch\Records\CacheEvent; -use Laravel\Passport\Passport; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\GoogleProvider; use PostHog\PostHog; @@ -94,24 +93,6 @@ public function boot(): void Cashier::useSubscriptionModel(Subscription::class); Cashier::useSubscriptionItemModel(SubscriptionItem::class); Cashier::keepPastDueSubscriptionsActive(); - - $this->configurePassport(); - } - - protected function configurePassport(): void - { - Passport::useTokenModel(AccessToken::class); - - // API keys may omit an application expiry ("never"). Passport still - // embeds a JWT `exp`, so keep that far ahead and enforce optional - // `oauth_access_tokens.expires_at` in LoadWorkspaceFromToken. - Passport::personalAccessTokensExpireIn(now()->addYears(100)); - - Passport::tokensCan([ - 'mcp:use' => 'Use MCP server', - ]); - - Passport::authorizationView('mcp.authorize'); } protected function configureMorphMap(): void diff --git a/app/Providers/PassportServiceProvider.php b/app/Providers/PassportServiceProvider.php new file mode 100644 index 00000000..44d21975 --- /dev/null +++ b/app/Providers/PassportServiceProvider.php @@ -0,0 +1,58 @@ +app->bind(PassportAuthCodeRepository::class, AuthCodeRepository::class); + $this->app->bind(PassportAccessTokenRepository::class, AccessTokenRepository::class); + $this->app->bind(PassportAuthorizationController::class, AuthorizationController::class); + + // Passport's contextual guard binding targets its own controller class; + // repeat it for our override so authorize() still receives the web guard. + $this->app->when([ + AuthorizationController::class, + DeviceAuthorizationController::class, + ])->needs(StatefulGuard::class)->give( + fn () => Auth::guard(config('passport.guard', null)), + ); + } + + public function boot(): void + { + Passport::useTokenModel(AccessToken::class); + Passport::useAuthCodeModel(AuthCode::class); + + // API keys may omit an application expiry ("never"). Passport still + // embeds a JWT `exp`, so keep that far ahead and enforce optional + // `oauth_access_tokens.expires_at` in LoadWorkspaceFromToken. + Passport::personalAccessTokensExpireIn(now()->addYears(100)); + + Passport::tokensCan([ + 'mcp:use' => 'Use MCP server', + ]); + + Passport::authorizationView( + fn (array $parameters) => $this->app->make(AuthorizationView::class)($parameters), + ); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 2345aa62..55339d1c 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,8 +2,10 @@ use App\Providers\AppServiceProvider; use App\Providers\HorizonServiceProvider; +use App\Providers\PassportServiceProvider; return [ AppServiceProvider::class, HorizonServiceProvider::class, + PassportServiceProvider::class, ]; diff --git a/database/migrations/2026_08_06_144847_add_workspace_id_to_oauth_auth_codes_table.php b/database/migrations/2026_08_06_144847_add_workspace_id_to_oauth_auth_codes_table.php new file mode 100644 index 00000000..e20e914b --- /dev/null +++ b/database/migrations/2026_08_06_144847_add_workspace_id_to_oauth_auth_codes_table.php @@ -0,0 +1,28 @@ +foreignUuid('workspace_id') + ->nullable() + ->after('client_id') + ->constrained() + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('oauth_auth_codes', function (Blueprint $table) { + $table->dropConstrainedForeignId('workspace_id'); + }); + } +}; diff --git a/database/migrations/2026_08_06_144848_backfill_mcp_oauth_token_workspaces.php b/database/migrations/2026_08_06_144848_backfill_mcp_oauth_token_workspaces.php new file mode 100644 index 00000000..20420d9e --- /dev/null +++ b/database/migrations/2026_08_06_144848_backfill_mcp_oauth_token_workspaces.php @@ -0,0 +1,102 @@ +connectedMcpOAuth() + ->whereNull('workspace_id') + ->orderBy('id') + ->chunkById(100, function (Collection $tokens): void { + $users = User::query() + ->whereIn('id', $tokens->pluck('user_id')->unique()->filter()->all()) + ->with('workspaces') + ->get() + ->keyBy('id'); + + $toRevoke = collect(); + + foreach ($tokens as $token) { + $workspaceId = $this->resolveWorkspaceId( + $users->get($token->user_id), + ); + + if ($workspaceId === null) { + $toRevoke->push($token); + + continue; + } + + $token->forceFill(['workspace_id' => $workspaceId])->saveQuietly(); + } + + if ($toRevoke->isNotEmpty()) { + RevokeAccessTokens::execute($toRevoke); + } + }); + + if (is_callable($this->beforeCommit)) { + ($this->beforeCommit)(); + } + + DB::commit(); + } catch (Throwable $e) { + DB::rollBack(); + + throw $e; + } + } + + public function down(): void + { + // Irreversible data migration — bound tokens keep their workspace_id. + } + + private function resolveWorkspaceId(?User $user): ?string + { + if ($user === null) { + return null; + } + + $accountWorkspaces = $user->workspaces + ->where('account_id', $user->account_id) + ->sortBy('created_at') + ->values(); + + if ($accountWorkspaces->count() !== 1) { + return null; + } + + return (string) $accountWorkspaces->first()->id; + } +}; diff --git a/lang/ar/mcp.php b/lang/ar/mcp.php index 266cb93c..c891b217 100644 --- a/lang/ar/mcp.php +++ b/lang/ar/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'أدلة الإعداد لكل عميل، والأدوات المتاحة، وحل المشكلات.', 'view_docs' => 'عرض التوثيق', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'تطبيقات أخرى', 'other_clients_description' => 'Cursor وVS Code وClaude Code وأي تطبيق يدعم MCP.', diff --git a/lang/de/mcp.php b/lang/de/mcp.php index f8131226..ea3b2944 100644 --- a/lang/de/mcp.php +++ b/lang/de/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Einrichtungsguides pro Client, verfügbare Tools und Fehlerhilfe.', 'view_docs' => 'Dokumentation ansehen', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Andere Apps', 'other_clients_description' => 'Cursor, VS Code, Claude Code und alles andere, das MCP spricht.', diff --git a/lang/el/mcp.php b/lang/el/mcp.php index 7fc07e3e..c6f1f684 100644 --- a/lang/el/mcp.php +++ b/lang/el/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Οδηγοί ανά client, διαθέσιμα tools και αντιμετώπιση προβλημάτων.', 'view_docs' => 'Δείτε την τεκμηρίωση', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Άλλες εφαρμογές', 'other_clients_description' => 'Cursor, VS Code, Claude Code και ό,τι άλλο μιλάει MCP.', diff --git a/lang/en/mcp.php b/lang/en/mcp.php index 5bfe9ef2..9b1285f9 100644 --- a/lang/en/mcp.php +++ b/lang/en/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Client setup guides, available tools, and troubleshooting.', 'view_docs' => 'View docs', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Other apps', 'other_clients_description' => 'Cursor, VS Code, Claude Code, and anything else that speaks MCP.', diff --git a/lang/es/mcp.php b/lang/es/mcp.php index 5a4a82f5..d2988170 100644 --- a/lang/es/mcp.php +++ b/lang/es/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Guías por cliente, tools disponibles y solución de problemas.', 'view_docs' => 'Ver documentación', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Otras apps', 'other_clients_description' => 'Cursor, VS Code, Claude Code y cualquier app que hable MCP.', diff --git a/lang/fr/mcp.php b/lang/fr/mcp.php index 9cf541e5..ea927840 100644 --- a/lang/fr/mcp.php +++ b/lang/fr/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Guides par client, tools disponibles et dépannage.', 'view_docs' => 'Voir la documentation', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Autres apps', 'other_clients_description' => 'Cursor, VS Code, Claude Code et toute app qui parle MCP.', diff --git a/lang/it/mcp.php b/lang/it/mcp.php index 9c5fd662..b4385ce0 100644 --- a/lang/it/mcp.php +++ b/lang/it/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Guide per client, tools disponibili e risoluzione problemi.', 'view_docs' => 'Vedi documentazione', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Altre app', 'other_clients_description' => 'Cursor, VS Code, Claude Code e qualsiasi app che parla MCP.', diff --git a/lang/ja/mcp.php b/lang/ja/mcp.php index 6d62d16e..3869e1bc 100644 --- a/lang/ja/mcp.php +++ b/lang/ja/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'クライアント別のセットアップ、利用可能なツール、トラブルシューティング。', 'view_docs' => 'ドキュメントを見る', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'その他のアプリ', 'other_clients_description' => 'Cursor、VS Code、Claude Code、その他MCP対応アプリ。', diff --git a/lang/ko/mcp.php b/lang/ko/mcp.php index 54583ff1..7374be93 100644 --- a/lang/ko/mcp.php +++ b/lang/ko/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => '클라이언트별 설정 가이드, 사용 가능한 도구, 문제 해결.', 'view_docs' => '문서 보기', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => '다른 앱', 'other_clients_description' => 'Cursor, VS Code, Claude Code 및 MCP를 지원하는 모든 앱.', diff --git a/lang/nl/mcp.php b/lang/nl/mcp.php index a5e29679..80c14153 100644 --- a/lang/nl/mcp.php +++ b/lang/nl/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Handleidingen per client, beschikbare tools en probleemoplossing.', 'view_docs' => 'Documentatie bekijken', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Andere apps', 'other_clients_description' => 'Cursor, VS Code, Claude Code en alles wat MCP spreekt.', diff --git a/lang/pl/mcp.php b/lang/pl/mcp.php index 91ab7033..622626c6 100644 --- a/lang/pl/mcp.php +++ b/lang/pl/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Przewodniki per klient, dostępne tools i rozwiązywanie problemów.', 'view_docs' => 'Zobacz dokumentację', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Inne aplikacje', 'other_clients_description' => 'Cursor, VS Code, Claude Code i wszystko, co mówi MCP.', diff --git a/lang/pt-BR/mcp.php b/lang/pt-BR/mcp.php index 977e39d6..ccfc4266 100644 --- a/lang/pt-BR/mcp.php +++ b/lang/pt-BR/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Guias por cliente, tools disponíveis e solução de problemas.', 'view_docs' => 'Ver documentação', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Conectado como:', + + 'authorize' => [ + 'page_title' => 'Autorizar aplicativo - TryPost', + 'app_title' => 'Autorizar MCP', + 'heading' => 'Autorizar :client', + 'intro' => 'Este aplicativo poderá:', + 'intro_capability' => 'Usar as funcionalidades MCP disponíveis.', + 'logged_in_as' => 'Conectado como:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'Esta conexão terá acesso somente ao workspace selecionado.', + 'permissions' => 'Permissões:', + 'cancel' => 'Cancelar', + 'approve' => 'Autorizar', + 'approving' => 'Autorizando...', + 'select_workspace' => 'Selecione um workspace', + 'search_workspace' => 'Buscar workspaces...', + 'no_workspace_found' => 'Nenhum workspace encontrado', + 'scope_mcp_use' => 'Usar o servidor MCP', + ], 'other_clients_title' => 'Outros apps', 'other_clients_description' => 'Cursor, VS Code, Claude Code e qualquer app que fale MCP.', diff --git a/lang/ru/mcp.php b/lang/ru/mcp.php index b00961ca..c6557cb5 100644 --- a/lang/ru/mcp.php +++ b/lang/ru/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Гайды по клиентам, доступные tools и решение проблем.', 'view_docs' => 'Открыть документацию', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Другие приложения', 'other_clients_description' => 'Cursor, VS Code, Claude Code и всё, что говорит на MCP.', diff --git a/lang/tr/mcp.php b/lang/tr/mcp.php index d77f7540..959afcf8 100644 --- a/lang/tr/mcp.php +++ b/lang/tr/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'İstemci kurulum rehberleri, kullanılabilir tools ve sorun giderme.', 'view_docs' => 'Dokümantasyonu görüntüle', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Diğer uygulamalar', 'other_clients_description' => 'Cursor, VS Code, Claude Code ve MCP konuşan diğer her şey.', diff --git a/lang/uk/mcp.php b/lang/uk/mcp.php index c88094ba..fa619293 100644 --- a/lang/uk/mcp.php +++ b/lang/uk/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => 'Гайди клієнтів, доступні tools і усунення несправностей.', 'view_docs' => 'Відкрити документацію', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => 'Інші застосунки', 'other_clients_description' => 'Cursor, VS Code, Claude Code і все, що підтримує MCP.', diff --git a/lang/zh/mcp.php b/lang/zh/mcp.php index 527072a8..f72c3af6 100644 --- a/lang/zh/mcp.php +++ b/lang/zh/mcp.php @@ -27,7 +27,25 @@ 'documentation_description' => '各客户端设置指南、可用工具和问题排查。', 'view_docs' => '查看文档', 'connector_name' => 'TryPost', - 'authorize_logged_in_as' => 'Logged in as:', + + 'authorize' => [ + 'page_title' => 'Authorize Application - TryPost', + 'app_title' => 'Authorize MCP', + 'heading' => 'Authorize :client', + 'intro' => 'This application will be able to:', + 'intro_capability' => 'Use available MCP functionality.', + 'logged_in_as' => 'Logged in as:', + 'workspace' => 'Workspace:', + 'workspace_scope' => 'This connection will only access the selected workspace.', + 'permissions' => 'Permissions:', + 'cancel' => 'Cancel', + 'approve' => 'Authorize', + 'approving' => 'Authorizing...', + 'select_workspace' => 'Select a workspace', + 'search_workspace' => 'Search workspaces...', + 'no_workspace_found' => 'No workspace found', + 'scope_mcp_use' => 'Use MCP server', + ], 'other_clients_title' => '其他应用', 'other_clients_description' => 'Cursor、VS Code、Claude Code 以及任何支持 MCP 的应用。', diff --git a/resources/js/pages/mcp/Authorize.vue b/resources/js/pages/mcp/Authorize.vue new file mode 100644 index 00000000..2a8e3028 --- /dev/null +++ b/resources/js/pages/mcp/Authorize.vue @@ -0,0 +1,302 @@ + + + diff --git a/resources/views/mcp/authorize.blade.php b/resources/views/mcp/authorize.blade.php deleted file mode 100644 index 7d147874..00000000 --- a/resources/views/mcp/authorize.blade.php +++ /dev/null @@ -1,180 +0,0 @@ - - ($appearance ?? 'system') == 'dark'])> - - - - - {{-- Inline script to detect system dark mode preference and apply it immediately --}} - - - - - Authorize Application - {{ config('app.name', 'MCP Server') }} - - - - - - - - - - - - @vite(['resources/css/app.css']) - - -
-
- -
- -
-
- - - - -
- -

- Authorize {{ $client->name }} -

- -

- This application will be able to:
Use available MCP functionality. -

-
- - -
- -
-

Logged in as:

-

{{ $user->email }}

-
- - - @if(count($scopes) > 0) -
-

Permissions:

- -
    - @foreach($scopes as $scope) -
  • -
    -
    -
    - - {{ $scope->description }} - -
  • - @endforeach -
-
- @endif -
- - -
- -
- @csrf - @method('DELETE') - - - - -
- - -
- @csrf - - - - -
-
-
-
-
- - - - diff --git a/tests/Feature/Actions/Invite/RemoveMemberTest.php b/tests/Feature/Actions/Invite/RemoveMemberTest.php index 5bf43c1b..4148f8fc 100644 --- a/tests/Feature/Actions/Invite/RemoveMemberTest.php +++ b/tests/Feature/Actions/Invite/RemoveMemberTest.php @@ -6,7 +6,6 @@ use App\Enums\UserWorkspace\Role; use App\Models\AccessToken; use App\Models\User; -use App\Models\Workspace; test('remove member clears current workspace when it was the removed membership', function () { [ @@ -64,7 +63,7 @@ expect($member->current_workspace_id)->toBe($sharedB->id); }); -test('remove member keeps mcp oauth when create-post access remains elsewhere', function () { +test('remove member keeps mcp oauth bound to another workspace', function () { [ 'member' => $member, 'shared_workspaces' => [$sharedA, $sharedB], @@ -72,7 +71,7 @@ sharedWorkspaces: 2, setMemberCurrent: true, ); - $oauth = mcpAccessToken($member, mcpOauthClient()); + $oauth = mcpAccessToken($member, mcpOauthClient(), $sharedB); RemoveMember::execute($sharedA, $member->id); @@ -80,7 +79,7 @@ ->and($member->fresh()->can('createPost', $sharedB))->toBeTrue(); }); -test('remove member keeps mcp oauth when the member remains a viewer elsewhere', function () { +test('remove member keeps mcp oauth on another workspace when the member remains a viewer there', function () { [ 'member' => $member, 'shared_workspaces' => [$sharedA, $sharedB], @@ -91,7 +90,7 @@ $sharedB->members()->updateExistingPivot($member->id, [ 'role' => Role::Viewer->value, ]); - $oauth = mcpAccessToken($member, mcpOauthClient()); + $oauth = mcpAccessToken($member, mcpOauthClient(), $sharedB); RemoveMember::execute($sharedA, $member->id); @@ -100,12 +99,12 @@ ->and($member->fresh()->can('view', $sharedB))->toBeTrue(); }); -test('remove member revokes api keys without touching mcp oauth grants on the same workspace id', function () { +test('remove member revokes workspace-scoped api keys and mcp oauth tokens', function () { [ 'member' => $member, - 'shared_workspaces' => [$workspace], + 'shared_workspaces' => [$workspace, $other], ] = strandedMemberOnSharedAccount( - sharedWorkspaces: 1, + sharedWorkspaces: 2, setMemberCurrent: true, ); @@ -113,19 +112,18 @@ $patToken = AccessToken::query()->findOrFail($pat->token->id); $patToken->forceFill(['workspace_id' => $workspace->id])->saveQuietly(); - // Mis-bound workspace_id must still not classify this as a PAT revoke target. - $oauth = mcpAccessToken($member, mcpOauthClient()); - $oauth->forceFill(['workspace_id' => $workspace->id])->saveQuietly(); + $otherPat = $member->createToken('Other Key'); + $otherPatToken = AccessToken::query()->findOrFail($otherPat->token->id); + $otherPatToken->forceFill(['workspace_id' => $other->id])->saveQuietly(); - // Keep the member alive (second workspace) so we only exercise API-key revoke. - $other = Workspace::factory()->create([ - 'account_id' => $member->account_id, - 'user_id' => $member->account->owner_id, - ]); - $other->members()->attach($member->id, ['role' => Role::Member->value]); + $oauth = mcpAccessToken($member, mcpOauthClient(), $workspace); + $otherOauth = mcpAccessToken($member, mcpOauthClient(), $other); RemoveMember::execute($workspace, $member->id); expect($patToken->fresh()->revoked)->toBeTrue() - ->and($oauth->fresh()->revoked)->toBeFalse(); + ->and($oauth->fresh()->revoked)->toBeTrue() + ->and($otherPatToken->fresh()->revoked)->toBeFalse() + ->and($otherOauth->fresh()->revoked)->toBeFalse() + ->and(User::find($member->id))->not->toBeNull(); }); diff --git a/tests/Feature/Api/ApiKeyApiTest.php b/tests/Feature/Api/ApiKeyApiTest.php index 00a2fc3f..70032615 100644 --- a/tests/Feature/Api/ApiKeyApiTest.php +++ b/tests/Feature/Api/ApiKeyApiTest.php @@ -60,6 +60,28 @@ function createApiKeyApiToken(array $overrides = []): array ->assertJsonMissing(['id' => $other->id]); }); +test('list api keys excludes workspace-bound mcp oauth grants', function () { + $result = createApiKeyApiToken(); + $oauth = mcpAccessToken($result['user'], mcpOauthClient('Claude'), $result['workspace']); + + $this->withHeaders(['Authorization' => 'Bearer '.$result['plain_token']]) + ->getJson(route('api.api-keys.index')) + ->assertOk() + ->assertJsonCount(1) + ->assertJsonMissing(['id' => $oauth->id]); +}); + +test('cannot delete a workspace-bound mcp oauth grant through the api', function () { + $result = createApiKeyApiToken(); + $oauth = mcpAccessToken($result['user'], mcpOauthClient('Claude'), $result['workspace']); + + $this->withHeaders(['Authorization' => 'Bearer '.$result['plain_token']]) + ->deleteJson(route('api.api-keys.destroy', $oauth->id)) + ->assertNotFound(); + + expect($oauth->fresh()->revoked)->toBeFalse(); +}); + test('create api key returns plain token', function () { $result = createApiKeyApiToken(); diff --git a/tests/Feature/Api/LoadWorkspaceFromTokenTest.php b/tests/Feature/Api/LoadWorkspaceFromTokenTest.php index ff5cab30..e2c547a0 100644 --- a/tests/Feature/Api/LoadWorkspaceFromTokenTest.php +++ b/tests/Feature/Api/LoadWorkspaceFromTokenTest.php @@ -81,14 +81,10 @@ test('allows scoped mcp oauth without a subscription in self-hosted mode', function () { config(['trypost.self_hosted' => true]); - $result = $this->user->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); + $issued = mcpBearerToken($this->user, $this->workspace); $this->withHeaders([ - 'Authorization' => "Bearer {$result->accessToken}", + 'Authorization' => "Bearer {$issued['plain_token']}", 'Accept' => 'application/json, text/event-stream', ])->postJson(route('mcp.trypost'), [ 'jsonrpc' => '2.0', @@ -142,13 +138,9 @@ $viewer = User::factory()->create(['account_id' => $this->user->account_id]); $this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); $viewer->update(['current_workspace_id' => $this->workspace->id]); - $result = $viewer->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); + $issued = mcpBearerToken($viewer, $this->workspace); - $this->withHeaders(['Authorization' => "Bearer {$result->accessToken}"]) + $this->withHeaders(['Authorization' => "Bearer {$issued['plain_token']}"]) ->getJson(route('api.workspace.show')) ->assertForbidden() ->assertJson(['message' => 'Personal access token required.']); @@ -160,13 +152,9 @@ $member = User::factory()->create(['account_id' => $this->user->account_id]); $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); $member->update(['current_workspace_id' => $this->workspace->id]); - $result = $member->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); + $issued = mcpBearerToken($member, $this->workspace); - $this->withHeaders(['Authorization' => "Bearer {$result->accessToken}"]) + $this->withHeaders(['Authorization' => "Bearer {$issued['plain_token']}"]) ->getJson(route('api.workspace.show')) ->assertForbidden() ->assertJson(['message' => 'Personal access token required.']); @@ -178,13 +166,9 @@ $member = User::factory()->create(['account_id' => $this->user->account_id]); $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); $member->update(['current_workspace_id' => $this->workspace->id]); - $result = $member->createToken('MCP'); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); + $issued = mcpBearerToken($member, $this->workspace, scopes: []); - $this->withHeaders(['Authorization' => "Bearer {$result->accessToken}"]) + $this->withHeaders(['Authorization' => "Bearer {$issued['plain_token']}"]) ->getJson(route('api.workspace.show')) ->assertForbidden() ->assertJson(['message' => 'Personal access token required.']); @@ -217,14 +201,10 @@ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); $member->update(['current_workspace_id' => $this->workspace->id]); - $result = $member->createToken('MCP'); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); + $issued = mcpBearerToken($member, $this->workspace, scopes: []); $this->withHeaders([ - 'Authorization' => "Bearer {$result->accessToken}", + 'Authorization' => "Bearer {$issued['plain_token']}", 'Accept' => 'application/json, text/event-stream', ])->postJson(route('mcp.trypost'), [ 'jsonrpc' => '2.0', @@ -245,14 +225,10 @@ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); $member->update(['current_workspace_id' => $this->workspace->id]); - $result = $member->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); + $issued = mcpBearerToken($member, $this->workspace); $this->withHeaders([ - 'Authorization' => "Bearer {$result->accessToken}", + 'Authorization' => "Bearer {$issued['plain_token']}", 'Accept' => 'application/json, text/event-stream', ])->postJson(route('mcp.trypost'), [ 'jsonrpc' => '2.0', @@ -273,14 +249,10 @@ $this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); $viewer->update(['current_workspace_id' => $this->workspace->id]); - $result = $viewer->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); + $issued = mcpBearerToken($viewer, $this->workspace); $this->withHeaders([ - 'Authorization' => "Bearer {$result->accessToken}", + 'Authorization' => "Bearer {$issued['plain_token']}", 'Accept' => 'application/json, text/event-stream', ])->postJson(route('mcp.trypost'), [ 'jsonrpc' => '2.0', @@ -310,14 +282,11 @@ }); test('does not treat oauth tokens with a revoked client as personal access tokens', function () { - $result = $this->user->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); + $issued = mcpBearerToken($this->user, $this->workspace); + $token = $issued['token']; DB::table('oauth_clients') ->where('id', $token->client_id) - ->update([ - 'grant_types' => json_encode(['authorization_code']), - 'revoked' => true, - ]); + ->update(['revoked' => true]); $token = $token->fresh(); @@ -330,19 +299,14 @@ ->assertUnauthorized(); }); -test('rejects mcp oauth when no current workspace is selected', function () { +test('rejects mcp oauth without a bound workspace', function () { subscribeAccount($this->user->account); - $result = $this->user->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); - - $this->user->update(['current_workspace_id' => null]); + $issued = mcpBearerToken($this->user, $this->workspace); + $issued['token']->forceFill(['workspace_id' => null])->saveQuietly(); $this->withHeaders([ - 'Authorization' => "Bearer {$result->accessToken}", + 'Authorization' => "Bearer {$issued['plain_token']}", 'Accept' => 'application/json, text/event-stream', ])->postJson(route('mcp.trypost'), [ 'jsonrpc' => '2.0', @@ -358,7 +322,7 @@ ->assertJson(['message' => 'No workspace selected.']); }); -test('allows mcp oauth that follows the users current workspace', function () { +test('mcp oauth uses its bound workspace even when the user switched current workspace', function () { subscribeAccount($this->user->account); $otherWorkspace = Workspace::factory()->create([ @@ -367,11 +331,7 @@ ]); $otherWorkspace->members()->attach($this->user->id, ['role' => Role::Member->value]); - $result = $this->user->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); + $issued = mcpBearerToken($this->user, $this->workspace); $payload = [ 'jsonrpc' => '2.0', @@ -387,16 +347,100 @@ $this->user->update(['current_workspace_id' => $otherWorkspace->id]); $this->withHeaders([ - 'Authorization' => "Bearer {$result->accessToken}", + 'Authorization' => "Bearer {$issued['plain_token']}", 'Accept' => 'application/json, text/event-stream', ])->postJson(route('mcp.trypost'), $payload)->assertSuccessful(); - $this->user->update(['current_workspace_id' => $this->workspace->id]); + expect($this->user->fresh()->current_workspace_id)->toBe($otherWorkspace->id); + expect($issued['token']->fresh()->workspace_id)->toBe($this->workspace->id); +}); + +test('same mcp client can stay connected to two workspaces independently', function () { + subscribeAccount($this->user->account); + + $workspaceB = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $workspaceB->members()->attach($this->user->id, ['role' => Role::Admin->value]); + + $clientId = mcpOauthClient('Claude'); + $onA = mcpBearerToken($this->user, $this->workspace); + $onB = mcpBearerToken($this->user, $workspaceB); + + $onA['token']->forceFill(['client_id' => $clientId])->saveQuietly(); + $onB['token']->forceFill(['client_id' => $clientId])->saveQuietly(); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => (object) [], + 'clientInfo' => ['name' => 'Pest', 'version' => '1.0'], + ], + ]; $this->withHeaders([ - 'Authorization' => "Bearer {$result->accessToken}", + 'Authorization' => "Bearer {$onA['plain_token']}", 'Accept' => 'application/json, text/event-stream', ])->postJson(route('mcp.trypost'), $payload)->assertSuccessful(); + + $this->withHeaders([ + 'Authorization' => "Bearer {$onB['plain_token']}", + 'Accept' => 'application/json, text/event-stream', + ])->postJson(route('mcp.trypost'), $payload)->assertSuccessful(); + + expect($onA['token']->fresh()->revoked)->toBeFalse() + ->and($onA['token']->fresh()->workspace_id)->toBe($this->workspace->id) + ->and($onB['token']->fresh()->revoked)->toBeFalse() + ->and($onB['token']->fresh()->workspace_id)->toBe($workspaceB->id); +}); + +test('rejects mcp oauth bound to a workspace the user no longer belongs to', function () { + subscribeAccount($this->user->account); + + $member = User::factory()->create(['account_id' => $this->user->account_id]); + $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); + $member->update(['current_workspace_id' => $this->workspace->id]); + + $issued = mcpBearerToken($member, $this->workspace); + $this->workspace->members()->detach($member->id); + + $this->withHeaders([ + 'Authorization' => "Bearer {$issued['plain_token']}", + 'Accept' => 'application/json, text/event-stream', + ])->postJson(route('mcp.trypost'), [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => (object) [], + 'clientInfo' => ['name' => 'Pest', 'version' => '1.0'], + ], + ]) + ->assertForbidden() + ->assertJson(['message' => 'Workspace access denied.']); +}); + +test('personal access token uses its bound workspace even when the user switched current workspace', function () { + subscribeAccount($this->user->account); + + $otherWorkspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + 'name' => 'Workspace B', + ]); + $otherWorkspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + + $this->user->update(['current_workspace_id' => $otherWorkspace->id]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->getJson(route('api.workspace.show')) + ->assertOk() + ->assertJsonPath('id', $this->workspace->id); }); test('records last_used_at on the access token after a successful api request', function () { @@ -419,16 +463,11 @@ test('rejects an expired mcp oauth grant on the mcp endpoint', function () { subscribeAccount($this->user->account); - $result = $this->user->createToken('MCP', ['mcp:use']); - $token = AccessToken::query()->findOrFail($result->token->id); - DB::table('oauth_clients') - ->where('id', $token->client_id) - ->update(['grant_types' => json_encode(['authorization_code'])]); - - $token->forceFill(['expires_at' => now()->subMinute()])->saveQuietly(); + $issued = mcpBearerToken($this->user, $this->workspace); + $issued['token']->forceFill(['expires_at' => now()->subMinute()])->saveQuietly(); $this->withHeaders([ - 'Authorization' => "Bearer {$result->accessToken}", + 'Authorization' => "Bearer {$issued['plain_token']}", 'Accept' => 'application/json, text/event-stream', ])->postJson(route('mcp.trypost'), [ 'jsonrpc' => '2.0', diff --git a/tests/Feature/ApiKeyControllerTest.php b/tests/Feature/ApiKeyControllerTest.php index a93265a7..7208c79c 100644 --- a/tests/Feature/ApiKeyControllerTest.php +++ b/tests/Feature/ApiKeyControllerTest.php @@ -42,6 +42,30 @@ function makeWorkspaceToken(User $user, Workspace $workspace): AccessToken ); }); +it('excludes workspace-bound mcp oauth grants from the api keys page', function () { + makeWorkspaceToken($this->user, $this->workspace); + $oauth = mcpAccessToken($this->user, mcpOauthClient('Claude'), $this->workspace); + + $this->actingAs($this->user) + ->get(route('app.api-keys.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('apiTokens', 1) + ->where('apiTokens', fn ($tokens): bool => collect($tokens)->every( + fn (array $token): bool => $token['id'] !== $oauth->id, + ))); +}); + +it('cannot delete a workspace-bound mcp oauth grant through api keys', function () { + $oauth = mcpAccessToken($this->user, mcpOauthClient('Claude'), $this->workspace); + + $this->actingAs($this->user) + ->delete(route('app.api-keys.destroy', $oauth->id)) + ->assertNotFound(); + + expect($oauth->fresh()->revoked)->toBeFalse(); +}); + it('creates an api key', function () { $this->actingAs($this->user) ->post(route('app.api-keys.store'), ['name' => 'My API Key']) diff --git a/tests/Feature/BackfillMcpOAuthWorkspaceTest.php b/tests/Feature/BackfillMcpOAuthWorkspaceTest.php new file mode 100644 index 00000000..fa970663 --- /dev/null +++ b/tests/Feature/BackfillMcpOAuthWorkspaceTest.php @@ -0,0 +1,206 @@ +create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $other = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $other->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->refresh()->revoked)->toBeTrue() + ->and($token->refresh()->workspace_id)->toBeNull(); +}); + +test('backfill binds the sole account workspace when current is missing', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => null]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->refresh()->workspace_id)->toBe($workspace->id); +}); + +test('backfill revokes tokens that cannot be mapped to a workspace', function () { + $user = User::factory()->create(); + $user->update(['current_workspace_id' => null]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->refresh()->revoked)->toBeTrue() + ->and($token->refresh()->workspace_id)->toBeNull(); +}); + +test('backfill ignores personal access tokens with null workspace', function () { + $user = User::factory()->create(); + $result = $user->createToken('PAT'); + $token = $result->token; + $token->forceFill(['workspace_id' => null])->saveQuietly(); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->fresh()->revoked)->toBeFalse() + ->and($token->fresh()->workspace_id)->toBeNull(); +}); + +test('backfill ignores oauth tokens without the mcp use scope', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null, scopes: []); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->fresh()->workspace_id)->toBeNull() + ->and($token->fresh()->revoked)->toBeFalse(); +}); + +test('backfill revokes when multiple workspaces exist without a valid current', function () { + $user = User::factory()->create(); + $alpha = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $beta = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $alpha->members()->attach($user->id, ['role' => Role::Admin->value]); + $beta->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => null]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->refresh()->revoked)->toBeTrue() + ->and($token->refresh()->workspace_id)->toBeNull(); +}); + +test('backfill binds the remaining membership when current workspace was left', function () { + $user = User::factory()->create(); + $current = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $other = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $other->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $current->id]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->refresh()->workspace_id)->toBe($other->id) + ->and($token->refresh()->revoked)->toBeFalse(); +}); + +test('backfill leaves dead expired mcp grants untouched', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null); + $token->forceFill(['expires_at' => now()->subDay()])->saveQuietly(); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->fresh()->workspace_id)->toBeNull() + ->and($token->fresh()->revoked)->toBeFalse(); +}); + +test('backfill binds expired access tokens that still have a live refresh token', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null); + $token->forceFill(['expires_at' => now()->subDay()])->saveQuietly(); + + DB::table('oauth_refresh_tokens')->insert([ + 'id' => Str::random(80), + 'access_token_id' => $token->id, + 'revoked' => false, + 'expires_at' => now()->addMonth(), + ]); + + backfillMcpOAuthTokenWorkspacesMigration()->up(); + + expect($token->refresh()->workspace_id)->toBe($workspace->id) + ->and($token->refresh()->revoked)->toBeFalse(); +}); + +test('backfill rolls back binds when the migration fails before commit', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $token = mcpAccessToken($user, mcpOauthClient(), workspace: null); + + $migration = backfillMcpOAuthTokenWorkspacesMigration(); + $migration->beforeCommit = function (): void { + throw new RuntimeException('forced backfill failure'); + }; + + expect(fn () => $migration->up()) + ->toThrow(RuntimeException::class, 'forced backfill failure'); + + expect($token->fresh()->workspace_id)->toBeNull() + ->and($token->fresh()->revoked)->toBeFalse(); +}); diff --git a/tests/Feature/Mcp/ApiKeyToolTest.php b/tests/Feature/Mcp/ApiKeyToolTest.php index e646f0d6..87cd4c54 100644 --- a/tests/Feature/Mcp/ApiKeyToolTest.php +++ b/tests/Feature/Mcp/ApiKeyToolTest.php @@ -51,15 +51,10 @@ function attachToken(User $user, Workspace $workspace): AccessToken }); }); -test('list api keys excludes OAuth tokens (workspace_id null)', function () { - // Personal Access Token (workspace bound) +test('list api keys excludes workspace-bound MCP OAuth grants', function () { attachToken($this->user, $this->workspace); - // OAuth-flow token (workspace_id null — like ChatGPT MCP session) - $oauthResult = $this->user->createToken('OAuth Session'); - AccessToken::find($oauthResult->token->id) - ->forceFill(['workspace_id' => null]) - ->saveQuietly(); + mcpAccessToken($this->user, mcpOauthClient('ChatGPT'), $this->workspace); $response = TryPostServer::actingAs($this->user) ->tool(ListApiKeysTool::class, []); @@ -215,11 +210,8 @@ function attachToken(User $user, Workspace $workspace): AccessToken $response->assertHasErrors(['API key not found.']); }); -test('cannot delete OAuth-flow token through this tool', function () { - // OAuth token has workspace_id null — DeleteApiKeyTool filter excludes it - $oauthResult = $this->user->createToken('OAuth Session'); - $oauthToken = AccessToken::find($oauthResult->token->id); - $oauthToken->forceFill(['workspace_id' => null])->saveQuietly(); +test('cannot delete workspace-bound MCP OAuth through the api key tool', function () { + $oauthToken = mcpAccessToken($this->user, mcpOauthClient('ChatGPT'), $this->workspace); $response = TryPostServer::actingAs($this->user) ->tool(DeleteApiKeyTool::class, ['api_key_id' => $oauthToken->id]); diff --git a/tests/Feature/Mcp/OAuthRegistrationTest.php b/tests/Feature/Mcp/OAuthRegistrationTest.php index 8ba1f83d..ccae950d 100644 --- a/tests/Feature/Mcp/OAuthRegistrationTest.php +++ b/tests/Feature/Mcp/OAuthRegistrationTest.php @@ -7,6 +7,7 @@ use App\Models\Account; use App\Models\User; use App\Models\Workspace; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; test('dynamic oauth client registration is rate limited', function () { @@ -38,13 +39,14 @@ 'vscode' => 'vscode://oauth/callback', ]); -test('mcp oauth consent view is available for workspace viewers', function () { +test('mcp oauth consent page is available for workspace viewers', function () { $account = Account::factory()->create(); $owner = User::factory()->create(['account_id' => $account->id]); $account->update(['owner_id' => $owner->id]); $workspace = Workspace::factory()->create([ 'account_id' => $account->id, 'user_id' => $owner->id, + 'name' => 'Viewer Workspace', ]); $workspace->members()->attach($owner->id, ['role' => Role::Admin->value]); @@ -52,24 +54,107 @@ $workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); $viewer->update(['current_workspace_id' => $workspace->id]); - $html = view('mcp.authorize', [ - 'client' => (object) [ - 'id' => (string) Str::uuid(), - 'name' => 'Viewer Agent', - ], - 'user' => $viewer, - 'scopes' => collect([(object) ['description' => 'Use MCP server']]), - 'authToken' => 'test-auth-token', - 'request' => request(), - ])->render(); + $clientId = mcpOauthClient('Viewer Agent'); + DB::table('oauth_clients')->where('id', $clientId)->update([ + 'redirect_uris' => json_encode(['https://client.example/callback']), + ]); - expect($html) - ->toContain('Authorize Viewer Agent') - ->toContain($viewer->email) - ->and(view()->exists('mcp.authorize-denied'))->toBeFalse() + $this->actingAs($viewer) + ->get(route('passport.authorizations.authorize', oauthAuthorizeQuery($clientId))) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('mcp/Authorize') + ->where('client.name', 'Viewer Agent') + ->where('user.email', $viewer->email) + ->where('selectedWorkspaceId', (string) $workspace->id) + ->has('workspaces', 1) + ->where('workspaces.0.id', (string) $workspace->id) + ->where('workspaces.0.name', 'Viewer Workspace') + ->has('scopes', 1) + ->where('scopes.0.id', 'mcp:use') + ->has('authToken') + ->where('state', 'test-state')); + + expect(view()->exists('mcp.authorize-denied'))->toBeFalse() ->and(class_exists(EnsureCanAuthorizeMcp::class))->toBeFalse(); }); +test('mcp oauth consent page uses the active locale', function () { + app()->setLocale('pt-BR'); + + expect(__('mcp.authorize.heading', ['client' => 'Claude']))->toBe('Autorizar Claude') + ->and(__('mcp.authorize.logged_in_as'))->toBe('Conectado como:') + ->and(__('mcp.authorize.workspace_scope'))->toBe('Esta conexão terá acesso somente ao workspace selecionado.') + ->and(__('mcp.authorize.approve'))->toBe('Autorizar') + ->and(__('mcp.authorize.cancel'))->toBe('Cancelar'); +}); + +test('mcp oauth consent page lists every workspace the user can access', function () { + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + + $alpha = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + 'name' => 'Alpha', + ]); + $beta = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + 'name' => 'Beta', + ]); + $alpha->members()->attach($user->id, ['role' => Role::Admin->value]); + $beta->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $alpha->id]); + + $clientId = mcpOauthClient('Claude'); + DB::table('oauth_clients')->where('id', $clientId)->update([ + 'redirect_uris' => json_encode(['https://client.example/callback']), + ]); + + $this->actingAs($user) + ->get(route('passport.authorizations.authorize', oauthAuthorizeQuery($clientId))) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('mcp/Authorize') + ->where('selectedWorkspaceId', (string) $alpha->id) + ->has('workspaces', 2) + ->where('workspaces.0.name', 'Alpha') + ->where('workspaces.1.name', 'Beta') + ->where('workspaces.0.id', (string) $alpha->id) + ->where('workspaces.1.id', (string) $beta->id)); +}); + +test('mcp oauth always shows consent even when scopes were previously granted', function () { + $account = Account::factory()->create(); + $user = User::factory()->create(['account_id' => $account->id]); + $account->update(['owner_id' => $user->id]); + $workspace = Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + $clientId = mcpOauthClient('Reconnect Agent'); + DB::table('oauth_clients')->where('id', $clientId)->update([ + 'redirect_uris' => json_encode(['https://client.example/callback']), + ]); + mcpAccessToken($user, $clientId, $workspace); + + $query = oauthAuthorizeQuery($clientId); + unset($query['prompt']); + + $this->actingAs($user) + ->get(route('passport.authorizations.authorize', $query)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('mcp/Authorize') + ->where('client.name', 'Reconnect Agent') + ->where('selectedWorkspaceId', (string) $workspace->id)); +}); + test('passport approve route has no mcp create-post role gate', function () { $route = app('router')->getRoutes()->getByName('passport.authorizations.approve'); @@ -81,3 +166,23 @@ expect($middleware)->not->toContain('EnsureCanAuthorizeMcp'); }); + +/** + * @return array + */ +function oauthAuthorizeQuery(string $clientId, string $redirectUri = 'https://client.example/callback'): array +{ + $verifier = Str::random(64); + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + return [ + 'client_id' => $clientId, + 'redirect_uri' => $redirectUri, + 'response_type' => 'code', + 'scope' => 'mcp:use', + 'state' => 'test-state', + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + 'prompt' => 'consent', + ]; +} diff --git a/tests/Feature/McpOAuthWorkspaceBindingTest.php b/tests/Feature/McpOAuthWorkspaceBindingTest.php new file mode 100644 index 00000000..de44cc1a --- /dev/null +++ b/tests/Feature/McpOAuthWorkspaceBindingTest.php @@ -0,0 +1,364 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + $this->user->refresh(); + $this->clientId = mcpOauthClient(); + $this->decryptor = app(OAuthPayloadDecryptor::class); +}); + +test('authorization code grant binds workspace from the encrypted auth code payload', function () { + $authCodeId = Str::random(80); + $tokenId = Str::random(80); + + AuthCode::query()->forceCreate([ + 'id' => $authCodeId, + 'user_id' => $this->user->id, + 'client_id' => $this->clientId, + 'workspace_id' => $this->workspace->id, + 'scopes' => '[]', + 'revoked' => true, + 'expires_at' => now()->addMinutes(10), + ]); + + // User switched workspace between consent and token exchange — auth code wins. + $otherWorkspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $otherWorkspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->update(['current_workspace_id' => $otherWorkspace->id]); + + request()->merge([ + 'grant_type' => 'authorization_code', + 'code' => $this->decryptor->encrypt([ + 'client_id' => $this->clientId, + 'auth_code_id' => $authCodeId, + 'user_id' => (string) $this->user->id, + 'scopes' => [], + 'expire_time' => now()->addMinutes(10)->timestamp, + ]), + ]); + + persistAccessTokenEntity($tokenId, (string) $this->user->id, $this->clientId); + + $token = AccessToken::query()->findOrFail($tokenId); + + expect($token->workspace_id)->toBe($this->workspace->id); +}); + +test('refresh grant inherits workspace from the refreshed access token id', function () { + $previous = mcpAccessToken($this->user, $this->clientId, $this->workspace); + $tokenId = Str::random(80); + + $otherWorkspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $otherWorkspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + + // Newer grant on another workspace for the same client must not win. + mcpAccessToken($this->user, $this->clientId, $otherWorkspace); + $this->user->update(['current_workspace_id' => $otherWorkspace->id]); + + request()->merge([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->decryptor->encrypt([ + 'client_id' => $this->clientId, + 'refresh_token_id' => Str::random(80), + 'access_token_id' => $previous->id, + 'scopes' => [], + 'user_id' => (string) $this->user->id, + 'expire_time' => now()->addMonth()->timestamp, + ]), + ]); + + persistAccessTokenEntity($tokenId, (string) $this->user->id, $this->clientId); + + $refreshed = AccessToken::query()->findOrFail($tokenId); + + expect($refreshed->workspace_id)->toBe($previous->workspace_id) + ->and($refreshed->workspace_id)->not->toBe($otherWorkspace->id); +}); + +test('refresh grant fails when the user no longer belongs to the bound workspace', function () { + $previous = mcpAccessToken($this->user, $this->clientId, $this->workspace); + $tokenId = Str::random(80); + + $this->workspace->members()->detach($this->user->id); + + request()->merge([ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->decryptor->encrypt([ + 'client_id' => $this->clientId, + 'refresh_token_id' => Str::random(80), + 'access_token_id' => $previous->id, + 'scopes' => [], + 'user_id' => (string) $this->user->id, + 'expire_time' => now()->addMonth()->timestamp, + ]), + ]); + + expect(fn () => persistAccessTokenEntity($tokenId, (string) $this->user->id, $this->clientId)) + ->toThrow(OAuthServerException::class); + + expect(AccessToken::query()->find($tokenId))->toBeNull(); +}); + +test('unexpected grant types fail closed instead of using the current workspace', function () { + $tokenId = Str::random(80); + + request()->merge(['grant_type' => 'client_credentials']); + + expect(fn () => persistAccessTokenEntity($tokenId, (string) $this->user->id, $this->clientId)) + ->toThrow(OAuthServerException::class); + + expect(AccessToken::query()->find($tokenId))->toBeNull(); +}); + +test('authorization code grant fails when the user left the auth code workspace before exchange', function () { + $authCodeId = Str::random(80); + $tokenId = Str::random(80); + + AuthCode::query()->forceCreate([ + 'id' => $authCodeId, + 'user_id' => $this->user->id, + 'client_id' => $this->clientId, + 'workspace_id' => $this->workspace->id, + 'scopes' => '[]', + 'revoked' => true, + 'expires_at' => now()->addMinutes(10), + ]); + + $this->workspace->members()->detach($this->user->id); + + request()->merge([ + 'grant_type' => 'authorization_code', + 'code' => $this->decryptor->encrypt([ + 'client_id' => $this->clientId, + 'auth_code_id' => $authCodeId, + 'user_id' => (string) $this->user->id, + 'scopes' => [], + 'expire_time' => now()->addMinutes(10)->timestamp, + ]), + ]); + + expect(fn () => persistAccessTokenEntity($tokenId, (string) $this->user->id, $this->clientId)) + ->toThrow(OAuthServerException::class); + + expect(AccessToken::query()->find($tokenId))->toBeNull(); +}); + +test('personal access tokens are left alone for controllers to bind', function () { + $result = $this->user->createToken('PAT'); + $token = AccessToken::query()->find($result->token->id); + + expect($token->workspace_id)->toBeNull(); +}); + +test('auth code repository captures workspace_id from the consent form', function () { + $this->actingAs($this->user); + request()->merge(['workspace_id' => $this->workspace->id]); + + $client = Mockery::mock(ClientEntityInterface::class); + $client->shouldReceive('getIdentifier')->andReturn($this->clientId); + + $entity = Mockery::mock(AuthCodeEntityInterface::class); + $entity->shouldReceive('getIdentifier')->andReturn(Str::random(80)); + $entity->shouldReceive('getUserIdentifier')->andReturn((string) $this->user->id); + $entity->shouldReceive('getClient')->andReturn($client); + $entity->shouldReceive('getScopes')->andReturn([]); + $entity->shouldReceive('getExpiryDateTime')->andReturn(now()->addMinutes(10)->toDateTimeImmutable()); + + app(AuthCodeRepository::class)->persistNewAuthCode($entity); + + $stored = AuthCode::query()->where('client_id', $this->clientId)->first(); + + expect($stored)->not->toBeNull(); + expect($stored->workspace_id)->toBe($this->workspace->id); +}); + +test('auth code repository prefers workspace_id from the consent form', function () { + $otherWorkspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + 'name' => 'Workspace B', + ]); + $otherWorkspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + + // Current workspace stays A; consent form picks B. + $this->actingAs($this->user); + request()->merge(['workspace_id' => $otherWorkspace->id]); + + $client = Mockery::mock(ClientEntityInterface::class); + $client->shouldReceive('getIdentifier')->andReturn($this->clientId); + + $entity = Mockery::mock(AuthCodeEntityInterface::class); + $entity->shouldReceive('getIdentifier')->andReturn(Str::random(80)); + $entity->shouldReceive('getUserIdentifier')->andReturn((string) $this->user->id); + $entity->shouldReceive('getClient')->andReturn($client); + $entity->shouldReceive('getScopes')->andReturn([]); + $entity->shouldReceive('getExpiryDateTime')->andReturn(now()->addMinutes(10)->toDateTimeImmutable()); + + app(AuthCodeRepository::class)->persistNewAuthCode($entity); + + $stored = AuthCode::query()->where('client_id', $this->clientId)->first(); + + expect($stored->workspace_id)->toBe($otherWorkspace->id); + expect($this->user->fresh()->current_workspace_id)->toBe($this->workspace->id); +}); + +test('auth code repository rejects a consent workspace the user does not belong to', function () { + $foreign = Workspace::factory()->create(); + + $this->actingAs($this->user); + request()->merge(['workspace_id' => $foreign->id]); + + $client = Mockery::mock(ClientEntityInterface::class); + $client->shouldReceive('getIdentifier')->andReturn($this->clientId); + + $entity = Mockery::mock(AuthCodeEntityInterface::class); + $entity->shouldReceive('getIdentifier')->andReturn(Str::random(80)); + $entity->shouldReceive('getUserIdentifier')->andReturn((string) $this->user->id); + $entity->shouldReceive('getClient')->andReturn($client); + $entity->shouldReceive('getScopes')->andReturn([]); + $entity->shouldReceive('getExpiryDateTime')->andReturn(now()->addMinutes(10)->toDateTimeImmutable()); + + app(AuthCodeRepository::class)->persistNewAuthCode($entity); + + $stored = AuthCode::query()->where('client_id', $this->clientId)->first(); + + expect($stored->workspace_id)->toBeNull(); +}); + +test('auth code repository requires workspace_id from the consent form', function () { + $otherWorkspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + 'name' => 'Workspace B', + ]); + $otherWorkspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->update(['current_workspace_id' => $otherWorkspace->id]); + $this->actingAs($this->user->fresh()); + // No workspace_id in the request — current workspace must not be used. + + $client = Mockery::mock(ClientEntityInterface::class); + $client->shouldReceive('getIdentifier')->andReturn($this->clientId); + + $entity = Mockery::mock(AuthCodeEntityInterface::class); + $entity->shouldReceive('getIdentifier')->andReturn(Str::random(80)); + $entity->shouldReceive('getUserIdentifier')->andReturn((string) $this->user->id); + $entity->shouldReceive('getClient')->andReturn($client); + $entity->shouldReceive('getScopes')->andReturn([]); + $entity->shouldReceive('getExpiryDateTime')->andReturn(now()->addMinutes(10)->toDateTimeImmutable()); + + app(AuthCodeRepository::class)->persistNewAuthCode($entity); + + $stored = AuthCode::query()->where('client_id', $this->clientId)->first(); + + expect($stored->workspace_id)->toBeNull(); +}); + +test('auth code repository rejects a consent workspace the user no longer belongs to', function () { + $this->workspace->members()->detach($this->user->id); + $this->actingAs($this->user->fresh()); + request()->merge(['workspace_id' => $this->workspace->id]); + + $client = Mockery::mock(ClientEntityInterface::class); + $client->shouldReceive('getIdentifier')->andReturn($this->clientId); + + $entity = Mockery::mock(AuthCodeEntityInterface::class); + $entity->shouldReceive('getIdentifier')->andReturn(Str::random(80)); + $entity->shouldReceive('getUserIdentifier')->andReturn((string) $this->user->id); + $entity->shouldReceive('getClient')->andReturn($client); + $entity->shouldReceive('getScopes')->andReturn([]); + $entity->shouldReceive('getExpiryDateTime')->andReturn(now()->addMinutes(10)->toDateTimeImmutable()); + + app(AuthCodeRepository::class)->persistNewAuthCode($entity); + + $stored = AuthCode::query()->where('client_id', $this->clientId)->first(); + + expect($stored->workspace_id)->toBeNull(); +}); + +test('oauth grant without a resolvable workspace fails before the token is saved', function () { + $this->user->update(['current_workspace_id' => null]); + $this->workspace->members()->detach($this->user->id); + + $tokenId = Str::random(80); + + request()->merge(['grant_type' => 'authorization_code']); + + expect(fn () => persistAccessTokenEntity($tokenId, (string) $this->user->id, $this->clientId)) + ->toThrow(OAuthServerException::class); + + expect(AccessToken::query()->find($tokenId))->toBeNull(); +}); + +test('authorization code grant does not fall back to the users current workspace', function () { + $authCodeId = Str::random(80); + $tokenId = Str::random(80); + + AuthCode::query()->forceCreate([ + 'id' => $authCodeId, + 'user_id' => $this->user->id, + 'client_id' => $this->clientId, + 'workspace_id' => null, + 'scopes' => '[]', + 'revoked' => true, + 'expires_at' => now()->addMinutes(10), + ]); + + request()->merge([ + 'grant_type' => 'authorization_code', + 'code' => $this->decryptor->encrypt([ + 'client_id' => $this->clientId, + 'auth_code_id' => $authCodeId, + 'user_id' => (string) $this->user->id, + 'scopes' => [], + 'expire_time' => now()->addMinutes(10)->timestamp, + ]), + ]); + + expect(fn () => persistAccessTokenEntity($tokenId, (string) $this->user->id, $this->clientId)) + ->toThrow(OAuthServerException::class); + + expect(AccessToken::query()->find($tokenId))->toBeNull(); +}); + +function persistAccessTokenEntity(string $tokenId, string $userId, string $clientId): void +{ + $client = Mockery::mock(ClientEntityInterface::class); + $client->shouldReceive('getIdentifier')->andReturn($clientId); + + $entity = Mockery::mock(AccessTokenEntityInterface::class); + $entity->shouldReceive('getIdentifier')->andReturn($tokenId); + $entity->shouldReceive('getUserIdentifier')->andReturn($userId); + $entity->shouldReceive('getClient')->andReturn($client); + $entity->shouldReceive('getScopes')->andReturn([]); + $entity->shouldReceive('getExpiryDateTime')->andReturn(now()->addHour()->toDateTimeImmutable()); + + app(AccessTokenRepository::class)->persistNewAccessToken($entity); +} diff --git a/tests/Feature/McpSettingsControllerTest.php b/tests/Feature/McpSettingsControllerTest.php index 76fd35f8..d4a8c38d 100644 --- a/tests/Feature/McpSettingsControllerTest.php +++ b/tests/Feature/McpSettingsControllerTest.php @@ -55,10 +55,10 @@ $member->update(['current_workspace_id' => $this->workspace->id]); $ownerClientId = mcpOauthClient('Owner Agent'); - mcpAccessToken($this->user, $ownerClientId); + mcpAccessToken($this->user, $ownerClientId, $this->workspace); $memberClientId = mcpOauthClient('Member Agent'); - mcpAccessToken($member, $memberClientId); + mcpAccessToken($member, $memberClientId, $this->workspace); $pat = $this->user->createToken('API Key'); AccessToken::query()->findOrFail($pat->token->id) @@ -77,7 +77,7 @@ }); it('excludes unscoped oauth grants from connected clients', function (): void { - mcpAccessToken($this->user, mcpOauthClient('Unscoped Agent'), scopes: []); + mcpAccessToken($this->user, mcpOauthClient('Unscoped Agent'), $this->workspace, scopes: []); $this->actingAs($this->user) ->get(route('app.mcp.index')) @@ -89,7 +89,7 @@ $this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); $viewer->update(['current_workspace_id' => $this->workspace->id]); - mcpAccessToken($viewer, mcpOauthClient('Viewer Agent')); + mcpAccessToken($viewer, mcpOauthClient('Viewer Agent'), $this->workspace); $this->actingAs($viewer->fresh()) ->get(route('app.mcp.index')) @@ -101,7 +101,7 @@ it('disconnects a client by revoking its tokens', function (): void { $clientId = mcpOauthClient(); - $token = mcpAccessToken($this->user, $clientId); + $token = mcpAccessToken($this->user, $clientId, $this->workspace); $this->actingAs($this->user) ->delete(route('app.mcp.disconnect', ['client' => $clientId])) @@ -113,7 +113,7 @@ it('disconnects a client when its access token expired but its refresh token is live', function (): void { $clientId = mcpOauthClient(); - $token = mcpAccessToken($this->user, $clientId); + $token = mcpAccessToken($this->user, $clientId, $this->workspace); $token->forceFill(['expires_at' => now()->subMinute()])->saveQuietly(); $refreshTokenId = Str::random(80); DB::table('oauth_refresh_tokens')->insert([ @@ -134,7 +134,7 @@ it('lists a client when its access token expired but its refresh token is live', function (): void { $clientId = mcpOauthClient('Recoverable Agent'); - $token = mcpAccessToken($this->user, $clientId); + $token = mcpAccessToken($this->user, $clientId, $this->workspace); $token->forceFill(['expires_at' => now()->subMinute()])->saveQuietly(); DB::table('oauth_refresh_tokens')->insert([ 'id' => Str::random(80), @@ -154,7 +154,7 @@ it('hides a client when both access and refresh tokens are expired', function (): void { $clientId = mcpOauthClient('Dead Agent'); - $token = mcpAccessToken($this->user, $clientId); + $token = mcpAccessToken($this->user, $clientId, $this->workspace); $token->forceFill(['expires_at' => now()->subMinute()])->saveQuietly(); DB::table('oauth_refresh_tokens')->insert([ 'id' => Str::random(80), @@ -181,7 +181,7 @@ $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); $member->update(['current_workspace_id' => $this->workspace->id]); - mcpAccessToken($this->user, mcpOauthClient('Owner Agent')); + mcpAccessToken($this->user, mcpOauthClient('Owner Agent'), $this->workspace); $this->actingAs($member->fresh()) ->get(route('app.mcp.index')) @@ -195,7 +195,7 @@ $member->update(['current_workspace_id' => $this->workspace->id]); $clientId = mcpOauthClient('Member Agent'); - $token = mcpAccessToken($member, $clientId); + $token = mcpAccessToken($member, $clientId, $this->workspace); $this->actingAs($member->fresh()) ->get(route('app.mcp.index')) @@ -218,7 +218,7 @@ $viewer->update(['current_workspace_id' => $this->workspace->id]); $clientId = mcpOauthClient('Viewer Agent'); - $token = mcpAccessToken($viewer, $clientId); + $token = mcpAccessToken($viewer, $clientId, $this->workspace); $this->actingAs($viewer->fresh()) ->get(route('app.mcp.index')) @@ -243,7 +243,7 @@ $member->update(['current_workspace_id' => $this->workspace->id]); $clientId = mcpOauthClient('Owner Agent'); - $token = mcpAccessToken($this->user, $clientId); + $token = mcpAccessToken($this->user, $clientId, $this->workspace); $this->actingAs($member->fresh()) ->delete(route('app.mcp.disconnect', ['client' => $clientId])) @@ -266,6 +266,91 @@ expect($token->fresh()->revoked)->toBeFalse(); }); +it('lists only mcp connections for the current workspace', function (): void { + $workspaceB = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + 'name' => 'Workspace B', + ]); + $workspaceB->members()->attach($this->user->id, ['role' => Role::Admin->value]); + + $clientId = mcpOauthClient('Claude'); + mcpAccessToken($this->user, $clientId, $this->workspace); + mcpAccessToken($this->user, $clientId, $workspaceB); + + $this->actingAs($this->user) + ->get(route('app.mcp.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('connectedClients', 1) + ->where('connectedClients.0.client_id', $clientId) + ->where('connectedClients.0.name', 'Claude')); + + $this->user->update(['current_workspace_id' => $workspaceB->id]); + + $this->actingAs($this->user->fresh()) + ->get(route('app.mcp.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('connectedClients', 1) + ->where('connectedClients.0.client_id', $clientId) + ->where('connectedClients.0.name', 'Claude')); +}); + +it('orders connected mcp clients by last used descending', function (): void { + $older = mcpAccessToken($this->user, mcpOauthClient('Older Agent'), $this->workspace); + $newer = mcpAccessToken($this->user, mcpOauthClient('Newer Agent'), $this->workspace); + + $older->forceFill(['last_used_at' => now()->subDay()])->saveQuietly(); + $newer->forceFill(['last_used_at' => now()])->saveQuietly(); + + $this->actingAs($this->user) + ->get(route('app.mcp.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('connectedClients', 2) + ->where('connectedClients.0.name', 'Newer Agent') + ->where('connectedClients.1.name', 'Older Agent')); +}); + +it('does not list unbound mcp grants', function (): void { + mcpAccessToken($this->user, mcpOauthClient('Legacy Agent'), workspace: null); + + $this->actingAs($this->user) + ->get(route('app.mcp.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('connectedClients', [])); +}); + +it('disconnects a client only on the current workspace', function (): void { + $workspaceB = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $workspaceB->members()->attach($this->user->id, ['role' => Role::Admin->value]); + + $clientId = mcpOauthClient('Claude'); + $onA = mcpAccessToken($this->user, $clientId, $this->workspace); + $onB = mcpAccessToken($this->user, $clientId, $workspaceB); + + $this->actingAs($this->user) + ->delete(route('app.mcp.disconnect', ['client' => $clientId])) + ->assertRedirect() + ->assertSessionHas('flash.success'); + + expect($onA->fresh()->revoked)->toBeTrue() + ->and($onB->fresh()->revoked)->toBeFalse(); + + $this->user->update(['current_workspace_id' => $workspaceB->id]); + + $this->actingAs($this->user->fresh()) + ->get(route('app.mcp.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('connectedClients', 1) + ->where('connectedClients.0.client_id', $clientId)); +}); + it('requires authentication', function (): void { $this->get(route('app.mcp.index'))->assertRedirect(); }); diff --git a/tests/Feature/Passport/AuthorizationViewTest.php b/tests/Feature/Passport/AuthorizationViewTest.php new file mode 100644 index 00000000..392a66c1 --- /dev/null +++ b/tests/Feature/Passport/AuthorizationViewTest.php @@ -0,0 +1,85 @@ +create(); + $alpha = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'name' => 'Alpha', + ]); + $beta = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'name' => 'Beta', + ]); + $alpha->members()->attach($user->id, ['role' => Role::Admin->value]); + $beta->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $beta->id]); + + $props = authorizationViewProps($user, state: 'consent-state'); + + expect($props) + ->client->id->toBe('client-1') + ->client->name->toBe('Claude') + ->user->email->toBe($user->email) + ->selectedWorkspaceId->toBe($beta->id) + ->workspaces->toHaveCount(2) + ->workspaces->{0}->name->toBe('Alpha') + ->workspaces->{1}->name->toBe('Beta') + ->scopes->{0}->id->toBe('mcp:use') + ->authToken->toBe('auth-token') + ->state->toBe('consent-state'); +}); + +test('authorization view falls back to the first workspace when current is unavailable', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + 'name' => 'Only', + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => null]); + + $props = authorizationViewProps($user); + + expect($props['selectedWorkspaceId'])->toBe($workspace->id) + ->and($props['workspaces'])->toHaveCount(1); +}); + +test('authorization view returns empty workspace props without a user', function () { + $props = authorizationViewProps(user: null); + + expect($props['workspaces'])->toBe([]) + ->and($props['selectedWorkspaceId'])->toBe('') + ->and($props['user']['email'])->toBeNull(); +}); + +/** + * @return array + */ +function authorizationViewProps(?User $user, string $state = ''): array +{ + $request = Request::create('/oauth/authorize', 'GET', ['state' => $state]); + $request->headers->set(Header::INERTIA, 'true'); + + $response = app(AuthorizationView::class)([ + 'client' => (object) ['id' => 'client-1', 'name' => 'Claude'], + 'user' => $user, + 'scopes' => [new Scope('mcp:use', 'Use MCP server')], + 'authToken' => 'auth-token', + 'request' => $request, + ])->toResponse($request); + + return $response->getData(true)['props']; +} diff --git a/tests/Feature/WorkspaceInviteControllerTest.php b/tests/Feature/WorkspaceInviteControllerTest.php index c9d396af..72c3c271 100644 --- a/tests/Feature/WorkspaceInviteControllerTest.php +++ b/tests/Feature/WorkspaceInviteControllerTest.php @@ -326,7 +326,7 @@ $this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]); $member->update(['current_workspace_id' => $this->workspace->id]); - $oauth = mcpAccessToken($member, mcpOauthClient()); + $oauth = mcpAccessToken($member, mcpOauthClient(), $this->workspace); $refreshTokenId = (string) Str::uuid(); DB::table('oauth_refresh_tokens')->insert([ 'id' => $refreshTokenId, @@ -357,7 +357,7 @@ $this->workspace->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]); $other->members()->attach($member->id, ['role' => WorkspaceRole::Member->value]); $member->update(['current_workspace_id' => $this->workspace->id]); - $oauth = mcpAccessToken($member, mcpOauthClient()); + $oauth = mcpAccessToken($member, mcpOauthClient(), $this->workspace); $response = $this->actingAs($this->user)->put(route('app.members.update-role', $member), [ 'role' => WorkspaceRole::Viewer->value, diff --git a/tests/Pest.php b/tests/Pest.php index 1397c2aa..acfc4b9d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -176,7 +176,7 @@ function mcpOauthClient(string $name = 'My Agent'): string 'secret' => null, 'provider' => null, 'redirect_uris' => '[]', - 'grant_types' => json_encode(['authorization_code']), + 'grant_types' => json_encode(['authorization_code', 'refresh_token']), 'revoked' => false, 'created_at' => now(), 'updated_at' => now(), @@ -190,22 +190,52 @@ function mcpOauthClient(string $name = 'My Agent'): string * * @param list $scopes */ -function mcpAccessToken(User $user, string $clientId, array $scopes = ['mcp:use']): AccessToken -{ +function mcpAccessToken( + User $user, + string $clientId, + ?Workspace $workspace = null, + array $scopes = ['mcp:use'], +): AccessToken { $token = new AccessToken; $token->forceFill([ 'id' => Str::random(80), 'user_id' => $user->id, 'client_id' => $clientId, - 'workspace_id' => null, + 'workspace_id' => $workspace?->id, 'name' => 'MCP', 'scopes' => $scopes, 'revoked' => false, + 'expires_at' => now()->addYear(), ])->save(); return $token->refresh(); } +/** + * Issue a Passport token, attach it to a dedicated MCP OAuth client, and bind + * it to a workspace — the post-#222 shape used by middleware / MCP endpoint tests. + * + * @param list $scopes + * @return array{token: AccessToken, plain_token: string} + */ +function mcpBearerToken(User $user, Workspace $workspace, array $scopes = ['mcp:use']): array +{ + $result = $user->createToken('MCP', $scopes); + $token = AccessToken::query()->findOrFail($result->token->id); + + // Reassign to a dedicated MCP client so we never mutate Passport's shared + // personal-access client (which would poison PAT fixtures in the same run). + $token->forceFill([ + 'client_id' => mcpOauthClient(), + 'workspace_id' => $workspace->id, + ])->saveQuietly(); + + return [ + 'token' => $token->refresh(), + 'plain_token' => $result->accessToken, + ]; +} + /** * Move a member onto a shared account (stranded-member / invitee fixture). * diff --git a/tests/Unit/RevokeAccessTokensTest.php b/tests/Unit/RevokeAccessTokensTest.php new file mode 100644 index 00000000..d876377d --- /dev/null +++ b/tests/Unit/RevokeAccessTokensTest.php @@ -0,0 +1,45 @@ +create(); + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + + $token = mcpAccessToken($user, mcpOauthClient(), $workspace); + $refreshId = Str::random(80); + + DB::table('oauth_refresh_tokens')->insert([ + 'id' => $refreshId, + 'access_token_id' => $token->id, + 'revoked' => false, + 'expires_at' => now()->addMonth(), + ]); + + RevokeAccessTokens::execute($token); + + expect(AccessToken::query()->find($token->id)->revoked)->toBeTrue(); + expect(DB::table('oauth_refresh_tokens')->where('id', $refreshId)->value('revoked'))->toBeTrue(); +}); + +test('ignores already revoked tokens without error', function () { + $user = User::factory()->create(); + $token = mcpAccessToken($user, mcpOauthClient()); + $token->forceFill(['revoked' => true])->saveQuietly(); + + RevokeAccessTokens::execute([$token]); + + expect(AccessToken::query()->find($token->id)->revoked)->toBeTrue(); +});