test(mcp): cover the OAuth 2.1 auth boundary
Discovery metadata (RFC 9728/8414), the mandatory 401 bootstrap challenge, dynamic client registration (allowed + rejected redirect URIs), the full Authorization Code + PKCE flow reaching a read tool, and the read-only guardrail that blocks write tools for OAuth connections.
This commit is contained in:
parent
5e06569615
commit
fb877faa0e
|
|
@ -0,0 +1,203 @@
|
|||
<?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);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Read-only guardrail (decision #1: OAuth connections cannot write)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
it('blocks write tools for an OAuth (read-only) connection and creates no row', 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('read-only', false);
|
||||
|
||||
expect(Label::query()->count())->toBe(0);
|
||||
});
|
||||
Loading…
Reference in New Issue