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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* Hardcode TryPost in the MCP authorize page title

Drop the config('app.name') interpolation from the consent screen title.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* Tighten MCP authorize workspace select spacing

Match NativeSelect styling and give the label, control, and helper text room to breathe.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* Drop Passport connection override from auth code migration

Always use the app default database connection from .env.

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* Retrigger CI after GitHub Actions infrastructure failures

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger CI

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: harden MCP OAuth workspace binding on refresh and backfill

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-08-06 20:59:34 -04:00 committed by GitHub
parent 27287aa130
commit 2ca5948309
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 2351 additions and 388 deletions

View file

@ -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<array{client_id: string, name: string, can_disconnect: bool, last_used_at: mixed}>
*/
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();
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Actions\AccessToken;
use App\Models\AccessToken;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class RevokeAccessTokens
{
/**
* @param Collection<int, AccessToken>|list<AccessToken>|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();
});
}
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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();
}

View file

@ -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);

View file

@ -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) {

View file

@ -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();

View file

@ -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);

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Passport;
use App\Models\AccessToken;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Contracts\Events\Dispatcher;
use Laravel\Passport\Bridge\AccessTokenRepository as PassportAccessTokenRepository;
use Laravel\Passport\Events\AccessTokenCreated;
use Laravel\Passport\Passport;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
/**
* Persists workspace_id on non-personal-access OAuth tokens at issue time
* same seam as AuthCodeRepository for consent codes. Personal-access tokens
* stay null so CreateApiKey (and friends) can bind afterward.
*
* Authorization-code grants use only the auth code's workspace (no current-
* workspace fallback). Refresh grants inherit the refreshed token's workspace
* only when the user still belongs to it. Unknown grant types fail closed.
*/
class AccessTokenRepository extends PassportAccessTokenRepository
{
public function __construct(
Dispatcher $events,
private OAuthPayloadDecryptor $decryptor,
) {
parent::__construct($events);
}
public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void
{
$id = $accessTokenEntity->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;
}
}

28
app/Passport/AuthCode.php Normal file
View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Passport;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Passport\AuthCode as PassportAuthCode;
class AuthCode extends PassportAuthCode
{
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'revoked' => 'bool',
'expires_at' => 'datetime',
];
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Passport;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Auth;
use Laravel\Passport\Bridge\AuthCodeRepository as PassportAuthCodeRepository;
use Laravel\Passport\Passport;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
/**
* Persists the chosen workspace on the auth code so the subsequent token
* exchange (no browser session) can bind the access token.
*
* Requires an explicit `workspace_id` from the consent form (membership-
* checked). No current-workspace fallback silent re-consent is disabled
* so the picker always runs.
*/
class AuthCodeRepository extends PassportAuthCodeRepository
{
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
{
Passport::authCode()->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;
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Passport;
use Illuminate\Contracts\Auth\Authenticatable;
use Laravel\Passport\Client;
use Laravel\Passport\Http\Controllers\AuthorizationController as PassportAuthorizationController;
use Laravel\Passport\Scope;
/**
* Always show the MCP consent screen so the user can pick a workspace.
*
* Passport otherwise skips consent when the user already granted the same
* scopes (silent re-consent), which would bind the auth code via
* current_workspace without an explicit pick.
*/
class AuthorizationController extends PassportAuthorizationController
{
/**
* @param Scope[] $scopes
*/
protected function hasGrantedScopes(Authenticatable $user, Client $client, array $scopes): bool
{
return false;
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Passport;
use App\Models\User;
use Inertia\Inertia;
use Inertia\Response;
class AuthorizationView
{
/**
* @param array<string, mixed> $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', ''),
]);
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Passport;
use Defuse\Crypto\Crypto;
use Illuminate\Contracts\Encryption\Encrypter;
use Laravel\Passport\Passport;
use Throwable;
/**
* Decrypt League OAuth2 auth-code / refresh-token request payloads using the
* same key Passport passes to AuthorizationServer.
*/
class OAuthPayloadDecryptor
{
public function __construct(private Encrypter $encrypter) {}
/**
* @return array<string, mixed>|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<string, mixed> $payload
*/
public function encrypt(array $payload): string
{
return Crypto::encryptWithPassword(
json_encode($payload, JSON_THROW_ON_ERROR),
Passport::tokenEncryptionKey($this->encrypter),
);
}
}

View file

@ -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

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Models\AccessToken;
use App\Passport\AccessTokenRepository;
use App\Passport\AuthCode;
use App\Passport\AuthCodeRepository;
use App\Passport\AuthorizationController;
use App\Passport\AuthorizationView;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Laravel\Passport\Bridge\AccessTokenRepository as PassportAccessTokenRepository;
use Laravel\Passport\Bridge\AuthCodeRepository as PassportAuthCodeRepository;
use Laravel\Passport\Http\Controllers\AuthorizationController as PassportAuthorizationController;
use Laravel\Passport\Http\Controllers\DeviceAuthorizationController;
use Laravel\Passport\Passport;
class PassportServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->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),
);
}
}

View file

@ -2,8 +2,10 @@
use App\Providers\AppServiceProvider;
use App\Providers\HorizonServiceProvider;
use App\Providers\PassportServiceProvider;
return [
AppServiceProvider::class,
HorizonServiceProvider::class,
PassportServiceProvider::class,
];

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('oauth_auth_codes', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Actions\AccessToken\RevokeAccessTokens;
use App\Models\AccessToken;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Assign existing MCP OAuth tokens (workspace_id null) to a workspace, or revoke
* them when no confident mapping exists. Runs in a single transaction so a
* failure leaves no partially backfilled rows.
*
* Only touches live/recoverable MCP sessions (AccessToken::connectedMcpOAuth).
* Mapping is conservative: a single account membership binds automatically;
* multiple memberships always revoke so the client reconnects with an explicit
* workspace pick (never guess from current_workspace_id).
*/
return new class extends Migration
{
/**
* Test seam: invoked after all chunks and before commit.
*
* @var (callable(): void)|null
*/
public $beforeCommit = null;
public function up(): void
{
DB::beginTransaction();
try {
AccessToken::query()
->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;
}
};

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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対応アプリ。',

View file

@ -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를 지원하는 모든 앱.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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 的应用。',

View file

@ -0,0 +1,302 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { IconChevronDown } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxAnchor,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { approve, deny } from '@/routes/passport/authorizations';
type Scope = {
id: string;
description: string;
};
type WorkspaceOption = {
id: string;
name: string;
};
const props = defineProps<{
client: {
id: string;
name: string;
};
user: {
email: string;
};
workspaces: WorkspaceOption[];
selectedWorkspaceId: string;
scopes: Scope[];
authToken: string;
state: string;
}>();
const selectedWorkspace = ref<WorkspaceOption | undefined>(
props.workspaces.find(
(workspace) => workspace.id === props.selectedWorkspaceId,
) ?? props.workspaces[0],
);
const workspaceIdForSubmit = computed(
() => selectedWorkspace.value?.id ?? '',
);
const approving = ref(false);
const csrfToken =
document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
?.content ?? '';
const scopeLabel = (scope: Scope): string =>
scope.id === 'mcp:use'
? trans('mcp.authorize.scope_mcp_use')
: scope.description;
const onApproveSubmit = (): void => {
approving.value = true;
// Popup MCP clients expect the window to close after the redirect.
window.setTimeout(() => {
const checkRedirect = window.setInterval(() => {
if (
!window.location.href.includes('/oauth/authorize') ||
window.location.search.includes('code=') ||
window.location.search.includes('error=')
) {
window.clearInterval(checkRedirect);
window.close();
}
}, 100);
window.setTimeout(() => {
window.clearInterval(checkRedirect);
window.close();
}, 5000);
}, 200);
};
const onDenySubmit = (): void => {
window.setTimeout(() => {
window.close();
}, 200);
};
</script>
<template>
<div
class="flex min-h-svh flex-col items-center justify-center bg-background p-6 md:p-10"
>
<Head :title="trans('mcp.authorize.page_title')" />
<div class="w-full max-w-md space-y-8">
<div class="flex flex-col items-center gap-4 text-center">
<img
src="/images/trypost/logo-light.png"
alt="TryPost"
class="h-10 w-auto"
/>
<div class="space-y-2">
<h1 class="text-xl font-semibold tracking-tight text-foreground">
{{
$t('mcp.authorize.heading', {
client: client.name,
})
}}
</h1>
<p class="text-sm text-muted-foreground">
{{ $t('mcp.authorize.intro') }}
{{ $t('mcp.authorize.intro_capability') }}
</p>
</div>
</div>
<div
class="space-y-6 rounded-xl border-2 border-foreground bg-card p-6 shadow-sm"
>
<div class="space-y-4">
<div class="space-y-1.5">
<p class="text-sm text-muted-foreground">
{{ $t('mcp.authorize.logged_in_as') }}
</p>
<p class="font-medium" dusk="mcp-authorize-email">
{{ user.email }}
</p>
</div>
<div class="space-y-1.5">
<Label>{{ $t('mcp.authorize.workspace') }}</Label>
<Combobox
v-model="selectedWorkspace"
by="id"
:display-value="
(workspace: WorkspaceOption | undefined) =>
workspace?.name ?? ''
"
>
<ComboboxAnchor class="w-full">
<ComboboxTrigger as-child>
<button
type="button"
class="flex h-10 w-full items-center justify-between rounded-md border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs transition-colors hover:bg-muted/40"
dusk="mcp-authorize-workspace"
>
<span
:class="
selectedWorkspace
? 'text-foreground'
: 'text-foreground/50'
"
>
{{
selectedWorkspace?.name ??
$t(
'mcp.authorize.select_workspace',
)
}}
</span>
<IconChevronDown
class="size-4 shrink-0 text-foreground/60"
/>
</button>
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxList>
<ComboboxInput
:placeholder="
$t('mcp.authorize.search_workspace')
"
/>
<ComboboxEmpty>
{{
$t('mcp.authorize.no_workspace_found')
}}
</ComboboxEmpty>
<ComboboxGroup>
<ComboboxItem
v-for="workspace in workspaces"
:key="workspace.id"
:value="workspace"
>
{{ workspace.name }}
</ComboboxItem>
</ComboboxGroup>
</ComboboxList>
</Combobox>
<p class="text-xs text-muted-foreground">
{{ $t('mcp.authorize.workspace_scope') }}
</p>
</div>
</div>
<div v-if="scopes.length > 0" class="space-y-2">
<p class="text-sm font-medium">
{{ $t('mcp.authorize.permissions') }}
</p>
<ul class="space-y-2">
<li
v-for="scope in scopes"
:key="scope.id"
class="flex items-start gap-2"
>
<div
class="mt-0.5 rounded-full bg-primary/10 p-1"
>
<div
class="size-1.5 rounded-full bg-primary"
/>
</div>
<span class="text-sm text-muted-foreground">
{{ scopeLabel(scope) }}
</span>
</li>
</ul>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-start">
<!-- Native forms: Passport redirects off-site; Inertia visits would break the OAuth popup. -->
<form
id="authorizeForm"
method="POST"
:action="approve.url()"
class="flex-1"
@submit="onApproveSubmit"
>
<input
type="hidden"
name="_token"
:value="csrfToken"
/>
<input type="hidden" name="state" :value="state" />
<input
type="hidden"
name="client_id"
:value="client.id"
/>
<input
type="hidden"
name="auth_token"
:value="authToken"
/>
<input
type="hidden"
name="workspace_id"
:value="workspaceIdForSubmit"
/>
<Button
type="submit"
class="w-full"
:loading="approving"
dusk="mcp-authorize-approve"
>
{{ $t('mcp.authorize.approve') }}
</Button>
</form>
<form
method="POST"
:action="deny.url()"
class="flex-1"
@submit="onDenySubmit"
>
<input
type="hidden"
name="_token"
:value="csrfToken"
/>
<input type="hidden" name="_method" value="DELETE" />
<input type="hidden" name="state" :value="state" />
<input
type="hidden"
name="client_id"
:value="client.id"
/>
<input
type="hidden"
name="auth_token"
:value="authToken"
/>
<Button
type="submit"
variant="outline"
class="w-full"
dusk="mcp-authorize-cancel"
>
{{ $t('mcp.authorize.cancel') }}
</Button>
</form>
</div>
</div>
</div>
</div>
</template>

View file

@ -1,180 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
(function() {
const appearance = '{{ $appearance ?? "system" }}';
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.classList.add('dark');
}
}
})();
</script>
<style>
html {
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
</style>
<title>Authorize Application - {{ config('app.name', 'MCP Server') }}</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Authorize MCP" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@vite(['resources/css/app.css'])
</head>
<body class="font-sans antialiased bg-background text-foreground">
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<!-- Card Container -->
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<!-- Header -->
<div class="flex flex-col space-y-1.5 p-6">
<div class="flex items-center justify-center mb-4">
<!-- Shield Icon -->
<svg class="h-12 w-12 text-primary" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.618 5.984A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.031 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
</div>
<h3 class="text-2xl font-semibold leading-none tracking-tight text-center">
Authorize {{ $client->name }}
</h3>
<p class="text-sm text-muted-foreground text-center">
This application will be able to:<br/>Use available MCP functionality.
</p>
</div>
<!-- Content -->
<div class="p-6 pt-0 space-y-4">
<!-- User Info -->
<div class="rounded-lg border p-4 bg-muted/50">
<p class="text-sm text-muted-foreground mb-2">Logged in as:</p>
<p class="font-medium">{{ $user->email }}</p>
</div>
<!-- Scopes / Permissions -->
@if(count($scopes) > 0)
<div class="space-y-2">
<p class="text-sm font-medium">Permissions:</p>
<ul class="space-y-2">
@foreach($scopes as $scope)
<li class="flex items-start gap-2">
<div class="rounded-full bg-primary/10 p-1 mt-0.5">
<div class="h-1.5 w-1.5 rounded-full bg-primary"></div>
</div>
<span class="text-sm text-muted-foreground">
{{ $scope->description }}
</span>
</li>
@endforeach
</ul>
</div>
@endif
</div>
<!-- Footer With Buttons -->
<div class="flex items-center p-6 pt-0 gap-3">
<!-- Deny Form -->
<form method="POST" action="{{ route('passport.authorizations.deny') }}" class="flex-1">
@csrf
@method('DELETE')
<input type="hidden" name="state" value="">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-full">
<svg class="mr-2 h-4 w-4" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Cancel
</button>
</form>
<!-- Approve Form -->
<form method="POST" action="{{ route('passport.authorizations.approve') }}" class="flex-1" id="authorizeForm">
@csrf
<input type="hidden" name="state" value="">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full" id="authorizeButton">
<span id="authorizeText">Authorize</span>
<svg id="loadingSpinner" class="animate-spin -ml-1 mr-3 h-4 w-4 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('authorizeForm');
const button = document.getElementById('authorizeButton');
const authorizeText = document.getElementById('authorizeText');
const loadingSpinner = document.getElementById('loadingSpinner');
form.addEventListener('submit', function(e) {
// Show loading state...
button.disabled = true;
authorizeText.textContent = 'Authorizing...';
loadingSpinner.classList.remove('hidden');
// After form submission, watch for redirect and close window...
setTimeout(function() {
const checkRedirect = setInterval(function() {
// If URL changed or we have OAuth params, redirect happened...
if (!window.location.href.includes('/oauth/authorize') ||
window.location.search.includes('code=') ||
window.location.search.includes('error=')) {
clearInterval(checkRedirect);
window.close();
}
}, 100);
// Fallback: Close after five seconds...
setTimeout(function() {
clearInterval(checkRedirect);
window.close();
}, 5000);
}, 200);
});
// Handle cancel button...
const cancelForm = document.querySelector('form[method="POST"]:has(input[name="_method"][value="DELETE"])');
if (cancelForm) {
cancelForm.addEventListener('submit', function(e) {
setTimeout(function() {
window.close();
}, 200);
});
}
});
</script>
</body>
</html>

View file

@ -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();
});

View file

@ -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();

View file

@ -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',

View file

@ -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'])

View file

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Load the data migration as an instance so its up() can run against rows that
* still have unbound MCP OAuth grants.
*/
function backfillMcpOAuthTokenWorkspacesMigration(): object
{
return require database_path('migrations/2026_08_06_144848_backfill_mcp_oauth_token_workspaces.php');
}
test('backfill revokes mcp oauth tokens when the user has multiple workspaces', function () {
$user = User::factory()->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();
});

View file

@ -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]);

View file

@ -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<string, string>
*/
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',
];
}

View file

@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\AccessToken;
use App\Models\User;
use App\Models\Workspace;
use App\Passport\AccessTokenRepository;
use App\Passport\AuthCode;
use App\Passport\AuthCodeRepository;
use App\Passport\OAuthPayloadDecryptor;
use Illuminate\Support\Str;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Exception\OAuthServerException;
beforeEach(function () {
$this->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);
}

View file

@ -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();
});

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use App\Passport\AuthorizationView;
use Illuminate\Http\Request;
use Inertia\Support\Header;
use Laravel\Passport\Scope;
test('authorization view prefers the current workspace and maps consent props', function () {
$user = User::factory()->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<string, mixed>
*/
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'];
}

View file

@ -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,

View file

@ -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<string> $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<string> $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).
*

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Actions\AccessToken\RevokeAccessTokens;
use App\Enums\UserWorkspace\Role;
use App\Models\AccessToken;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
test('revokes access tokens and their refresh tokens', 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]);
$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();
});