Compare commits
16 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51400e8eda | |||
| 12beeaacd7 | |||
| 477aa5b5b8 | |||
| 365a6623ea | |||
| 408b16b9f8 | |||
| 9f41dcd22f | |||
| c883add6c3 | |||
| 53995cf3a3 | |||
| 260d0cad40 | |||
| d16354d260 | |||
| 9e1808921b | |||
| 429964c166 | |||
| 1afb42362d | |||
| 431e840497 | |||
| 101bd6f5e3 | |||
| fcf1d7a21f |
29 changed files with 5854 additions and 191 deletions
100
.env.example
100
.env.example
|
|
@ -1,27 +1,101 @@
|
|||
# Edge Connect MCP Server Configuration
|
||||
# Copy this file to .env and update with your values
|
||||
|
||||
# ===================================
|
||||
# Edge Connect API Configuration
|
||||
# ===================================
|
||||
|
||||
# Base URL of the Edge Connect API (required)
|
||||
EDGE_CONNECT_BASE_URL=https://hub.apps.edge.platform.mg3.mdb.osc.live
|
||||
|
||||
# Authentication type: token, credentials, or none (required)
|
||||
EDGE_CONNECT_AUTH_TYPE=credentials
|
||||
|
||||
# Authentication - Token based (when auth_type=token)
|
||||
# EDGE_CONNECT_TOKEN=your-bearer-token-here
|
||||
# For token-based authentication (if auth_type=token)
|
||||
#EDGE_CONNECT_TOKEN=your-token-here
|
||||
|
||||
# Authentication - Credentials based (when auth_type=credentials)
|
||||
# For credentials-based authentication (if auth_type=credentials)
|
||||
EDGE_CONNECT_USERNAME=your-username
|
||||
EDGE_CONNECT_PASSWORD=your-password
|
||||
|
||||
# Optional Configuration
|
||||
# Default region (optional, default: EU)
|
||||
EDGE_CONNECT_DEFAULT_REGION=EU
|
||||
EDGE_CONNECT_DEBUG=false
|
||||
|
||||
# MCP Server Mode Configuration
|
||||
# Options: "stdio" (local) or "remote" (HTTP/SSE)
|
||||
MCP_SERVER_MODE=stdio
|
||||
# Enable debug logging (optional)
|
||||
#EDGE_CONNECT_DEBUG=true
|
||||
|
||||
# Remote Server Configuration (when MCP_SERVER_MODE=remote)
|
||||
# ===================================
|
||||
# MCP Server Configuration
|
||||
# ===================================
|
||||
|
||||
# Server mode: stdio or remote (default: stdio)
|
||||
MCP_SERVER_MODE=remote
|
||||
|
||||
# Remote server host (default: 0.0.0.0)
|
||||
MCP_REMOTE_HOST=0.0.0.0
|
||||
|
||||
# Remote server port (default: 8080)
|
||||
MCP_REMOTE_PORT=8080
|
||||
|
||||
# Remote Server Authentication (optional but recommended for production)
|
||||
MCP_REMOTE_AUTH_REQUIRED=false
|
||||
# Comma-separated list of valid Bearer tokens
|
||||
# MCP_REMOTE_AUTH_TOKENS=token1,token2,token3
|
||||
# ===================================
|
||||
# Simple Bearer Token Authentication
|
||||
# (Used when OAuth is disabled)
|
||||
# ===================================
|
||||
|
||||
# Enable bearer token authentication for remote access (optional)
|
||||
#MCP_REMOTE_AUTH_REQUIRED=true
|
||||
|
||||
# Comma-separated list of valid bearer tokens (optional)
|
||||
#MCP_REMOTE_AUTH_TOKENS=token1,token2,token3
|
||||
|
||||
# ===================================
|
||||
# OAuth 2.1 Configuration
|
||||
# (Recommended for production)
|
||||
# ===================================
|
||||
|
||||
# Enable OAuth 2.1 authorization (optional, default: false)
|
||||
OAUTH_ENABLED=true
|
||||
|
||||
# OAuth mode (default: resource_server)
|
||||
OAUTH_MODE=resource_server
|
||||
|
||||
# Resource URI - the canonical URI of this MCP server (required if OAuth enabled)
|
||||
OAUTH_RESOURCE_URI=http://localhost:8080
|
||||
|
||||
# Comma-separated list of authorization server URLs (required if OAuth enabled)
|
||||
OAUTH_AUTH_SERVERS=http://localhost:8081
|
||||
|
||||
# Expected issuer in JWT tokens (required if OAuth enabled)
|
||||
OAUTH_ISSUER=http://localhost:8081
|
||||
|
||||
# JWKS endpoint URL for token validation (required if OAuth enabled)
|
||||
OAUTH_JWKS_URL=http://localhost:8081/.well-known/jwks.json
|
||||
|
||||
# ===================================
|
||||
# Basic Authorization Server
|
||||
# (For development/testing only)
|
||||
# ===================================
|
||||
|
||||
# Enable built-in basic authorization server (optional, default: false)
|
||||
OAUTH_AUTH_SERVER_ENABLED=true
|
||||
|
||||
# Port for the authorization server (default: 8081)
|
||||
OAUTH_AUTH_SERVER_PORT=8081
|
||||
|
||||
# OAuth client ID to register (required if auth server enabled)
|
||||
OAUTH_CLIENT_ID=test-client
|
||||
|
||||
# OAuth redirect URI for the client (required if auth server enabled)
|
||||
OAUTH_REDIRECT_URI=http://localhost:3000/callback
|
||||
|
||||
# ===================================
|
||||
# Production OAuth Configuration
|
||||
# ===================================
|
||||
# For production, use an external authorization server:
|
||||
#
|
||||
# OAUTH_ENABLED=true
|
||||
# OAUTH_RESOURCE_URI=https://mcp.example.com
|
||||
# OAUTH_AUTH_SERVERS=https://auth.example.com
|
||||
# OAUTH_ISSUER=https://auth.example.com
|
||||
# OAUTH_JWKS_URL=https://auth.example.com/.well-known/jwks.json
|
||||
# OAUTH_AUTH_SERVER_ENABLED=false # Don't use basic auth server in production
|
||||
|
|
|
|||
4
.gitleaksignore
Normal file
4
.gitleaksignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# False positives - documentation examples with placeholder credentials
|
||||
053e909940b7b5370e855b9bf5236f04d8bdd451:QUICKSTART.md:curl-auth-user:197
|
||||
b8b8da13d3c62c597d4029c23ed1a0ae7073e561:REMOTE_SERVER.md:curl-auth-header:61
|
||||
b8b8da13d3c62c597d4029c23ed1a0ae7073e561:REMOTE_SERVER.md:curl-auth-header:358
|
||||
419
MCP_UI.md
Normal file
419
MCP_UI.md
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
# MCP UI Integration
|
||||
|
||||
This document describes the MCP UI integration in the Edge Connect MCP server, which provides interactive web-based visualizations for Edge Connect resources.
|
||||
|
||||
## Overview
|
||||
|
||||
The Edge Connect MCP server now includes support for [MCP-UI](https://github.com/think-ahead-technologies/mcp-ui), a protocol that enables servers to deliver interactive web components through the Model Context Protocol. When users query Edge Connect resources, they receive both traditional text-based responses and rich, interactive HTML dashboards.
|
||||
|
||||
## What is MCP-UI?
|
||||
|
||||
MCP-UI brings interactive web components to the Model Context Protocol, allowing MCP servers to deliver rich, dynamic UI resources directly to clients. Instead of receiving only plain text or JSON, users get beautifully rendered, interactive visualizations of their data.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Applications Dashboard (`list_apps` tool)
|
||||
|
||||
When listing Edge Connect applications, users receive an interactive dashboard featuring:
|
||||
|
||||
- **Statistics Cards**:
|
||||
- Total application count
|
||||
- Docker application count
|
||||
- Kubernetes application count
|
||||
- Serverless-enabled applications count
|
||||
|
||||
- **Application Cards Grid**:
|
||||
- Organization and application name
|
||||
- Version badge
|
||||
- Deployment type badge (Docker/Kubernetes)
|
||||
- Serverless badge (if enabled)
|
||||
- Image path (truncated for display)
|
||||
- Access ports configuration
|
||||
- Interactive buttons for viewing details and deletion
|
||||
|
||||
- **Visual Design**:
|
||||
- Purple gradient background (#667eea to #764ba2)
|
||||
- Responsive grid layout (auto-adjusts to screen size)
|
||||
- Hover effects and smooth transitions
|
||||
- Card-based design with shadows
|
||||
|
||||
### 2. Application Detail View (`show_app` tool)
|
||||
|
||||
When viewing a specific application, users receive a detailed view with:
|
||||
|
||||
- **Property Grid**:
|
||||
- Organization, name, and version
|
||||
- Region information
|
||||
- Deployment type with colored badge
|
||||
- Image type and full image path
|
||||
- Access ports configuration
|
||||
- Serverless status with colored badge
|
||||
|
||||
- **Raw JSON Viewer**:
|
||||
- Syntax-highlighted JSON display
|
||||
- Complete application object for reference
|
||||
- Dark theme code viewer
|
||||
|
||||
### 3. Application Instances Dashboard (`list_app_instances` tool)
|
||||
|
||||
When listing application instances, users receive an interactive dashboard featuring:
|
||||
|
||||
- **Statistics Cards**:
|
||||
- Total instances count
|
||||
- Running instances count
|
||||
- Stopped instances count
|
||||
|
||||
- **Instances Table**:
|
||||
- Instance name and organization
|
||||
- Cloudlet location (org/name)
|
||||
- Associated application and version
|
||||
- Power state with status badges (Running/Stopped/Unknown)
|
||||
- Flavor information
|
||||
- Quick action buttons
|
||||
|
||||
- **Visual Design**:
|
||||
- Blue/purple gradient background (#4f46e5 to #7c3aed)
|
||||
- Professional table layout
|
||||
- Status badges with semantic colors
|
||||
- Hover effects on rows
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ MCP Client (Claude Desktop, etc.) │
|
||||
│ - Requests tool execution │
|
||||
│ - Renders embedded UI resources │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Edge Connect MCP Server │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ tools.go │ │
|
||||
│ │ - Executes Edge Connect API calls │ │
|
||||
│ │ - Calls UI generation functions │ │
|
||||
│ │ - Returns text + embedded resources │ │
|
||||
│ └──────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────▼───────────────────────┐ │
|
||||
│ │ ui.go │ │
|
||||
│ │ - createAppListUI() │ │
|
||||
│ │ - createAppDetailUI() │ │
|
||||
│ │ - createAppInstanceListUI() │ │
|
||||
│ │ - Generates HTML with inline CSS │ │
|
||||
│ └──────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────▼───────────────────────┐ │
|
||||
│ │ mcp-ui Go SDK │ │
|
||||
│ │ - CreateUIResource() │ │
|
||||
│ │ - Validates UI URIs │ │
|
||||
│ │ - Encodes HTML content │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
|
||||
#### 1. `go.mod` and `go.sum`
|
||||
Added dependency on the mcp-ui Go SDK:
|
||||
|
||||
```go
|
||||
require (
|
||||
github.com/MCP-UI-Org/mcp-ui/sdks/go/server v0.0.1
|
||||
)
|
||||
|
||||
replace github.com/MCP-UI-Org/mcp-ui/sdks/go/server =>
|
||||
github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112091603-a8343491b666
|
||||
```
|
||||
|
||||
#### 2. `ui.go` (New File)
|
||||
Contains all UI generation logic:
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
- `createAppListUI(apps []v2.App, region string) (*mcpuiserver.UIResource, error)`
|
||||
- Generates interactive dashboard for application listings
|
||||
- Returns a UIResource with HTML content
|
||||
|
||||
- `createAppDetailUI(app v2.App, region string) (*mcpuiserver.UIResource, error)`
|
||||
- Generates detailed view for a single application
|
||||
- Includes property grid and JSON viewer
|
||||
|
||||
- `createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuiserver.UIResource, error)`
|
||||
- Generates dashboard for application instances
|
||||
- Includes statistics and interactive table
|
||||
|
||||
**Helper Functions**:
|
||||
- `generateAppCard(app v2.App) string` - Creates HTML for individual app cards
|
||||
- `generateInstanceRow(inst v2.AppInstance) string` - Creates HTML table row for instance
|
||||
- `countDeploymentType()`, `countServerlessApps()`, `countPowerState()` - Statistics helpers
|
||||
- `getAccessPorts()`, `getServerlessBadgeClass()`, `getServerlessStatus()` - Display helpers
|
||||
|
||||
#### 3. `tools.go`
|
||||
Enhanced three tool handlers to include UI resources:
|
||||
|
||||
**Modified Functions**:
|
||||
|
||||
1. **`registerListAppsTool()`** (lines ~197-223):
|
||||
```go
|
||||
// Create UI resource for apps list
|
||||
uiResource, err := createAppListUI(apps, region)
|
||||
if err != nil {
|
||||
// Fallback to text-only response
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: result}},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// Convert UIResource to MCP EmbeddedResource
|
||||
resourceContents := &mcp.ResourceContents{
|
||||
URI: uiResource.Resource.URI,
|
||||
MIMEType: uiResource.Resource.MimeType,
|
||||
Text: uiResource.Resource.Text,
|
||||
}
|
||||
|
||||
// Return both text and UI resource
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: result},
|
||||
&mcp.EmbeddedResource{Resource: resourceContents},
|
||||
},
|
||||
}, nil, nil
|
||||
```
|
||||
|
||||
2. **`registerShowAppTool()`** (lines ~143-166):
|
||||
- Similar pattern for app detail view
|
||||
|
||||
3. **`registerListAppInstancesTool()`** (lines ~609-635):
|
||||
- Similar pattern for instance dashboard
|
||||
|
||||
### UI Resource Structure
|
||||
|
||||
Each UI resource follows the MCP-UI protocol structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "resource",
|
||||
"resource": {
|
||||
"uri": "ui://apps-dashboard",
|
||||
"mimeType": "text/html",
|
||||
"text": "<html>...</html>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**URI Patterns**:
|
||||
- `ui://apps-dashboard` - Application listing dashboard
|
||||
- `ui://app-detail/{org}/{name}/{version}` - Single app detail view
|
||||
- `ui://app-instances-dashboard` - Instance listing dashboard
|
||||
|
||||
## Usage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **MCP Client Support**: Your MCP client must support UI resources (embedded resources in tool responses)
|
||||
2. **Edge Connect Configuration**: Server must be properly configured with Edge Connect credentials
|
||||
|
||||
### Example Workflow
|
||||
|
||||
1. **List Applications**:
|
||||
```bash
|
||||
# User asks: "List all Edge Connect applications"
|
||||
# Server executes list_apps tool
|
||||
# Returns: Text summary + Interactive HTML dashboard
|
||||
```
|
||||
|
||||
2. **View Application Details**:
|
||||
```bash
|
||||
# User asks: "Show me details for app myorg/myapp:1.0.0"
|
||||
# Server executes show_app tool
|
||||
# Returns: JSON data + Interactive detail view
|
||||
```
|
||||
|
||||
3. **List Instances**:
|
||||
```bash
|
||||
# User asks: "Show all application instances"
|
||||
# Server executes list_app_instances tool
|
||||
# Returns: Text summary + Interactive dashboard
|
||||
```
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
The implementation includes graceful degradation:
|
||||
|
||||
1. If UI resource creation fails, the server returns text-only content
|
||||
2. Clients that don't support UI resources can still use the text content
|
||||
3. No breaking changes to existing tool behavior
|
||||
|
||||
## Design Principles
|
||||
|
||||
### Visual Design
|
||||
|
||||
- **Modern Aesthetics**: Gradient backgrounds, card-based layouts, smooth shadows
|
||||
- **Professional Color Scheme**: Purple/blue palette with semantic colors for status indicators
|
||||
- **Typography**: System font stack for optimal rendering across platforms
|
||||
- **Responsive Design**: Layouts adapt to different screen sizes using CSS Grid
|
||||
|
||||
### User Experience
|
||||
|
||||
- **Information Hierarchy**: Most important info at the top, details below
|
||||
- **Scannable Content**: Clear labels, consistent spacing, visual grouping
|
||||
- **Interactive Elements**: Hover effects, clickable cards (prepared for future actions)
|
||||
- **Status Indicators**: Color-coded badges for quick status recognition
|
||||
|
||||
### Code Quality
|
||||
|
||||
- **Separation of Concerns**: UI logic isolated in `ui.go`
|
||||
- **Error Handling**: Graceful fallbacks if UI generation fails
|
||||
- **HTML Safety**: All user content is escaped using `html.EscapeString()`
|
||||
- **Performance**: String concatenation in loops (acceptable for small datasets)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### HTML Escaping
|
||||
|
||||
All dynamic content is properly escaped to prevent XSS vulnerabilities:
|
||||
|
||||
```go
|
||||
html.EscapeString(app.Key.Name)
|
||||
html.EscapeString(app.ImagePath)
|
||||
html.EscapeString(inst.PowerState)
|
||||
```
|
||||
|
||||
### URI Validation
|
||||
|
||||
The mcp-ui SDK validates that all resource URIs start with `ui://` to prevent unauthorized resource access.
|
||||
|
||||
### Content Isolation
|
||||
|
||||
UI resources are rendered in isolated contexts by MCP clients, preventing cross-site scripting or malicious code execution.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
|
||||
1. **Interactive Actions**:
|
||||
- Add UI action handlers for delete, update, refresh operations
|
||||
- Use `UIActionResultToolCall()` to trigger backend operations from UI
|
||||
|
||||
2. **Real-time Updates**:
|
||||
- WebSocket integration for live status updates
|
||||
- Auto-refresh dashboards when resources change
|
||||
|
||||
3. **Advanced Visualizations**:
|
||||
- Charts for resource utilization
|
||||
- Geographic maps for cloudlet locations
|
||||
- Timeline views for deployment history
|
||||
|
||||
4. **Forms and Input**:
|
||||
- Create/update forms embedded in UI
|
||||
- Validation and error feedback
|
||||
- Wizard-style multi-step processes
|
||||
|
||||
5. **Customization**:
|
||||
- Theme selection (light/dark mode)
|
||||
- User-configurable layouts
|
||||
- Saved filter preferences
|
||||
|
||||
### Extensibility
|
||||
|
||||
To add UI resources to other tools:
|
||||
|
||||
1. Create a new UI generation function in `ui.go`:
|
||||
```go
|
||||
func createMyResourceUI(data MyData) (*mcpuiserver.UIResource, error) {
|
||||
htmlContent := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>/* Your CSS */</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Your HTML -->
|
||||
</body>
|
||||
</html>
|
||||
`, /* your data */)
|
||||
|
||||
return mcpuiserver.CreateUIResource(
|
||||
"ui://my-resource",
|
||||
&mcpuiserver.RawHTMLPayload{
|
||||
Type: mcpuiserver.ContentTypeRawHTML,
|
||||
HTMLString: htmlContent,
|
||||
},
|
||||
mcpuiserver.EncodingText,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
2. Call it from your tool handler in `tools.go`:
|
||||
```go
|
||||
uiResource, err := createMyResourceUI(data)
|
||||
if err == nil {
|
||||
resourceContents := &mcp.ResourceContents{
|
||||
URI: uiResource.Resource.URI,
|
||||
MIMEType: uiResource.Resource.MimeType,
|
||||
Text: uiResource.Resource.Text,
|
||||
}
|
||||
// Add to result.Content
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### UI Not Displaying
|
||||
|
||||
**Problem**: UI resources aren't showing up in the client.
|
||||
|
||||
**Solutions**:
|
||||
1. Verify your MCP client supports UI resources
|
||||
2. Check server logs for UI generation errors
|
||||
3. Ensure HTML content is valid (no syntax errors)
|
||||
4. Verify URI starts with `ui://`
|
||||
|
||||
### Styling Issues
|
||||
|
||||
**Problem**: UI looks broken or unstyled.
|
||||
|
||||
**Solutions**:
|
||||
1. Check for CSS syntax errors in HTML
|
||||
2. Verify inline styles are properly escaped
|
||||
3. Test HTML in a browser independently
|
||||
4. Ensure percentage signs are doubled in `fmt.Sprintf()` (e.g., `%%`)
|
||||
|
||||
### Build Errors
|
||||
|
||||
**Problem**: Compilation fails after integrating mcp-ui.
|
||||
|
||||
**Solutions**:
|
||||
1. Run `go mod tidy` to resolve dependencies
|
||||
2. Verify replace directive in `go.mod` points to correct commit
|
||||
3. Check import paths in `ui.go` and `tools.go`
|
||||
4. Ensure mcp-ui SDK version is compatible
|
||||
|
||||
## References
|
||||
|
||||
- [MCP-UI GitHub Repository](https://github.com/think-ahead-technologies/mcp-ui)
|
||||
- [Model Context Protocol Specification](https://modelcontextprotocol.io/)
|
||||
- [Edge Connect API Documentation](https://edp.buildth.ing/)
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-01-12 - Initial MCP-UI Integration
|
||||
|
||||
**Added**:
|
||||
- `ui.go` with three UI generation functions
|
||||
- MCP-UI Go SDK dependency
|
||||
- Interactive dashboards for apps and instances
|
||||
- Graceful fallback to text-only responses
|
||||
|
||||
**Modified**:
|
||||
- `tools.go`: Enhanced `list_apps`, `show_app`, `list_app_instances` tools
|
||||
- `go.mod`: Added mcp-ui dependency with replace directive
|
||||
- `go.sum`: Updated checksums
|
||||
|
||||
**Files**:
|
||||
- New: `ui.go` (~780 lines)
|
||||
- Modified: `tools.go` (+70 lines), `go.mod` (+3 lines)
|
||||
11
Makefile
11
Makefile
|
|
@ -4,12 +4,15 @@
|
|||
BINARY_NAME := edge-connect-mcp
|
||||
GO := go
|
||||
GOLANGCI_LINT := $(GO) run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2
|
||||
GITLEAKS := $(GO) run github.com/zricethezav/gitleaks/v8@v8.30.0
|
||||
|
||||
# Build flags
|
||||
LDFLAGS := -s -w
|
||||
BUILD_FLAGS := -ldflags "$(LDFLAGS)"
|
||||
|
||||
.PHONY: all build clean fmt format lint test run help vet tidy install-hooks
|
||||
default: run
|
||||
|
||||
.PHONY: all build clean fmt format lint gitleaks test run help vet tidy install-hooks
|
||||
|
||||
# Default target
|
||||
all: fmt vet lint build
|
||||
|
|
@ -36,6 +39,12 @@ vet: ## Run go vet
|
|||
lint: ## Run golangci-lint
|
||||
$(GOLANGCI_LINT) run ./...
|
||||
|
||||
gitleaks: ## Check for secrets in git history
|
||||
$(GITLEAKS) git --staged
|
||||
|
||||
gitleaks-all: ## Check for secrets in git history
|
||||
$(GITLEAKS) git .
|
||||
|
||||
## Dependency management
|
||||
|
||||
tidy: ## Tidy go modules
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ Get up and running with the Edge Connect MCP Server in 5 minutes.
|
|||
```bash
|
||||
git clone <repository-url>
|
||||
cd edge-connect-mcp
|
||||
go build -o edge-connect-mcp
|
||||
make build
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Create a `.env` file or export environment variables:
|
||||
Create a `.env` file from `.env.example`, or export environment variables:
|
||||
|
||||
```bash
|
||||
export EDGE_CONNECT_BASE_URL="https://hub.apps.edge.platform.mg3.mdb.osc.live"
|
||||
|
|
@ -34,17 +34,45 @@ export EDGE_CONNECT_DEFAULT_REGION="EU"
|
|||
|
||||
```bash
|
||||
# Verify it starts without errors
|
||||
./edge-connect-mcp &
|
||||
./edge-connect-mcp
|
||||
# Alternatively:
|
||||
make run
|
||||
# Kill it with Ctrl+C after verifying
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
### Option A: Claude Code (Recommended)
|
||||
### Option A: Claude or Claude Desktop locally (Recommended)
|
||||
|
||||
Edit your config file:
|
||||
|
||||
**Claude Desktop**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
**Claude Code**: `~/.claude.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"edge-connect": {
|
||||
"command": "/path/to/edge-connect-mcp",
|
||||
"env": {
|
||||
"EDGE_CONNECT_BASE_URL": "https://hub.apps.edge.platform.mg3.mdb.osc.live",
|
||||
"EDGE_CONNECT_AUTH_TYPE": "credentials",
|
||||
"EDGE_CONNECT_USERNAME": "your-username",
|
||||
"EDGE_CONNECT_PASSWORD": "your-password",
|
||||
"EDGE_CONNECT_DEFAULT_REGION": "EU"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Desktop or Claude Code to load the server.
|
||||
|
||||
### Option B: Claude Code locally (not currently working)
|
||||
|
||||
```bash
|
||||
# Add the server
|
||||
claude mcp add edge-connect
|
||||
claude mcp add edge-connect ./edge-connect-mcp
|
||||
|
||||
# Configure it (use absolute path to binary)
|
||||
claude mcp edit edge-connect --set command=$(pwd)/edge-connect-mcp
|
||||
|
|
@ -61,32 +89,7 @@ claude mcp list
|
|||
claude mcp test edge-connect
|
||||
```
|
||||
|
||||
### Option B: Claude Desktop
|
||||
|
||||
Edit your config file:
|
||||
|
||||
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"edge-connect": {
|
||||
"command": "/absolute/path/to/edge-connect-mcp",
|
||||
"env": {
|
||||
"EDGE_CONNECT_BASE_URL": "https://hub.apps.edge.platform.mg3.mdb.osc.live",
|
||||
"EDGE_CONNECT_AUTH_TYPE": "credentials",
|
||||
"EDGE_CONNECT_USERNAME": "your-username",
|
||||
"EDGE_CONNECT_PASSWORD": "your-password",
|
||||
"EDGE_CONNECT_DEFAULT_REGION": "EU"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Claude Desktop to load the server.
|
||||
|
||||
### Option C: Remote Server
|
||||
### Option C: Remote Server (http communication)
|
||||
|
||||
For remote access:
|
||||
|
||||
|
|
@ -113,7 +116,7 @@ Please create an Edge Connect app:
|
|||
- Organization: my-org
|
||||
- Name: my-nginx-app
|
||||
- Version: 1.0.0
|
||||
- Deployment: docker
|
||||
- Deployment: kubernetes
|
||||
- Image: https://registry-1.docker.io/library/nginx:latest
|
||||
- Ports: tcp:80
|
||||
- Flavor: EU.small
|
||||
|
|
|
|||
118
README.md
118
README.md
|
|
@ -6,19 +6,31 @@ Supports both **local (stdio)** and **remote (HTTP/SSE)** operation modes.
|
|||
|
||||
## Features
|
||||
|
||||
### Interactive UI Resources (MCP-UI)
|
||||
|
||||
This server includes **rich, interactive web-based visualizations** powered by [MCP-UI](https://github.com/think-ahead-technologies/mcp-ui). When using compatible MCP clients, you'll receive beautiful HTML dashboards instead of plain text:
|
||||
|
||||
- **📊 Applications Dashboard** - Visual grid of applications with stats, deployment badges, and quick actions
|
||||
- **🔍 Application Detail View** - Comprehensive property display with JSON viewer
|
||||
- **⚡ Instances Dashboard** - Interactive table with status indicators and instance management
|
||||
|
||||
For clients that don't support UI resources, the server gracefully falls back to text-based responses. See [MCP_UI.md](./MCP_UI.md) for full documentation.
|
||||
|
||||
### Edge Connect API Tools
|
||||
|
||||
This MCP server implements all Edge Connect API endpoints for:
|
||||
|
||||
### Apps Management
|
||||
#### Apps Management
|
||||
- `create_app` - Create a new Edge Connect application
|
||||
- `show_app` - Retrieve a specific application by key
|
||||
- `list_apps` - List all applications matching filter criteria
|
||||
- `show_app` - Retrieve a specific application by key (includes UI)
|
||||
- `list_apps` - List all applications matching filter criteria (includes UI)
|
||||
- `update_app` - Update an existing application
|
||||
- `delete_app` - Delete an application (idempotent)
|
||||
|
||||
### App Instance Management
|
||||
#### App Instance Management
|
||||
- `create_app_instance` - Create a new application instance on a cloudlet
|
||||
- `show_app_instance` - Retrieve a specific application instance
|
||||
- `list_app_instances` - List all application instances matching filter criteria
|
||||
- `list_app_instances` - List all application instances matching filter criteria (includes UI)
|
||||
- `update_app_instance` - Update an existing application instance
|
||||
- `refresh_app_instance` - Refresh instance state
|
||||
- `delete_app_instance` - Delete an application instance (idempotent)
|
||||
|
|
@ -110,6 +122,67 @@ Command-line flags override environment variables:
|
|||
- `-host`: Host to bind to (remote mode only)
|
||||
- `-port`: Port to bind to (remote mode only)
|
||||
|
||||
#### 3. Remote Mode with OAuth 2.1 (Recommended for Production)
|
||||
|
||||
For production deployments, use OAuth 2.1 authorization:
|
||||
|
||||
```bash
|
||||
# Edge Connect API configuration (unchanged)
|
||||
export EDGE_CONNECT_BASE_URL="https://hub.apps.edge.platform.mg3.mdb.osc.live"
|
||||
export EDGE_CONNECT_AUTH_TYPE="credentials"
|
||||
export EDGE_CONNECT_USERNAME="your-username"
|
||||
export EDGE_CONNECT_PASSWORD="your-password"
|
||||
|
||||
# MCP Server configuration
|
||||
export MCP_SERVER_MODE="remote"
|
||||
export MCP_REMOTE_HOST="0.0.0.0"
|
||||
export MCP_REMOTE_PORT="8080"
|
||||
|
||||
# OAuth 2.1 configuration
|
||||
export OAUTH_ENABLED="true"
|
||||
export OAUTH_MODE="resource_server"
|
||||
export OAUTH_RESOURCE_URI="https://mcp.example.com"
|
||||
export OAUTH_AUTH_SERVERS="https://auth.example.com"
|
||||
export OAUTH_ISSUER="https://auth.example.com"
|
||||
export OAUTH_JWKS_URL="https://auth.example.com/.well-known/jwks.json"
|
||||
|
||||
# Run the server
|
||||
./edge-connect-mcp -mode remote
|
||||
```
|
||||
|
||||
For local development/testing with the built-in basic authorization server:
|
||||
|
||||
```bash
|
||||
# Enable built-in authorization server
|
||||
export OAUTH_AUTH_SERVER_ENABLED="true"
|
||||
export OAUTH_AUTH_SERVER_PORT="8081"
|
||||
export OAUTH_CLIENT_ID="test-client"
|
||||
export OAUTH_REDIRECT_URI="http://localhost:5173/callback"
|
||||
|
||||
# Use localhost URIs
|
||||
export OAUTH_RESOURCE_URI="http://localhost:8080"
|
||||
export OAUTH_AUTH_SERVERS="http://localhost:8081"
|
||||
export OAUTH_ISSUER="http://localhost:8081"
|
||||
export OAUTH_JWKS_URL="http://localhost:8081/.well-known/jwks.json"
|
||||
|
||||
./edge-connect-mcp -mode remote
|
||||
```
|
||||
|
||||
The server provides these OAuth endpoints:
|
||||
|
||||
**MCP Server (Protected Resource)**:
|
||||
- `GET /.well-known/oauth-protected-resource` - Protected Resource Metadata (RFC 9728)
|
||||
|
||||
**Basic Authorization Server** (if enabled):
|
||||
- `GET /authorize` - Authorization endpoint
|
||||
- `POST /token` - Token endpoint
|
||||
- `GET /.well-known/jwks.json` - JWKS endpoint
|
||||
- `GET /.well-known/oauth-authorization-server` - Authorization server metadata (RFC 8414)
|
||||
|
||||
For detailed OAuth setup and security best practices, see:
|
||||
- [OAuth Setup Guide](docs/OAUTH_SETUP.md)
|
||||
- [OAuth Security Best Practices](docs/OAUTH_SECURITY.md)
|
||||
|
||||
### Integrating with Claude Code (CLI)
|
||||
|
||||
To add this MCP server to Claude Code, use the `mcp add` command:
|
||||
|
|
@ -269,25 +342,31 @@ This implementation follows the security guidelines from CLAUDE.md:
|
|||
|
||||
### Remote Server Security
|
||||
When running in remote mode:
|
||||
1. **Bearer Token Authentication**: Optional Bearer token validation for remote access
|
||||
2. **Rate Limiting**: Basic rate limiting to prevent DoS attacks
|
||||
3. **CORS**: Configurable CORS headers for web client access
|
||||
4. **Timeouts**: Request/response timeouts to prevent resource exhaustion
|
||||
5. **Graceful Shutdown**: Proper shutdown handling for safe termination
|
||||
1. **OAuth 2.1 Authorization** (Recommended): Full OAuth 2.1 support with JWT validation, PKCE, and RFC 8707 token audience binding
|
||||
2. **Simple Bearer Token Authentication**: Optional Bearer token validation for remote access (fallback)
|
||||
3. **Rate Limiting**: Basic rate limiting to prevent DoS attacks
|
||||
4. **CORS**: Configurable CORS headers for web client access
|
||||
5. **Timeouts**: Request/response timeouts to prevent resource exhaustion
|
||||
6. **Graceful Shutdown**: Proper shutdown handling for safe termination
|
||||
|
||||
**Production Recommendations**:
|
||||
- Always enable `MCP_REMOTE_AUTH_REQUIRED=true` for remote deployments
|
||||
- Use strong, randomly generated tokens (min 32 characters)
|
||||
- **Use OAuth 2.1** with a production authorization server (Auth0, Cognito, Keycloak)
|
||||
- Deploy behind a reverse proxy with HTTPS/TLS termination
|
||||
- Implement proper OAuth 2.1 with PKCE for production use
|
||||
- Use firewall rules to restrict access to trusted networks
|
||||
- Enable rate limiting at the reverse proxy level
|
||||
- Monitor and log all access attempts
|
||||
- Follow security best practices in [docs/OAUTH_SECURITY.md](docs/OAUTH_SECURITY.md)
|
||||
|
||||
**Development/Testing**:
|
||||
- For simple testing, use `MCP_REMOTE_AUTH_REQUIRED=true` with bearer tokens
|
||||
- For OAuth testing, use the built-in basic authorization server
|
||||
- Never use the basic authorization server in production
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `edp.buildth.ing/DevFW-CICD/edge-connect-client/v2` - Edge Connect Go SDK
|
||||
- `github.com/modelcontextprotocol/go-sdk` - Model Context Protocol Go SDK
|
||||
- `github.com/MCP-UI-Org/mcp-ui/sdks/go/server` - MCP-UI Go SDK for interactive visualizations
|
||||
|
||||
## Development
|
||||
|
||||
|
|
@ -298,7 +377,18 @@ When running in remote mode:
|
|||
├── main.go # Server entry point and initialization
|
||||
├── config.go # Configuration loading and validation
|
||||
├── tools.go # MCP tool definitions and handlers
|
||||
├── utils.go # Utility functions
|
||||
├── ui.go # MCP-UI visualization generators
|
||||
├── auth.go # Authentication utilities
|
||||
├── oauth/ # OAuth 2.1 implementation
|
||||
│ ├── oauth.go # OAuth types and interfaces
|
||||
│ ├── authz_server.go # Basic authorization server
|
||||
│ ├── resource_server.go # Protected resource server
|
||||
│ ├── middleware.go # OAuth middleware
|
||||
│ ├── token_validator.go # JWT token validation
|
||||
│ ├── jwks.go # JWKS key management
|
||||
│ ├── pkce.go # PKCE implementation
|
||||
│ └── storage.go # In-memory storage
|
||||
├── MCP_UI.md # MCP-UI integration documentation
|
||||
├── README.md # This file
|
||||
└── .env.example # Example environment configuration
|
||||
```
|
||||
|
|
|
|||
136
config.go
136
config.go
|
|
@ -2,10 +2,13 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -40,21 +43,51 @@ type Config struct {
|
|||
|
||||
// Debug
|
||||
Debug bool `json:"debug"`
|
||||
|
||||
// OAuth Configuration
|
||||
OAuthEnabled bool `json:"oauth_enabled"`
|
||||
OAuthMode string `json:"oauth_mode"` // "resource_server"
|
||||
OAuthResourceURI string `json:"oauth_resource_uri"`
|
||||
OAuthAuthServers []string `json:"oauth_auth_servers"`
|
||||
OAuthIssuer string `json:"oauth_issuer"`
|
||||
OAuthJWKSURL string `json:"oauth_jwks_url"`
|
||||
|
||||
// Basic Auth Server (for testing)
|
||||
OAuthAuthServerEnabled bool `json:"oauth_auth_server_enabled"`
|
||||
OAuthAuthServerPort int `json:"oauth_auth_server_port"`
|
||||
OAuthClientID string `json:"oauth_client_id"`
|
||||
OAuthRedirectURI string `json:"oauth_redirect_uri"`
|
||||
}
|
||||
|
||||
func LoadConfig() (*Config, error) {
|
||||
// Load .env file if it exists (optional)
|
||||
// Silently ignore if .env doesn't exist - environment variables will be used
|
||||
if err := godotenv.Load(); err != nil {
|
||||
// Try .env in current directory first
|
||||
if err := godotenv.Load(".env"); err != nil {
|
||||
// Not an error - just means we'll use environment variables only
|
||||
log.Printf("No .env file found, using environment variables only")
|
||||
}
|
||||
} else {
|
||||
log.Printf("Loaded configuration from .env file")
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
// Default values
|
||||
DefaultRegion: "EU",
|
||||
RetryMaxRetries: 3,
|
||||
RetryInitialDelay: 1 * time.Second,
|
||||
RetryMaxDelay: 30 * time.Second,
|
||||
RetryMultiplier: 2.0,
|
||||
Debug: false,
|
||||
ServerMode: "stdio",
|
||||
RemoteHost: "0.0.0.0",
|
||||
RemotePort: 8080,
|
||||
RemoteAuthRequired: false,
|
||||
DefaultRegion: "EU",
|
||||
RetryMaxRetries: 3,
|
||||
RetryInitialDelay: 1 * time.Second,
|
||||
RetryMaxDelay: 30 * time.Second,
|
||||
RetryMultiplier: 2.0,
|
||||
Debug: false,
|
||||
ServerMode: "stdio",
|
||||
RemoteHost: "0.0.0.0",
|
||||
RemotePort: 8080,
|
||||
RemoteAuthRequired: false,
|
||||
OAuthEnabled: false,
|
||||
OAuthMode: "resource_server",
|
||||
OAuthAuthServerEnabled: false,
|
||||
OAuthAuthServerPort: 8081,
|
||||
}
|
||||
|
||||
// Load Edge Connect API configuration
|
||||
|
|
@ -114,6 +147,55 @@ func LoadConfig() (*Config, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Load OAuth configuration
|
||||
if oauthEnabled := os.Getenv("OAUTH_ENABLED"); oauthEnabled == "true" || oauthEnabled == "1" {
|
||||
cfg.OAuthEnabled = true
|
||||
}
|
||||
|
||||
if oauthMode := os.Getenv("OAUTH_MODE"); oauthMode != "" {
|
||||
cfg.OAuthMode = oauthMode
|
||||
}
|
||||
|
||||
if oauthResourceURI := os.Getenv("OAUTH_RESOURCE_URI"); oauthResourceURI != "" {
|
||||
cfg.OAuthResourceURI = oauthResourceURI
|
||||
}
|
||||
|
||||
if oauthAuthServers := os.Getenv("OAUTH_AUTH_SERVERS"); oauthAuthServers != "" {
|
||||
// Support comma-separated list of authorization servers
|
||||
cfg.OAuthAuthServers = strings.Split(oauthAuthServers, ",")
|
||||
// Trim whitespace from each server
|
||||
for i, server := range cfg.OAuthAuthServers {
|
||||
cfg.OAuthAuthServers[i] = strings.TrimSpace(server)
|
||||
}
|
||||
}
|
||||
|
||||
if oauthIssuer := os.Getenv("OAUTH_ISSUER"); oauthIssuer != "" {
|
||||
cfg.OAuthIssuer = oauthIssuer
|
||||
}
|
||||
|
||||
if oauthJWKSURL := os.Getenv("OAUTH_JWKS_URL"); oauthJWKSURL != "" {
|
||||
cfg.OAuthJWKSURL = oauthJWKSURL
|
||||
}
|
||||
|
||||
// Load Basic Auth Server configuration
|
||||
if authServerEnabled := os.Getenv("OAUTH_AUTH_SERVER_ENABLED"); authServerEnabled == "true" || authServerEnabled == "1" {
|
||||
cfg.OAuthAuthServerEnabled = true
|
||||
}
|
||||
|
||||
if authServerPortStr := os.Getenv("OAUTH_AUTH_SERVER_PORT"); authServerPortStr != "" {
|
||||
if port, err := strconv.Atoi(authServerPortStr); err == nil {
|
||||
cfg.OAuthAuthServerPort = port
|
||||
}
|
||||
}
|
||||
|
||||
if oauthClientID := os.Getenv("OAUTH_CLIENT_ID"); oauthClientID != "" {
|
||||
cfg.OAuthClientID = oauthClientID
|
||||
}
|
||||
|
||||
if oauthRedirectURI := os.Getenv("OAUTH_REDIRECT_URI"); oauthRedirectURI != "" {
|
||||
cfg.OAuthRedirectURI = oauthRedirectURI
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
|
@ -154,5 +236,39 @@ func (c *Config) Validate() error {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate OAuth configuration
|
||||
if c.OAuthEnabled {
|
||||
if c.OAuthResourceURI == "" {
|
||||
return fmt.Errorf("oauth_resource_uri is required when oauth is enabled (set OAUTH_RESOURCE_URI)")
|
||||
}
|
||||
|
||||
if len(c.OAuthAuthServers) == 0 {
|
||||
return fmt.Errorf("oauth_auth_servers is required when oauth is enabled (set OAUTH_AUTH_SERVERS)")
|
||||
}
|
||||
|
||||
if c.OAuthIssuer == "" {
|
||||
return fmt.Errorf("oauth_issuer is required when oauth is enabled (set OAUTH_ISSUER)")
|
||||
}
|
||||
|
||||
if c.OAuthJWKSURL == "" {
|
||||
return fmt.Errorf("oauth_jwks_url is required when oauth is enabled (set OAUTH_JWKS_URL)")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Basic Auth Server configuration
|
||||
if c.OAuthAuthServerEnabled {
|
||||
if c.OAuthAuthServerPort <= 0 || c.OAuthAuthServerPort > 65535 {
|
||||
return fmt.Errorf("oauth_auth_server_port must be between 1 and 65535")
|
||||
}
|
||||
|
||||
if c.OAuthClientID == "" {
|
||||
return fmt.Errorf("oauth_client_id is required when auth server is enabled (set OAUTH_CLIENT_ID)")
|
||||
}
|
||||
|
||||
if c.OAuthRedirectURI == "" {
|
||||
return fmt.Errorf("oauth_redirect_uri is required when auth server is enabled (set OAUTH_REDIRECT_URI)")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
458
docs/OAUTH_SECURITY.md
Normal file
458
docs/OAUTH_SECURITY.md
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
# OAuth 2.1 Security Best Practices
|
||||
|
||||
This document outlines security best practices for implementing and deploying OAuth 2.1 authorization with the Edge Connect MCP server.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Token Security](#token-security)
|
||||
2. [HTTPS Requirements](#https-requirements)
|
||||
3. [Token Audience Validation (RFC 8707)](#token-audience-validation-rfc-8707)
|
||||
4. [PKCE Requirements](#pkce-requirements)
|
||||
5. [Token Lifetime](#token-lifetime)
|
||||
6. [Key Management](#key-management)
|
||||
7. [Rate Limiting](#rate-limiting)
|
||||
8. [Logging and Monitoring](#logging-and-monitoring)
|
||||
9. [Production Deployment](#production-deployment)
|
||||
|
||||
## Token Security
|
||||
|
||||
### Never Log Tokens
|
||||
|
||||
**Critical**: Never log access tokens, refresh tokens, or authorization codes in application logs.
|
||||
|
||||
```bash
|
||||
# BAD - logs contain tokens
|
||||
log.Printf("Token received: %s", token)
|
||||
|
||||
# GOOD - log without sensitive data
|
||||
log.Printf("Token validation successful for client: %s", clientID)
|
||||
```
|
||||
|
||||
### Secure Token Storage
|
||||
|
||||
- **MCP Clients**: Store tokens in secure storage (OS keychain, encrypted file)
|
||||
- **MCP Server**: Tokens should only be held in memory during validation
|
||||
- **Authorization Server**: Use encrypted database or secure key-value store
|
||||
|
||||
### Token Theft Prevention
|
||||
|
||||
If tokens are stolen, attackers can impersonate legitimate users. Mitigate this risk:
|
||||
|
||||
1. **Short-lived access tokens** (1 hour or less)
|
||||
2. **Token audience binding** (RFC 8707) - ensures tokens can only be used with intended resource
|
||||
3. **HTTPS only** - prevents token interception in transit
|
||||
4. **Monitor for anomalies** - detect unusual token usage patterns
|
||||
|
||||
## HTTPS Requirements
|
||||
|
||||
### Mandatory HTTPS in Production
|
||||
|
||||
All OAuth 2.1 endpoints **MUST** use HTTPS in production:
|
||||
|
||||
- Authorization server endpoints
|
||||
- Token endpoint
|
||||
- JWKS endpoint
|
||||
- MCP server endpoints
|
||||
|
||||
### Development Exceptions
|
||||
|
||||
For local development, HTTP is acceptable for localhost:
|
||||
|
||||
```bash
|
||||
# Development - OK
|
||||
export OAUTH_RESOURCE_URI=http://localhost:8080
|
||||
export OAUTH_ISSUER=http://localhost:8081
|
||||
|
||||
# Production - MUST use HTTPS
|
||||
export OAUTH_RESOURCE_URI=https://mcp.example.com
|
||||
export OAUTH_ISSUER=https://auth.example.com
|
||||
```
|
||||
|
||||
### TLS Configuration
|
||||
|
||||
For production deployments:
|
||||
|
||||
1. **Use TLS 1.2 or higher**
|
||||
2. **Strong cipher suites only** (ECDHE-RSA-AES256-GCM-SHA384, etc.)
|
||||
3. **Valid certificates** from trusted CA (Let's Encrypt, etc.)
|
||||
4. **Certificate pinning** (optional, for enhanced security)
|
||||
|
||||
### Mutual TLS (mTLS)
|
||||
|
||||
For high-security environments, consider implementing mTLS:
|
||||
|
||||
- Client presents certificate to server
|
||||
- Server validates client certificate
|
||||
- Additional layer of authentication beyond OAuth tokens
|
||||
|
||||
## Token Audience Validation (RFC 8707)
|
||||
|
||||
### Critical Security Requirement
|
||||
|
||||
Token audience validation is **MANDATORY** to prevent confused deputy attacks.
|
||||
|
||||
**RFC 8707 Resource Indicators** bind tokens to their intended resource:
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "https://auth.example.com",
|
||||
"sub": "client-123",
|
||||
"aud": "https://mcp.example.com", // ← MUST match MCP server URI
|
||||
"exp": 1735689600,
|
||||
"scope": "mcp"
|
||||
}
|
||||
```
|
||||
|
||||
### Confused Deputy Attack
|
||||
|
||||
Without audience validation:
|
||||
|
||||
1. Attacker obtains token for `https://api-a.example.com`
|
||||
2. Attacker uses token to access `https://api-b.example.com`
|
||||
3. If `api-b` doesn't validate audience, attack succeeds
|
||||
|
||||
### Implementation
|
||||
|
||||
The MCP server **automatically validates** audience:
|
||||
|
||||
```go
|
||||
// In token_validator.go - validates aud claim
|
||||
audienceValid := false
|
||||
for _, a := range aud {
|
||||
if a == v.expectedAudience {
|
||||
audienceValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !audienceValid {
|
||||
return nil, fmt.Errorf("token audience does not match expected audience")
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Ensure `OAUTH_RESOURCE_URI` matches exactly:
|
||||
|
||||
```bash
|
||||
# MCP server URI
|
||||
export OAUTH_RESOURCE_URI=https://mcp.example.com
|
||||
|
||||
# Client must request token with matching resource
|
||||
# Authorization request:
|
||||
# ?resource=https://mcp.example.com
|
||||
```
|
||||
|
||||
## PKCE Requirements
|
||||
|
||||
### Mandatory PKCE
|
||||
|
||||
OAuth 2.1 **requires** PKCE (Proof Key for Code Exchange) for all clients.
|
||||
|
||||
### S256 Challenge Method
|
||||
|
||||
The MCP server **only supports S256** (SHA256):
|
||||
|
||||
```bash
|
||||
# Generate code verifier (43-128 characters)
|
||||
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_')
|
||||
|
||||
# Generate code challenge (SHA256 of verifier, base64url encoded)
|
||||
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_')
|
||||
|
||||
# Authorization request
|
||||
curl "http://localhost:8081/authorize?...&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"
|
||||
```
|
||||
|
||||
### Why PKCE?
|
||||
|
||||
PKCE prevents authorization code interception attacks:
|
||||
|
||||
1. Attacker intercepts authorization code
|
||||
2. Attacker tries to exchange code for token
|
||||
3. Server requires code verifier (only original client has it)
|
||||
4. Attack fails
|
||||
|
||||
### Plain Method Not Supported
|
||||
|
||||
The `plain` challenge method is **not supported** per OAuth 2.1 requirements.
|
||||
|
||||
## Token Lifetime
|
||||
|
||||
### Access Token Lifetime
|
||||
|
||||
**Default**: 1 hour
|
||||
|
||||
For higher security environments, use shorter lifetimes:
|
||||
|
||||
```go
|
||||
// In authz_server.go
|
||||
tokenLifetime: 15 * time.Minute, // 15 minutes instead of 1 hour
|
||||
```
|
||||
|
||||
### Authorization Code Lifetime
|
||||
|
||||
**Default**: 10 minutes
|
||||
|
||||
Authorization codes should be short-lived and single-use:
|
||||
|
||||
```go
|
||||
// In storage.go
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
```
|
||||
|
||||
### Refresh Tokens
|
||||
|
||||
Phase 1 does not implement refresh tokens. For Phase 2:
|
||||
|
||||
- Issue refresh tokens with long lifetime (days/weeks)
|
||||
- Rotate refresh tokens on each use (OAuth 2.1 requirement)
|
||||
- Bind refresh tokens to specific clients
|
||||
|
||||
## Key Management
|
||||
|
||||
### RSA Key Pair
|
||||
|
||||
The basic authorization server generates a 2048-bit RSA key pair on startup.
|
||||
|
||||
### Key Rotation
|
||||
|
||||
**Production Recommendation**: Rotate signing keys regularly (e.g., every 90 days).
|
||||
|
||||
For production authorization servers:
|
||||
|
||||
1. Generate new key pair
|
||||
2. Add new public key to JWKS with new `kid`
|
||||
3. Sign new tokens with new key
|
||||
4. Keep old public key in JWKS for existing token validation
|
||||
5. Remove old key after all tokens signed with it expire
|
||||
|
||||
### Key Storage
|
||||
|
||||
**Basic Authorization Server** (development):
|
||||
- Key stored in memory
|
||||
- Lost on restart
|
||||
- Not suitable for production
|
||||
|
||||
**Production Authorization Server**:
|
||||
- Store private key in Hardware Security Module (HSM)
|
||||
- Or use Key Management Service (AWS KMS, Azure Key Vault)
|
||||
- Never store private keys in code or version control
|
||||
|
||||
### JWKS Caching
|
||||
|
||||
The MCP server caches JWKS for 15 minutes:
|
||||
|
||||
```go
|
||||
// In token_validator.go
|
||||
cacheDuration: 15 * time.Minute,
|
||||
```
|
||||
|
||||
This reduces load on the authorization server while allowing for key rotation.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Protect Authorization Endpoints
|
||||
|
||||
Rate limit these endpoints to prevent abuse:
|
||||
|
||||
- `/authorize` - prevent authorization request floods
|
||||
- `/token` - prevent token exchange brute force
|
||||
- `/sse` - prevent resource access floods
|
||||
|
||||
### Implementation
|
||||
|
||||
Use a reverse proxy (nginx, HAProxy) for rate limiting:
|
||||
|
||||
```nginx
|
||||
# nginx example
|
||||
limit_req_zone $binary_remote_addr zone=oauth_token:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=oauth_authorize:10m rate=30r/m;
|
||||
limit_req_zone $binary_remote_addr zone=mcp_sse:10m rate=100r/m;
|
||||
|
||||
server {
|
||||
location /token {
|
||||
limit_req zone=oauth_token burst=5;
|
||||
proxy_pass http://localhost:8081;
|
||||
}
|
||||
|
||||
location /authorize {
|
||||
limit_req zone=oauth_authorize burst=10;
|
||||
proxy_pass http://localhost:8081;
|
||||
}
|
||||
|
||||
location /sse {
|
||||
limit_req zone=mcp_sse burst=20;
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Application-Level Rate Limiting
|
||||
|
||||
Consider implementing rate limiting in the application:
|
||||
|
||||
- Per client ID
|
||||
- Per IP address
|
||||
- Per user (if user context is available)
|
||||
|
||||
## Logging and Monitoring
|
||||
|
||||
### What to Log
|
||||
|
||||
**DO log**:
|
||||
- Authentication failures (with reason)
|
||||
- Token validation failures
|
||||
- Authorization code generation
|
||||
- Token issuance (without token value)
|
||||
- Client ID and user ID
|
||||
- IP address and user agent
|
||||
- Timestamp
|
||||
|
||||
**DO NOT log**:
|
||||
- Access tokens
|
||||
- Refresh tokens
|
||||
- Authorization codes
|
||||
- Code verifiers
|
||||
- Client secrets
|
||||
|
||||
### Example Logging
|
||||
|
||||
```go
|
||||
// GOOD
|
||||
log.Printf("Token validation successful: client_id=%s, subject=%s, scopes=%v",
|
||||
claims.ClientID, claims.Subject, claims.Scopes)
|
||||
|
||||
// BAD - includes token
|
||||
log.Printf("Validating token: %s", tokenString)
|
||||
```
|
||||
|
||||
### Security Monitoring
|
||||
|
||||
Monitor for suspicious patterns:
|
||||
|
||||
1. **High failure rates** - potential brute force attack
|
||||
2. **Token reuse** - potential token theft
|
||||
3. **Unusual geographic locations** - potential account compromise
|
||||
4. **High token request rate** - potential abuse
|
||||
|
||||
### SIEM Integration
|
||||
|
||||
For production, integrate with a Security Information and Event Management (SIEM) system:
|
||||
|
||||
- Splunk
|
||||
- ELK Stack (Elasticsearch, Logstash, Kibana)
|
||||
- AWS CloudWatch Insights
|
||||
- Google Cloud Logging
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Checklist
|
||||
|
||||
Before deploying to production:
|
||||
|
||||
- [ ] HTTPS enabled for all endpoints
|
||||
- [ ] Valid TLS certificates installed
|
||||
- [ ] Token audience validation configured correctly
|
||||
- [ ] PKCE enforcement enabled (automatic)
|
||||
- [ ] Appropriate token lifetimes configured
|
||||
- [ ] Rate limiting configured
|
||||
- [ ] Logging configured (without sensitive data)
|
||||
- [ ] Monitoring and alerting set up
|
||||
- [ ] Key management strategy implemented
|
||||
- [ ] Backup and recovery procedures documented
|
||||
- [ ] Security testing completed
|
||||
|
||||
### Architecture Recommendations
|
||||
|
||||
**For Production**, do not use the basic authorization server. Instead:
|
||||
|
||||
1. **Use a production-grade OAuth server**:
|
||||
- Auth0
|
||||
- AWS Cognito
|
||||
- Azure AD / Entra ID
|
||||
- Keycloak
|
||||
- Okta
|
||||
|
||||
2. **Or build a production authorization server** with:
|
||||
- Database-backed storage (PostgreSQL, MongoDB)
|
||||
- HSM or KMS for key management
|
||||
- Horizontal scalability (multiple instances)
|
||||
- High availability (failover, load balancing)
|
||||
- Audit logging
|
||||
- Admin interface for client management
|
||||
|
||||
3. **Deploy behind reverse proxy**:
|
||||
- nginx or HAProxy for rate limiting
|
||||
- TLS termination at proxy
|
||||
- Web Application Firewall (WAF)
|
||||
|
||||
4. **Use infrastructure as code**:
|
||||
- Terraform or CloudFormation
|
||||
- Version control all infrastructure
|
||||
- Automated deployments
|
||||
|
||||
### Network Security
|
||||
|
||||
1. **Firewall rules**:
|
||||
- Only expose HTTPS ports (443) publicly
|
||||
- Restrict database access to application servers only
|
||||
- Use VPC/VNET for internal communication
|
||||
|
||||
2. **DDoS protection**:
|
||||
- CloudFlare
|
||||
- AWS Shield
|
||||
- Azure DDoS Protection
|
||||
|
||||
3. **Intrusion detection**:
|
||||
- AWS GuardDuty
|
||||
- Azure Security Center
|
||||
- Suricata / Snort
|
||||
|
||||
### Compliance
|
||||
|
||||
Ensure compliance with relevant standards:
|
||||
|
||||
- **GDPR** - if processing EU user data
|
||||
- **SOC 2** - for security controls
|
||||
- **PCI DSS** - if processing payment data
|
||||
- **HIPAA** - if processing health data
|
||||
|
||||
## Incident Response
|
||||
|
||||
### Token Compromise
|
||||
|
||||
If tokens are compromised:
|
||||
|
||||
1. **Revoke affected tokens** (requires token revocation endpoint - Phase 2 feature)
|
||||
2. **Rotate signing keys** immediately
|
||||
3. **Force re-authentication** for affected users
|
||||
4. **Investigate breach** to understand how tokens were compromised
|
||||
5. **Update security controls** to prevent future compromises
|
||||
|
||||
### Key Compromise
|
||||
|
||||
If signing keys are compromised:
|
||||
|
||||
1. **Immediately generate new keys**
|
||||
2. **Revoke all existing tokens**
|
||||
3. **Update JWKS** with new public key
|
||||
4. **Remove old key** from JWKS
|
||||
5. **Force re-authentication** for all users
|
||||
6. **Conduct security audit**
|
||||
|
||||
## Security Contacts
|
||||
|
||||
For security vulnerabilities or concerns:
|
||||
|
||||
- Review the [MCP Security Best Practices](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices)
|
||||
- Report security issues to your security team
|
||||
- Follow responsible disclosure practices
|
||||
|
||||
## References
|
||||
|
||||
- [OAuth 2.1 IETF Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13)
|
||||
- [RFC 8707: Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707.html)
|
||||
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728)
|
||||
- [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414)
|
||||
- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
|
||||
- [OWASP OAuth 2.0 Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth_2_0_Security_Cheat_Sheet.html)
|
||||
315
docs/OAUTH_SETUP.md
Normal file
315
docs/OAUTH_SETUP.md
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
# OAuth 2.1 Setup Guide
|
||||
|
||||
This guide explains how to configure and use OAuth 2.1 authorization with the Edge Connect MCP server.
|
||||
|
||||
## Overview
|
||||
|
||||
The Edge Connect MCP server implements OAuth 2.1 authorization following the [Model Context Protocol authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). The server acts as an **OAuth 2.1 Protected Resource Server**, validating JWT tokens from MCP clients.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
MCP Client → OAuth Token → MCP Server (Resource Server) → Edge Connect API
|
||||
↓ validates against
|
||||
Authorization Server
|
||||
```
|
||||
|
||||
### Phase 1 (Current Implementation)
|
||||
- MCP server validates OAuth tokens from MCP clients
|
||||
- MCP server uses its own credentials (token/username/password) to access Edge Connect API
|
||||
|
||||
### Phase 2 (Future)
|
||||
- MCP server will also use OAuth to access Edge Connect API
|
||||
- End-to-end OAuth flow
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
### Option 1: Using the Built-in Basic Authorization Server
|
||||
|
||||
The easiest way to test OAuth locally is to use the built-in basic authorization server:
|
||||
|
||||
```bash
|
||||
# MCP Server (Resource Server)
|
||||
export OAUTH_ENABLED=true
|
||||
export OAUTH_MODE=resource_server
|
||||
export OAUTH_RESOURCE_URI=http://localhost:8080
|
||||
export OAUTH_AUTH_SERVERS=http://localhost:8081
|
||||
export OAUTH_ISSUER=http://localhost:8081
|
||||
export OAUTH_JWKS_URL=http://localhost:8081/.well-known/jwks.json
|
||||
|
||||
# Basic Auth Server (for testing)
|
||||
export OAUTH_AUTH_SERVER_ENABLED=true
|
||||
export OAUTH_AUTH_SERVER_PORT=8081
|
||||
export OAUTH_CLIENT_ID=test-client
|
||||
export OAUTH_REDIRECT_URI=http://localhost:5173/callback
|
||||
|
||||
# Edge Connect (unchanged - uses server's own credentials)
|
||||
export EDGE_CONNECT_BASE_URL=https://api.edge-connect.example.com
|
||||
export EDGE_CONNECT_AUTH_TYPE=token
|
||||
export EDGE_CONNECT_TOKEN=your-edge-connect-token
|
||||
|
||||
# MCP Server
|
||||
export MCP_SERVER_MODE=remote
|
||||
export MCP_REMOTE_HOST=0.0.0.0
|
||||
export MCP_REMOTE_PORT=8080
|
||||
|
||||
# Start the server
|
||||
./edge-connect-mcp
|
||||
```
|
||||
|
||||
The server will start both the MCP server (port 8080) and the basic authorization server (port 8081).
|
||||
|
||||
### Option 2: Using an External Authorization Server
|
||||
|
||||
For production or testing with a real authorization server (Auth0, Cognito, Keycloak):
|
||||
|
||||
```bash
|
||||
# MCP Server (Resource Server)
|
||||
export OAUTH_ENABLED=true
|
||||
export OAUTH_MODE=resource_server
|
||||
export OAUTH_RESOURCE_URI=https://mcp.example.com
|
||||
export OAUTH_AUTH_SERVERS=https://auth.example.com
|
||||
export OAUTH_ISSUER=https://auth.example.com
|
||||
export OAUTH_JWKS_URL=https://auth.example.com/.well-known/jwks.json
|
||||
|
||||
# Don't enable basic auth server
|
||||
export OAUTH_AUTH_SERVER_ENABLED=false
|
||||
|
||||
# Edge Connect (unchanged)
|
||||
export EDGE_CONNECT_BASE_URL=https://api.edge-connect.example.com
|
||||
export EDGE_CONNECT_AUTH_TYPE=token
|
||||
export EDGE_CONNECT_TOKEN=your-edge-connect-token
|
||||
|
||||
# MCP Server
|
||||
export MCP_SERVER_MODE=remote
|
||||
export MCP_REMOTE_HOST=0.0.0.0
|
||||
export MCP_REMOTE_PORT=8080
|
||||
|
||||
# Start the server
|
||||
./edge-connect-mcp
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### OAuth Resource Server Configuration
|
||||
|
||||
| Environment Variable | Required | Default | Description |
|
||||
|---------------------|----------|---------|-------------|
|
||||
| `OAUTH_ENABLED` | No | `false` | Enable OAuth 2.1 authorization |
|
||||
| `OAUTH_MODE` | No | `resource_server` | OAuth mode (currently only `resource_server` supported) |
|
||||
| `OAUTH_RESOURCE_URI` | Yes (if OAuth enabled) | - | The canonical URI of this MCP server (RFC 8707) |
|
||||
| `OAUTH_AUTH_SERVERS` | Yes (if OAuth enabled) | - | Comma-separated list of authorization server URLs |
|
||||
| `OAUTH_ISSUER` | Yes (if OAuth enabled) | - | Expected issuer in JWT tokens |
|
||||
| `OAUTH_JWKS_URL` | Yes (if OAuth enabled) | - | JWKS endpoint URL for token validation |
|
||||
|
||||
### Basic Authorization Server Configuration
|
||||
|
||||
| Environment Variable | Required | Default | Description |
|
||||
|---------------------|----------|---------|-------------|
|
||||
| `OAUTH_AUTH_SERVER_ENABLED` | No | `false` | Enable built-in basic authorization server |
|
||||
| `OAUTH_AUTH_SERVER_PORT` | No | `8081` | Port for the authorization server |
|
||||
| `OAUTH_CLIENT_ID` | Yes (if auth server enabled) | - | OAuth client ID to register |
|
||||
| `OAUTH_REDIRECT_URI` | Yes (if auth server enabled) | - | OAuth redirect URI for the client |
|
||||
|
||||
## OAuth Flow
|
||||
|
||||
### 1. Discovery
|
||||
|
||||
MCP clients discover the OAuth configuration through Protected Resource Metadata (RFC 9728):
|
||||
|
||||
```bash
|
||||
# Request without token
|
||||
curl http://localhost:8080/sse
|
||||
|
||||
# Response: 401 Unauthorized with WWW-Authenticate header
|
||||
# WWW-Authenticate: Bearer realm="edge-connect-mcp", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
|
||||
```
|
||||
|
||||
Fetch Protected Resource Metadata:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/.well-known/oauth-protected-resource
|
||||
|
||||
# Response:
|
||||
{
|
||||
"resource": "http://localhost:8080",
|
||||
"authorization_servers": ["http://localhost:8081"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Authorization Server Metadata
|
||||
|
||||
Fetch authorization server metadata:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8081/.well-known/oauth-authorization-server
|
||||
|
||||
# Response:
|
||||
{
|
||||
"issuer": "http://localhost:8081",
|
||||
"authorization_endpoint": "http://localhost:8081/authorize",
|
||||
"token_endpoint": "http://localhost:8081/token",
|
||||
"jwks_uri": "http://localhost:8081/.well-known/jwks.json",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"scopes_supported": ["mcp"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Authorization Request
|
||||
|
||||
Request an authorization code with PKCE:
|
||||
|
||||
```bash
|
||||
# Generate PKCE code verifier and challenge (using your OAuth client)
|
||||
CODE_VERIFIER="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
CODE_CHALLENGE="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
# Authorization request
|
||||
curl "http://localhost:8081/authorize?client_id=test-client&redirect_uri=http://localhost:5173/callback&response_type=code&scope=mcp&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&resource=http://localhost:8080"
|
||||
|
||||
# The server redirects to: http://localhost:5173/callback?code=AUTHORIZATION_CODE
|
||||
```
|
||||
|
||||
### 4. Token Request
|
||||
|
||||
Exchange the authorization code for an access token:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=authorization_code" \
|
||||
-d "code=AUTHORIZATION_CODE" \
|
||||
-d "client_id=test-client" \
|
||||
-d "redirect_uri=http://localhost:5173/callback" \
|
||||
-d "code_verifier=$CODE_VERIFIER" \
|
||||
-d "resource=http://localhost:8080"
|
||||
|
||||
# Response:
|
||||
{
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIs...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "mcp"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Access Protected Resource
|
||||
|
||||
Use the access token to access the MCP server:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
|
||||
http://localhost:8080/sse
|
||||
```
|
||||
|
||||
## Integration with External Authorization Servers
|
||||
|
||||
### Auth0
|
||||
|
||||
```bash
|
||||
export OAUTH_ENABLED=true
|
||||
export OAUTH_RESOURCE_URI=https://mcp.example.com
|
||||
export OAUTH_AUTH_SERVERS=https://YOUR_DOMAIN.auth0.com
|
||||
export OAUTH_ISSUER=https://YOUR_DOMAIN.auth0.com/
|
||||
export OAUTH_JWKS_URL=https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json
|
||||
```
|
||||
|
||||
Configure your Auth0 application:
|
||||
- Application Type: Regular Web Application
|
||||
- Allowed Callback URLs: Your MCP client callback URL
|
||||
- Enable PKCE
|
||||
|
||||
### AWS Cognito
|
||||
|
||||
```bash
|
||||
export OAUTH_ENABLED=true
|
||||
export OAUTH_RESOURCE_URI=https://mcp.example.com
|
||||
export OAUTH_AUTH_SERVERS=https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID
|
||||
export OAUTH_ISSUER=https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID
|
||||
export OAUTH_JWKS_URL=https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID/.well-known/jwks.json
|
||||
```
|
||||
|
||||
Configure your Cognito User Pool:
|
||||
- Enable OAuth 2.0 flows
|
||||
- Configure App Client with Authorization Code Grant
|
||||
- Enable PKCE
|
||||
|
||||
### Keycloak
|
||||
|
||||
```bash
|
||||
export OAUTH_ENABLED=true
|
||||
export OAUTH_RESOURCE_URI=https://mcp.example.com
|
||||
export OAUTH_AUTH_SERVERS=https://keycloak.example.com/realms/YOUR_REALM
|
||||
export OAUTH_ISSUER=https://keycloak.example.com/realms/YOUR_REALM
|
||||
export OAUTH_JWKS_URL=https://keycloak.example.com/realms/YOUR_REALM/protocol/openid-connect/certs
|
||||
```
|
||||
|
||||
Configure your Keycloak client:
|
||||
- Client Protocol: openid-connect
|
||||
- Access Type: public (for PKCE) or confidential
|
||||
- Valid Redirect URIs: Your MCP client callback URLs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Token Validation Fails
|
||||
|
||||
**Symptom**: 401 Unauthorized responses even with valid token
|
||||
|
||||
**Solutions**:
|
||||
1. Check token audience (`aud` claim) matches `OAUTH_RESOURCE_URI`
|
||||
2. Verify token issuer (`iss` claim) matches `OAUTH_ISSUER`
|
||||
3. Ensure token is not expired
|
||||
4. Verify JWKS URL is accessible and returns valid keys
|
||||
5. Check that the authorization server's public key is available in JWKS
|
||||
|
||||
### PKCE Validation Fails
|
||||
|
||||
**Symptom**: Token endpoint returns `invalid_grant` error
|
||||
|
||||
**Solutions**:
|
||||
1. Ensure code challenge method is `S256` (plain is not supported)
|
||||
2. Verify code verifier matches the original challenge
|
||||
3. Check that the authorization code hasn't expired (10 minute lifetime)
|
||||
|
||||
### Authorization Server Not Reachable
|
||||
|
||||
**Symptom**: Cannot fetch JWKS or metadata
|
||||
|
||||
**Solutions**:
|
||||
1. Check network connectivity to authorization server
|
||||
2. Verify authorization server URL is correct
|
||||
3. Check firewall rules allow outbound HTTPS connections
|
||||
4. Ensure authorization server is running and accessible
|
||||
|
||||
### Token Audience Mismatch
|
||||
|
||||
**Symptom**: 401 Unauthorized with "token audience does not match expected audience" error
|
||||
|
||||
**Solutions**:
|
||||
1. Ensure `resource` parameter in authorization and token requests matches `OAUTH_RESOURCE_URI`
|
||||
2. Check that authorization server issues tokens with correct `aud` claim
|
||||
3. Verify the MCP server's resource URI configuration
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
See [OAUTH_SECURITY.md](./OAUTH_SECURITY.md) for detailed security guidance.
|
||||
|
||||
## Migration from Simple Bearer Token Auth
|
||||
|
||||
If you're currently using simple bearer token authentication, follow these steps:
|
||||
|
||||
1. **Deploy with OAuth disabled** (current state)
|
||||
2. **Configure OAuth settings** as environment variables
|
||||
3. **Enable OAuth** by setting `OAUTH_ENABLED=true`
|
||||
4. **Test OAuth flow** with a test client
|
||||
5. **Migrate clients** to use OAuth tokens
|
||||
6. **Disable simple bearer token auth** by setting `MCP_REMOTE_AUTH_REQUIRED=false`
|
||||
|
||||
Both authentication methods can coexist during migration:
|
||||
- OAuth takes precedence if enabled
|
||||
- Simple bearer token auth is used as fallback if OAuth is disabled
|
||||
13
go.mod
13
go.mod
|
|
@ -1,17 +1,22 @@
|
|||
module edp.buildth.ing/DevFW-CICD/edge-connect-mcp
|
||||
|
||||
go 1.25.3
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.1.2
|
||||
edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.2.0
|
||||
github.com/MCP-UI-Org/mcp-ui/sdks/go/server v0.0.0-00010101000000-000000000000
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
)
|
||||
|
||||
replace github.com/MCP-UI-Org/mcp-ui/sdks/go/server => github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112164030-ad21634cf92b
|
||||
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.3.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
)
|
||||
|
|
|
|||
20
go.sum
20
go.sum
|
|
@ -1,21 +1,23 @@
|
|||
edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.1.2 h1:g1iY/8Au4T6UV6cFm8/SQXAAF+DvFcjR6Hb0TqTF064=
|
||||
edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.1.2/go.mod h1:nPZ4K4BB7eXyeSrcHXvSPkNZbs+XgmxbDJOM4KhbI1A=
|
||||
edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.2.0 h1:yULvMTngSD66uWgoPJDHI2IHfRfKvr8Ai3cdHD+FcII=
|
||||
edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.2.0/go.mod h1:nPZ4K4BB7eXyeSrcHXvSPkNZbs+XgmxbDJOM4KhbI1A=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
|
||||
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
|
@ -26,10 +28,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112164030-ad21634cf92b h1:61a70q4I3O1dl520AnUFsNXJSGUMVmDCs9hZ/N57iW0=
|
||||
github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112164030-ad21634cf92b/go.mod h1:4IuZxAliFv0IcIeWVHW6yDq2tdY9Sbu2WQePgh9Md6k=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||
|
|
|
|||
128
main.go
128
main.go
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
|
@ -13,6 +14,8 @@ import (
|
|||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"edp.buildth.ing/DevFW-CICD/edge-connect-mcp/oauth"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -47,6 +50,8 @@ func main() {
|
|||
config.RemotePort = *port
|
||||
}
|
||||
|
||||
// config.Debug = true
|
||||
|
||||
// Validate configuration
|
||||
if err := config.Validate(); err != nil {
|
||||
log.Fatalf("Invalid configuration: %v", err)
|
||||
|
|
@ -90,34 +95,63 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error {
|
|||
// Create HTTP mux
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Create SSE handler that returns our MCP server
|
||||
sseHandler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server {
|
||||
// Authentication middleware
|
||||
if cfg.RemoteAuthRequired {
|
||||
streamableHttpHandler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
|
||||
// Simple bearer token auth - only if OAuth is disabled and auth is required
|
||||
if !cfg.OAuthEnabled && cfg.RemoteAuthRequired {
|
||||
if !authenticateRequest(r, cfg) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return mcpServer
|
||||
}, nil)
|
||||
}, &mcp.StreamableHTTPOptions{})
|
||||
|
||||
// Health check endpoint
|
||||
// Health check endpoint (no auth required)
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"healthy"}`))
|
||||
})
|
||||
|
||||
// SSE endpoint
|
||||
mux.Handle("/sse", sseHandler)
|
||||
// Configure OAuth if enabled
|
||||
if cfg.OAuthEnabled {
|
||||
// Create JWT validator
|
||||
validator := oauth.NewJWTValidator(
|
||||
cfg.OAuthResourceURI,
|
||||
cfg.OAuthIssuer,
|
||||
cfg.OAuthJWKSURL,
|
||||
)
|
||||
|
||||
// Create resource server
|
||||
resourceServer := oauth.NewResourceServer(
|
||||
cfg.OAuthResourceURI,
|
||||
cfg.OAuthAuthServers,
|
||||
validator,
|
||||
)
|
||||
|
||||
// Register Protected Resource Metadata endpoint (RFC 9728)
|
||||
mux.HandleFunc("/.well-known/oauth-protected-resource", resourceServer.ServeMetadata)
|
||||
|
||||
// Create OAuth middleware
|
||||
authMiddleware := oauth.AuthMiddleware(resourceServer, validator)
|
||||
|
||||
// Wrap MCP handler with OAuth middleware
|
||||
mux.Handle("/mcp", authMiddleware(streamableHttpHandler))
|
||||
|
||||
log.Printf("OAuth 2.1 enabled")
|
||||
log.Printf("Protected Resource URI: %s", cfg.OAuthResourceURI)
|
||||
log.Printf("Authorization Servers: %v", cfg.OAuthAuthServers)
|
||||
} else {
|
||||
mux.Handle("/mcp", streamableHttpHandler)
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
addr := fmt.Sprintf("%s:%d", cfg.RemoteHost, cfg.RemotePort)
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
// WriteTimeout disabled for SSE long-lived connections
|
||||
WriteTimeout: 0,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
|
|
@ -127,13 +161,21 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error {
|
|||
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
// Start basic auth server if enabled
|
||||
if cfg.OAuthAuthServerEnabled {
|
||||
go startBasicAuthServer(cfg)
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
go func() {
|
||||
log.Printf("HTTP server listening on %s", addr)
|
||||
log.Printf("SSE endpoint: http://%s/sse", addr)
|
||||
log.Printf("Health check: http://%s/health", addr)
|
||||
if cfg.RemoteAuthRequired {
|
||||
log.Printf("Authentication: ENABLED (Bearer token required)")
|
||||
if cfg.OAuthEnabled {
|
||||
log.Printf("Authentication: OAuth 2.1 (JWT Bearer tokens required)")
|
||||
log.Printf("Protected Resource Metadata: http://%s/.well-known/oauth-protected-resource", addr)
|
||||
} else if cfg.RemoteAuthRequired {
|
||||
log.Printf("Authentication: Simple Bearer token")
|
||||
} else {
|
||||
log.Printf("Authentication: DISABLED (anyone can connect)")
|
||||
}
|
||||
|
|
@ -226,3 +268,61 @@ func registerTools(s *mcp.Server) {
|
|||
|
||||
log.Printf("Registered 11 tools")
|
||||
}
|
||||
|
||||
func startBasicAuthServer(cfg *Config) {
|
||||
// Create basic authorization server
|
||||
authServer, err := oauth.NewBasicAuthServer(
|
||||
cfg.OAuthIssuer,
|
||||
fmt.Sprintf("http://localhost:%d", cfg.OAuthAuthServerPort),
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create basic auth server: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Register client from configuration
|
||||
authServer.RegisterClient(cfg.OAuthClientID, []string{cfg.OAuthRedirectURI})
|
||||
|
||||
// Create HTTP mux for auth server
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Authorization endpoint (GET /authorize)
|
||||
mux.HandleFunc("/authorize", authServer.HandleAuthorize)
|
||||
|
||||
// Token endpoint (POST /token)
|
||||
mux.HandleFunc("/token", authServer.HandleToken)
|
||||
|
||||
// Registration endpoint (POST /register) - RFC 7591
|
||||
mux.HandleFunc("/register", authServer.HandleRegistration)
|
||||
|
||||
// JWKS endpoint (GET /.well-known/jwks.json)
|
||||
mux.HandleFunc("/.well-known/jwks.json", authServer.HandleJWKS)
|
||||
|
||||
// Authorization server metadata endpoint (GET /.well-known/oauth-authorization-server)
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
if err := json.NewEncoder(w).Encode(authServer.GetMetadata()); err != nil {
|
||||
log.Printf("Failed to encode metadata response: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Start auth server
|
||||
addr := fmt.Sprintf(":%d", cfg.OAuthAuthServerPort)
|
||||
log.Printf("Basic OAuth 2.1 Authorization Server starting on http://localhost%s", addr)
|
||||
log.Printf(" Authorization endpoint: http://localhost%s/authorize", addr)
|
||||
log.Printf(" Token endpoint: http://localhost%s/token", addr)
|
||||
log.Printf(" Registration endpoint: http://localhost%s/register", addr)
|
||||
log.Printf(" JWKS endpoint: http://localhost%s/.well-known/jwks.json", addr)
|
||||
log.Printf(" Metadata endpoint: http://localhost%s/.well-known/oauth-authorization-server", addr)
|
||||
log.Printf(" Registered client: %s", cfg.OAuthClientID)
|
||||
log.Printf(" Dynamic client registration: enabled (RFC 7591)")
|
||||
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Printf("Basic auth server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
466
oauth/authz_server.go
Normal file
466
oauth/authz_server.go
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// BasicAuthServer implements a simple OAuth 2.1 Authorization Server
|
||||
// for development and testing purposes
|
||||
type BasicAuthServer struct {
|
||||
issuer string
|
||||
baseURL string
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
keyID string
|
||||
storage *AuthStorage
|
||||
tokenLifetime time.Duration
|
||||
codeLifetime time.Duration
|
||||
refreshTokenLifetime time.Duration
|
||||
}
|
||||
|
||||
// NewBasicAuthServer creates a new basic authorization server
|
||||
func NewBasicAuthServer(issuer, baseURL string) (*BasicAuthServer, error) {
|
||||
// Generate RSA key pair (2048-bit)
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate RSA key: %w", err)
|
||||
}
|
||||
|
||||
// Generate key ID
|
||||
keyID, err := GenerateKeyID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key ID: %w", err)
|
||||
}
|
||||
|
||||
return &BasicAuthServer{
|
||||
issuer: issuer,
|
||||
baseURL: baseURL,
|
||||
privateKey: privateKey,
|
||||
publicKey: &privateKey.PublicKey,
|
||||
keyID: keyID,
|
||||
storage: NewAuthStorage(),
|
||||
tokenLifetime: 1 * time.Hour,
|
||||
codeLifetime: 10 * time.Minute,
|
||||
refreshTokenLifetime: 30 * 24 * time.Hour, // 30 days
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns authorization server metadata (RFC 8414)
|
||||
func (s *BasicAuthServer) GetMetadata() *AuthServerMetadata {
|
||||
return &AuthServerMetadata{
|
||||
Issuer: s.issuer,
|
||||
AuthorizationEndpoint: s.baseURL + "/authorize",
|
||||
TokenEndpoint: s.baseURL + "/token",
|
||||
RegistrationEndpoint: s.baseURL + "/register",
|
||||
JWKSURI: s.baseURL + "/.well-known/jwks.json",
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||
CodeChallengeMethodsSupported: []string{"S256"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"none"},
|
||||
ScopesSupported: []string{"mcp"},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAuthorize handles authorization requests (GET /authorize)
|
||||
// Implements OAuth 2.1 Authorization Code Flow with PKCE
|
||||
func (s *BasicAuthServer) HandleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
query := r.URL.Query()
|
||||
clientID := query.Get("client_id")
|
||||
redirectURI := query.Get("redirect_uri")
|
||||
responseType := query.Get("response_type")
|
||||
scope := query.Get("scope")
|
||||
state := query.Get("state")
|
||||
codeChallenge := query.Get("code_challenge")
|
||||
codeChallengeMethod := query.Get("code_challenge_method")
|
||||
resource := query.Get("resource") // RFC 8707
|
||||
|
||||
// Validate required parameters
|
||||
if clientID == "" || redirectURI == "" || responseType != "code" {
|
||||
http.Error(w, "Invalid request: missing required parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate PKCE (MANDATORY in OAuth 2.1)
|
||||
if codeChallenge == "" || codeChallengeMethod != "S256" {
|
||||
http.Error(w, "PKCE required (code_challenge with S256)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client
|
||||
if !s.storage.ValidateClient(clientID, redirectURI) {
|
||||
http.Error(w, "Invalid client or redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// For basic auth server: auto-approve (no user interaction)
|
||||
// Production would show consent screen here
|
||||
|
||||
// Generate authorization code
|
||||
code, err := s.storage.CreateAuthorizationCode(clientID, redirectURI, scope, codeChallenge, resource)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create authorization code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect with authorization code
|
||||
redirectURL := fmt.Sprintf("%s?code=%s", redirectURI, code)
|
||||
if state != "" {
|
||||
redirectURL += fmt.Sprintf("&state=%s", state)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// HandleToken handles token requests (POST /token)
|
||||
func (s *BasicAuthServer) HandleToken(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Printf("DEBUG: === Token endpoint request received ===\n")
|
||||
fmt.Printf("DEBUG: Remote addr: %s\n", r.RemoteAddr)
|
||||
fmt.Printf("DEBUG: User-Agent: %s\n", r.Header.Get("User-Agent"))
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
fmt.Printf("DEBUG: Failed to parse form: %v\n", err)
|
||||
sendTokenError(w, "invalid_request", "Invalid form data")
|
||||
return
|
||||
}
|
||||
|
||||
grantType := r.FormValue("grant_type")
|
||||
fmt.Printf("DEBUG: Token request with grant_type=%s\n", grantType)
|
||||
fmt.Printf("DEBUG: Full form data: %v\n", r.Form)
|
||||
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
s.handleAuthorizationCodeGrant(w, r)
|
||||
case "refresh_token":
|
||||
s.handleRefreshTokenGrant(w, r)
|
||||
default:
|
||||
sendTokenError(w, "unsupported_grant_type", "Only 'authorization_code' and 'refresh_token' grants are supported")
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuthorizationCodeGrant handles the authorization_code grant type
|
||||
func (s *BasicAuthServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.FormValue("code")
|
||||
clientID := r.FormValue("client_id")
|
||||
redirectURI := r.FormValue("redirect_uri")
|
||||
codeVerifier := r.FormValue("code_verifier")
|
||||
resource := r.FormValue("resource")
|
||||
|
||||
// Validate authorization code
|
||||
authCode, err := s.storage.GetAuthorizationCode(code)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG: Authorization code validation failed: %v\n", err)
|
||||
sendTokenError(w, "invalid_grant", "Invalid authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate PKCE
|
||||
if !ValidatePKCE(codeVerifier, authCode.CodeChallenge) {
|
||||
fmt.Printf("DEBUG: PKCE validation failed\n")
|
||||
sendTokenError(w, "invalid_grant", "Invalid PKCE verifier")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client and redirect URI
|
||||
if authCode.ClientID != clientID || authCode.RedirectURI != redirectURI {
|
||||
fmt.Printf("DEBUG: Client or redirect URI mismatch\n")
|
||||
sendTokenError(w, "invalid_grant", "Client or redirect URI mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
// Use resource from auth code if not provided in token request
|
||||
if resource == "" {
|
||||
resource = authCode.Resource
|
||||
}
|
||||
|
||||
// Issue access token
|
||||
accessToken, err := s.issueAccessToken(clientID, authCode.Scope, resource)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG: Failed to issue access token: %v\n", err)
|
||||
sendTokenError(w, "server_error", "Failed to issue token")
|
||||
return
|
||||
}
|
||||
|
||||
// Issue refresh token
|
||||
refreshToken, err := s.storage.CreateRefreshToken(clientID, authCode.Scope, resource, s.refreshTokenLifetime)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG: Failed to issue refresh token: %v\n", err)
|
||||
sendTokenError(w, "server_error", "Failed to issue refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
s.storage.RevokeAuthorizationCode(code)
|
||||
|
||||
fmt.Printf("DEBUG: Tokens issued successfully for client %s\n", clientID)
|
||||
|
||||
// Send token response
|
||||
sendTokenResponse(w, accessToken, refreshToken, int(s.tokenLifetime.Seconds()), authCode.Scope)
|
||||
}
|
||||
|
||||
// handleRefreshTokenGrant handles the refresh_token grant type
|
||||
func (s *BasicAuthServer) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) {
|
||||
refreshTokenValue := r.FormValue("refresh_token")
|
||||
clientID := r.FormValue("client_id")
|
||||
resource := r.FormValue("resource")
|
||||
|
||||
fmt.Printf("DEBUG: Refresh token request: client_id=%s, resource=%s, token_length=%d\n",
|
||||
clientID, resource, len(refreshTokenValue))
|
||||
|
||||
// Validate refresh token
|
||||
refreshToken, err := s.storage.GetRefreshToken(refreshTokenValue)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG: Refresh token validation failed: %v\n", err)
|
||||
if len(refreshTokenValue) > 20 {
|
||||
fmt.Printf("DEBUG: Token value (first 20 chars): %s...\n", refreshTokenValue[:20])
|
||||
}
|
||||
sendTokenError(w, "invalid_grant", "Invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client
|
||||
if refreshToken.ClientID != clientID {
|
||||
fmt.Printf("DEBUG: Client ID mismatch\n")
|
||||
sendTokenError(w, "invalid_grant", "Client ID mismatch")
|
||||
return
|
||||
}
|
||||
|
||||
// Use resource from refresh token if not provided
|
||||
if resource == "" {
|
||||
resource = refreshToken.Resource
|
||||
}
|
||||
|
||||
// Issue new access token
|
||||
accessToken, err := s.issueAccessToken(clientID, refreshToken.Scope, resource)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG: Failed to issue access token: %v\n", err)
|
||||
sendTokenError(w, "server_error", "Failed to issue token")
|
||||
return
|
||||
}
|
||||
|
||||
// Issue new refresh token (refresh token rotation for security)
|
||||
newRefreshToken, err := s.storage.CreateRefreshToken(clientID, refreshToken.Scope, resource, s.refreshTokenLifetime)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG: Failed to issue new refresh token: %v\n", err)
|
||||
sendTokenError(w, "server_error", "Failed to issue refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke old refresh token
|
||||
s.storage.RevokeRefreshToken(refreshTokenValue)
|
||||
|
||||
fmt.Printf("DEBUG: Tokens refreshed successfully for client %s\n", clientID)
|
||||
|
||||
// Send token response
|
||||
sendTokenResponse(w, accessToken, newRefreshToken, int(s.tokenLifetime.Seconds()), refreshToken.Scope)
|
||||
}
|
||||
|
||||
// HandleJWKS returns the JWKS document (GET /.well-known/jwks.json)
|
||||
func (s *BasicAuthServer) HandleJWKS(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
jwks := CreateJWKS(s.publicKey, s.keyID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
if err := json.NewEncoder(w).Encode(jwks); err != nil {
|
||||
fmt.Printf("Failed to encode JWKS response: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRegistration handles dynamic client registration (POST /register)
|
||||
// Implements RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol
|
||||
func (s *BasicAuthServer) HandleRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse registration request
|
||||
var req ClientRegistrationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
fmt.Printf("DEBUG: Registration request parsing failed: %v\n", err)
|
||||
sendRegistrationError(w, "invalid_request", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Client registration request: redirect_uris=%v, grant_types=%v, response_types=%v\n",
|
||||
req.RedirectURIs, req.GrantTypes, req.ResponseTypes)
|
||||
|
||||
// Validate redirect URIs (required)
|
||||
if len(req.RedirectURIs) == 0 {
|
||||
sendRegistrationError(w, "invalid_redirect_uri", "At least one redirect_uri is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate redirect URIs format
|
||||
for _, uri := range req.RedirectURIs {
|
||||
if uri == "" {
|
||||
sendRegistrationError(w, "invalid_redirect_uri", "redirect_uri cannot be empty")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults for optional fields
|
||||
if req.TokenEndpointAuthMethod == "" {
|
||||
req.TokenEndpointAuthMethod = "none"
|
||||
}
|
||||
|
||||
if len(req.GrantTypes) == 0 {
|
||||
req.GrantTypes = []string{"authorization_code", "refresh_token"}
|
||||
}
|
||||
|
||||
if len(req.ResponseTypes) == 0 {
|
||||
req.ResponseTypes = []string{"code"}
|
||||
}
|
||||
|
||||
// Validate that only supported values are used
|
||||
if req.TokenEndpointAuthMethod != "none" {
|
||||
sendRegistrationError(w, "invalid_client_metadata", "Only token_endpoint_auth_method 'none' is supported")
|
||||
return
|
||||
}
|
||||
|
||||
for _, grantType := range req.GrantTypes {
|
||||
if grantType != "authorization_code" && grantType != "refresh_token" {
|
||||
sendRegistrationError(w, "invalid_client_metadata", "Only grant_types 'authorization_code' and 'refresh_token' are supported")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, responseType := range req.ResponseTypes {
|
||||
if responseType != "code" {
|
||||
sendRegistrationError(w, "invalid_client_metadata", "Only response_type 'code' is supported")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate client ID
|
||||
clientID, err := GenerateClientID()
|
||||
if err != nil {
|
||||
sendRegistrationError(w, "server_error", "Failed to generate client ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Register the client
|
||||
s.storage.RegisterClient(clientID, req.RedirectURIs)
|
||||
|
||||
// Build response
|
||||
response := ClientRegistrationResponse{
|
||||
ClientID: clientID,
|
||||
ClientIDIssuedAt: time.Now().Unix(),
|
||||
RedirectURIs: req.RedirectURIs,
|
||||
TokenEndpointAuthMethod: req.TokenEndpointAuthMethod,
|
||||
GrantTypes: req.GrantTypes,
|
||||
ResponseTypes: req.ResponseTypes,
|
||||
ClientName: req.ClientName,
|
||||
ClientURI: req.ClientURI,
|
||||
Scope: req.Scope,
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG: Client registered successfully: client_id=%s, grant_types=%v\n", clientID, response.GrantTypes)
|
||||
|
||||
// Send successful response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
fmt.Printf("Failed to encode registration response: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterClient registers a new OAuth client
|
||||
func (s *BasicAuthServer) RegisterClient(clientID string, redirectURIs []string) {
|
||||
s.storage.RegisterClient(clientID, redirectURIs)
|
||||
}
|
||||
|
||||
// issueAccessToken creates a signed JWT access token
|
||||
func (s *BasicAuthServer) issueAccessToken(clientID, scope, audience string) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"iss": s.issuer,
|
||||
"sub": clientID,
|
||||
"aud": audience,
|
||||
"exp": now.Add(s.tokenLifetime).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"client_id": clientID,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
token.Header["kid"] = s.keyID
|
||||
|
||||
return token.SignedString(s.privateKey)
|
||||
}
|
||||
|
||||
// sendTokenResponse sends a successful token response
|
||||
func sendTokenResponse(w http.ResponseWriter, accessToken string, refreshToken string, expiresIn int, scope string) {
|
||||
response := TokenResponse{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: expiresIn,
|
||||
RefreshToken: refreshToken,
|
||||
Scope: scope,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
fmt.Printf("Failed to encode token response: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendTokenError sends an OAuth 2.1 token error response
|
||||
func sendTokenError(w http.ResponseWriter, error, errorDescription string) {
|
||||
response := TokenErrorResponse{
|
||||
Error: error,
|
||||
ErrorDescription: errorDescription,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
fmt.Printf("Failed to encode token error response: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendRegistrationError sends an RFC 7591 client registration error response
|
||||
func sendRegistrationError(w http.ResponseWriter, error, errorDescription string) {
|
||||
response := TokenErrorResponse{
|
||||
Error: error,
|
||||
ErrorDescription: errorDescription,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
fmt.Printf("Failed to encode registration error response: %v\n", err)
|
||||
}
|
||||
}
|
||||
550
oauth/authz_server_test.go
Normal file
550
oauth/authz_server_test.go
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewBasicAuthServer(t *testing.T) {
|
||||
server, err := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081")
|
||||
if err != nil {
|
||||
t.Fatalf("NewBasicAuthServer() error = %v", err)
|
||||
}
|
||||
|
||||
if server.issuer != "http://localhost:8081" {
|
||||
t.Errorf("server.issuer = %v, want http://localhost:8081", server.issuer)
|
||||
}
|
||||
if server.privateKey == nil {
|
||||
t.Error("server.privateKey is nil")
|
||||
}
|
||||
if server.publicKey == nil {
|
||||
t.Error("server.publicKey is nil")
|
||||
}
|
||||
if server.keyID == "" {
|
||||
t.Error("server.keyID is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthServerGetMetadata(t *testing.T) {
|
||||
server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081")
|
||||
metadata := server.GetMetadata()
|
||||
|
||||
if metadata.Issuer != "http://localhost:8081" {
|
||||
t.Errorf("metadata.Issuer = %v, want http://localhost:8081", metadata.Issuer)
|
||||
}
|
||||
if metadata.AuthorizationEndpoint != "http://localhost:8081/authorize" {
|
||||
t.Errorf("metadata.AuthorizationEndpoint = %v", metadata.AuthorizationEndpoint)
|
||||
}
|
||||
if metadata.TokenEndpoint != "http://localhost:8081/token" {
|
||||
t.Errorf("metadata.TokenEndpoint = %v", metadata.TokenEndpoint)
|
||||
}
|
||||
if metadata.RegistrationEndpoint != "http://localhost:8081/register" {
|
||||
t.Errorf("metadata.RegistrationEndpoint = %v, want http://localhost:8081/register", metadata.RegistrationEndpoint)
|
||||
}
|
||||
if metadata.JWKSURI != "http://localhost:8081/.well-known/jwks.json" {
|
||||
t.Errorf("metadata.JWKSURI = %v", metadata.JWKSURI)
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if len(metadata.ResponseTypesSupported) == 0 {
|
||||
t.Error("metadata.ResponseTypesSupported is empty")
|
||||
}
|
||||
if len(metadata.GrantTypesSupported) == 0 {
|
||||
t.Error("metadata.GrantTypesSupported is empty")
|
||||
}
|
||||
if len(metadata.CodeChallengeMethodsSupported) == 0 {
|
||||
t.Error("metadata.CodeChallengeMethodsSupported is empty")
|
||||
}
|
||||
if metadata.CodeChallengeMethodsSupported[0] != "S256" {
|
||||
t.Errorf("metadata.CodeChallengeMethodsSupported[0] = %v, want S256", metadata.CodeChallengeMethodsSupported[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthServerHandleJWKS(t *testing.T) {
|
||||
server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleJWKS(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("HandleJWKS() status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var jwks JWKS
|
||||
if err := json.NewDecoder(w.Body).Decode(&jwks); err != nil {
|
||||
t.Fatalf("Failed to decode JWKS: %v", err)
|
||||
}
|
||||
|
||||
if len(jwks.Keys) != 1 {
|
||||
t.Errorf("JWKS Keys length = %d, want 1", len(jwks.Keys))
|
||||
}
|
||||
if jwks.Keys[0].KeyType != "RSA" {
|
||||
t.Errorf("JWKS Keys[0].KeyType = %v, want RSA", jwks.Keys[0].KeyType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthServerHandleAuthorize(t *testing.T) {
|
||||
server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081")
|
||||
server.RegisterClient("test-client", []string{"http://localhost:3000/callback"})
|
||||
|
||||
// Generate PKCE challenge
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge := GenerateCodeChallenge(verifier)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
params map[string]string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid authorization request",
|
||||
params: map[string]string{
|
||||
"client_id": "test-client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"response_type": "code",
|
||||
"scope": "mcp",
|
||||
"state": "random-state",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"resource": "http://localhost:8080",
|
||||
},
|
||||
wantStatus: http.StatusFound, // Redirect
|
||||
},
|
||||
{
|
||||
name: "missing client_id",
|
||||
params: map[string]string{
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"response_type": "code",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing code_challenge",
|
||||
params: map[string]string{
|
||||
"client_id": "test-client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"response_type": "code",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid code_challenge_method",
|
||||
params: map[string]string{
|
||||
"client_id": "test-client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"response_type": "code",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "plain",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid client",
|
||||
params: map[string]string{
|
||||
"client_id": "invalid-client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"response_type": "code",
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Build query string
|
||||
query := url.Values{}
|
||||
for key, value := range tt.params {
|
||||
query.Set(key, value)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/authorize?"+query.Encode(), nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleAuthorize(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("HandleAuthorize() status = %d, want %d", w.Code, tt.wantStatus)
|
||||
}
|
||||
|
||||
// For successful authorization, check redirect location
|
||||
if tt.wantStatus == http.StatusFound {
|
||||
location := w.Header().Get("Location")
|
||||
if location == "" {
|
||||
t.Error("HandleAuthorize() missing Location header")
|
||||
}
|
||||
|
||||
// Parse redirect URL
|
||||
redirectURL, err := url.Parse(location)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse redirect URL: %v", err)
|
||||
}
|
||||
|
||||
// Check for authorization code
|
||||
code := redirectURL.Query().Get("code")
|
||||
if code == "" {
|
||||
t.Error("HandleAuthorize() missing code in redirect")
|
||||
}
|
||||
|
||||
// Check state parameter if provided
|
||||
if tt.params["state"] != "" {
|
||||
state := redirectURL.Query().Get("state")
|
||||
if state != tt.params["state"] {
|
||||
t.Errorf("HandleAuthorize() state = %v, want %v", state, tt.params["state"])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthServerHandleToken(t *testing.T) {
|
||||
server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081")
|
||||
server.RegisterClient("test-client", []string{"http://localhost:3000/callback"})
|
||||
|
||||
// Generate PKCE pair
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
challenge := GenerateCodeChallenge(verifier)
|
||||
|
||||
// Create authorization code
|
||||
code, _ := server.storage.CreateAuthorizationCode(
|
||||
"test-client",
|
||||
"http://localhost:3000/callback",
|
||||
"mcp",
|
||||
challenge,
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
formData map[string]string
|
||||
wantStatus int
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "valid token request",
|
||||
formData: map[string]string{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": "test-client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"code_verifier": verifier,
|
||||
"resource": "http://localhost:8080",
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "invalid grant_type",
|
||||
formData: map[string]string{
|
||||
"grant_type": "client_credentials",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantError: "unsupported_grant_type",
|
||||
},
|
||||
{
|
||||
name: "invalid code",
|
||||
formData: map[string]string{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "invalid-code",
|
||||
"client_id": "test-client",
|
||||
"redirect_uri": "http://localhost:3000/callback",
|
||||
"code_verifier": verifier,
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantError: "invalid_grant",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Build form data
|
||||
formData := url.Values{}
|
||||
for key, value := range tt.formData {
|
||||
formData.Set(key, value)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(formData.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleToken(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("HandleToken() status = %d, want %d", w.Code, tt.wantStatus)
|
||||
}
|
||||
|
||||
if tt.wantStatus == http.StatusOK {
|
||||
var tokenResp TokenResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&tokenResp); err != nil {
|
||||
t.Fatalf("Failed to decode token response: %v", err)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
t.Error("HandleToken() missing access_token")
|
||||
}
|
||||
if tokenResp.TokenType != "Bearer" {
|
||||
t.Errorf("HandleToken() token_type = %v, want Bearer", tokenResp.TokenType)
|
||||
}
|
||||
if tokenResp.ExpiresIn <= 0 {
|
||||
t.Errorf("HandleToken() expires_in = %d, want > 0", tokenResp.ExpiresIn)
|
||||
}
|
||||
} else if tt.wantError != "" {
|
||||
var errorResp TokenErrorResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
|
||||
if errorResp.Error != tt.wantError {
|
||||
t.Errorf("HandleToken() error = %v, want %v", errorResp.Error, tt.wantError)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthServerFullFlow(t *testing.T) {
|
||||
// Create server
|
||||
server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081")
|
||||
server.RegisterClient("test-client", []string{"http://localhost:3000/callback"})
|
||||
|
||||
// Step 1: Generate PKCE pair
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCodeVerifier() error = %v", err)
|
||||
}
|
||||
challenge := GenerateCodeChallenge(verifier)
|
||||
|
||||
// Step 2: Authorization request
|
||||
query := url.Values{}
|
||||
query.Set("client_id", "test-client")
|
||||
query.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
query.Set("response_type", "code")
|
||||
query.Set("scope", "mcp")
|
||||
query.Set("state", "random-state")
|
||||
query.Set("code_challenge", challenge)
|
||||
query.Set("code_challenge_method", "S256")
|
||||
query.Set("resource", "http://localhost:8080")
|
||||
|
||||
authReq := httptest.NewRequest(http.MethodGet, "/authorize?"+query.Encode(), nil)
|
||||
authW := httptest.NewRecorder()
|
||||
|
||||
server.HandleAuthorize(authW, authReq)
|
||||
|
||||
if authW.Code != http.StatusFound {
|
||||
t.Fatalf("Authorization request failed with status %d", authW.Code)
|
||||
}
|
||||
|
||||
// Extract authorization code from redirect
|
||||
location, _ := url.Parse(authW.Header().Get("Location"))
|
||||
code := location.Query().Get("code")
|
||||
if code == "" {
|
||||
t.Fatal("No authorization code in redirect")
|
||||
}
|
||||
|
||||
// Step 3: Token request
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("code", code)
|
||||
formData.Set("client_id", "test-client")
|
||||
formData.Set("redirect_uri", "http://localhost:3000/callback")
|
||||
formData.Set("code_verifier", verifier)
|
||||
formData.Set("resource", "http://localhost:8080")
|
||||
|
||||
tokenReq := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(formData.Encode()))
|
||||
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
tokenW := httptest.NewRecorder()
|
||||
|
||||
server.HandleToken(tokenW, tokenReq)
|
||||
|
||||
if tokenW.Code != http.StatusOK {
|
||||
t.Fatalf("Token request failed with status %d: %s", tokenW.Code, tokenW.Body.String())
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.NewDecoder(tokenW.Body).Decode(&tokenResp); err != nil {
|
||||
t.Fatalf("Failed to decode token response: %v", err)
|
||||
}
|
||||
|
||||
// Step 4: Validate token using JWT validator
|
||||
// Create test JWKS server
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
server.HandleJWKS(w, r)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", jwksServer.URL)
|
||||
|
||||
// Wait a moment for JWKS to be ready
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
claims, err := validator.ValidateToken(context.Background(), tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify claims
|
||||
if claims.Issuer != "http://localhost:8081" {
|
||||
t.Errorf("claims.Issuer = %v, want http://localhost:8081", claims.Issuer)
|
||||
}
|
||||
if len(claims.Audience) == 0 || claims.Audience[0] != "http://localhost:8080" {
|
||||
t.Errorf("claims.Audience = %v, want [http://localhost:8080]", claims.Audience)
|
||||
}
|
||||
if claims.Subject != "test-client" {
|
||||
t.Errorf("claims.Subject = %v, want test-client", claims.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthServerHandleRegistration(t *testing.T) {
|
||||
server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request ClientRegistrationRequest
|
||||
wantStatus int
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "valid registration request",
|
||||
request: ClientRegistrationRequest{
|
||||
RedirectURIs: []string{"http://localhost:3000/callback"},
|
||||
TokenEndpointAuthMethod: "none",
|
||||
GrantTypes: []string{"authorization_code"},
|
||||
ResponseTypes: []string{"code"},
|
||||
ClientName: "Test Client",
|
||||
ClientURI: "http://localhost:3000",
|
||||
Scope: "mcp",
|
||||
},
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "valid registration with defaults",
|
||||
request: ClientRegistrationRequest{
|
||||
RedirectURIs: []string{"http://localhost:3000/callback"},
|
||||
},
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "multiple redirect URIs",
|
||||
request: ClientRegistrationRequest{
|
||||
RedirectURIs: []string{
|
||||
"http://localhost:3000/callback",
|
||||
"http://localhost:3001/callback",
|
||||
},
|
||||
},
|
||||
wantStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "missing redirect_uris",
|
||||
request: ClientRegistrationRequest{},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantError: "invalid_redirect_uri",
|
||||
},
|
||||
{
|
||||
name: "empty redirect_uri",
|
||||
request: ClientRegistrationRequest{
|
||||
RedirectURIs: []string{""},
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantError: "invalid_redirect_uri",
|
||||
},
|
||||
{
|
||||
name: "unsupported token_endpoint_auth_method",
|
||||
request: ClientRegistrationRequest{
|
||||
RedirectURIs: []string{"http://localhost:3000/callback"},
|
||||
TokenEndpointAuthMethod: "client_secret_basic",
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantError: "invalid_client_metadata",
|
||||
},
|
||||
{
|
||||
name: "unsupported grant_type",
|
||||
request: ClientRegistrationRequest{
|
||||
RedirectURIs: []string{"http://localhost:3000/callback"},
|
||||
GrantTypes: []string{"client_credentials"},
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantError: "invalid_client_metadata",
|
||||
},
|
||||
{
|
||||
name: "unsupported response_type",
|
||||
request: ClientRegistrationRequest{
|
||||
RedirectURIs: []string{"http://localhost:3000/callback"},
|
||||
ResponseTypes: []string{"token"},
|
||||
},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantError: "invalid_client_metadata",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Marshal request to JSON
|
||||
body, err := json.Marshal(tt.request)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.HandleRegistration(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("HandleRegistration() status = %d, want %d, body = %s", w.Code, tt.wantStatus, w.Body.String())
|
||||
}
|
||||
|
||||
if tt.wantStatus == http.StatusCreated {
|
||||
var resp ClientRegistrationResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("Failed to decode registration response: %v", err)
|
||||
}
|
||||
|
||||
// Verify response fields
|
||||
if resp.ClientID == "" {
|
||||
t.Error("HandleRegistration() missing client_id")
|
||||
}
|
||||
if resp.ClientIDIssuedAt == 0 {
|
||||
t.Error("HandleRegistration() missing client_id_issued_at")
|
||||
}
|
||||
if len(resp.RedirectURIs) == 0 {
|
||||
t.Error("HandleRegistration() missing redirect_uris")
|
||||
}
|
||||
if resp.TokenEndpointAuthMethod != "none" {
|
||||
t.Errorf("HandleRegistration() token_endpoint_auth_method = %v, want none", resp.TokenEndpointAuthMethod)
|
||||
}
|
||||
if len(resp.GrantTypes) == 0 {
|
||||
t.Error("HandleRegistration() missing grant_types")
|
||||
}
|
||||
if len(resp.ResponseTypes) == 0 {
|
||||
t.Error("HandleRegistration() missing response_types")
|
||||
}
|
||||
|
||||
// Verify client was registered in storage
|
||||
if !server.storage.ValidateClient(resp.ClientID, resp.RedirectURIs[0]) {
|
||||
t.Error("HandleRegistration() client not registered in storage")
|
||||
}
|
||||
} else if tt.wantError != "" {
|
||||
var errorResp TokenErrorResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
|
||||
if errorResp.Error != tt.wantError {
|
||||
t.Errorf("HandleRegistration() error = %v, want %v", errorResp.Error, tt.wantError)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
oauth/jwks.go
Normal file
84
oauth/jwks.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// GenerateKeyID generates a random key ID for JWKS
|
||||
func GenerateKeyID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate key ID: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GenerateClientID generates a random client ID for OAuth clients
|
||||
func GenerateClientID() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate client ID: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// RSAPublicKeyToJWK converts an RSA public key to JWK format
|
||||
func RSAPublicKeyToJWK(publicKey *rsa.PublicKey, keyID string) JWK {
|
||||
// Encode modulus (N) as base64url
|
||||
nBytes := publicKey.N.Bytes()
|
||||
n := base64.RawURLEncoding.EncodeToString(nBytes)
|
||||
|
||||
// Encode exponent (E) as base64url
|
||||
eBytes := big.NewInt(int64(publicKey.E)).Bytes()
|
||||
e := base64.RawURLEncoding.EncodeToString(eBytes)
|
||||
|
||||
return JWK{
|
||||
KeyType: "RSA",
|
||||
Use: "sig",
|
||||
KeyID: keyID,
|
||||
Algorithm: "RS256",
|
||||
N: n,
|
||||
E: e,
|
||||
}
|
||||
}
|
||||
|
||||
// JWKToRSAPublicKey converts a JWK to an RSA public key
|
||||
func JWKToRSAPublicKey(jwk JWK) (*rsa.PublicKey, error) {
|
||||
if jwk.KeyType != "RSA" {
|
||||
return nil, fmt.Errorf("unsupported key type: %s", jwk.KeyType)
|
||||
}
|
||||
|
||||
// Decode modulus (N) from base64url
|
||||
nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode modulus: %w", err)
|
||||
}
|
||||
n := new(big.Int).SetBytes(nBytes)
|
||||
|
||||
// Decode exponent (E) from base64url
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode exponent: %w", err)
|
||||
}
|
||||
e := new(big.Int).SetBytes(eBytes)
|
||||
|
||||
// Create RSA public key
|
||||
publicKey := &rsa.PublicKey{
|
||||
N: n,
|
||||
E: int(e.Int64()),
|
||||
}
|
||||
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
// CreateJWKS creates a JWKS from an RSA public key
|
||||
func CreateJWKS(publicKey *rsa.PublicKey, keyID string) *JWKS {
|
||||
jwk := RSAPublicKeyToJWK(publicKey, keyID)
|
||||
return &JWKS{
|
||||
Keys: []JWK{jwk},
|
||||
}
|
||||
}
|
||||
147
oauth/jwks_test.go
Normal file
147
oauth/jwks_test.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateKeyID(t *testing.T) {
|
||||
// Generate multiple key IDs to ensure randomness
|
||||
keyIDs := make(map[string]bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
keyID, err := GenerateKeyID()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyID() error = %v", err)
|
||||
}
|
||||
|
||||
// Check that it's not empty
|
||||
if keyID == "" {
|
||||
t.Error("GenerateKeyID() returned empty string")
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
if keyIDs[keyID] {
|
||||
t.Errorf("GenerateKeyID() generated duplicate: %s", keyID)
|
||||
}
|
||||
keyIDs[keyID] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSAPublicKeyToJWK(t *testing.T) {
|
||||
// Generate RSA key pair
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||
}
|
||||
|
||||
keyID := "test-key-id"
|
||||
jwk := RSAPublicKeyToJWK(&privateKey.PublicKey, keyID)
|
||||
|
||||
// Check JWK fields
|
||||
if jwk.KeyType != "RSA" {
|
||||
t.Errorf("JWK KeyType = %v, want RSA", jwk.KeyType)
|
||||
}
|
||||
if jwk.Use != "sig" {
|
||||
t.Errorf("JWK Use = %v, want sig", jwk.Use)
|
||||
}
|
||||
if jwk.KeyID != keyID {
|
||||
t.Errorf("JWK KeyID = %v, want %v", jwk.KeyID, keyID)
|
||||
}
|
||||
if jwk.Algorithm != "RS256" {
|
||||
t.Errorf("JWK Algorithm = %v, want RS256", jwk.Algorithm)
|
||||
}
|
||||
if jwk.N == "" {
|
||||
t.Error("JWK N (modulus) is empty")
|
||||
}
|
||||
if jwk.E == "" {
|
||||
t.Error("JWK E (exponent) is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKToRSAPublicKey(t *testing.T) {
|
||||
// Generate RSA key pair
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||
}
|
||||
|
||||
originalPublicKey := &privateKey.PublicKey
|
||||
|
||||
// Convert to JWK
|
||||
keyID := "test-key-id"
|
||||
jwk := RSAPublicKeyToJWK(originalPublicKey, keyID)
|
||||
|
||||
// Convert back to RSA public key
|
||||
convertedPublicKey, err := JWKToRSAPublicKey(jwk)
|
||||
if err != nil {
|
||||
t.Fatalf("JWKToRSAPublicKey() error = %v", err)
|
||||
}
|
||||
|
||||
// Compare modulus
|
||||
if originalPublicKey.N.Cmp(convertedPublicKey.N) != 0 {
|
||||
t.Error("Converted public key modulus doesn't match original")
|
||||
}
|
||||
|
||||
// Compare exponent
|
||||
if originalPublicKey.E != convertedPublicKey.E {
|
||||
t.Errorf("Converted public key exponent = %v, want %v", convertedPublicKey.E, originalPublicKey.E)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWKToRSAPublicKeyInvalidKeyType(t *testing.T) {
|
||||
jwk := JWK{
|
||||
KeyType: "EC",
|
||||
KeyID: "test-key",
|
||||
}
|
||||
|
||||
_, err := JWKToRSAPublicKey(jwk)
|
||||
if err == nil {
|
||||
t.Error("JWKToRSAPublicKey() should fail with non-RSA key type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateJWKS(t *testing.T) {
|
||||
// Generate RSA key pair
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||
}
|
||||
|
||||
keyID := "test-key-id"
|
||||
jwks := CreateJWKS(&privateKey.PublicKey, keyID)
|
||||
|
||||
// Check JWKS structure
|
||||
if jwks == nil || len(jwks.Keys) != 1 {
|
||||
t.Fatalf("CreateJWKS() returned invalid JWKS: jwks=%v", jwks)
|
||||
}
|
||||
if jwks.Keys[0].KeyID != keyID {
|
||||
t.Errorf("JWKS Keys[0].KeyID = %v, want %v", jwks.Keys[0].KeyID, keyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSAPublicKeyRoundTrip(t *testing.T) {
|
||||
// Generate RSA key pair
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||
}
|
||||
|
||||
originalPublicKey := &privateKey.PublicKey
|
||||
|
||||
// Convert to JWK and back
|
||||
keyID := "test-key-id"
|
||||
jwk := RSAPublicKeyToJWK(originalPublicKey, keyID)
|
||||
convertedPublicKey, err := JWKToRSAPublicKey(jwk)
|
||||
if err != nil {
|
||||
t.Fatalf("Round trip conversion failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify they match
|
||||
if originalPublicKey.N.Cmp(convertedPublicKey.N) != 0 {
|
||||
t.Error("Round trip: modulus doesn't match")
|
||||
}
|
||||
if originalPublicKey.E != convertedPublicKey.E {
|
||||
t.Error("Round trip: exponent doesn't match")
|
||||
}
|
||||
}
|
||||
88
oauth/middleware.go
Normal file
88
oauth/middleware.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const TokenClaimsKey contextKey = "token_claims"
|
||||
|
||||
// AuthMiddleware creates HTTP middleware for OAuth token validation
|
||||
func AuthMiddleware(resourceServer *ResourceServer, validator TokenValidator) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract Bearer token
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
println("DEBUG: No Authorization header")
|
||||
resourceServer.SendUnauthorized(w, "edge-connect-mcp")
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
println("DEBUG: Invalid Authorization header format")
|
||||
resourceServer.SendUnauthorized(w, "edge-connect-mcp")
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
println("DEBUG: SSE request with Bearer token (length:", len(token), ")")
|
||||
|
||||
// Validate token
|
||||
claims, err := validator.ValidateToken(r.Context(), token)
|
||||
if err != nil {
|
||||
println("DEBUG: Token validation failed:", err.Error())
|
||||
resourceServer.SendUnauthorized(w, "edge-connect-mcp")
|
||||
return
|
||||
}
|
||||
|
||||
println("DEBUG: Token validated successfully for subject:", claims.Subject)
|
||||
|
||||
// Add claims to context
|
||||
ctx := context.WithValue(r.Context(), TokenClaimsKey, claims)
|
||||
|
||||
// Continue to next handler
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetTokenClaims extracts token claims from request context
|
||||
func GetTokenClaims(ctx context.Context) (*TokenClaims, bool) {
|
||||
claims, ok := ctx.Value(TokenClaimsKey).(*TokenClaims)
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
// RequireScope creates middleware that checks for required scopes
|
||||
func RequireScope(resourceServer *ResourceServer, requiredScope string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := GetTokenClaims(r.Context())
|
||||
if !ok {
|
||||
resourceServer.SendUnauthorized(w, "edge-connect-mcp")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if required scope is present
|
||||
hasScope := false
|
||||
for _, scope := range claims.Scopes {
|
||||
if scope == requiredScope {
|
||||
hasScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasScope {
|
||||
resourceServer.SendInsufficientScope(w, requiredScope)
|
||||
return
|
||||
}
|
||||
|
||||
// Continue to next handler
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
269
oauth/middleware_test.go
Normal file
269
oauth/middleware_test.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthMiddlewareNoToken(t *testing.T) {
|
||||
// Create a mock resource server and validator
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
// Create middleware
|
||||
middleware := AuthMiddleware(resourceServer, validator)
|
||||
|
||||
// Create a test handler
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("success"))
|
||||
})
|
||||
|
||||
// Wrap with middleware
|
||||
wrappedHandler := middleware(testHandler)
|
||||
|
||||
// Make request without token
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
// Should return 401 Unauthorized
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("AuthMiddleware() status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareInvalidTokenFormat(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
middleware := AuthMiddleware(resourceServer, validator)
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
wrappedHandler := middleware(testHandler)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
}{
|
||||
{
|
||||
name: "missing bearer prefix",
|
||||
header: "invalid-token",
|
||||
},
|
||||
{
|
||||
name: "wrong auth scheme",
|
||||
header: "Basic dXNlcjpwYXNz",
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
header: "Bearer ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", tt.header)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("AuthMiddleware() status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareWithValidToken(t *testing.T) {
|
||||
// Create auth server and issue a token
|
||||
server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081")
|
||||
server.RegisterClient("test-client", []string{"http://localhost:3000/callback"})
|
||||
|
||||
// Issue token directly (no need to exchange code in this test)
|
||||
token, err := server.issueAccessToken("test-client", "mcp", "http://localhost:8080")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to issue token: %v", err)
|
||||
}
|
||||
|
||||
// Create test JWKS server
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
server.HandleJWKS(w, r)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
// Create validator and resource server
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", jwksServer.URL)
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
// Create middleware
|
||||
middleware := AuthMiddleware(resourceServer, validator)
|
||||
|
||||
// Create test handler that checks for token claims
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := GetTokenClaims(r.Context())
|
||||
if !ok {
|
||||
t.Error("GetTokenClaims() failed to extract claims from context")
|
||||
}
|
||||
if claims.Subject != "test-client" {
|
||||
t.Errorf("claims.Subject = %v, want test-client", claims.Subject)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("success"))
|
||||
})
|
||||
|
||||
wrappedHandler := middleware(testHandler)
|
||||
|
||||
// Wait for JWKS to be cached
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Make request with valid token
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("AuthMiddleware() status = %d, want %d, body: %s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenClaims(t *testing.T) {
|
||||
// Test with claims in context
|
||||
claims := &TokenClaims{
|
||||
Subject: "test-user",
|
||||
Audience: []string{"http://localhost:8080"},
|
||||
Issuer: "http://localhost:8081",
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), TokenClaimsKey, claims)
|
||||
|
||||
retrievedClaims, ok := GetTokenClaims(ctx)
|
||||
if !ok {
|
||||
t.Error("GetTokenClaims() should return true when claims are in context")
|
||||
}
|
||||
if retrievedClaims.Subject != "test-user" {
|
||||
t.Errorf("retrievedClaims.Subject = %v, want test-user", retrievedClaims.Subject)
|
||||
}
|
||||
|
||||
// Test without claims in context
|
||||
emptyCtx := context.Background()
|
||||
_, ok = GetTokenClaims(emptyCtx)
|
||||
if ok {
|
||||
t.Error("GetTokenClaims() should return false when claims are not in context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireScopeMiddleware(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
// Create test handler
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("success"))
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requiredScope string
|
||||
claimsScopes []string
|
||||
wantStatus int
|
||||
wantBodyPrefix string
|
||||
}{
|
||||
{
|
||||
name: "has required scope",
|
||||
requiredScope: "admin",
|
||||
claimsScopes: []string{"mcp", "admin"},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "missing required scope",
|
||||
requiredScope: "admin",
|
||||
claimsScopes: []string{"mcp"},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "no scopes",
|
||||
requiredScope: "admin",
|
||||
claimsScopes: []string{},
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create middleware
|
||||
scopeMiddleware := RequireScope(resourceServer, tt.requiredScope)
|
||||
wrappedHandler := scopeMiddleware(testHandler)
|
||||
|
||||
// Create request with claims in context
|
||||
claims := &TokenClaims{
|
||||
Subject: "test-user",
|
||||
Audience: []string{"http://localhost:8080"},
|
||||
Issuer: "http://localhost:8081",
|
||||
Scopes: tt.claimsScopes,
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), TokenClaimsKey, claims)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("RequireScope() status = %d, want %d", w.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireScopeMiddlewareNoClaims(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
scopeMiddleware := RequireScope(resourceServer, "admin")
|
||||
wrappedHandler := scopeMiddleware(testHandler)
|
||||
|
||||
// Request without claims in context
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
// Should return 401 Unauthorized
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("RequireScope() status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
118
oauth/oauth.go
Normal file
118
oauth/oauth.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenValidator validates OAuth 2.1 Bearer tokens
|
||||
type TokenValidator interface {
|
||||
// ValidateToken validates a Bearer token and returns claims
|
||||
ValidateToken(ctx context.Context, token string) (*TokenClaims, error)
|
||||
|
||||
// GetJWKS returns the JSON Web Key Set for token verification
|
||||
GetJWKS(ctx context.Context) (*JWKS, error)
|
||||
}
|
||||
|
||||
// TokenClaims represents validated token claims
|
||||
type TokenClaims struct {
|
||||
Subject string // "sub" - user identifier
|
||||
Audience []string // "aud" - intended recipients (RFC 8707)
|
||||
Issuer string // "iss" - authorization server
|
||||
ExpiresAt time.Time // "exp" - expiration time
|
||||
IssuedAt time.Time // "iat" - issue time
|
||||
Scopes []string // "scope" - granted scopes
|
||||
ClientID string // "client_id" - client identifier
|
||||
}
|
||||
|
||||
// AuthorizationServer defines the basic authorization server interface
|
||||
type AuthorizationServer interface {
|
||||
// HandleAuthorize handles authorization requests (GET /authorize)
|
||||
HandleAuthorize(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// HandleToken handles token requests (POST /token)
|
||||
HandleToken(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// HandleJWKS returns the JWKS document (GET /.well-known/jwks.json)
|
||||
HandleJWKS(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// GetMetadata returns authorization server metadata (RFC 8414)
|
||||
GetMetadata() *AuthServerMetadata
|
||||
}
|
||||
|
||||
// ProtectedResourceMetadata represents RFC 9728 metadata
|
||||
type ProtectedResourceMetadata struct {
|
||||
Resource string `json:"resource"`
|
||||
AuthorizationServers []string `json:"authorization_servers"`
|
||||
BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"`
|
||||
ResourceSigningAlgs []string `json:"resource_signing_alg_values_supported,omitempty"`
|
||||
}
|
||||
|
||||
// AuthServerMetadata represents RFC 8414 metadata
|
||||
type AuthServerMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
ScopesSupported []string `json:"scopes_supported,omitempty"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
}
|
||||
|
||||
// JWKS represents a JSON Web Key Set
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
// JWK represents a JSON Web Key
|
||||
type JWK struct {
|
||||
KeyType string `json:"kty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
KeyID string `json:"kid,omitempty"`
|
||||
Algorithm string `json:"alg,omitempty"`
|
||||
N string `json:"n,omitempty"` // RSA modulus
|
||||
E string `json:"e,omitempty"` // RSA exponent
|
||||
}
|
||||
|
||||
// TokenResponse represents an OAuth 2.1 token response
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// TokenErrorResponse represents an OAuth 2.1 token error response
|
||||
type TokenErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// ClientRegistrationRequest represents RFC 7591 client registration request
|
||||
type ClientRegistrationRequest struct {
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// ClientRegistrationResponse represents RFC 7591 client registration response
|
||||
type ClientRegistrationResponse struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"`
|
||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
48
oauth/pkce.go
Normal file
48
oauth/pkce.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ValidatePKCE validates a PKCE code verifier against a code challenge
|
||||
// using the S256 method (SHA256)
|
||||
// As per OAuth 2.1, only S256 is supported
|
||||
func ValidatePKCE(verifier, challenge string) bool {
|
||||
if verifier == "" || challenge == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute SHA256 hash of verifier
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
|
||||
// Encode with base64url (no padding)
|
||||
computed := base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
|
||||
// Compare with challenge
|
||||
return computed == challenge
|
||||
}
|
||||
|
||||
// GenerateCodeVerifier generates a random PKCE code verifier
|
||||
// The verifier is a high-entropy cryptographic random string
|
||||
// Length: 43-128 characters from [A-Z, a-z, 0-9, -, ., _, ~]
|
||||
func GenerateCodeVerifier() (string, error) {
|
||||
// Generate 32 random bytes (will be base64url encoded to 43 chars)
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Encode with base64url (no padding)
|
||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
// GenerateCodeChallenge generates a PKCE code challenge from a verifier
|
||||
// using the S256 method (SHA256)
|
||||
func GenerateCodeChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
115
oauth/pkce_test.go
Normal file
115
oauth/pkce_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidatePKCE(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
verifier string
|
||||
challenge string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "valid PKCE S256",
|
||||
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid verifier",
|
||||
verifier: "wrong-verifier",
|
||||
challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty verifier",
|
||||
verifier: "",
|
||||
challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty challenge",
|
||||
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
challenge: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
verifier: "",
|
||||
challenge: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ValidatePKCE(tt.verifier, tt.challenge)
|
||||
if got != tt.want {
|
||||
t.Errorf("ValidatePKCE() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCodeVerifier(t *testing.T) {
|
||||
// Generate multiple verifiers to ensure randomness
|
||||
verifiers := make(map[string]bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCodeVerifier() error = %v", err)
|
||||
}
|
||||
|
||||
// Check length (should be 43 characters for 32 random bytes base64url encoded)
|
||||
if len(verifier) != 43 {
|
||||
t.Errorf("GenerateCodeVerifier() length = %d, want 43", len(verifier))
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
if verifiers[verifier] {
|
||||
t.Errorf("GenerateCodeVerifier() generated duplicate: %s", verifier)
|
||||
}
|
||||
verifiers[verifier] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCodeChallenge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
verifier string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "known verifier",
|
||||
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
want: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateCodeChallenge(tt.verifier)
|
||||
if got != tt.want {
|
||||
t.Errorf("GenerateCodeChallenge() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCERoundTrip(t *testing.T) {
|
||||
// Generate verifier
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCodeVerifier() error = %v", err)
|
||||
}
|
||||
|
||||
// Generate challenge from verifier
|
||||
challenge := GenerateCodeChallenge(verifier)
|
||||
|
||||
// Validate that they match
|
||||
if !ValidatePKCE(verifier, challenge) {
|
||||
t.Errorf("PKCE round trip failed: verifier=%s, challenge=%s", verifier, challenge)
|
||||
}
|
||||
}
|
||||
95
oauth/resource_server.go
Normal file
95
oauth/resource_server.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ResourceServer implements OAuth 2.1 Protected Resource functionality
|
||||
type ResourceServer struct {
|
||||
resourceURI string
|
||||
authorizationServers []string
|
||||
validator TokenValidator
|
||||
}
|
||||
|
||||
// NewResourceServer creates a new protected resource server
|
||||
func NewResourceServer(resourceURI string, authServers []string, validator TokenValidator) *ResourceServer {
|
||||
return &ResourceServer{
|
||||
resourceURI: resourceURI,
|
||||
authorizationServers: authServers,
|
||||
validator: validator,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetadata returns the Protected Resource Metadata (RFC 9728)
|
||||
func (rs *ResourceServer) GetMetadata() *ProtectedResourceMetadata {
|
||||
return &ProtectedResourceMetadata{
|
||||
Resource: rs.resourceURI,
|
||||
AuthorizationServers: rs.authorizationServers,
|
||||
BearerMethodsSupported: []string{"header"},
|
||||
ResourceSigningAlgs: []string{"RS256"},
|
||||
}
|
||||
}
|
||||
|
||||
// ServeMetadata serves the Protected Resource Metadata endpoint
|
||||
func (rs *ResourceServer) ServeMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
metadata := rs.GetMetadata()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
if err := json.NewEncoder(w).Encode(metadata); err != nil {
|
||||
fmt.Printf("Failed to encode resource metadata: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendUnauthorized sends a 401 response with WWW-Authenticate header (RFC 9728 Section 5.1)
|
||||
func (rs *ResourceServer) SendUnauthorized(w http.ResponseWriter, realm string) {
|
||||
metadataURL := rs.resourceURI + "/.well-known/oauth-protected-resource"
|
||||
|
||||
authHeader := fmt.Sprintf(
|
||||
`Bearer realm="%s", resource_metadata="%s"`,
|
||||
realm,
|
||||
metadataURL,
|
||||
)
|
||||
|
||||
w.Header().Set("WWW-Authenticate", authHeader)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "invalid_token",
|
||||
"error_description": "The access token is invalid or expired",
|
||||
}); err != nil {
|
||||
fmt.Printf("Failed to encode unauthorized response: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendInsufficientScope sends a 403 response for insufficient scope errors (RFC 6750 Section 3.1)
|
||||
func (rs *ResourceServer) SendInsufficientScope(w http.ResponseWriter, requiredScope string) {
|
||||
metadataURL := rs.resourceURI + "/.well-known/oauth-protected-resource"
|
||||
|
||||
authHeader := fmt.Sprintf(
|
||||
`Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="Additional permissions required"`,
|
||||
requiredScope,
|
||||
metadataURL,
|
||||
)
|
||||
|
||||
w.Header().Set("WWW-Authenticate", authHeader)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "insufficient_scope",
|
||||
"error_description": "Additional permissions required",
|
||||
"scope": requiredScope,
|
||||
}); err != nil {
|
||||
fmt.Printf("Failed to encode insufficient scope response: %v\n", err)
|
||||
}
|
||||
}
|
||||
209
oauth/resource_server_test.go
Normal file
209
oauth/resource_server_test.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewResourceServer(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
if resourceServer.resourceURI != "http://localhost:8080" {
|
||||
t.Errorf("resourceURI = %v, want http://localhost:8080", resourceServer.resourceURI)
|
||||
}
|
||||
if len(resourceServer.authorizationServers) != 1 {
|
||||
t.Errorf("authorizationServers length = %d, want 1", len(resourceServer.authorizationServers))
|
||||
}
|
||||
if resourceServer.validator == nil {
|
||||
t.Error("validator is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceServerGetMetadata(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
metadata := resourceServer.GetMetadata()
|
||||
|
||||
if metadata.Resource != "http://localhost:8080" {
|
||||
t.Errorf("metadata.Resource = %v, want http://localhost:8080", metadata.Resource)
|
||||
}
|
||||
if len(metadata.AuthorizationServers) != 1 {
|
||||
t.Errorf("metadata.AuthorizationServers length = %d, want 1", len(metadata.AuthorizationServers))
|
||||
}
|
||||
if metadata.AuthorizationServers[0] != "http://localhost:8081" {
|
||||
t.Errorf("metadata.AuthorizationServers[0] = %v, want http://localhost:8081", metadata.AuthorizationServers[0])
|
||||
}
|
||||
if len(metadata.BearerMethodsSupported) == 0 {
|
||||
t.Error("metadata.BearerMethodsSupported is empty")
|
||||
}
|
||||
if metadata.BearerMethodsSupported[0] != "header" {
|
||||
t.Errorf("metadata.BearerMethodsSupported[0] = %v, want header", metadata.BearerMethodsSupported[0])
|
||||
}
|
||||
if len(metadata.ResourceSigningAlgs) == 0 {
|
||||
t.Error("metadata.ResourceSigningAlgs is empty")
|
||||
}
|
||||
if metadata.ResourceSigningAlgs[0] != "RS256" {
|
||||
t.Errorf("metadata.ResourceSigningAlgs[0] = %v, want RS256", metadata.ResourceSigningAlgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceServerServeMetadata(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
resourceServer.ServeMetadata(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("ServeMetadata() status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Content-Type = %v, want application/json", contentType)
|
||||
}
|
||||
|
||||
var metadata ProtectedResourceMetadata
|
||||
if err := json.NewDecoder(w.Body).Decode(&metadata); err != nil {
|
||||
t.Fatalf("Failed to decode metadata: %v", err)
|
||||
}
|
||||
|
||||
if metadata.Resource != "http://localhost:8080" {
|
||||
t.Errorf("metadata.Resource = %v, want http://localhost:8080", metadata.Resource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceServerServeMetadataMethodNotAllowed(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/.well-known/oauth-protected-resource", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
resourceServer.ServeMetadata(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("ServeMetadata() status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceServerSendUnauthorized(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
resourceServer.SendUnauthorized(w, "test-realm")
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("SendUnauthorized() status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// Check WWW-Authenticate header
|
||||
authHeader := w.Header().Get("WWW-Authenticate")
|
||||
if authHeader == "" {
|
||||
t.Error("SendUnauthorized() missing WWW-Authenticate header")
|
||||
}
|
||||
|
||||
// Check for required parameters
|
||||
if !contains(authHeader, "Bearer") {
|
||||
t.Error("WWW-Authenticate header missing Bearer scheme")
|
||||
}
|
||||
if !contains(authHeader, "realm=") {
|
||||
t.Error("WWW-Authenticate header missing realm parameter")
|
||||
}
|
||||
if !contains(authHeader, "resource_metadata=") {
|
||||
t.Error("WWW-Authenticate header missing resource_metadata parameter")
|
||||
}
|
||||
|
||||
// Check response body
|
||||
var errorResp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
|
||||
if errorResp["error"] != "invalid_token" {
|
||||
t.Errorf("error = %v, want invalid_token", errorResp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceServerSendInsufficientScope(t *testing.T) {
|
||||
validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json")
|
||||
resourceServer := NewResourceServer(
|
||||
"http://localhost:8080",
|
||||
[]string{"http://localhost:8081"},
|
||||
validator,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
resourceServer.SendInsufficientScope(w, "admin")
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("SendInsufficientScope() status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// Check WWW-Authenticate header
|
||||
authHeader := w.Header().Get("WWW-Authenticate")
|
||||
if authHeader == "" {
|
||||
t.Error("SendInsufficientScope() missing WWW-Authenticate header")
|
||||
}
|
||||
|
||||
// Check for required parameters
|
||||
if !contains(authHeader, "error=\"insufficient_scope\"") {
|
||||
t.Error("WWW-Authenticate header missing error=insufficient_scope")
|
||||
}
|
||||
if !contains(authHeader, "scope=") {
|
||||
t.Error("WWW-Authenticate header missing scope parameter")
|
||||
}
|
||||
|
||||
// Check response body
|
||||
var errorResp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
|
||||
if errorResp["error"] != "insufficient_scope" {
|
||||
t.Errorf("error = %v, want insufficient_scope", errorResp["error"])
|
||||
}
|
||||
if errorResp["scope"] != "admin" {
|
||||
t.Errorf("scope = %v, want admin", errorResp["scope"])
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr)))
|
||||
}
|
||||
|
||||
func containsMiddle(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
227
oauth/storage.go
Normal file
227
oauth/storage.go
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuthorizationCode represents an authorization code with metadata
|
||||
type AuthorizationCode struct {
|
||||
Code string
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
Scope string
|
||||
CodeChallenge string
|
||||
Resource string
|
||||
ExpiresAt time.Time
|
||||
Used bool
|
||||
}
|
||||
|
||||
// RefreshToken represents a refresh token with metadata
|
||||
type RefreshToken struct {
|
||||
Token string
|
||||
ClientID string
|
||||
Scope string
|
||||
Resource string
|
||||
ExpiresAt time.Time
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
// Client represents a registered OAuth client
|
||||
type Client struct {
|
||||
ClientID string
|
||||
RedirectURIs []string
|
||||
}
|
||||
|
||||
// AuthStorage provides in-memory storage for authorization codes and clients
|
||||
type AuthStorage struct {
|
||||
mu sync.RWMutex
|
||||
codes map[string]*AuthorizationCode
|
||||
refreshTokens map[string]*RefreshToken
|
||||
clients map[string]*Client
|
||||
}
|
||||
|
||||
// NewAuthStorage creates a new AuthStorage instance
|
||||
func NewAuthStorage() *AuthStorage {
|
||||
return &AuthStorage{
|
||||
codes: make(map[string]*AuthorizationCode),
|
||||
refreshTokens: make(map[string]*RefreshToken),
|
||||
clients: make(map[string]*Client),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAuthorizationCode generates and stores a new authorization code
|
||||
func (s *AuthStorage) CreateAuthorizationCode(clientID, redirectURI, scope, codeChallenge, resource string) (string, error) {
|
||||
// Generate random authorization code
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate authorization code: %w", err)
|
||||
}
|
||||
code := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Store authorization code
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.codes[code] = &AuthorizationCode{
|
||||
Code: code,
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
Scope: scope,
|
||||
CodeChallenge: codeChallenge,
|
||||
Resource: resource,
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute), // 10 minute expiration
|
||||
Used: false,
|
||||
}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// GetAuthorizationCode retrieves an authorization code
|
||||
func (s *AuthStorage) GetAuthorizationCode(code string) (*AuthorizationCode, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
authCode, exists := s.codes[code]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("authorization code not found")
|
||||
}
|
||||
|
||||
if authCode.Used {
|
||||
return nil, fmt.Errorf("authorization code already used")
|
||||
}
|
||||
|
||||
if time.Now().After(authCode.ExpiresAt) {
|
||||
return nil, fmt.Errorf("authorization code expired")
|
||||
}
|
||||
|
||||
return authCode, nil
|
||||
}
|
||||
|
||||
// RevokeAuthorizationCode marks an authorization code as used
|
||||
func (s *AuthStorage) RevokeAuthorizationCode(code string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if authCode, exists := s.codes[code]; exists {
|
||||
authCode.Used = true
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterClient registers a new OAuth client
|
||||
func (s *AuthStorage) RegisterClient(clientID string, redirectURIs []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.clients[clientID] = &Client{
|
||||
ClientID: clientID,
|
||||
RedirectURIs: redirectURIs,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateClient validates a client ID and redirect URI
|
||||
func (s *AuthStorage) ValidateClient(clientID, redirectURI string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
client, exists := s.clients[clientID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if redirect URI is registered
|
||||
for _, uri := range client.RedirectURIs {
|
||||
if uri == redirectURI {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CleanupExpiredCodes removes expired authorization codes
|
||||
// This should be called periodically in production
|
||||
func (s *AuthStorage) CleanupExpiredCodes() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for code, authCode := range s.codes {
|
||||
if now.After(authCode.ExpiresAt) || authCode.Used {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRefreshToken generates and stores a new refresh token
|
||||
func (s *AuthStorage) CreateRefreshToken(clientID, scope, resource string, lifetime time.Duration) (string, error) {
|
||||
// Generate random refresh token
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate refresh token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Store refresh token
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.refreshTokens[token] = &RefreshToken{
|
||||
Token: token,
|
||||
ClientID: clientID,
|
||||
Scope: scope,
|
||||
Resource: resource,
|
||||
ExpiresAt: time.Now().Add(lifetime),
|
||||
Revoked: false,
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetRefreshToken retrieves a refresh token
|
||||
func (s *AuthStorage) GetRefreshToken(token string) (*RefreshToken, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
refreshToken, exists := s.refreshTokens[token]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("refresh token not found")
|
||||
}
|
||||
|
||||
if refreshToken.Revoked {
|
||||
return nil, fmt.Errorf("refresh token revoked")
|
||||
}
|
||||
|
||||
if time.Now().After(refreshToken.ExpiresAt) {
|
||||
return nil, fmt.Errorf("refresh token expired")
|
||||
}
|
||||
|
||||
return refreshToken, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshToken marks a refresh token as revoked
|
||||
func (s *AuthStorage) RevokeRefreshToken(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if refreshToken, exists := s.refreshTokens[token]; exists {
|
||||
refreshToken.Revoked = true
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupExpiredRefreshTokens removes expired refresh tokens
|
||||
// This should be called periodically in production
|
||||
func (s *AuthStorage) CleanupExpiredRefreshTokens() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for token, refreshToken := range s.refreshTokens {
|
||||
if now.After(refreshToken.ExpiresAt) || refreshToken.Revoked {
|
||||
delete(s.refreshTokens, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
193
oauth/storage_test.go
Normal file
193
oauth/storage_test.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthStorageCreateAuthorizationCode(t *testing.T) {
|
||||
storage := NewAuthStorage()
|
||||
|
||||
code, err := storage.CreateAuthorizationCode(
|
||||
"client-123",
|
||||
"http://localhost:3000/callback",
|
||||
"mcp",
|
||||
"challenge-abc",
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuthorizationCode() error = %v", err)
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
t.Error("CreateAuthorizationCode() returned empty code")
|
||||
}
|
||||
|
||||
// Verify code is stored
|
||||
authCode, err := storage.GetAuthorizationCode(code)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthorizationCode() error = %v", err)
|
||||
}
|
||||
|
||||
if authCode.ClientID != "client-123" {
|
||||
t.Errorf("authCode.ClientID = %v, want client-123", authCode.ClientID)
|
||||
}
|
||||
if authCode.RedirectURI != "http://localhost:3000/callback" {
|
||||
t.Errorf("authCode.RedirectURI = %v, want http://localhost:3000/callback", authCode.RedirectURI)
|
||||
}
|
||||
if authCode.Scope != "mcp" {
|
||||
t.Errorf("authCode.Scope = %v, want mcp", authCode.Scope)
|
||||
}
|
||||
if authCode.CodeChallenge != "challenge-abc" {
|
||||
t.Errorf("authCode.CodeChallenge = %v, want challenge-abc", authCode.CodeChallenge)
|
||||
}
|
||||
if authCode.Resource != "http://localhost:8080" {
|
||||
t.Errorf("authCode.Resource = %v, want http://localhost:8080", authCode.Resource)
|
||||
}
|
||||
if authCode.Used {
|
||||
t.Error("authCode.Used = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageGetAuthorizationCodeNotFound(t *testing.T) {
|
||||
storage := NewAuthStorage()
|
||||
|
||||
_, err := storage.GetAuthorizationCode("non-existent-code")
|
||||
if err == nil {
|
||||
t.Error("GetAuthorizationCode() should return error for non-existent code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageRevokeAuthorizationCode(t *testing.T) {
|
||||
storage := NewAuthStorage()
|
||||
|
||||
code, _ := storage.CreateAuthorizationCode(
|
||||
"client-123",
|
||||
"http://localhost:3000/callback",
|
||||
"mcp",
|
||||
"challenge-abc",
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
// Revoke the code
|
||||
storage.RevokeAuthorizationCode(code)
|
||||
|
||||
// Try to get the code
|
||||
_, err := storage.GetAuthorizationCode(code)
|
||||
if err == nil {
|
||||
t.Error("GetAuthorizationCode() should return error for used code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageExpiredCode(t *testing.T) {
|
||||
storage := NewAuthStorage()
|
||||
|
||||
code, _ := storage.CreateAuthorizationCode(
|
||||
"client-123",
|
||||
"http://localhost:3000/callback",
|
||||
"mcp",
|
||||
"challenge-abc",
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
// Manually expire the code
|
||||
storage.mu.Lock()
|
||||
if authCode, exists := storage.codes[code]; exists {
|
||||
authCode.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||
}
|
||||
storage.mu.Unlock()
|
||||
|
||||
// Try to get the expired code
|
||||
_, err := storage.GetAuthorizationCode(code)
|
||||
if err == nil {
|
||||
t.Error("GetAuthorizationCode() should return error for expired code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageRegisterClient(t *testing.T) {
|
||||
storage := NewAuthStorage()
|
||||
|
||||
clientID := "test-client"
|
||||
redirectURIs := []string{"http://localhost:3000/callback", "http://localhost:3001/callback"}
|
||||
|
||||
storage.RegisterClient(clientID, redirectURIs)
|
||||
|
||||
// Validate with correct redirect URI
|
||||
if !storage.ValidateClient(clientID, "http://localhost:3000/callback") {
|
||||
t.Error("ValidateClient() should return true for registered client and redirect URI")
|
||||
}
|
||||
|
||||
// Validate with another correct redirect URI
|
||||
if !storage.ValidateClient(clientID, "http://localhost:3001/callback") {
|
||||
t.Error("ValidateClient() should return true for second registered redirect URI")
|
||||
}
|
||||
|
||||
// Validate with incorrect redirect URI
|
||||
if storage.ValidateClient(clientID, "http://malicious.com/callback") {
|
||||
t.Error("ValidateClient() should return false for unregistered redirect URI")
|
||||
}
|
||||
|
||||
// Validate with non-existent client
|
||||
if storage.ValidateClient("non-existent-client", "http://localhost:3000/callback") {
|
||||
t.Error("ValidateClient() should return false for non-existent client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageCleanupExpiredCodes(t *testing.T) {
|
||||
storage := NewAuthStorage()
|
||||
|
||||
// Create multiple codes
|
||||
code1, _ := storage.CreateAuthorizationCode("client-1", "http://localhost:3000/callback", "mcp", "challenge-1", "http://localhost:8080")
|
||||
code2, _ := storage.CreateAuthorizationCode("client-2", "http://localhost:3000/callback", "mcp", "challenge-2", "http://localhost:8080")
|
||||
code3, _ := storage.CreateAuthorizationCode("client-3", "http://localhost:3000/callback", "mcp", "challenge-3", "http://localhost:8080")
|
||||
|
||||
// Expire code1 and mark code2 as used
|
||||
storage.mu.Lock()
|
||||
if authCode, exists := storage.codes[code1]; exists {
|
||||
authCode.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||
}
|
||||
storage.mu.Unlock()
|
||||
storage.RevokeAuthorizationCode(code2)
|
||||
|
||||
// Cleanup
|
||||
storage.CleanupExpiredCodes()
|
||||
|
||||
// code1 should be removed (expired)
|
||||
if _, err := storage.GetAuthorizationCode(code1); err == nil {
|
||||
t.Error("Expired code should be removed")
|
||||
}
|
||||
|
||||
// code2 should be removed (used)
|
||||
if _, err := storage.GetAuthorizationCode(code2); err == nil {
|
||||
t.Error("Used code should be removed")
|
||||
}
|
||||
|
||||
// code3 should still exist
|
||||
if _, err := storage.GetAuthorizationCode(code3); err != nil {
|
||||
t.Error("Valid code should not be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageCodeUniqueness(t *testing.T) {
|
||||
storage := NewAuthStorage()
|
||||
|
||||
codes := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
code, err := storage.CreateAuthorizationCode(
|
||||
"client-123",
|
||||
"http://localhost:3000/callback",
|
||||
"mcp",
|
||||
"challenge-abc",
|
||||
"http://localhost:8080",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuthorizationCode() error = %v", err)
|
||||
}
|
||||
|
||||
if codes[code] {
|
||||
t.Errorf("Duplicate code generated: %s", code)
|
||||
}
|
||||
codes[code] = true
|
||||
}
|
||||
}
|
||||
239
oauth/token_validator.go
Normal file
239
oauth/token_validator.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTValidator implements TokenValidator using JWT
|
||||
type JWTValidator struct {
|
||||
expectedAudience string
|
||||
expectedIssuer string
|
||||
jwksURL string
|
||||
publicKeys map[string]*rsa.PublicKey
|
||||
lastFetch time.Time
|
||||
cacheDuration time.Duration
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewJWTValidator creates a new JWT token validator
|
||||
func NewJWTValidator(audience, issuer, jwksURL string) *JWTValidator {
|
||||
return &JWTValidator{
|
||||
expectedAudience: audience,
|
||||
expectedIssuer: issuer,
|
||||
jwksURL: jwksURL,
|
||||
publicKeys: make(map[string]*rsa.PublicKey),
|
||||
cacheDuration: 15 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT token
|
||||
func (v *JWTValidator) ValidateToken(ctx context.Context, tokenString string) (*TokenClaims, error) {
|
||||
// Parse JWT without verification first to get key ID
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// Get key ID from header
|
||||
kid, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing or invalid kid in token header")
|
||||
}
|
||||
|
||||
// Fetch public key
|
||||
publicKey, err := v.getPublicKey(ctx, kid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
return publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf("token is invalid")
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
// Validate audience (RFC 8707 - CRITICAL for security)
|
||||
aud, err := claims.GetAudience()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid audience claim: %w", err)
|
||||
}
|
||||
|
||||
// Normalize expected audience (remove trailing slash)
|
||||
expectedAudience := strings.TrimSuffix(v.expectedAudience, "/")
|
||||
|
||||
audienceValid := false
|
||||
for _, a := range aud {
|
||||
// Normalize token audience (remove trailing slash)
|
||||
normalizedAud := strings.TrimSuffix(a, "/")
|
||||
if normalizedAud == expectedAudience {
|
||||
audienceValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !audienceValid {
|
||||
fmt.Printf("DEBUG: Audience validation failed. Expected: %s, Got: %v\n", expectedAudience, aud)
|
||||
return nil, fmt.Errorf("token audience does not match expected audience")
|
||||
}
|
||||
|
||||
// Validate issuer
|
||||
iss, err := claims.GetIssuer()
|
||||
if err != nil || iss != v.expectedIssuer {
|
||||
return nil, fmt.Errorf("invalid issuer: expected %s, got %s", v.expectedIssuer, iss)
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
exp, err := claims.GetExpirationTime()
|
||||
if err != nil || exp.Before(time.Now()) {
|
||||
return nil, fmt.Errorf("token is expired")
|
||||
}
|
||||
|
||||
// Validate issued at
|
||||
iat, err := claims.GetIssuedAt()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid issued at claim: %w", err)
|
||||
}
|
||||
|
||||
// Extract subject
|
||||
sub, err := claims.GetSubject()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid subject claim: %w", err)
|
||||
}
|
||||
|
||||
// Extract scopes
|
||||
scopes := []string{}
|
||||
if scopeClaim, ok := claims["scope"].(string); ok {
|
||||
if scopeClaim != "" {
|
||||
scopes = strings.Split(scopeClaim, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// Build token claims
|
||||
tokenClaims := &TokenClaims{
|
||||
Subject: sub,
|
||||
Audience: aud,
|
||||
Issuer: iss,
|
||||
ExpiresAt: exp.Time,
|
||||
IssuedAt: iat.Time,
|
||||
Scopes: scopes,
|
||||
}
|
||||
|
||||
if clientID, ok := claims["client_id"].(string); ok {
|
||||
tokenClaims.ClientID = clientID
|
||||
}
|
||||
|
||||
return tokenClaims, nil
|
||||
}
|
||||
|
||||
// GetJWKS fetches and returns the JWKS
|
||||
func (v *JWTValidator) GetJWKS(ctx context.Context) (*JWKS, error) {
|
||||
if err := v.refreshJWKS(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
|
||||
// Build JWKS from cached keys
|
||||
jwks := &JWKS{
|
||||
Keys: make([]JWK, 0, len(v.publicKeys)),
|
||||
}
|
||||
|
||||
for kid, pubKey := range v.publicKeys {
|
||||
jwk := RSAPublicKeyToJWK(pubKey, kid)
|
||||
jwks.Keys = append(jwks.Keys, jwk)
|
||||
}
|
||||
|
||||
return jwks, nil
|
||||
}
|
||||
|
||||
// getPublicKey fetches a public key by ID from JWKS endpoint
|
||||
func (v *JWTValidator) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
|
||||
// Check cache
|
||||
v.mu.RLock()
|
||||
key, exists := v.publicKeys[kid]
|
||||
lastFetch := v.lastFetch
|
||||
v.mu.RUnlock()
|
||||
|
||||
if exists && time.Since(lastFetch) < v.cacheDuration {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Fetch JWKS
|
||||
if err := v.refreshJWKS(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check cache again after refresh
|
||||
v.mu.RLock()
|
||||
key, exists = v.publicKeys[kid]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("public key with kid %s not found", kid)
|
||||
}
|
||||
|
||||
// refreshJWKS fetches the latest JWKS from the authorization server
|
||||
func (v *JWTValidator) refreshJWKS(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var jwks JWKS
|
||||
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse and cache keys
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
for _, key := range jwks.Keys {
|
||||
if key.KeyType == "RSA" && key.KeyID != "" {
|
||||
publicKey, err := JWKToRSAPublicKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
v.publicKeys[key.KeyID] = publicKey
|
||||
}
|
||||
}
|
||||
|
||||
v.lastFetch = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -21,4 +21,8 @@ fi
|
|||
echo "Running linter..."
|
||||
make lint
|
||||
|
||||
# Check for secrets with gitleaks
|
||||
echo "Checking for secrets..."
|
||||
make gitleaks
|
||||
|
||||
echo "Pre-commit checks passed!"
|
||||
|
|
|
|||
497
tools.go
497
tools.go
|
|
@ -4,30 +4,241 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
mcpuiserver "github.com/MCP-UI-Org/mcp-ui/sdks/go/server"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// Lightweight DTOs for list responses to reduce token usage
|
||||
|
||||
// AppListItem represents a lightweight app for list responses
|
||||
type AppListItem struct {
|
||||
Key struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"key"`
|
||||
Deployment string `json:"deployment"`
|
||||
ImagePath string `json:"image_path"`
|
||||
AccessPorts string `json:"access_ports"`
|
||||
AllowServerless bool `json:"allow_serverless"`
|
||||
}
|
||||
|
||||
// AppInstanceListItem represents a lightweight app instance for list responses
|
||||
type AppInstanceListItem struct {
|
||||
Key struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
CloudletKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
} `json:"cloudlet_key"`
|
||||
} `json:"key"`
|
||||
AppKey struct {
|
||||
Organization string `json:"organization"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"app_key"`
|
||||
PowerState string `json:"power_state"`
|
||||
Flavor struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"flavor"`
|
||||
}
|
||||
|
||||
// convertToAppListItem converts a full App to a lightweight AppListItem
|
||||
func convertToAppListItem(app v2.App) AppListItem {
|
||||
item := AppListItem{
|
||||
Deployment: app.Deployment,
|
||||
ImagePath: app.ImagePath,
|
||||
AccessPorts: app.AccessPorts,
|
||||
AllowServerless: app.AllowServerless,
|
||||
}
|
||||
item.Key.Organization = app.Key.Organization
|
||||
item.Key.Name = app.Key.Name
|
||||
item.Key.Version = app.Key.Version
|
||||
return item
|
||||
}
|
||||
|
||||
// convertToAppListItems converts a slice of Apps to AppListItems
|
||||
func convertToAppListItems(apps []v2.App) []AppListItem {
|
||||
items := make([]AppListItem, len(apps))
|
||||
for i, app := range apps {
|
||||
items[i] = convertToAppListItem(app)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// convertToAppInstanceListItem converts a full AppInstance to a lightweight AppInstanceListItem
|
||||
func convertToAppInstanceListItem(inst v2.AppInstance) AppInstanceListItem {
|
||||
item := AppInstanceListItem{
|
||||
PowerState: inst.PowerState,
|
||||
}
|
||||
item.Key.Organization = inst.Key.Organization
|
||||
item.Key.Name = inst.Key.Name
|
||||
item.Key.CloudletKey.Organization = inst.Key.CloudletKey.Organization
|
||||
item.Key.CloudletKey.Name = inst.Key.CloudletKey.Name
|
||||
item.AppKey.Organization = inst.AppKey.Organization
|
||||
item.AppKey.Name = inst.AppKey.Name
|
||||
item.AppKey.Version = inst.AppKey.Version
|
||||
item.Flavor.Name = inst.Flavor.Name
|
||||
return item
|
||||
}
|
||||
|
||||
// convertToAppInstanceListItems converts a slice of AppInstances to AppInstanceListItems
|
||||
func convertToAppInstanceListItems(instances []v2.AppInstance) []AppInstanceListItem {
|
||||
items := make([]AppInstanceListItem, len(instances))
|
||||
for i, inst := range instances {
|
||||
items[i] = convertToAppInstanceListItem(inst)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// getProtocolFromRequest extracts the protocol from the MCP initialize request
|
||||
func getProtocolFromRequest(req *mcp.CallToolRequest) mcpuiserver.ProtocolType {
|
||||
// Get initialize params from session
|
||||
initParams := req.Session.InitializeParams()
|
||||
if initParams == nil {
|
||||
return "" // Return empty if not available
|
||||
}
|
||||
|
||||
// Convert InitializeParams to map for ParseProtocolFromInitialize
|
||||
paramsMap := make(map[string]any)
|
||||
paramsJSON, err := json.Marshal(initParams)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if err := json.Unmarshal(paramsJSON, ¶msMap); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse protocol from initialize params
|
||||
return mcpuiserver.ParseProtocolFromInitialize(paramsMap)
|
||||
}
|
||||
|
||||
// filterAppInstances performs client-side partial matching on app instances
|
||||
func filterAppInstances(instances []v2.AppInstance, organization, instanceName, cloudletOrg, cloudletName, appOrg, appName, appVersion *string) []v2.AppInstance {
|
||||
if organization == nil && instanceName == nil &&
|
||||
cloudletOrg == nil && cloudletName == nil &&
|
||||
appOrg == nil && appName == nil &&
|
||||
appVersion == nil {
|
||||
return instances // No filters, return all
|
||||
}
|
||||
|
||||
var filtered []v2.AppInstance
|
||||
for _, inst := range instances {
|
||||
match := true
|
||||
|
||||
if organization != nil && *organization != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.Key.Organization), strings.ToLower(*organization)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match && instanceName != nil && *instanceName != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.Key.Name), strings.ToLower(*instanceName)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match && cloudletOrg != nil && *cloudletOrg != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.Key.CloudletKey.Organization), strings.ToLower(*cloudletOrg)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match && cloudletName != nil && *cloudletName != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.Key.CloudletKey.Name), strings.ToLower(*cloudletName)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match && appOrg != nil && *appOrg != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.AppKey.Organization), strings.ToLower(*appOrg)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match && appName != nil && *appName != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.AppKey.Name), strings.ToLower(*appName)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match && appVersion != nil && *appVersion != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.AppKey.Version), strings.ToLower(*appVersion)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
filtered = append(filtered, inst)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// filterApps performs client-side partial matching on apps
|
||||
func filterApps(apps []v2.App, organization, name, version *string) []v2.App {
|
||||
if organization == nil && name == nil && version == nil {
|
||||
return apps // No filters, return all
|
||||
}
|
||||
|
||||
var filtered []v2.App
|
||||
for _, app := range apps {
|
||||
match := true
|
||||
|
||||
if organization != nil && *organization != "" {
|
||||
if !strings.Contains(strings.ToLower(app.Key.Organization), strings.ToLower(*organization)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match && name != nil && *name != "" {
|
||||
if !strings.Contains(strings.ToLower(app.Key.Name), strings.ToLower(*name)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match && version != nil && *version != "" {
|
||||
if !strings.Contains(strings.ToLower(app.Key.Version), strings.ToLower(*version)) {
|
||||
match = false
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
filtered = append(filtered, app)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Apps Tool Registrations
|
||||
|
||||
func registerCreateAppTool(s *mcp.Server) {
|
||||
type args struct {
|
||||
Organization string `json:"organization" jsonschema:"Organization name"`
|
||||
Name string `json:"name" jsonschema:"Application name"`
|
||||
Version string `json:"version" jsonschema:"Application version (e.g. '1.0.0')"`
|
||||
Deployment string `json:"deployment" jsonschema:"Deployment type: 'docker' or 'kubernetes'"`
|
||||
ImageType *string `json:"image_type,omitempty" jsonschema:"Image type (default: 'ImageTypeDocker')"`
|
||||
ImagePath string `json:"image_path" jsonschema:"Docker registry URL (e.g. 'https://registry-1.docker.io/library/nginx:latest')"`
|
||||
AccessPorts *string `json:"access_ports,omitempty" jsonschema:"Access ports specification (e.g. 'tcp:80')"`
|
||||
DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"Default flavor name (e.g. 'EU.small')"`
|
||||
AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"Allow serverless deployment"`
|
||||
Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."`
|
||||
Organization string `json:"organization" jsonschema:"Organization name"`
|
||||
Name string `json:"name" jsonschema:"Application name"`
|
||||
Version string `json:"version" jsonschema:"Application version (e.g. '1.0.0')"`
|
||||
Deployment string `json:"deployment" jsonschema:"Deployment type: 'docker' or 'kubernetes'"`
|
||||
ImageType *string `json:"image_type,omitempty" jsonschema:"Image type (default: 'ImageTypeDocker')"`
|
||||
ImagePath string `json:"image_path" jsonschema:"Docker registry URL (e.g. 'https://registry-1.docker.io/library/nginx:latest')"`
|
||||
DeploymentManifest *string `json:"deployment_manifest,omitempty" jsonschema:"Kubernetes manifest (YAML) or Docker Compose file content (optional)"`
|
||||
AccessPorts *string `json:"access_ports,omitempty" jsonschema:"Access ports specification (e.g. 'tcp:80')"`
|
||||
DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"Default flavor name (e.g. 'EU.small')"`
|
||||
AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"Allow serverless deployment"`
|
||||
ServerlessMinReplicas *int `json:"serverless_min_replicas,omitempty" jsonschema:"Serverless minimum replicas (optional, e.g. 1)"`
|
||||
ServerlessRAM *int `json:"serverless_ram,omitempty" jsonschema:"Serverless RAM in MB (optional, e.g. 512)"`
|
||||
ServerlessVCPUs *int `json:"serverless_vcpus,omitempty" jsonschema:"Serverless vCPUs (optional, e.g. 2)"`
|
||||
Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."`
|
||||
}
|
||||
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: "create_app",
|
||||
Description: "Create a new Edge Connect application. Requires organization, name, version, deployment type, image path, and region.",
|
||||
Description: "Create a new Edge Connect application. Requires organization, name, version, deployment type, and image path. Optionally accepts deployment manifest for Kubernetes or Docker Compose configurations. Either default_flavor_name or serverless configuration (serverless_min_replicas, serverless_ram, serverless_vcpus) must be specified.",
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) {
|
||||
imageType := "ImageTypeDocker"
|
||||
if a.ImageType != nil {
|
||||
|
|
@ -44,6 +255,11 @@ func registerCreateAppTool(s *mcp.Server) {
|
|||
allowServerless = *a.AllowServerless
|
||||
}
|
||||
|
||||
deploymentManifest := ""
|
||||
if a.DeploymentManifest != nil {
|
||||
deploymentManifest = *a.DeploymentManifest
|
||||
}
|
||||
|
||||
region := config.DefaultRegion
|
||||
if a.Region != nil {
|
||||
region = *a.Region
|
||||
|
|
@ -55,17 +271,33 @@ func registerCreateAppTool(s *mcp.Server) {
|
|||
Name: a.Name,
|
||||
Version: a.Version,
|
||||
},
|
||||
Deployment: a.Deployment,
|
||||
ImageType: imageType,
|
||||
ImagePath: a.ImagePath,
|
||||
AccessPorts: accessPorts,
|
||||
AllowServerless: allowServerless,
|
||||
Deployment: a.Deployment,
|
||||
ImageType: imageType,
|
||||
ImagePath: a.ImagePath,
|
||||
DeploymentManifest: deploymentManifest,
|
||||
AccessPorts: accessPorts,
|
||||
AllowServerless: allowServerless,
|
||||
}
|
||||
|
||||
if a.DefaultFlavorName != nil && *a.DefaultFlavorName != "" {
|
||||
app.DefaultFlavor = v2.Flavor{Name: *a.DefaultFlavorName}
|
||||
}
|
||||
|
||||
// Build serverless config if any serverless parameters are provided
|
||||
if a.ServerlessMinReplicas != nil || a.ServerlessRAM != nil || a.ServerlessVCPUs != nil {
|
||||
serverlessConfig := v2.ServerlessConfig{}
|
||||
if a.ServerlessMinReplicas != nil {
|
||||
serverlessConfig.MinReplicas = *a.ServerlessMinReplicas
|
||||
}
|
||||
if a.ServerlessRAM != nil {
|
||||
serverlessConfig.RAM = *a.ServerlessRAM
|
||||
}
|
||||
if a.ServerlessVCPUs != nil {
|
||||
serverlessConfig.VCPUs = *a.ServerlessVCPUs
|
||||
}
|
||||
app.ServerlessConfig = serverlessConfig
|
||||
}
|
||||
|
||||
input := &v2.NewAppInput{
|
||||
Region: region,
|
||||
App: app,
|
||||
|
|
@ -115,8 +347,33 @@ func registerShowAppTool(s *mcp.Server) {
|
|||
return nil, nil, fmt.Errorf("failed to serialize app: %w", err)
|
||||
}
|
||||
|
||||
// Parse protocol from MCP initialize request
|
||||
protocol := getProtocolFromRequest(req)
|
||||
|
||||
// Create UI resource for app detail
|
||||
uiResource, err := createAppDetailUI(app, region, protocol)
|
||||
if err != nil {
|
||||
// If UI creation fails, just return text content
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: string(appJSON)}},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
// Convert UIResource to MCP EmbeddedResource
|
||||
resourceContents := &mcp.ResourceContents{
|
||||
URI: uiResource.Resource.URI,
|
||||
MIMEType: uiResource.Resource.MimeType,
|
||||
Text: uiResource.Resource.Text,
|
||||
Meta: uiResource.Resource.Meta,
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: string(appJSON)}},
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(appJSON)},
|
||||
&mcp.EmbeddedResource{
|
||||
Resource: resourceContents,
|
||||
},
|
||||
},
|
||||
}, nil, nil
|
||||
})
|
||||
}
|
||||
|
|
@ -131,66 +388,88 @@ func registerListAppsTool(s *mcp.Server) {
|
|||
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: "list_apps",
|
||||
Description: "List all Edge Connect applications matching the specified filter. Can filter by organization, name, or version pattern.",
|
||||
Description: "List all Edge Connect applications matching the specified filter. Supports partial (substring) matching for all filter fields.",
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) {
|
||||
organization := ""
|
||||
if a.Organization != nil {
|
||||
organization = *a.Organization
|
||||
}
|
||||
|
||||
name := ""
|
||||
if a.Name != nil {
|
||||
name = *a.Name
|
||||
}
|
||||
|
||||
version := ""
|
||||
if a.Version != nil {
|
||||
version = *a.Version
|
||||
}
|
||||
|
||||
region := config.DefaultRegion
|
||||
if a.Region != nil {
|
||||
region = *a.Region
|
||||
}
|
||||
|
||||
// Fetch all apps (empty filters) to enable client-side partial matching
|
||||
appKey := v2.AppKey{
|
||||
Organization: organization,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Organization: "",
|
||||
Name: "",
|
||||
Version: "",
|
||||
}
|
||||
|
||||
apps, err := edgeClient.ShowApps(ctx, appKey, region)
|
||||
allApps, err := edgeClient.ShowApps(ctx, appKey, region)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to list apps: %w", err)
|
||||
}
|
||||
|
||||
appsJSON, err := json.MarshalIndent(apps, "", " ")
|
||||
// Apply client-side partial matching filters
|
||||
apps := filterApps(allApps, a.Organization, a.Name, a.Version)
|
||||
|
||||
// Convert to lightweight DTOs for reduced token usage
|
||||
appListItems := convertToAppListItems(apps)
|
||||
appsJSON, err := json.MarshalIndent(appListItems, "", " ")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to serialize apps: %w", err)
|
||||
}
|
||||
|
||||
// Parse protocol from MCP initialize request
|
||||
protocol := getProtocolFromRequest(req)
|
||||
|
||||
// Create UI resource for apps list (uses full apps)
|
||||
uiResource, err := createAppListUI(apps, region, protocol)
|
||||
if err != nil {
|
||||
// If UI creation fails, just return text content
|
||||
result := fmt.Sprintf("Found %d apps:\n%s", len(apps), string(appsJSON))
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: result}},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Found %d apps:\n%s", len(apps), string(appsJSON))
|
||||
|
||||
// Convert UIResource to MCP EmbeddedResource
|
||||
resourceContents := &mcp.ResourceContents{
|
||||
URI: uiResource.Resource.URI,
|
||||
MIMEType: uiResource.Resource.MimeType,
|
||||
Text: uiResource.Resource.Text,
|
||||
Meta: uiResource.Resource.Meta,
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: result}},
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: result},
|
||||
&mcp.EmbeddedResource{
|
||||
Resource: resourceContents,
|
||||
},
|
||||
},
|
||||
}, nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
func registerUpdateAppTool(s *mcp.Server) {
|
||||
type args struct {
|
||||
Organization string `json:"organization" jsonschema:"Organization name"`
|
||||
Name string `json:"name" jsonschema:"Application name"`
|
||||
Version string `json:"version" jsonschema:"Application version"`
|
||||
ImagePath *string `json:"image_path,omitempty" jsonschema:"New Docker registry URL (optional)"`
|
||||
AccessPorts *string `json:"access_ports,omitempty" jsonschema:"New access ports specification (optional)"`
|
||||
DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"New default flavor name (optional)"`
|
||||
AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"New serverless setting (optional)"`
|
||||
Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."`
|
||||
Organization string `json:"organization" jsonschema:"Organization name"`
|
||||
Name string `json:"name" jsonschema:"Application name"`
|
||||
Version string `json:"version" jsonschema:"Application version"`
|
||||
ImagePath *string `json:"image_path,omitempty" jsonschema:"New Docker registry URL (optional)"`
|
||||
DeploymentManifest *string `json:"deployment_manifest,omitempty" jsonschema:"New Kubernetes manifest (YAML) or Docker Compose file content (optional)"`
|
||||
AccessPorts *string `json:"access_ports,omitempty" jsonschema:"New access ports specification (optional)"`
|
||||
DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"New default flavor name (optional)"`
|
||||
AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"New serverless setting (optional)"`
|
||||
ServerlessMinReplicas *int `json:"serverless_min_replicas,omitempty" jsonschema:"New serverless minimum replicas (optional)"`
|
||||
ServerlessRAM *int `json:"serverless_ram,omitempty" jsonschema:"New serverless RAM in MB (optional)"`
|
||||
ServerlessVCPUs *int `json:"serverless_vcpus,omitempty" jsonschema:"New serverless vCPUs (optional)"`
|
||||
Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."`
|
||||
}
|
||||
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: "update_app",
|
||||
Description: "Update an existing Edge Connect application. Only specified fields will be updated.",
|
||||
Description: "Update an existing Edge Connect application. Only specified fields will be updated. Supports updating deployment manifest, image path, access ports, flavor, serverless settings, and serverless configuration (min_replicas, ram, vcpus).",
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) {
|
||||
region := config.DefaultRegion
|
||||
if a.Region != nil {
|
||||
|
|
@ -215,6 +494,11 @@ func registerUpdateAppTool(s *mcp.Server) {
|
|||
fields = append(fields, v2.AppFieldImagePath)
|
||||
}
|
||||
|
||||
if a.DeploymentManifest != nil && *a.DeploymentManifest != "" {
|
||||
currentApp.DeploymentManifest = *a.DeploymentManifest
|
||||
fields = append(fields, v2.AppFieldDeploymentManifest)
|
||||
}
|
||||
|
||||
if a.AccessPorts != nil && *a.AccessPorts != "" {
|
||||
currentApp.AccessPorts = *a.AccessPorts
|
||||
fields = append(fields, v2.AppFieldAccessPorts)
|
||||
|
|
@ -230,6 +514,31 @@ func registerUpdateAppTool(s *mcp.Server) {
|
|||
fields = append(fields, v2.AppFieldAllowServerless)
|
||||
}
|
||||
|
||||
// Update serverless config if any serverless parameters are provided
|
||||
if a.ServerlessMinReplicas != nil || a.ServerlessRAM != nil || a.ServerlessVCPUs != nil {
|
||||
// Get existing serverless config or create new one
|
||||
serverlessConfig := v2.ServerlessConfig{}
|
||||
|
||||
// Try to preserve existing config by converting through JSON
|
||||
if configBytes, err := json.Marshal(currentApp.ServerlessConfig); err == nil {
|
||||
_ = json.Unmarshal(configBytes, &serverlessConfig)
|
||||
}
|
||||
|
||||
// Update provided fields
|
||||
if a.ServerlessMinReplicas != nil {
|
||||
serverlessConfig.MinReplicas = *a.ServerlessMinReplicas
|
||||
}
|
||||
if a.ServerlessRAM != nil {
|
||||
serverlessConfig.RAM = *a.ServerlessRAM
|
||||
}
|
||||
if a.ServerlessVCPUs != nil {
|
||||
serverlessConfig.VCPUs = *a.ServerlessVCPUs
|
||||
}
|
||||
|
||||
currentApp.ServerlessConfig = serverlessConfig
|
||||
fields = append(fields, v2.AppFieldServerlessConfig)
|
||||
}
|
||||
|
||||
if len(fields) == 0 {
|
||||
return nil, nil, fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
|
@ -263,7 +572,7 @@ func registerDeleteAppTool(s *mcp.Server) {
|
|||
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: "delete_app",
|
||||
Description: "Delete an Edge Connect application. This operation is idempotent (safe to call multiple times).",
|
||||
Description: "Delete an Edge Connect application. This operation is idempotent (safe to call multiple times). Apps can only be deleted if all app instances referencing the app has been deleted.",
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) {
|
||||
region := config.DefaultRegion
|
||||
if a.Region != nil {
|
||||
|
|
@ -436,76 +745,74 @@ func registerListAppInstancesTool(s *mcp.Server) {
|
|||
|
||||
mcp.AddTool(s, &mcp.Tool{
|
||||
Name: "list_app_instances",
|
||||
Description: "List all Edge Connect application instances matching the specified filter.",
|
||||
Description: "List all Edge Connect application instances matching the specified filter. Supports partial (substring) matching for all filter fields.",
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) {
|
||||
organization := ""
|
||||
if a.Organization != nil {
|
||||
organization = *a.Organization
|
||||
}
|
||||
|
||||
instanceName := ""
|
||||
if a.InstanceName != nil {
|
||||
instanceName = *a.InstanceName
|
||||
}
|
||||
|
||||
cloudletOrg := ""
|
||||
if a.CloudletOrg != nil {
|
||||
cloudletOrg = *a.CloudletOrg
|
||||
}
|
||||
|
||||
cloudletName := ""
|
||||
if a.CloudletName != nil {
|
||||
cloudletName = *a.CloudletName
|
||||
}
|
||||
|
||||
appOrg := ""
|
||||
if a.AppOrg != nil {
|
||||
appOrg = *a.AppOrg
|
||||
}
|
||||
|
||||
appName := ""
|
||||
if a.AppName != nil {
|
||||
appName = *a.AppName
|
||||
}
|
||||
|
||||
appVersion := ""
|
||||
if a.AppVersion != nil {
|
||||
appVersion = *a.AppVersion
|
||||
}
|
||||
|
||||
region := config.DefaultRegion
|
||||
if a.Region != nil {
|
||||
region = *a.Region
|
||||
}
|
||||
|
||||
// Fetch all instances (empty filters) to enable client-side partial matching
|
||||
appInstKey := v2.AppInstanceKey{
|
||||
Organization: organization,
|
||||
Name: instanceName,
|
||||
Organization: "",
|
||||
Name: "",
|
||||
CloudletKey: v2.CloudletKey{
|
||||
Organization: cloudletOrg,
|
||||
Name: cloudletName,
|
||||
Organization: "",
|
||||
Name: "",
|
||||
},
|
||||
}
|
||||
|
||||
appKey := v2.AppKey{
|
||||
Organization: appOrg,
|
||||
Name: appName,
|
||||
Version: appVersion,
|
||||
Organization: "",
|
||||
Name: "",
|
||||
Version: "",
|
||||
}
|
||||
|
||||
appInsts, err := edgeClient.ShowAppInstances(ctx, appInstKey, appKey, region)
|
||||
allAppInsts, err := edgeClient.ShowAppInstances(ctx, appInstKey, appKey, region)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to list app instances: %w", err)
|
||||
}
|
||||
|
||||
appInstsJSON, err := json.MarshalIndent(appInsts, "", " ")
|
||||
// Apply client-side partial matching filters
|
||||
appInsts := filterAppInstances(allAppInsts, a.Organization, a.InstanceName, a.CloudletOrg, a.CloudletName, a.AppOrg, a.AppName, a.AppVersion)
|
||||
|
||||
// Convert to lightweight DTOs for reduced token usage
|
||||
appInstListItems := convertToAppInstanceListItems(appInsts)
|
||||
appInstsJSON, err := json.MarshalIndent(appInstListItems, "", " ")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to serialize app instances: %w", err)
|
||||
}
|
||||
|
||||
// Parse protocol from MCP initialize request
|
||||
protocol := getProtocolFromRequest(req)
|
||||
|
||||
// Create UI resource for app instances list (uses full instances)
|
||||
uiResource, err := createAppInstanceListUI(appInsts, region, protocol)
|
||||
if err != nil {
|
||||
// If UI creation fails, just return text content
|
||||
result := fmt.Sprintf("Found %d app instances:\n%s", len(appInsts), string(appInstsJSON))
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: result}},
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Found %d app instances:\n%s", len(appInsts), string(appInstsJSON))
|
||||
|
||||
// Convert UIResource to MCP EmbeddedResource
|
||||
resourceContents := &mcp.ResourceContents{
|
||||
URI: uiResource.Resource.URI,
|
||||
MIMEType: uiResource.Resource.MimeType,
|
||||
Text: uiResource.Resource.Text,
|
||||
Meta: uiResource.Resource.Meta,
|
||||
}
|
||||
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{&mcp.TextContent{Text: result}},
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: result},
|
||||
&mcp.EmbeddedResource{
|
||||
Resource: resourceContents,
|
||||
},
|
||||
},
|
||||
}, nil, nil
|
||||
})
|
||||
}
|
||||
|
|
|
|||
907
ui.go
Normal file
907
ui.go
Normal file
|
|
@ -0,0 +1,907 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2"
|
||||
mcpuiserver "github.com/MCP-UI-Org/mcp-ui/sdks/go/server"
|
||||
)
|
||||
|
||||
// createAppListUI generates an interactive UI for listing apps
|
||||
func createAppListUI(apps []v2.App, region string, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) {
|
||||
htmlContent := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edge Connect Applications</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header h1 {
|
||||
color: #2d3748;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.header .region {
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-card .label {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-card .value {
|
||||
color: #2d3748;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.apps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.app-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.app-card .app-name {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #2d3748;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.app-card .app-org {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.app-card .app-version {
|
||||
display: inline-block;
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.app-card .detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.app-card .detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.app-card .detail-label {
|
||||
color: #718096;
|
||||
font-weight: 500;
|
||||
}
|
||||
.app-card .detail-value {
|
||||
color: #2d3748;
|
||||
font-weight: 400;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.docker {
|
||||
background: #bee3f8;
|
||||
color: #2c5282;
|
||||
}
|
||||
.badge.kubernetes {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
.badge.serverless {
|
||||
background: #fef5e7;
|
||||
color: #975a16;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #f56565;
|
||||
color: white;
|
||||
}
|
||||
.empty-state {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.empty-state h2 {
|
||||
color: #2d3748;
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.empty-state p {
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
function sendMessage(type, payload) {
|
||||
const messageId = 'msg-' + Date.now();
|
||||
console.log('Sending:', type, payload);
|
||||
window.parent.postMessage({ type, messageId, payload }, '*');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Attach event listeners to all view buttons
|
||||
document.querySelectorAll('.btn-view-app').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const card = this.closest('.app-card');
|
||||
const org = card.dataset.org;
|
||||
const name = card.dataset.name;
|
||||
const version = card.dataset.version;
|
||||
|
||||
console.log('View app clicked:', org, name, version);
|
||||
sendMessage('tool', {
|
||||
toolName: 'show_app',
|
||||
params: {
|
||||
organization: org,
|
||||
name: name,
|
||||
version: version
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Attach event listeners to all delete buttons
|
||||
document.querySelectorAll('.btn-delete-app').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const card = this.closest('.app-card');
|
||||
const org = card.dataset.org;
|
||||
const name = card.dataset.name;
|
||||
const version = card.dataset.version;
|
||||
|
||||
console.log('Delete app clicked:', org, name, version);
|
||||
sendMessage('tool', {
|
||||
toolName: 'delete_app',
|
||||
params: {
|
||||
organization: org,
|
||||
name: name,
|
||||
version: version
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Edge Connect Applications</h1>
|
||||
<span class="region">Region: %s</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Applications</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Docker Apps</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Kubernetes Apps</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Serverless Enabled</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
</div>
|
||||
`, region, len(apps), countDeploymentType(apps, "docker"), countDeploymentType(apps, "kubernetes"), countServerlessApps(apps))
|
||||
|
||||
if len(apps) == 0 {
|
||||
htmlContent += `
|
||||
<div class="empty-state">
|
||||
<h2>No Applications Found</h2>
|
||||
<p>Start by creating your first Edge Connect application</p>
|
||||
</div>
|
||||
`
|
||||
} else {
|
||||
htmlContent += ` <div class="apps-grid">`
|
||||
for _, app := range apps {
|
||||
htmlContent += generateAppCard(app)
|
||||
}
|
||||
htmlContent += ` </div>`
|
||||
}
|
||||
|
||||
htmlContent += `
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
resource, err := mcpuiserver.CreateUIResource(
|
||||
"ui://apps-dashboard",
|
||||
&mcpuiserver.RawHTMLPayload{
|
||||
Type: mcpuiserver.ContentTypeRawHTML,
|
||||
HTMLString: htmlContent,
|
||||
},
|
||||
mcpuiserver.EncodingText,
|
||||
mcpuiserver.WithProtocol(protocol),
|
||||
mcpuiserver.WithUIMetadata(map[string]any{
|
||||
"preferred-frame-size": []string{"800px", "600px"},
|
||||
}),
|
||||
)
|
||||
|
||||
return resource, err
|
||||
}
|
||||
|
||||
// generateAppCard creates HTML for a single app card
|
||||
func generateAppCard(app v2.App) string {
|
||||
deploymentBadge := fmt.Sprintf(`<span class="badge docker">%s</span>`, strings.ToUpper(app.Deployment))
|
||||
if strings.ToLower(app.Deployment) == "kubernetes" {
|
||||
deploymentBadge = fmt.Sprintf(`<span class="badge kubernetes">%s</span>`, strings.ToUpper(app.Deployment))
|
||||
}
|
||||
|
||||
serverlessBadge := ""
|
||||
if app.AllowServerless {
|
||||
serverlessBadge = ` <span class="badge serverless">SERVERLESS</span>`
|
||||
}
|
||||
|
||||
imagePath := html.EscapeString(app.ImagePath)
|
||||
if len(imagePath) > 50 {
|
||||
imagePath = imagePath[:47] + "..."
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`
|
||||
<div class="app-card" data-org="%s" data-name="%s" data-version="%s">
|
||||
<div class="app-name">%s</div>
|
||||
<div class="app-org">%s</div>
|
||||
<div class="app-version">v%s</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Deployment</span>
|
||||
<span class="detail-value">%s%s</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Image</span>
|
||||
<span class="detail-value" title="%s">%s</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Ports</span>
|
||||
<span class="detail-value">%s</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-view-app">View Details</button>
|
||||
<button class="btn btn-danger btn-delete-app">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
html.EscapeString(app.Key.Organization),
|
||||
html.EscapeString(app.Key.Name),
|
||||
html.EscapeString(app.Key.Version),
|
||||
html.EscapeString(app.Key.Name),
|
||||
html.EscapeString(app.Key.Organization),
|
||||
html.EscapeString(app.Key.Version),
|
||||
deploymentBadge,
|
||||
serverlessBadge,
|
||||
html.EscapeString(app.ImagePath),
|
||||
imagePath,
|
||||
getAccessPorts(app.AccessPorts),
|
||||
)
|
||||
}
|
||||
|
||||
// createAppDetailUI generates a detailed view for a single app
|
||||
func createAppDetailUI(app v2.App, region string, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) {
|
||||
appJSON, _ := json.MarshalIndent(app, "", " ")
|
||||
|
||||
htmlContent := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s - App Details</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #2d3748;
|
||||
font-size: 28px;
|
||||
}
|
||||
.back-btn {
|
||||
background: #e2e8f0;
|
||||
color: #2d3748;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 16px 24px;
|
||||
}
|
||||
.detail-label {
|
||||
color: #718096;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-value {
|
||||
color: #2d3748;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.json-viewer {
|
||||
background: #2d3748;
|
||||
color: #68d391;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.success {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
.badge.info {
|
||||
background: #bee3f8;
|
||||
color: #2c5282;
|
||||
}
|
||||
.badge.warning {
|
||||
background: #fef5e7;
|
||||
color: #975a16;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>%s</h1>
|
||||
<a href="#" class="back-btn" onclick="history.back()">← Back to Apps</a>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-label">Organization</div>
|
||||
<div class="detail-value">%s</div>
|
||||
<div class="detail-label">Name</div>
|
||||
<div class="detail-value">%s</div>
|
||||
<div class="detail-label">Version</div>
|
||||
<div class="detail-value">%s</div>
|
||||
<div class="detail-label">Region</div>
|
||||
<div class="detail-value">%s</div>
|
||||
<div class="detail-label">Deployment</div>
|
||||
<div class="detail-value"><span class="badge info">%s</span></div>
|
||||
<div class="detail-label">Image Type</div>
|
||||
<div class="detail-value">%s</div>
|
||||
<div class="detail-label">Image Path</div>
|
||||
<div class="detail-value">%s</div>
|
||||
<div class="detail-label">Access Ports</div>
|
||||
<div class="detail-value">%s</div>
|
||||
<div class="detail-label">Serverless</div>
|
||||
<div class="detail-value"><span class="badge %s">%s</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 16px; color: #2d3748;">Raw JSON</h2>
|
||||
<div class="json-viewer">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
html.EscapeString(app.Key.Name),
|
||||
html.EscapeString(app.Key.Name),
|
||||
html.EscapeString(app.Key.Organization),
|
||||
html.EscapeString(app.Key.Name),
|
||||
html.EscapeString(app.Key.Version),
|
||||
html.EscapeString(region),
|
||||
html.EscapeString(strings.ToUpper(app.Deployment)),
|
||||
html.EscapeString(app.ImageType),
|
||||
html.EscapeString(app.ImagePath),
|
||||
getAccessPorts(app.AccessPorts),
|
||||
getServerlessBadgeClass(app.AllowServerless),
|
||||
getServerlessStatus(app.AllowServerless),
|
||||
html.EscapeString(string(appJSON)),
|
||||
)
|
||||
|
||||
resource, err := mcpuiserver.CreateUIResource(
|
||||
fmt.Sprintf("ui://app-detail/%s/%s/%s", app.Key.Organization, app.Key.Name, app.Key.Version),
|
||||
&mcpuiserver.RawHTMLPayload{
|
||||
Type: mcpuiserver.ContentTypeRawHTML,
|
||||
HTMLString: htmlContent,
|
||||
},
|
||||
mcpuiserver.EncodingText,
|
||||
mcpuiserver.WithProtocol(protocol),
|
||||
mcpuiserver.WithUIMetadata(map[string]any{
|
||||
"preferred-frame-size": []string{"800px", "600px"},
|
||||
}),
|
||||
)
|
||||
|
||||
return resource, err
|
||||
}
|
||||
|
||||
// createAppInstanceListUI generates an interactive UI for listing app instances
|
||||
func createAppInstanceListUI(instances []v2.AppInstance, region string, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) {
|
||||
htmlContent := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edge Connect App Instances</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #4f46e5 0%%, #7c3aed 100%%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header h1 {
|
||||
color: #2d3748;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.header .region {
|
||||
display: inline-block;
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-card .label {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-card .value {
|
||||
color: #2d3748;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.instances-table {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
table {
|
||||
width: 100%%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead {
|
||||
background: #f7fafc;
|
||||
}
|
||||
th {
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
color: #2d3748;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
td {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
tr:hover {
|
||||
background: #f7fafc;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.running {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
.badge.stopped {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
}
|
||||
.badge.unknown {
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.btn-sm {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #f56565;
|
||||
color: white;
|
||||
}
|
||||
.empty-state {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.empty-state h2 {
|
||||
color: #2d3748;
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.empty-state p {
|
||||
color: #718096;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
function sendMessage(type, payload) {
|
||||
const messageId = 'msg-' + Date.now();
|
||||
console.log('Sending:', type, payload);
|
||||
window.parent.postMessage({ type, messageId, payload }, '*');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Attach event listeners to all view buttons
|
||||
document.querySelectorAll('.btn-view-instance').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const org = row.dataset.org;
|
||||
const name = row.dataset.name;
|
||||
const cloudletOrg = row.dataset.cloudletOrg;
|
||||
const cloudletName = row.dataset.cloudletName;
|
||||
|
||||
console.log('View instance clicked:', org, name, cloudletOrg, cloudletName);
|
||||
sendMessage('tool', {
|
||||
toolName: 'show_app_instance',
|
||||
params: {
|
||||
organization: org,
|
||||
instance_name: name,
|
||||
cloudlet_org: cloudletOrg,
|
||||
cloudlet_name: cloudletName
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Attach event listeners to all delete buttons
|
||||
document.querySelectorAll('.btn-delete-instance').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const row = this.closest('tr');
|
||||
const org = row.dataset.org;
|
||||
const name = row.dataset.name;
|
||||
const cloudletOrg = row.dataset.cloudletOrg;
|
||||
const cloudletName = row.dataset.cloudletName;
|
||||
|
||||
console.log('Delete instance clicked:', org, name, cloudletOrg, cloudletName);
|
||||
sendMessage('tool', {
|
||||
toolName: 'delete_app_instance',
|
||||
params: {
|
||||
organization: org,
|
||||
instance_name: name,
|
||||
cloudlet_org: cloudletOrg,
|
||||
cloudlet_name: cloudletName
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Application Instances</h1>
|
||||
<span class="region">Region: %s</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Instances</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Running</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Stopped</div>
|
||||
<div class="value">%d</div>
|
||||
</div>
|
||||
</div>
|
||||
`, region, len(instances), countPowerState(instances, "PowerOn"), countPowerState(instances, "PowerOff"))
|
||||
|
||||
if len(instances) == 0 {
|
||||
htmlContent += `
|
||||
<div class="empty-state">
|
||||
<h2>No App Instances Found</h2>
|
||||
<p>Deploy your first application instance to get started</p>
|
||||
</div>
|
||||
`
|
||||
} else {
|
||||
htmlContent += `
|
||||
<div class="instances-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instance Name</th>
|
||||
<th>Organization</th>
|
||||
<th>Cloudlet</th>
|
||||
<th>Application</th>
|
||||
<th>Status</th>
|
||||
<th>Flavor</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`
|
||||
for _, inst := range instances {
|
||||
htmlContent += generateInstanceRow(inst)
|
||||
}
|
||||
htmlContent += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
htmlContent += `
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
resource, err := mcpuiserver.CreateUIResource(
|
||||
"ui://app-instances-dashboard",
|
||||
&mcpuiserver.RawHTMLPayload{
|
||||
Type: mcpuiserver.ContentTypeRawHTML,
|
||||
HTMLString: htmlContent,
|
||||
},
|
||||
mcpuiserver.EncodingText,
|
||||
mcpuiserver.WithProtocol(protocol),
|
||||
mcpuiserver.WithUIMetadata(map[string]any{
|
||||
"preferred-frame-size": []string{"800px", "600px"},
|
||||
}),
|
||||
)
|
||||
|
||||
return resource, err
|
||||
}
|
||||
|
||||
// generateInstanceRow creates HTML for a single instance table row
|
||||
func generateInstanceRow(inst v2.AppInstance) string {
|
||||
var statusBadge string
|
||||
switch inst.PowerState {
|
||||
case "PowerOn":
|
||||
statusBadge = `<span class="badge running">RUNNING</span>`
|
||||
case "PowerOff":
|
||||
statusBadge = `<span class="badge stopped">STOPPED</span>`
|
||||
default:
|
||||
statusBadge = `<span class="badge unknown">UNKNOWN</span>`
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`
|
||||
<tr data-org="%s" data-name="%s" data-cloudlet-org="%s" data-cloudlet-name="%s">
|
||||
<td><strong>%s</strong></td>
|
||||
<td>%s</td>
|
||||
<td>%s/%s</td>
|
||||
<td>%s:%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-view-instance">View</button>
|
||||
<button class="btn btn-danger btn-delete-instance">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
html.EscapeString(inst.Key.Organization),
|
||||
html.EscapeString(inst.Key.Name),
|
||||
html.EscapeString(inst.Key.CloudletKey.Organization),
|
||||
html.EscapeString(inst.Key.CloudletKey.Name),
|
||||
html.EscapeString(inst.Key.Name),
|
||||
html.EscapeString(inst.Key.Organization),
|
||||
html.EscapeString(inst.Key.CloudletKey.Organization),
|
||||
html.EscapeString(inst.Key.CloudletKey.Name),
|
||||
html.EscapeString(inst.AppKey.Name),
|
||||
html.EscapeString(inst.AppKey.Version),
|
||||
statusBadge,
|
||||
html.EscapeString(inst.Flavor.Name),
|
||||
)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func countDeploymentType(apps []v2.App, deploymentType string) int {
|
||||
count := 0
|
||||
for _, app := range apps {
|
||||
if strings.EqualFold(app.Deployment, deploymentType) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countServerlessApps(apps []v2.App) int {
|
||||
count := 0
|
||||
for _, app := range apps {
|
||||
if app.AllowServerless {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countPowerState(instances []v2.AppInstance, state string) int {
|
||||
count := 0
|
||||
for _, inst := range instances {
|
||||
if inst.PowerState == state {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func getAccessPorts(ports string) string {
|
||||
if ports == "" {
|
||||
return "None"
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func getServerlessBadgeClass(allowServerless bool) string {
|
||||
if allowServerless {
|
||||
return "success"
|
||||
}
|
||||
return "warning"
|
||||
}
|
||||
|
||||
func getServerlessStatus(allowServerless bool) string {
|
||||
if allowServerless {
|
||||
return "ENABLED"
|
||||
}
|
||||
return "DISABLED"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue