$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'); });