trypost/app/Passport/AccessTokenRepository.php

115 lines
3.8 KiB
PHP
Raw Permalink Normal View History

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>
2026-08-07 00:59:34 +00:00
<?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;
}
}