206 lines
7.3 KiB
PHP
206 lines
7.3 KiB
PHP
<?php
|
|
|
|
use App\Models\Label;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Str;
|
|
use Laravel\Passport\ClientRepository;
|
|
|
|
use function Pest\Laravel\get;
|
|
use function Pest\Laravel\postJson;
|
|
use function Pest\Laravel\withHeaders;
|
|
|
|
/**
|
|
* A hosted-callback redirect URI inside the config('mcp.redirect_domains')
|
|
* allowlist (Anthropic's Claude connector callback).
|
|
*/
|
|
const CLAUDE_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
|
|
/**
|
|
* Generate a PKCE (verifier, S256 challenge) pair.
|
|
*
|
|
* @return array{0: string, 1: string}
|
|
*/
|
|
function pkcePair(): array
|
|
{
|
|
$verifier = Str::random(64);
|
|
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
|
|
|
return [$verifier, $challenge];
|
|
}
|
|
|
|
/**
|
|
* Drive the full Authorization Code + PKCE flow for the user and return the
|
|
* issued bearer access token, exactly as Claude/ChatGPT would obtain one:
|
|
* register a public client, authorize with a code challenge, approve on the
|
|
* consent screen, then exchange the code + verifier at the token endpoint.
|
|
*
|
|
* @param list<string> $scopes
|
|
*/
|
|
function issueOAuthToken(User $user, array $scopes = ['mcp:use']): string
|
|
{
|
|
$client = app(ClientRepository::class)->createAuthorizationCodeGrantClient(
|
|
name: 'Claude',
|
|
redirectUris: [CLAUDE_CALLBACK],
|
|
confidential: false,
|
|
);
|
|
|
|
[$verifier, $challenge] = pkcePair();
|
|
|
|
// 1. Authorize: renders the consent screen and primes the session.
|
|
$authorize = test()->actingAs($user)->get('/oauth/authorize?'.http_build_query([
|
|
'client_id' => $client->id,
|
|
'redirect_uri' => CLAUDE_CALLBACK,
|
|
'response_type' => 'code',
|
|
'scope' => implode(' ', $scopes),
|
|
'state' => 'state-token',
|
|
'code_challenge' => $challenge,
|
|
'code_challenge_method' => 'S256',
|
|
]))->assertOk();
|
|
|
|
// The consent form carries the single-use auth token we must echo back.
|
|
preg_match('/name="auth_token" value="([^"]+)"/', $authorize->getContent(), $matches);
|
|
$authToken = $matches[1] ?? null;
|
|
expect($authToken)->not->toBeNull();
|
|
|
|
// 2. Approve: redirects back to the client with the authorization code.
|
|
$approve = test()->actingAs($user)->post('/oauth/authorize', [
|
|
'auth_token' => $authToken,
|
|
'client_id' => $client->id,
|
|
'state' => 'state-token',
|
|
])->assertRedirect();
|
|
|
|
parse_str((string) parse_url($approve->headers->get('Location'), PHP_URL_QUERY), $redirect);
|
|
expect($redirect['code'] ?? null)->not->toBeNull();
|
|
|
|
// 3. Exchange the code + verifier for a token (public client, no secret).
|
|
$token = test()->post('/oauth/token', [
|
|
'grant_type' => 'authorization_code',
|
|
'client_id' => $client->id,
|
|
'redirect_uri' => CLAUDE_CALLBACK,
|
|
'code_verifier' => $verifier,
|
|
'code' => $redirect['code'],
|
|
])->assertOk();
|
|
|
|
return $token->json('access_token');
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Discovery (RFC 9728 / RFC 8414)
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
it('serves protected resource metadata for the OAuth MCP endpoint', function () {
|
|
$response = get('/.well-known/oauth-protected-resource/mcp/oauth')->assertOk();
|
|
|
|
expect($response->json('resource'))->toEndWith('/mcp/oauth');
|
|
expect($response->json('authorization_servers'))->not->toBeEmpty();
|
|
expect($response->json('scopes_supported'))->toBe(['mcp:use']);
|
|
});
|
|
|
|
it('serves authorization server metadata advertising PKCE and the mcp:use scope', function () {
|
|
$json = get('/.well-known/oauth-authorization-server')->assertOk()->json();
|
|
|
|
expect($json['response_types_supported'])->toBe(['code']);
|
|
expect($json['code_challenge_methods_supported'])->toBe(['S256']);
|
|
expect($json['scopes_supported'])->toBe(['mcp:use']);
|
|
expect($json['grant_types_supported'])->toBe(['authorization_code', 'refresh_token']);
|
|
expect($json['authorization_endpoint'])->toContain('oauth/authorize');
|
|
expect($json['token_endpoint'])->toContain('oauth/token');
|
|
expect($json['registration_endpoint'])->toContain('oauth/register');
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| 401 bootstrap challenge (mandatory for Claude / ChatGPT)
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
it('returns a 401 bootstrap challenge for an unauthenticated OAuth MCP request', function () {
|
|
$response = postJson('/mcp/oauth', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
|
|
|
$response->assertUnauthorized();
|
|
|
|
$header = $response->headers->get('WWW-Authenticate');
|
|
expect($header)->toContain('Bearer');
|
|
expect($header)->toContain('resource_metadata=');
|
|
expect($header)->toContain('/.well-known/oauth-protected-resource/mcp/oauth');
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Dynamic Client Registration (RFC 7591)
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
it('registers a public PKCE client via dynamic client registration', function () {
|
|
$response = postJson('/oauth/register', [
|
|
'client_name' => 'Claude',
|
|
'redirect_uris' => [CLAUDE_CALLBACK],
|
|
])->assertOk()->assertJson([
|
|
'token_endpoint_auth_method' => 'none',
|
|
'scope' => 'mcp:use',
|
|
]);
|
|
|
|
expect($response->json('client_id'))->not->toBeEmpty();
|
|
expect($response->json('redirect_uris'))->toContain(CLAUDE_CALLBACK);
|
|
});
|
|
|
|
it('rejects a DCR redirect URI outside the allowlist', function () {
|
|
postJson('/oauth/register', [
|
|
'client_name' => 'Evil',
|
|
'redirect_uris' => ['https://evil.example.com/callback'],
|
|
])->assertStatus(400)->assertJson(['error' => 'invalid_redirect_uri']);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| End-to-end Authorization Code + PKCE flow
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
it('authenticates a full Authorization Code + PKCE flow and serves read tools', function () {
|
|
$user = User::factory()->create();
|
|
$token = issueOAuthToken($user);
|
|
|
|
expect($token)->not->toBeEmpty();
|
|
|
|
withHeaders(['Authorization' => "Bearer {$token}"])
|
|
->postJson('/mcp/oauth', [
|
|
'jsonrpc' => '2.0',
|
|
'id' => 2,
|
|
'method' => 'tools/call',
|
|
'params' => ['name' => 'list_spaces', 'arguments' => (object) []],
|
|
])
|
|
->assertOk()
|
|
->assertSee('Personal', false);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Write access over OAuth (Claude Desktop / ChatGPT connections can write)
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
it('lets an OAuth connection use write tools', function () {
|
|
$user = User::factory()->create();
|
|
$token = issueOAuthToken($user);
|
|
|
|
withHeaders(['Authorization' => "Bearer {$token}"])
|
|
->postJson('/mcp/oauth', [
|
|
'jsonrpc' => '2.0',
|
|
'id' => 3,
|
|
'method' => 'tools/call',
|
|
'params' => [
|
|
'name' => 'create_label',
|
|
'arguments' => ['name' => 'Groceries', 'color' => 'blue'],
|
|
],
|
|
])
|
|
->assertOk()
|
|
->assertSee('Groceries', false);
|
|
|
|
$label = Label::query()->where('user_id', $user->id)->first();
|
|
expect($label)->not->toBeNull();
|
|
expect($label->name)->toBe('Groceries');
|
|
});
|