mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
# Add MCP HTTP server with streamable transport support - Add MCP HTTP server with streamable transport support - Integrate with existing toolsdk for Coder workspace operations - Add comprehensive E2E tests with OAuth2 bearer token support - Register MCP endpoint at /api/experimental/mcp/http with authentication - Support RFC 6750 Bearer token authentication for MCP clients Change-Id: Ib9024569ae452729908797c42155006aa04330af Signed-off-by: Thomas Kosiewski <tk@coder.com>
1224 lines
41 KiB
Go
1224 lines
41 KiB
Go
package mcp_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
mcpclient "github.com/mark3labs/mcp-go/client"
|
|
"github.com/mark3labs/mcp-go/client/transport"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
mcpserver "github.com/coder/coder/v2/coderd/mcp"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/toolsdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestMCPHTTP_E2E_ClientIntegration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Setup Coder server with authentication
|
|
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
|
|
defer closer.Close()
|
|
|
|
_ = coderdtest.CreateFirstUser(t, coderClient)
|
|
|
|
// Create MCP client pointing to our endpoint
|
|
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http"
|
|
|
|
// Configure client with authentication headers using RFC 6750 Bearer token
|
|
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + coderClient.SessionToken(),
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := mcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Start client
|
|
err = mcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Initialize connection
|
|
initReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-client",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := mcpClient.Initialize(ctx, initReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
|
|
require.Equal(t, mcp.LATEST_PROTOCOL_VERSION, result.ProtocolVersion)
|
|
require.NotNil(t, result.Capabilities)
|
|
|
|
// Test tool listing
|
|
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, tools.Tools)
|
|
|
|
// Verify we have some expected Coder tools
|
|
var foundTools []string
|
|
for _, tool := range tools.Tools {
|
|
foundTools = append(foundTools, tool.Name)
|
|
}
|
|
|
|
// Check for some basic tools that should be available
|
|
assert.Contains(t, foundTools, toolsdk.ToolNameGetAuthenticatedUser, "Should have authenticated user tool")
|
|
|
|
// Find and execute the authenticated user tool
|
|
var userTool *mcp.Tool
|
|
for _, tool := range tools.Tools {
|
|
if tool.Name == toolsdk.ToolNameGetAuthenticatedUser {
|
|
userTool = &tool
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, userTool, "Expected to find "+toolsdk.ToolNameGetAuthenticatedUser+" tool")
|
|
|
|
// Execute the tool
|
|
toolReq := mcp.CallToolRequest{
|
|
Params: mcp.CallToolParams{
|
|
Name: userTool.Name,
|
|
Arguments: map[string]any{},
|
|
},
|
|
}
|
|
|
|
toolResult, err := mcpClient.CallTool(ctx, toolReq)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, toolResult.Content)
|
|
|
|
// Verify the result contains user information
|
|
assert.Len(t, toolResult.Content, 1)
|
|
if textContent, ok := toolResult.Content[0].(mcp.TextContent); ok {
|
|
assert.Equal(t, "text", textContent.Type)
|
|
assert.NotEmpty(t, textContent.Text)
|
|
} else {
|
|
t.Errorf("Expected TextContent type, got %T", toolResult.Content[0])
|
|
}
|
|
|
|
// Test ping functionality
|
|
err = mcpClient.Ping(ctx)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestMCPHTTP_E2E_UnauthenticatedAccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Setup Coder server
|
|
_, closer, api := coderdtest.NewWithAPI(t, nil)
|
|
defer closer.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Test direct HTTP request to verify 401 status code
|
|
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http"
|
|
|
|
// Make a POST request without authentication (MCP over HTTP uses POST)
|
|
//nolint:gosec // Test code using controlled localhost URL
|
|
req, err := http.NewRequestWithContext(ctx, "POST", mcpURL, strings.NewReader(`{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}`))
|
|
require.NoError(t, err, "Should be able to create HTTP request")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err, "Should be able to make HTTP request")
|
|
defer resp.Body.Close()
|
|
|
|
// Verify we get 401 Unauthorized
|
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should get HTTP 401 for unauthenticated access")
|
|
|
|
// Also test with MCP client to ensure it handles the error gracefully
|
|
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL)
|
|
require.NoError(t, err, "Should be able to create MCP client without authentication")
|
|
defer func() {
|
|
if closeErr := mcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
// Start client and try to initialize - this should fail due to authentication
|
|
err = mcpClient.Start(ctx)
|
|
if err != nil {
|
|
// Authentication failed at transport level - this is expected
|
|
t.Logf("Unauthenticated access test successful: Transport-level authentication error: %v", err)
|
|
return
|
|
}
|
|
|
|
initReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-client-unauth",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err = mcpClient.Initialize(ctx, initReq)
|
|
require.Error(t, err, "Should fail during MCP initialization without authentication")
|
|
}
|
|
|
|
func TestMCPHTTP_E2E_ToolWithWorkspace(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Setup Coder server with full workspace environment
|
|
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
|
IncludeProvisionerDaemon: true,
|
|
})
|
|
defer closer.Close()
|
|
|
|
user := coderdtest.CreateFirstUser(t, coderClient)
|
|
|
|
// Create template and workspace for testing
|
|
version := coderdtest.CreateTemplateVersion(t, coderClient, user.OrganizationID, nil)
|
|
coderdtest.AwaitTemplateVersionJobCompleted(t, coderClient, version.ID)
|
|
template := coderdtest.CreateTemplate(t, coderClient, user.OrganizationID, version.ID)
|
|
workspace := coderdtest.CreateWorkspace(t, coderClient, template.ID)
|
|
|
|
// Create MCP client
|
|
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http"
|
|
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + coderClient.SessionToken(),
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := mcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Start and initialize client
|
|
err = mcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
initReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-client-workspace",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err = mcpClient.Initialize(ctx, initReq)
|
|
require.NoError(t, err)
|
|
|
|
// Test workspace-related tools
|
|
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
|
|
require.NoError(t, err)
|
|
|
|
// Find workspace listing tool
|
|
var workspaceTool *mcp.Tool
|
|
for _, tool := range tools.Tools {
|
|
if tool.Name == toolsdk.ToolNameListWorkspaces {
|
|
workspaceTool = &tool
|
|
break
|
|
}
|
|
}
|
|
|
|
if workspaceTool != nil {
|
|
// Execute workspace listing tool
|
|
toolReq := mcp.CallToolRequest{
|
|
Params: mcp.CallToolParams{
|
|
Name: workspaceTool.Name,
|
|
Arguments: map[string]any{},
|
|
},
|
|
}
|
|
|
|
toolResult, err := mcpClient.CallTool(ctx, toolReq)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, toolResult.Content)
|
|
|
|
// Verify the result mentions our workspace
|
|
if textContent, ok := toolResult.Content[0].(mcp.TextContent); ok {
|
|
assert.Contains(t, textContent.Text, workspace.Name, "Workspace listing should include our test workspace")
|
|
} else {
|
|
t.Error("Expected TextContent type from workspace tool")
|
|
}
|
|
|
|
t.Logf("Workspace tool test successful: Found workspace %s in results", workspace.Name)
|
|
} else {
|
|
t.Skip("Workspace listing tool not available, skipping workspace-specific test")
|
|
}
|
|
}
|
|
|
|
func TestMCPHTTP_E2E_ErrorHandling(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Setup Coder server
|
|
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
|
IncludeProvisionerDaemon: true,
|
|
})
|
|
defer closer.Close()
|
|
|
|
_ = coderdtest.CreateFirstUser(t, coderClient)
|
|
|
|
// Create MCP client
|
|
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http"
|
|
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + coderClient.SessionToken(),
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := mcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Start and initialize client
|
|
err = mcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
initReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-client-errors",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err = mcpClient.Initialize(ctx, initReq)
|
|
require.NoError(t, err)
|
|
|
|
// Test calling non-existent tool
|
|
toolReq := mcp.CallToolRequest{
|
|
Params: mcp.CallToolParams{
|
|
Name: "nonexistent_tool",
|
|
Arguments: map[string]any{},
|
|
},
|
|
}
|
|
|
|
_, err = mcpClient.CallTool(ctx, toolReq)
|
|
require.Error(t, err, "Should get error when calling non-existent tool")
|
|
require.Contains(t, err.Error(), "nonexistent_tool", "Should mention the tool name in error message")
|
|
|
|
t.Logf("Error handling test successful: Got expected error for non-existent tool")
|
|
}
|
|
|
|
func TestMCPHTTP_E2E_ConcurrentRequests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Setup Coder server
|
|
coderClient, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
|
IncludeProvisionerDaemon: true,
|
|
})
|
|
defer closer.Close()
|
|
|
|
_ = coderdtest.CreateFirstUser(t, coderClient)
|
|
|
|
// Create MCP client
|
|
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http"
|
|
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + coderClient.SessionToken(),
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := mcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Start and initialize client
|
|
err = mcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
initReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-client-concurrent",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err = mcpClient.Initialize(ctx, initReq)
|
|
require.NoError(t, err)
|
|
|
|
// Test concurrent tool listings
|
|
const numConcurrent = 5
|
|
eg, egCtx := errgroup.WithContext(ctx)
|
|
|
|
for range numConcurrent {
|
|
eg.Go(func() error {
|
|
reqCtx, reqCancel := context.WithTimeout(egCtx, testutil.WaitLong)
|
|
defer reqCancel()
|
|
|
|
tools, err := mcpClient.ListTools(reqCtx, mcp.ListToolsRequest{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(tools.Tools) == 0 {
|
|
return assert.AnError
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Wait for all concurrent requests to complete
|
|
err = eg.Wait()
|
|
require.NoError(t, err, "All concurrent requests should succeed")
|
|
|
|
t.Logf("Concurrent requests test successful: All %d requests completed successfully", numConcurrent)
|
|
}
|
|
|
|
func TestMCPHTTP_E2E_RFC6750_UnauthenticatedRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Setup Coder server
|
|
_, closer, api := coderdtest.NewWithAPI(t, nil)
|
|
defer closer.Close()
|
|
|
|
// Make a request without any authentication headers
|
|
req := &http.Request{
|
|
Method: "POST",
|
|
URL: mustParseURL(t, api.AccessURL.String()+"/api/experimental/mcp/http"),
|
|
Header: make(http.Header),
|
|
}
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Should get 401 Unauthorized
|
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
// RFC 6750 requires WWW-Authenticate header on 401 responses
|
|
wwwAuth := resp.Header.Get("WWW-Authenticate")
|
|
require.NotEmpty(t, wwwAuth, "RFC 6750 requires WWW-Authenticate header for 401 responses")
|
|
require.Contains(t, wwwAuth, "Bearer", "WWW-Authenticate header should indicate Bearer authentication")
|
|
require.Contains(t, wwwAuth, `realm="coder"`, "WWW-Authenticate header should include realm")
|
|
|
|
t.Logf("RFC 6750 WWW-Authenticate header test successful: %s", wwwAuth)
|
|
}
|
|
|
|
func TestMCPHTTP_E2E_OAuth2_EndToEnd(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Setup Coder server with OAuth2 provider enabled
|
|
coderClient, closer, api := coderdtest.NewWithAPI(t, nil)
|
|
t.Cleanup(func() { closer.Close() })
|
|
|
|
_ = coderdtest.CreateFirstUser(t, coderClient)
|
|
|
|
ctx := t.Context()
|
|
|
|
// Create OAuth2 app (for demonstration that OAuth2 provider is working)
|
|
_, err := coderClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
|
|
Name: "test-mcp-app",
|
|
CallbackURL: "http://localhost:3000/callback",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Test 1: OAuth2 Token Endpoint Error Format
|
|
t.Run("OAuth2TokenEndpointErrorFormat", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Test that the /oauth2/tokens endpoint responds with proper OAuth2 error format
|
|
// Note: The endpoint is /oauth2/tokens (plural), not /oauth2/token (singular)
|
|
req := &http.Request{
|
|
Method: "POST",
|
|
URL: mustParseURL(t, api.AccessURL.String()+"/oauth2/tokens"),
|
|
Header: map[string][]string{
|
|
"Content-Type": {"application/x-www-form-urlencoded"},
|
|
},
|
|
Body: http.NoBody,
|
|
}
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// The OAuth2 token endpoint should return HTTP 400 for invalid requests
|
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
|
|
// Read and verify the response is OAuth2-compliant JSON error format
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
|
|
t.Logf("OAuth2 tokens endpoint returned status: %d, body: %q", resp.StatusCode, string(bodyBytes))
|
|
|
|
// Should be valid JSON with OAuth2 error format
|
|
var errorResponse map[string]any
|
|
err = json.Unmarshal(bodyBytes, &errorResponse)
|
|
require.NoError(t, err, "Response should be valid JSON")
|
|
|
|
// Verify OAuth2 error format (RFC 6749 section 5.2)
|
|
require.NotEmpty(t, errorResponse["error"], "Error field should not be empty")
|
|
})
|
|
|
|
// Test 2: MCP with OAuth2 Bearer Token
|
|
t.Run("MCPWithOAuth2BearerToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
// For this test, we'll use the user's regular session token formatted as a Bearer token
|
|
// In a real OAuth2 flow, this would be an OAuth2 access token
|
|
sessionToken := coderClient.SessionToken()
|
|
|
|
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http"
|
|
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + sessionToken,
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := mcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Start and initialize MCP client with Bearer token
|
|
err = mcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
initReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-oauth2-client",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := mcpClient.Initialize(ctx, initReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
|
|
|
|
// Test tool listing with OAuth2 Bearer token
|
|
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, tools.Tools)
|
|
|
|
t.Logf("OAuth2 Bearer token MCP test successful: Found %d tools", len(tools.Tools))
|
|
})
|
|
|
|
// Test 3: Full OAuth2 Authorization Code Flow with Token Refresh
|
|
t.Run("OAuth2FullFlowWithTokenRefresh", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Create an OAuth2 app specifically for this test
|
|
app, err := coderClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
|
|
Name: "test-oauth2-flow-app",
|
|
CallbackURL: "http://localhost:3000/callback",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create a client secret for the app
|
|
secret, err := coderClient.PostOAuth2ProviderAppSecret(ctx, app.ID)
|
|
require.NoError(t, err)
|
|
|
|
// Step 1: Simulate authorization code flow by creating an authorization code
|
|
// In a real flow, this would be done through the browser consent page
|
|
// For testing, we'll create the code directly using the internal API
|
|
|
|
// First, we need to authorize the app (simulating user consent)
|
|
authURL := fmt.Sprintf("%s/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&state=test_state",
|
|
api.AccessURL.String(), app.ID, "http://localhost:3000/callback")
|
|
|
|
// Create an HTTP client that follows redirects but captures the final redirect
|
|
client := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse // Stop following redirects
|
|
},
|
|
}
|
|
|
|
// Make the authorization request (this would normally be done in a browser)
|
|
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
|
|
require.NoError(t, err)
|
|
// Use RFC 6750 Bearer token for authentication
|
|
req.Header.Set("Authorization", "Bearer "+coderClient.SessionToken())
|
|
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// The response should be a redirect to the consent page or directly to callback
|
|
// For testing purposes, let's simulate the POST consent approval
|
|
if resp.StatusCode == http.StatusOK {
|
|
// This means we got the consent page, now we need to POST consent
|
|
consentReq, err := http.NewRequestWithContext(ctx, "POST", authURL, nil)
|
|
require.NoError(t, err)
|
|
consentReq.Header.Set("Authorization", "Bearer "+coderClient.SessionToken())
|
|
consentReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err = client.Do(consentReq)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
}
|
|
|
|
// Extract authorization code from redirect URL
|
|
require.True(t, resp.StatusCode >= 300 && resp.StatusCode < 400, "Expected redirect response")
|
|
location := resp.Header.Get("Location")
|
|
require.NotEmpty(t, location, "Expected Location header in redirect")
|
|
|
|
redirectURL, err := url.Parse(location)
|
|
require.NoError(t, err)
|
|
authCode := redirectURL.Query().Get("code")
|
|
require.NotEmpty(t, authCode, "Expected authorization code in redirect URL")
|
|
|
|
t.Logf("Successfully obtained authorization code: %s", authCode[:10]+"...")
|
|
|
|
// Step 2: Exchange authorization code for access token and refresh token
|
|
tokenRequestBody := url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"client_id": {app.ID.String()},
|
|
"client_secret": {secret.ClientSecretFull},
|
|
"code": {authCode},
|
|
"redirect_uri": {"http://localhost:3000/callback"},
|
|
}
|
|
|
|
tokenReq, err := http.NewRequestWithContext(ctx, "POST", api.AccessURL.String()+"/oauth2/tokens",
|
|
strings.NewReader(tokenRequestBody.Encode()))
|
|
require.NoError(t, err)
|
|
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
tokenResp, err := http.DefaultClient.Do(tokenReq)
|
|
require.NoError(t, err)
|
|
defer tokenResp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, tokenResp.StatusCode, "Token exchange should succeed")
|
|
|
|
// Parse token response
|
|
var tokenResponse map[string]any
|
|
err = json.NewDecoder(tokenResp.Body).Decode(&tokenResponse)
|
|
require.NoError(t, err)
|
|
|
|
accessToken, ok := tokenResponse["access_token"].(string)
|
|
require.True(t, ok, "Response should contain access_token")
|
|
require.NotEmpty(t, accessToken)
|
|
|
|
refreshToken, ok := tokenResponse["refresh_token"].(string)
|
|
require.True(t, ok, "Response should contain refresh_token")
|
|
require.NotEmpty(t, refreshToken)
|
|
|
|
tokenType, ok := tokenResponse["token_type"].(string)
|
|
require.True(t, ok, "Response should contain token_type")
|
|
require.Equal(t, "Bearer", tokenType)
|
|
|
|
t.Logf("Successfully obtained access token: %s...", accessToken[:10])
|
|
t.Logf("Successfully obtained refresh token: %s...", refreshToken[:10])
|
|
|
|
// Step 3: Use access token to authenticate with MCP endpoint
|
|
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http"
|
|
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + accessToken,
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := mcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
// Initialize and test the MCP connection with OAuth2 access token
|
|
err = mcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
initReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-oauth2-flow-client",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := mcpClient.Initialize(ctx, initReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
|
|
|
|
// Test tool execution with OAuth2 access token
|
|
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, tools.Tools)
|
|
|
|
// Find and execute the authenticated user tool
|
|
var userTool *mcp.Tool
|
|
for _, tool := range tools.Tools {
|
|
if tool.Name == toolsdk.ToolNameGetAuthenticatedUser {
|
|
userTool = &tool
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, userTool, "Expected to find "+toolsdk.ToolNameGetAuthenticatedUser+" tool")
|
|
|
|
toolReq := mcp.CallToolRequest{
|
|
Params: mcp.CallToolParams{
|
|
Name: userTool.Name,
|
|
Arguments: map[string]any{},
|
|
},
|
|
}
|
|
|
|
toolResult, err := mcpClient.CallTool(ctx, toolReq)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, toolResult.Content)
|
|
|
|
t.Logf("Successfully executed tool with OAuth2 access token")
|
|
|
|
// Step 4: Refresh the access token using refresh token
|
|
refreshRequestBody := url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"client_id": {app.ID.String()},
|
|
"client_secret": {secret.ClientSecretFull},
|
|
"refresh_token": {refreshToken},
|
|
}
|
|
|
|
refreshReq, err := http.NewRequestWithContext(ctx, "POST", api.AccessURL.String()+"/oauth2/tokens",
|
|
strings.NewReader(refreshRequestBody.Encode()))
|
|
require.NoError(t, err)
|
|
refreshReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
refreshResp, err := http.DefaultClient.Do(refreshReq)
|
|
require.NoError(t, err)
|
|
defer refreshResp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, refreshResp.StatusCode, "Token refresh should succeed")
|
|
|
|
// Parse refresh response
|
|
var refreshResponse map[string]any
|
|
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResponse)
|
|
require.NoError(t, err)
|
|
|
|
newAccessToken, ok := refreshResponse["access_token"].(string)
|
|
require.True(t, ok, "Refresh response should contain new access_token")
|
|
require.NotEmpty(t, newAccessToken)
|
|
require.NotEqual(t, accessToken, newAccessToken, "New access token should be different")
|
|
|
|
newRefreshToken, ok := refreshResponse["refresh_token"].(string)
|
|
require.True(t, ok, "Refresh response should contain new refresh_token")
|
|
require.NotEmpty(t, newRefreshToken)
|
|
|
|
t.Logf("Successfully refreshed token: %s...", newAccessToken[:10])
|
|
|
|
// Step 5: Use new access token to create another MCP connection
|
|
newMcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + newAccessToken,
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := newMcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close new MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
// Test the new token works
|
|
err = newMcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
newInitReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-refreshed-token-client",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
newResult, err := newMcpClient.Initialize(ctx, newInitReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, mcpserver.MCPServerName, newResult.ServerInfo.Name)
|
|
|
|
// Verify we can still execute tools with the refreshed token
|
|
newTools, err := newMcpClient.ListTools(ctx, mcp.ListToolsRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, newTools.Tools)
|
|
|
|
t.Logf("OAuth2 full flow test successful: app creation -> authorization -> token exchange -> MCP usage -> token refresh -> MCP usage with refreshed token")
|
|
})
|
|
|
|
// Test 4: Invalid Bearer Token
|
|
t.Run("InvalidBearerToken", func(t *testing.T) {
|
|
t.Parallel()
|
|
req := &http.Request{
|
|
Method: "POST",
|
|
URL: mustParseURL(t, api.AccessURL.String()+"/api/experimental/mcp/http"),
|
|
Header: map[string][]string{
|
|
"Authorization": {"Bearer invalid_token_value"},
|
|
"Content-Type": {"application/json"},
|
|
},
|
|
}
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Should get 401 Unauthorized
|
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
|
|
// Should have RFC 6750 compliant WWW-Authenticate header
|
|
wwwAuth := resp.Header.Get("WWW-Authenticate")
|
|
require.NotEmpty(t, wwwAuth)
|
|
require.Contains(t, wwwAuth, "Bearer")
|
|
require.Contains(t, wwwAuth, `realm="coder"`)
|
|
require.Contains(t, wwwAuth, "invalid_token")
|
|
|
|
t.Logf("Invalid Bearer token test successful: %s", wwwAuth)
|
|
})
|
|
|
|
// Test 5: Dynamic Client Registration with Unauthenticated MCP Access
|
|
t.Run("DynamicClientRegistrationWithMCPFlow", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Step 1: Attempt unauthenticated MCP access
|
|
mcpURL := api.AccessURL.String() + "/api/experimental/mcp/http"
|
|
req := &http.Request{
|
|
Method: "POST",
|
|
URL: mustParseURL(t, mcpURL),
|
|
Header: make(http.Header),
|
|
}
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
// Should get 401 Unauthorized with WWW-Authenticate header
|
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
wwwAuth := resp.Header.Get("WWW-Authenticate")
|
|
require.NotEmpty(t, wwwAuth, "RFC 6750 requires WWW-Authenticate header for 401 responses")
|
|
require.Contains(t, wwwAuth, "Bearer", "WWW-Authenticate header should indicate Bearer authentication")
|
|
require.Contains(t, wwwAuth, `realm="coder"`, "WWW-Authenticate header should include realm")
|
|
|
|
t.Logf("Unauthenticated MCP access properly returned WWW-Authenticate: %s", wwwAuth)
|
|
|
|
// Step 2: Perform dynamic client registration (RFC 7591)
|
|
dynamicRegURL := api.AccessURL.String() + "/oauth2/register"
|
|
|
|
// Create dynamic client registration request
|
|
registrationRequest := map[string]any{
|
|
"client_name": "dynamic-mcp-client",
|
|
"redirect_uris": []string{"http://localhost:3000/callback"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
}
|
|
|
|
regBody, err := json.Marshal(registrationRequest)
|
|
require.NoError(t, err)
|
|
|
|
regReq, err := http.NewRequestWithContext(ctx, "POST", dynamicRegURL, strings.NewReader(string(regBody)))
|
|
require.NoError(t, err)
|
|
regReq.Header.Set("Content-Type", "application/json")
|
|
|
|
// Dynamic client registration should not require authentication (public endpoint)
|
|
regResp, err := http.DefaultClient.Do(regReq)
|
|
require.NoError(t, err)
|
|
defer regResp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusCreated, regResp.StatusCode, "Dynamic client registration should succeed")
|
|
|
|
// Parse the registration response
|
|
var regResponse map[string]any
|
|
err = json.NewDecoder(regResp.Body).Decode(®Response)
|
|
require.NoError(t, err)
|
|
|
|
clientID, ok := regResponse["client_id"].(string)
|
|
require.True(t, ok, "Registration response should contain client_id")
|
|
require.NotEmpty(t, clientID)
|
|
|
|
clientSecret, ok := regResponse["client_secret"].(string)
|
|
require.True(t, ok, "Registration response should contain client_secret")
|
|
require.NotEmpty(t, clientSecret)
|
|
|
|
t.Logf("Successfully registered dynamic client: %s", clientID)
|
|
|
|
// Step 3: Perform OAuth2 authorization code flow with dynamically registered client
|
|
authURL := fmt.Sprintf("%s/oauth2/authorize?client_id=%s&response_type=code&redirect_uri=%s&state=dynamic_state",
|
|
api.AccessURL.String(), clientID, "http://localhost:3000/callback")
|
|
|
|
// Create an HTTP client that captures redirects
|
|
authClient := &http.Client{
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse // Stop following redirects
|
|
},
|
|
}
|
|
|
|
// Make the authorization request with authentication
|
|
authReq, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
|
|
require.NoError(t, err)
|
|
authReq.Header.Set("Cookie", fmt.Sprintf("coder_session_token=%s", coderClient.SessionToken()))
|
|
|
|
authResp, err := authClient.Do(authReq)
|
|
require.NoError(t, err)
|
|
defer authResp.Body.Close()
|
|
|
|
// Handle the response - check for error first
|
|
if authResp.StatusCode == http.StatusBadRequest {
|
|
// Read error response for debugging
|
|
bodyBytes, err := io.ReadAll(authResp.Body)
|
|
require.NoError(t, err)
|
|
t.Logf("OAuth2 authorization error: %s", string(bodyBytes))
|
|
t.FailNow()
|
|
}
|
|
|
|
// Handle consent flow if needed
|
|
if authResp.StatusCode == http.StatusOK {
|
|
// This means we got the consent page, now we need to POST consent
|
|
consentReq, err := http.NewRequestWithContext(ctx, "POST", authURL, nil)
|
|
require.NoError(t, err)
|
|
consentReq.Header.Set("Cookie", fmt.Sprintf("coder_session_token=%s", coderClient.SessionToken()))
|
|
consentReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
authResp, err = authClient.Do(consentReq)
|
|
require.NoError(t, err)
|
|
defer authResp.Body.Close()
|
|
}
|
|
|
|
// Extract authorization code from redirect
|
|
require.True(t, authResp.StatusCode >= 300 && authResp.StatusCode < 400,
|
|
"Expected redirect response, got %d", authResp.StatusCode)
|
|
location := authResp.Header.Get("Location")
|
|
require.NotEmpty(t, location, "Expected Location header in redirect")
|
|
|
|
redirectURL, err := url.Parse(location)
|
|
require.NoError(t, err)
|
|
authCode := redirectURL.Query().Get("code")
|
|
require.NotEmpty(t, authCode, "Expected authorization code in redirect URL")
|
|
|
|
t.Logf("Successfully obtained authorization code: %s", authCode[:10]+"...")
|
|
|
|
// Step 4: Exchange authorization code for access token
|
|
tokenRequestBody := url.Values{
|
|
"grant_type": {"authorization_code"},
|
|
"client_id": {clientID},
|
|
"client_secret": {clientSecret},
|
|
"code": {authCode},
|
|
"redirect_uri": {"http://localhost:3000/callback"},
|
|
}
|
|
|
|
tokenReq, err := http.NewRequestWithContext(ctx, "POST", api.AccessURL.String()+"/oauth2/tokens",
|
|
strings.NewReader(tokenRequestBody.Encode()))
|
|
require.NoError(t, err)
|
|
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
tokenResp, err := http.DefaultClient.Do(tokenReq)
|
|
require.NoError(t, err)
|
|
defer tokenResp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, tokenResp.StatusCode, "Token exchange should succeed")
|
|
|
|
// Parse token response
|
|
var tokenResponse map[string]any
|
|
err = json.NewDecoder(tokenResp.Body).Decode(&tokenResponse)
|
|
require.NoError(t, err)
|
|
|
|
accessToken, ok := tokenResponse["access_token"].(string)
|
|
require.True(t, ok, "Response should contain access_token")
|
|
require.NotEmpty(t, accessToken)
|
|
|
|
refreshToken, ok := tokenResponse["refresh_token"].(string)
|
|
require.True(t, ok, "Response should contain refresh_token")
|
|
require.NotEmpty(t, refreshToken)
|
|
|
|
t.Logf("Successfully obtained access token: %s...", accessToken[:10])
|
|
|
|
// Step 5: Use access token to get user information via MCP
|
|
mcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + accessToken,
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := mcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
// Initialize MCP connection
|
|
err = mcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
initReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-dynamic-client",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := mcpClient.Initialize(ctx, initReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, mcpserver.MCPServerName, result.ServerInfo.Name)
|
|
|
|
// Get user information
|
|
tools, err := mcpClient.ListTools(ctx, mcp.ListToolsRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, tools.Tools)
|
|
|
|
// Find and execute the authenticated user tool
|
|
var userTool *mcp.Tool
|
|
for _, tool := range tools.Tools {
|
|
if tool.Name == toolsdk.ToolNameGetAuthenticatedUser {
|
|
userTool = &tool
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, userTool, "Expected to find "+toolsdk.ToolNameGetAuthenticatedUser+" tool")
|
|
|
|
toolReq := mcp.CallToolRequest{
|
|
Params: mcp.CallToolParams{
|
|
Name: userTool.Name,
|
|
Arguments: map[string]any{},
|
|
},
|
|
}
|
|
|
|
toolResult, err := mcpClient.CallTool(ctx, toolReq)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, toolResult.Content)
|
|
|
|
// Extract user info from first token
|
|
var firstUserInfo string
|
|
if textContent, ok := toolResult.Content[0].(mcp.TextContent); ok {
|
|
firstUserInfo = textContent.Text
|
|
} else {
|
|
t.Errorf("Expected TextContent type, got %T", toolResult.Content[0])
|
|
}
|
|
require.NotEmpty(t, firstUserInfo)
|
|
|
|
t.Logf("Successfully retrieved user info with first token")
|
|
|
|
// Step 6: Refresh the token
|
|
refreshRequestBody := url.Values{
|
|
"grant_type": {"refresh_token"},
|
|
"client_id": {clientID},
|
|
"client_secret": {clientSecret},
|
|
"refresh_token": {refreshToken},
|
|
}
|
|
|
|
refreshReq, err := http.NewRequestWithContext(ctx, "POST", api.AccessURL.String()+"/oauth2/tokens",
|
|
strings.NewReader(refreshRequestBody.Encode()))
|
|
require.NoError(t, err)
|
|
refreshReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
refreshResp, err := http.DefaultClient.Do(refreshReq)
|
|
require.NoError(t, err)
|
|
defer refreshResp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, refreshResp.StatusCode, "Token refresh should succeed")
|
|
|
|
// Parse refresh response
|
|
var refreshResponse map[string]any
|
|
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResponse)
|
|
require.NoError(t, err)
|
|
|
|
newAccessToken, ok := refreshResponse["access_token"].(string)
|
|
require.True(t, ok, "Refresh response should contain new access_token")
|
|
require.NotEmpty(t, newAccessToken)
|
|
require.NotEqual(t, accessToken, newAccessToken, "New access token should be different")
|
|
|
|
t.Logf("Successfully refreshed token: %s...", newAccessToken[:10])
|
|
|
|
// Step 7: Use refreshed token to get user information again via MCP
|
|
newMcpClient, err := mcpclient.NewStreamableHttpClient(mcpURL,
|
|
transport.WithHTTPHeaders(map[string]string{
|
|
"Authorization": "Bearer " + newAccessToken,
|
|
}))
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
if closeErr := newMcpClient.Close(); closeErr != nil {
|
|
t.Logf("Failed to close new MCP client: %v", closeErr)
|
|
}
|
|
}()
|
|
|
|
// Initialize new MCP connection
|
|
err = newMcpClient.Start(ctx)
|
|
require.NoError(t, err)
|
|
|
|
newInitReq := mcp.InitializeRequest{
|
|
Params: mcp.InitializeParams{
|
|
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
|
|
ClientInfo: mcp.Implementation{
|
|
Name: "test-dynamic-client-refreshed",
|
|
Version: "1.0.0",
|
|
},
|
|
},
|
|
}
|
|
|
|
newResult, err := newMcpClient.Initialize(ctx, newInitReq)
|
|
require.NoError(t, err)
|
|
require.Equal(t, mcpserver.MCPServerName, newResult.ServerInfo.Name)
|
|
|
|
// Get user information with refreshed token
|
|
newTools, err := newMcpClient.ListTools(ctx, mcp.ListToolsRequest{})
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, newTools.Tools)
|
|
|
|
// Execute user tool again
|
|
newToolResult, err := newMcpClient.CallTool(ctx, toolReq)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, newToolResult.Content)
|
|
|
|
// Extract user info from refreshed token
|
|
var secondUserInfo string
|
|
if textContent, ok := newToolResult.Content[0].(mcp.TextContent); ok {
|
|
secondUserInfo = textContent.Text
|
|
} else {
|
|
t.Errorf("Expected TextContent type, got %T", newToolResult.Content[0])
|
|
}
|
|
require.NotEmpty(t, secondUserInfo)
|
|
|
|
// Step 8: Compare user information before and after token refresh
|
|
// Parse JSON to compare the important fields, ignoring timestamp differences
|
|
var firstUser, secondUser map[string]any
|
|
err = json.Unmarshal([]byte(firstUserInfo), &firstUser)
|
|
require.NoError(t, err)
|
|
err = json.Unmarshal([]byte(secondUserInfo), &secondUser)
|
|
require.NoError(t, err)
|
|
|
|
// Compare key fields that should be identical
|
|
require.Equal(t, firstUser["id"], secondUser["id"], "User ID should be identical")
|
|
require.Equal(t, firstUser["username"], secondUser["username"], "Username should be identical")
|
|
require.Equal(t, firstUser["email"], secondUser["email"], "Email should be identical")
|
|
require.Equal(t, firstUser["status"], secondUser["status"], "Status should be identical")
|
|
require.Equal(t, firstUser["login_type"], secondUser["login_type"], "Login type should be identical")
|
|
require.Equal(t, firstUser["roles"], secondUser["roles"], "Roles should be identical")
|
|
require.Equal(t, firstUser["organization_ids"], secondUser["organization_ids"], "Organization IDs should be identical")
|
|
|
|
// Note: last_seen_at will be different since time passed between calls, which is expected
|
|
|
|
t.Logf("Dynamic client registration flow test successful: " +
|
|
"unauthenticated access → WWW-Authenticate → dynamic registration → OAuth2 flow → " +
|
|
"MCP usage → token refresh → MCP usage with consistent user info")
|
|
})
|
|
|
|
// Test 6: Verify duplicate client names are allowed (RFC 7591 compliance)
|
|
t.Run("DuplicateClientNamesAllowed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dynamicRegURL := api.AccessURL.String() + "/oauth2/register"
|
|
clientName := "duplicate-name-test-client"
|
|
|
|
// Register first client with a specific name
|
|
registrationRequest1 := map[string]any{
|
|
"client_name": clientName,
|
|
"redirect_uris": []string{"http://localhost:3000/callback1"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
}
|
|
|
|
regBody1, err := json.Marshal(registrationRequest1)
|
|
require.NoError(t, err)
|
|
|
|
regReq1, err := http.NewRequestWithContext(ctx, "POST", dynamicRegURL, strings.NewReader(string(regBody1)))
|
|
require.NoError(t, err)
|
|
regReq1.Header.Set("Content-Type", "application/json")
|
|
|
|
regResp1, err := http.DefaultClient.Do(regReq1)
|
|
require.NoError(t, err)
|
|
defer regResp1.Body.Close()
|
|
|
|
require.Equal(t, http.StatusCreated, regResp1.StatusCode, "First client registration should succeed")
|
|
|
|
var regResponse1 map[string]any
|
|
err = json.NewDecoder(regResp1.Body).Decode(®Response1)
|
|
require.NoError(t, err)
|
|
|
|
clientID1, ok := regResponse1["client_id"].(string)
|
|
require.True(t, ok, "First registration response should contain client_id")
|
|
require.NotEmpty(t, clientID1)
|
|
|
|
// Register second client with the same name
|
|
registrationRequest2 := map[string]any{
|
|
"client_name": clientName, // Same name as first client
|
|
"redirect_uris": []string{"http://localhost:3000/callback2"},
|
|
"grant_types": []string{"authorization_code", "refresh_token"},
|
|
"response_types": []string{"code"},
|
|
"token_endpoint_auth_method": "client_secret_basic",
|
|
}
|
|
|
|
regBody2, err := json.Marshal(registrationRequest2)
|
|
require.NoError(t, err)
|
|
|
|
regReq2, err := http.NewRequestWithContext(ctx, "POST", dynamicRegURL, strings.NewReader(string(regBody2)))
|
|
require.NoError(t, err)
|
|
regReq2.Header.Set("Content-Type", "application/json")
|
|
|
|
regResp2, err := http.DefaultClient.Do(regReq2)
|
|
require.NoError(t, err)
|
|
defer regResp2.Body.Close()
|
|
|
|
// This should succeed per RFC 7591 (no unique name requirement)
|
|
require.Equal(t, http.StatusCreated, regResp2.StatusCode,
|
|
"Second client registration with duplicate name should succeed (RFC 7591 compliance)")
|
|
|
|
var regResponse2 map[string]any
|
|
err = json.NewDecoder(regResp2.Body).Decode(®Response2)
|
|
require.NoError(t, err)
|
|
|
|
clientID2, ok := regResponse2["client_id"].(string)
|
|
require.True(t, ok, "Second registration response should contain client_id")
|
|
require.NotEmpty(t, clientID2)
|
|
|
|
// Verify client IDs are different even though names are the same
|
|
require.NotEqual(t, clientID1, clientID2, "Client IDs should be unique even with duplicate names")
|
|
|
|
// Verify both clients have the same name but unique IDs
|
|
name1, ok := regResponse1["client_name"].(string)
|
|
require.True(t, ok)
|
|
name2, ok := regResponse2["client_name"].(string)
|
|
require.True(t, ok)
|
|
|
|
require.Equal(t, clientName, name1, "First client should have the expected name")
|
|
require.Equal(t, clientName, name2, "Second client should have the same name")
|
|
require.Equal(t, name1, name2, "Both clients should have identical names")
|
|
|
|
t.Logf("Successfully registered two OAuth2 clients with duplicate name '%s' but unique IDs: %s, %s",
|
|
clientName, clientID1, clientID2)
|
|
})
|
|
}
|
|
|
|
// Helper function to parse URL safely in tests
|
|
func mustParseURL(t *testing.T, rawURL string) *url.URL {
|
|
u, err := url.Parse(rawURL)
|
|
require.NoError(t, err, "Failed to parse URL %q", rawURL)
|
|
return u
|
|
}
|