From 2ca594830908cf73d0f932c506093e5844553ceb Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 6 Aug 2026 20:59:34 -0400 Subject: [PATCH] Scope MCP OAuth tokens to user + workspace (#222) (#245) * Scope MCP OAuth tokens to user + workspace Bind authorization-code grants to the authorizing workspace (via auth codes), inherit workspace on refresh, resolve MCP/API requests from the token instead of current_workspace_id, backfill existing grants, and revoke workspace tokens when a member is removed. Co-authored-by: Cursor * Add multi-workspace MCP OAuth coverage Cover coexistence of the same client across workspaces, settings list/disconnect scoped to the current workspace, and API key controllers excluding workspace-bound MCP grants. Co-authored-by: Cursor * Use constrained foreignUuid for oauth_auth_codes.workspace_id Match the project's UUID foreign-key convention instead of a separate foreign() call. Co-authored-by: Cursor * Localize the MCP OAuth authorize consent screen Wire authorize.blade.php to mcp.* translation keys (including the workspace scope copy) and cover pt-BR rendering. Co-authored-by: Cursor * Fix invalid Mockery import in bind workspace test CI treats the non-compound `use Mockery` as an ErrorException and aborts the whole parallel suite. Co-authored-by: Cursor * Inline MCP OAuth workspace backfill into the migration Move the one-shot backfill out of a dedicated Action and wrap it in an explicit transaction so a failure rolls back partial binds/revokes. Co-authored-by: Cursor * Nest MCP authorize i18n keys and test backfill rollback Group consent-screen copy under mcp.authorize.*, and assert the workspace backfill migration rolls back binds when it fails before commit. Co-authored-by: Cursor * Hardcode TryPost in the MCP authorize page title Drop the config('app.name') interpolation from the consent screen title. Co-authored-by: Cursor * Add workspace picker to MCP OAuth consent screen Let users choose which workspace to bind at authorize time instead of always using current_workspace_id; silent re-consent still falls back. Co-authored-by: Cursor * Tighten MCP authorize workspace select spacing Match NativeSelect styling and give the label, control, and helper text room to breathe. Co-authored-by: Cursor * Convert MCP OAuth consent screen to Inertia Vue Reuse AuthCardLayout, Button, and NativeSelect so the authorize page matches the app UI. Keep native form posts so Passport's external redirect still works for MCP client popups. Co-authored-by: Cursor * Polish MCP authorize layout with logo and workspace combobox Drop the shield and AuthCardLayout double-logo, put TryPost branding at the top, and reuse the app Combobox pattern for workspace search. Co-authored-by: Cursor * Align MCP OAuth workspace backfill with mcpOAuth scope Reuse AccessToken::mcpOAuth() so the migration only touches mcp:use grants on non-PAT clients, matching the rest of the codebase. Co-authored-by: Cursor * Tighten MCP OAuth workspace backfill heuristics Only touch connected MCP sessions, bind a sole membership or a valid current workspace, and revoke ambiguous multi-workspace grants instead of guessing the oldest workspace. Co-authored-by: Cursor * Drop Passport connection override from auth code migration Always use the app default database connection from .env. Co-authored-by: Cursor * Bind MCP OAuth workspace in AccessTokenRepository Replace the AccessTokenCreated listener with the same Passport repository override pattern used for auth codes, so workspace_id is set at persist. Co-authored-by: Cursor * Simplify AccessTokenRepository workspace binding Drop redundant string casts and the oldest-workspace fallback; keep a small ownedWorkspace/payloadId helper surface instead. Co-authored-by: Cursor * Extract Passport MCP authorization view from AppServiceProvider Keep configurePassport thin by moving the Inertia consent props into an invokable App\Passport\AuthorizationView class. Co-authored-by: Cursor * Simplify AuthorizationView and cover it with direct tests Use collection higher-order mapping for workspaces/scopes and add focused tests for current-workspace selection and empty-user props. Co-authored-by: Cursor * Rename BindWorkspaceToAccessTokenTest after listener removal The suite now covers AuthCodeRepository and AccessTokenRepository workspace binding, not an AccessTokenCreated listener. * Fail closed when auth code has no bindable workspace Authorization-code grants no longer fall back to the user's current workspace, so a token cannot be minted for a different tenant than consent. Co-authored-by: Cursor * Retrigger CI after GitHub Actions infrastructure failures Co-authored-by: Cursor * chore: retrigger CI Co-authored-by: Cursor * fix: harden MCP OAuth workspace binding on refresh and backfill Co-authored-by: Cursor * fix: always show MCP OAuth consent to pick a workspace Disable Passport silent re-consent and require an explicit workspace_id from the consent form, with Passport wiring moved to its own provider. Co-authored-by: Cursor * fix: sort MCP connected clients by last used Show most recently used OAuth connections first on the workspace MCP settings page. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../AccessToken/ListConnectedMcpClients.php | 12 +- .../AccessToken/RevokeAccessTokens.php | 40 ++ .../AccessToken/RevokeMcpOAuthGrants.php | 41 +- app/Actions/Invite/RemoveMember.php | 4 +- app/Http/Controllers/Api/ApiKeyController.php | 2 + app/Http/Controllers/App/ApiKeyController.php | 2 + .../Controllers/App/McpSettingsController.php | 7 +- .../Middleware/Api/LoadWorkspaceFromToken.php | 11 +- app/Mcp/Tools/ApiKey/DeleteApiKeyTool.php | 6 +- app/Mcp/Tools/ApiKey/ListApiKeysTool.php | 6 +- app/Models/AccessToken.php | 3 +- app/Passport/AccessTokenRepository.php | 114 ++++++ app/Passport/AuthCode.php | 28 ++ app/Passport/AuthCodeRepository.php | 63 +++ app/Passport/AuthorizationController.php | 28 ++ app/Passport/AuthorizationView.php | 41 ++ app/Passport/OAuthPayloadDecryptor.php | 51 +++ app/Providers/AppServiceProvider.php | 19 - app/Providers/PassportServiceProvider.php | 58 +++ bootstrap/providers.php | 2 + ...workspace_id_to_oauth_auth_codes_table.php | 28 ++ ...48_backfill_mcp_oauth_token_workspaces.php | 102 +++++ lang/ar/mcp.php | 20 +- lang/de/mcp.php | 20 +- lang/el/mcp.php | 20 +- lang/en/mcp.php | 20 +- lang/es/mcp.php | 20 +- lang/fr/mcp.php | 20 +- lang/it/mcp.php | 20 +- lang/ja/mcp.php | 20 +- lang/ko/mcp.php | 20 +- lang/nl/mcp.php | 20 +- lang/pl/mcp.php | 20 +- lang/pt-BR/mcp.php | 20 +- lang/ru/mcp.php | 20 +- lang/tr/mcp.php | 20 +- lang/uk/mcp.php | 20 +- lang/zh/mcp.php | 20 +- resources/js/pages/mcp/Authorize.vue | 302 +++++++++++++++ resources/views/mcp/authorize.blade.php | 180 --------- .../Actions/Invite/RemoveMemberTest.php | 34 +- tests/Feature/Api/ApiKeyApiTest.php | 22 ++ .../Api/LoadWorkspaceFromTokenTest.php | 187 +++++---- tests/Feature/ApiKeyControllerTest.php | 24 ++ .../Feature/BackfillMcpOAuthWorkspaceTest.php | 206 ++++++++++ tests/Feature/Mcp/ApiKeyToolTest.php | 16 +- tests/Feature/Mcp/OAuthRegistrationTest.php | 135 ++++++- .../Feature/McpOAuthWorkspaceBindingTest.php | 364 ++++++++++++++++++ tests/Feature/McpSettingsControllerTest.php | 109 +++++- .../Passport/AuthorizationViewTest.php | 85 ++++ .../Feature/WorkspaceInviteControllerTest.php | 4 +- tests/Pest.php | 38 +- tests/Unit/RevokeAccessTokensTest.php | 45 +++ 53 files changed, 2351 insertions(+), 388 deletions(-) create mode 100644 app/Actions/AccessToken/RevokeAccessTokens.php create mode 100644 app/Passport/AccessTokenRepository.php create mode 100644 app/Passport/AuthCode.php create mode 100644 app/Passport/AuthCodeRepository.php create mode 100644 app/Passport/AuthorizationController.php create mode 100644 app/Passport/AuthorizationView.php create mode 100644 app/Passport/OAuthPayloadDecryptor.php create mode 100644 app/Providers/PassportServiceProvider.php create mode 100644 database/migrations/2026_08_06_144847_add_workspace_id_to_oauth_auth_codes_table.php create mode 100644 database/migrations/2026_08_06_144848_backfill_mcp_oauth_token_workspaces.php create mode 100644 resources/js/pages/mcp/Authorize.vue delete mode 100644 resources/views/mcp/authorize.blade.php create mode 100644 tests/Feature/BackfillMcpOAuthWorkspaceTest.php create mode 100644 tests/Feature/McpOAuthWorkspaceBindingTest.php create mode 100644 tests/Feature/Passport/AuthorizationViewTest.php create mode 100644 tests/Unit/RevokeAccessTokensTest.php 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(); +});