diff --git a/tests/Feature/Mcp/McpOAuthTest.php b/tests/Feature/Mcp/McpOAuthTest.php new file mode 100644 index 00000000..ea37b19b --- /dev/null +++ b/tests/Feature/Mcp/McpOAuthTest.php @@ -0,0 +1,203 @@ + $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); +});