From cfa9f465ed22451e8e89344fc796db59c13b7382 Mon Sep 17 00:00:00 2001 From: Pengfei Ni Date: Mon, 25 Aug 2025 15:55:46 +0800 Subject: [PATCH 01/10] remove: VMSS run-command tool for AKS policy compliance Remove VMSS run-command functionality that violates Azure support policies by enabling out-of-band VM manipulation outside AKS-supported paths. Changes: - Remove OpVMRunCommand and OpVMSSRunCommand operation types - Remove run-command from tool descriptions and examples - Remove run-command from access level validation and command mapping - Remove run-command test cases and error handling - Update documentation to remove run-command references --- README.md | 3 -- internal/components/compute/azcommands.go | 17 +++-------- internal/components/compute/executor.go | 8 ++--- internal/components/compute/unified_test.go | 6 ---- prompts/azure-vmss-tools.md | 34 +++------------------ 5 files changed, 12 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 2d48189..cac26bf 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,6 @@ Unified tool for Azure monitoring and diagnostics operations for AKS clusters. - Get detailed VMSS configuration for node pools in the AKS cluster -**Tool:** `az_vmss_run-command_invoke` *(readwrite/admin only)* - -- Execute commands on Virtual Machine Scale Set instances diff --git a/internal/components/compute/azcommands.go b/internal/components/compute/azcommands.go index 21b6add..a82d0ec 100644 --- a/internal/components/compute/azcommands.go +++ b/internal/components/compute/azcommands.go @@ -22,7 +22,6 @@ const ( OpVMStop ComputeOperationType = "stop" OpVMRestart ComputeOperationType = "restart" OpVMGetInstanceView ComputeOperationType = "get-instance-view" - OpVMRunCommand ComputeOperationType = "run-command" // VMSS operations - only safe operations for AKS-managed VMSS OpVMSSShow ComputeOperationType = "show" @@ -30,7 +29,6 @@ const ( OpVMSSRestart ComputeOperationType = "restart" OpVMSSReimage ComputeOperationType = "reimage" OpVMSSGetInstanceView ComputeOperationType = "get-instance-view" - OpVMSSRunCommand ComputeOperationType = "run-command" // Resource types ResourceTypeVM ResourceType = "vm" @@ -60,7 +58,6 @@ Available operation values:` desc += "- start: Start VM\n" desc += "- stop: Stop VM\n" desc += "- restart: Restart VM/VMSS instances\n" - desc += "- run-command: Execute commands remotely on VM/VMSS instances\n" desc += "- reimage: Reimage VMSS instances (VM not supported for reimage)\n" } @@ -76,8 +73,6 @@ Available operation values:` if accessLevel == "readwrite" || accessLevel == "admin" { desc += `Restart VMSS: operation="restart", resource_type="vmss", args="--name myVMSS --resource-group myRG"` + "\n" desc += `Reimage VMSS: operation="reimage", resource_type="vmss", args="--name myVMSS --resource-group myRG"` + "\n" - desc += `Run command on VM: operation="run-command", resource_type="vm", args="--name myVM --resource-group myRG --command-id RunShellScript --scripts 'echo hello'"` + "\n" - desc += `Run command on VMSS: operation="run-command", resource_type="vmss", args="--name myVMSS --resource-group myRG --command-id RunShellScript --scripts 'hostname' --instance-id 0"` + "\n" } return desc @@ -91,7 +86,7 @@ func RegisterAzComputeOperations(cfg *config.ConfigData) mcp.Tool { mcp.WithDescription(description), mcp.WithString("operation", mcp.Required(), - mcp.Description("Operation to perform. Common operations: list, show, start, stop, restart, deallocate, run-command, scale, etc."), + mcp.Description("Operation to perform. Common operations: list, show, start, stop, restart, deallocate, scale, etc."), ), mcp.WithString("resource_type", mcp.Required(), @@ -113,9 +108,9 @@ func GetOperationAccessLevel(operation string) string { readWriteOps := []string{ // VM operations - safe operations only - string(OpVMStart), string(OpVMStop), string(OpVMRestart), string(OpVMRunCommand), + string(OpVMStart), string(OpVMStop), string(OpVMRestart), // VMSS operations - only safe operations for AKS-managed VMSS - string(OpVMSSRestart), string(OpVMSSReimage), string(OpVMSSRunCommand), + string(OpVMSSRestart), string(OpVMSSReimage), } // No admin operations - all unsafe operations removed @@ -174,7 +169,6 @@ func MapOperationToCommand(operation string, resourceType string) (string, error string(OpVMStop): "az vm stop", string(OpVMRestart): "az vm restart", string(OpVMGetInstanceView): "az vm get-instance-view", - string(OpVMRunCommand): "az vm run-command invoke", }, string(ResourceTypeVMSS): { // Read-only operations @@ -182,9 +176,8 @@ func MapOperationToCommand(operation string, resourceType string) (string, error string(OpVMSSList): "az vmss list", string(OpVMSSGetInstanceView): "az vmss get-instance-view", // Safe operations for AKS-managed VMSS - string(OpVMSSRestart): "az vmss restart", - string(OpVMSSReimage): "az vmss reimage", - string(OpVMSSRunCommand): "az vmss run-command invoke", + string(OpVMSSRestart): "az vmss restart", + string(OpVMSSReimage): "az vmss reimage", // Removed unsafe operations: create, delete, start, stop, deallocate, scale, update }, } diff --git a/internal/components/compute/executor.go b/internal/components/compute/executor.go index 916119d..53b3637 100644 --- a/internal/components/compute/executor.go +++ b/internal/components/compute/executor.go @@ -22,7 +22,7 @@ func (e *ComputeOperationsExecutor) Execute(params map[string]interface{}, cfg * // Parse operation parameter operation, ok := params["operation"].(string) if !ok { - return "", fmt.Errorf("missing or invalid 'operation' parameter. Common operations: list, show, start, stop, restart, run-command, reimage. Example: operation=\"list\"") + return "", fmt.Errorf("missing or invalid 'operation' parameter. Common operations: list, show, start, stop, restart, reimage. Example: operation=\"list\"") } // Parse resource_type parameter @@ -102,8 +102,6 @@ func (e *ComputeOperationsExecutor) Execute(params map[string]interface{}, cfg * errorMsg += "\nTip: Verify the resource exists and check if it's already in the desired state" case "reimage": errorMsg += "\nTip: Verify the VMSS name is correct and the instances are ready for reimaging" - case "run-command": - errorMsg += "\nTip: Ensure the resource is running and the command syntax is correct. Use --command-id RunShellScript for shell commands" } return "", fmt.Errorf("%s\nExecuted command: %s", errorMsg, fullCommand) @@ -124,10 +122,10 @@ func getSuggestedOperations(resourceType, accessLevel string) string { switch resourceType { case "vm": // Only safe VM operations - operations = append(operations, "start", "stop", "restart", "run-command") + operations = append(operations, "start", "stop", "restart") case "vmss": // Only safe operations for AKS-managed VMSS - operations = append(operations, "restart", "reimage", "run-command") + operations = append(operations, "restart", "reimage") } } diff --git a/internal/components/compute/unified_test.go b/internal/components/compute/unified_test.go index bdcb9ce..cdb117e 100644 --- a/internal/components/compute/unified_test.go +++ b/internal/components/compute/unified_test.go @@ -53,9 +53,6 @@ func TestValidateOperationAccess(t *testing.T) { {"restart", "readonly", false}, {"restart", "readwrite", true}, {"restart", "admin", true}, - {"run-command", "readonly", false}, - {"run-command", "readwrite", true}, - {"run-command", "admin", true}, {"reimage", "readonly", false}, {"reimage", "readwrite", true}, {"reimage", "admin", true}, @@ -89,13 +86,11 @@ func TestMapOperationToCommand(t *testing.T) { // VM operations {"show", "vm", "az vm show", true}, {"start", "vm", "az vm start", true}, - {"run-command", "vm", "az vm run-command invoke", true}, // VMSS operations {"show", "vmss", "az vmss show", true}, {"restart", "vmss", "az vmss restart", true}, {"reimage", "vmss", "az vmss reimage", true}, - {"run-command", "vmss", "az vmss run-command invoke", true}, // Scale operation removed - not safe for AKS-managed VMSS // Invalid resource types @@ -140,7 +135,6 @@ func TestGetOperationAccessLevel(t *testing.T) { {"stop", "readwrite"}, {"restart", "readwrite"}, {"reimage", "readwrite"}, - {"run-command", "readwrite"}, // Unknown operations {"invalid-op", "unknown"}, diff --git a/prompts/azure-vmss-tools.md b/prompts/azure-vmss-tools.md index 5db4f8b..87a60c0 100644 --- a/prompts/azure-vmss-tools.md +++ b/prompts/azure-vmss-tools.md @@ -35,35 +35,13 @@ Get detailed Virtual Machine Scale Set (VMSS) information for AKS node pools and - Per-node pool VMSS details or error messages - Complete VMSS configuration for each successfully retrieved node pool -### `az_vmss_run-command_invoke` - -**Purpose**: Execute commands on Virtual Machine Scale Set instances (AKS node pools) - -**Parameters**: -- `args` (required): Arguments for the `az vmss run-command invoke` command - -**Example Usage**: -``` ---name myVMSS --resource-group myResourceGroup --command-id RunShellScript --scripts 'echo Hello World' --instance-ids 0 1 -``` - -**Returns**: Command execution results from the specified VMSS instances: -- Exit codes and status for each instance -- Command output (stdout/stderr) -- Execution timestamps and duration -- Error messages if command fails - -**Access Level**: Requires `readwrite` or `admin` access level - ## Key Use Cases 1. **Troubleshooting Node Issues**: Get detailed VM configuration when nodes aren't behaving as expected -2. **Remote Command Execution**: Execute diagnostic commands, collect logs, or perform maintenance tasks on AKS nodes -3. **Security Auditing**: Review VM extensions, security settings, and network configurations -4. **Performance Analysis**: Check VM sizes, storage types, and networking setup -5. **Compliance Checking**: Verify OS images, patches, and security configurations -6. **Resource Planning**: Understand current VM configurations for capacity planning -7. **Node Maintenance**: Run scripts to update configurations, restart services, or apply patches +2. **Security Auditing**: Review VM extensions, security settings, and network configurations +3. **Performance Analysis**: Check VM sizes, storage types, and networking setup +4. **Compliance Checking**: Verify OS images, patches, and security configurations +5. **Resource Planning**: Understand current VM configurations for capacity planning ## What You Get vs Standard AKS Commands @@ -80,7 +58,6 @@ Get detailed Virtual Machine Scale Set (VMSS) information for AKS node pools and - Load balancer backend pool memberships - Detailed OS and image information - Scaling and upgrade policies -- Remote command execution on VMSS instances ## Code Structure @@ -134,9 +111,6 @@ func RegisterAzComputeCommand(cmd ComputeCommand) mcp.Tool { func GetReadWriteVmssCommands() []ComputeCommand { return []ComputeCommand{ - {Name: "az vmss run-command invoke", - Description: "...", - ArgsExample: "..."}, } } ``` From fefd38cb3672c714471f220ca1a9211496e33d1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:22:05 +0000 Subject: [PATCH 02/10] chore(deps): bump the all-gomod group with 3 updates Bumps the all-gomod group with 3 updates: [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery), [k8s.io/cli-runtime](https://github.com/kubernetes/cli-runtime) and [k8s.io/client-go](https://github.com/kubernetes/client-go). Updates `k8s.io/apimachinery` from 0.33.4 to 0.34.0 - [Commits](https://github.com/kubernetes/apimachinery/compare/v0.33.4...v0.34.0) Updates `k8s.io/cli-runtime` from 0.33.4 to 0.34.0 - [Commits](https://github.com/kubernetes/cli-runtime/compare/v0.33.4...v0.34.0) Updates `k8s.io/client-go` from 0.33.4 to 0.34.0 - [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/kubernetes/client-go/compare/v0.33.4...v0.34.0) --- updated-dependencies: - dependency-name: k8s.io/apimachinery dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod - dependency-name: k8s.io/cli-runtime dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod - dependency-name: k8s.io/client-go dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod ... Signed-off-by: dependabot[bot] --- go.mod | 29 +++++++++++++++-------------- go.sum | 58 ++++++++++++++++++++++++++++------------------------------ 2 files changed, 43 insertions(+), 44 deletions(-) diff --git a/go.mod b/go.mod index 38e1fad..b00daa6 100644 --- a/go.mod +++ b/go.mod @@ -22,9 +22,9 @@ require ( go.opentelemetry.io/otel/sdk v1.37.0 go.opentelemetry.io/otel/trace v1.37.0 helm.sh/helm/v3 v3.18.6 - k8s.io/apimachinery v0.33.4 - k8s.io/cli-runtime v0.33.4 - k8s.io/client-go v0.33.4 + k8s.io/apimachinery v0.34.0 + k8s.io/cli-runtime v0.34.0 + k8s.io/client-go v0.34.0 ) require ( @@ -53,12 +53,12 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.13.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.8.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.4.2 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -72,7 +72,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -104,7 +104,7 @@ require ( github.com/moby/spdystream v0.5.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect @@ -114,6 +114,7 @@ require ( github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect @@ -136,7 +137,7 @@ require ( go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/net v0.42.0 // indirect @@ -153,19 +154,19 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.33.4 // indirect + k8s.io/api v0.34.0 // indirect k8s.io/apiextensions-apiserver v0.33.3 // indirect k8s.io/apiserver v0.33.3 // indirect k8s.io/component-base v0.33.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/kubectl v0.33.3 // indirect - k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect oras.land/oras-go/v2 v2.6.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect - sigs.k8s.io/kustomize/api v0.19.0 // indirect - sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index c9d4d08..8585934 100644 --- a/go.sum +++ b/go.sum @@ -111,8 +111,8 @@ github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= -github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= -github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= @@ -128,8 +128,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= -github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= @@ -166,9 +166,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -282,8 +281,9 @@ github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFL github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -436,8 +436,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= -go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -521,41 +521,39 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= helm.sh/helm/v3 v3.18.6 h1:S/2CqcYnNfLckkHLI0VgQbxgcDaU3N4A/46E3n9wSNY= helm.sh/helm/v3 v3.18.6/go.mod h1:L/dXDR2r539oPlFP1PJqKAC1CUgqHJDLkxKpDGrWnyg= -k8s.io/api v0.33.4 h1:oTzrFVNPXBjMu0IlpA2eDDIU49jsuEorGHB4cvKupkk= -k8s.io/api v0.33.4/go.mod h1:VHQZ4cuxQ9sCUMESJV5+Fe8bGnqAARZ08tSTdHWfeAc= +k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= +k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= k8s.io/apiextensions-apiserver v0.33.3 h1:qmOcAHN6DjfD0v9kxL5udB27SRP6SG/MTopmge3MwEs= k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8= -k8s.io/apimachinery v0.33.4 h1:SOf/JW33TP0eppJMkIgQ+L6atlDiP/090oaX0y9pd9s= -k8s.io/apimachinery v0.33.4/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= +k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4= k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E= -k8s.io/cli-runtime v0.33.4 h1:V8NSxGfh24XzZVhXmIGzsApdBpGq0RQS2u/Fz1GvJwk= -k8s.io/cli-runtime v0.33.4/go.mod h1:V+ilyokfqjT5OI+XE+O515K7jihtr0/uncwoyVqXaIU= -k8s.io/client-go v0.33.4 h1:TNH+CSu8EmXfitntjUPwaKVPN0AYMbc9F1bBS8/ABpw= -k8s.io/client-go v0.33.4/go.mod h1:LsA0+hBG2DPwovjd931L/AoaezMPX9CmBgyVyBZmbCY= +k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw= +k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8= +k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= +k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA= k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/kubectl v0.33.3 h1:r/phHvH1iU7gO/l7tTjQk2K01ER7/OAJi8uFHHyWSac= k8s.io/kubectl v0.33.3/go.mod h1:euj2bG56L6kUGOE/ckZbCoudPwuj4Kud7BR0GzyNiT0= -k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= -k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ= -sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o= -sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA= -sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From 75610036b5c2919b857d82589a11816c4f8e7921 Mon Sep 17 00:00:00 2001 From: Pengfei Ni Date: Fri, 29 Aug 2025 13:24:41 +0800 Subject: [PATCH 03/10] chore: bump k8s tools and fix the tool list in README (#192) mcp-kubernetes bumped to support switching kubectl context. --- README.md | 40 +++++++++++++++++++++++++++------------- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index cac26bf..3667405 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,21 @@ Unified tool for Azure monitoring and diagnostics operations for AKS clusters. - Get detailed VMSS configuration for node pools in the AKS cluster +**Tool:** `az_compute_operations` + +Unified tool for managing Azure Virtual Machines (VMs) and Virtual Machine Scale Sets (VMSS) used by AKS. + +**Available Operations:** + +- `show`: Get details of a VM/VMSS +- `list`: List VMs/VMSS in subscription or resource group +- `get-instance-view`: Get runtime status +- `start`: Start VM +- `stop`: Stop VM +- `restart`: Restart VM/VMSS instances +- `reimage`: Reimage VMSS instances (VM not supported for reimage) + +**Resource Types:** `vm` (single virtual machines), `vmss` (virtual machine scale sets) @@ -220,20 +235,19 @@ Retrieve and manage Azure Advisor recommendations for AKS clusters. *Note: kubectl commands are available with all access levels. Additional tools require explicit enablement via `--additional-tools`* -**kubectl Commands (Read-Only):** - -- `kubectl_get`, `kubectl_describe`, `kubectl_explain`, `kubectl_logs` -- `kubectl_api-resources`, `kubectl_api-versions`, `kubectl_diff` -- `kubectl_cluster-info`, `kubectl_top`, `kubectl_events`, `kubectl_auth` +**kubectl Tools (Unified Interface):** -**kubectl Commands (Read-Write/Admin):** - -- `kubectl_create`, `kubectl_delete`, `kubectl_apply`, `kubectl_expose`, - `kubectl_run` -- `kubectl_set`, `kubectl_rollout`, `kubectl_scale`, `kubectl_autoscale` -- `kubectl_label`, `kubectl_annotate`, `kubectl_patch`, `kubectl_replace` -- `kubectl_cp`, `kubectl_exec`, `kubectl_cordon`, `kubectl_uncordon` -- `kubectl_drain`, `kubectl_taint`, `kubectl_certificate` +- **Read-Only** (all access levels): + - `kubectl_resources`: View resources (get, describe) - filtered to read-only operations in readonly mode + - `kubectl_diagnostics`: Debug and diagnose (logs, events, top, exec, cp) + - `kubectl_cluster`: Cluster information (cluster-info, api-resources, api-versions, explain) + - `kubectl_config`: Configuration management (diff, auth, config) - filtered to read-only operations in readonly mode + +- **Read-Write/Admin** (`readwrite`/`admin` access levels): + - `kubectl_resources`: Full resource management (get, describe, create, delete, apply, patch, replace, cordon, uncordon, drain, taint) + - `kubectl_workloads`: Workload lifecycle (run, expose, scale, autoscale, rollout) + - `kubectl_metadata`: Metadata management (label, annotate, set) + - `kubectl_config`: Full configuration management (diff, auth, certificate, config) **Additional Tools (Optional):** diff --git a/go.mod b/go.mod index b00daa6..923da8a 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v2 v2.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/monitor/armmonitor v0.11.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.2.1 - github.com/Azure/mcp-kubernetes v0.0.8 + github.com/Azure/mcp-kubernetes v0.0.9 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/inspektor-gadget/inspektor-gadget v0.43.0 github.com/mark3labs/mcp-go v0.38.0 diff --git a/go.sum b/go.sum index 8585934..75b6404 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1. github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.1.1/go.mod h1:c/wcGeGx5FUPbM/JltUYHZcKmigwyVLJlDq+4HdtXaw= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Azure/mcp-kubernetes v0.0.8 h1:I12xT+EV0epjPR2CbXWIsYFjgAH9xeUUt2mKT5E6FnA= -github.com/Azure/mcp-kubernetes v0.0.8/go.mod h1:H+UCYBw8V3dJGRwB+l+0doPuI/r6n8o1Wlj1kwI++wk= +github.com/Azure/mcp-kubernetes v0.0.9 h1:IRVEAnJW8Eg1e4QTBiA3xSI8/vAdGeiuoq3bKfhrM1I= +github.com/Azure/mcp-kubernetes v0.0.9/go.mod h1:8B91zFpju5S65I93OUSg4Ek8D4JGOpy0vbbVWbO8YAY= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= From c8001662f2f1d438f3e3da67bec47482b8b24e0c Mon Sep 17 00:00:00 2001 From: Pengfei Ni Date: Fri, 29 Aug 2025 13:25:44 +0800 Subject: [PATCH 04/10] Fix README WSL guidance to prevent ENOENT errors (#193) - Replace ambiguous WSL note with clear guidance for two scenarios: * Windows host VS Code: Use "command": "wsl" * Remote-WSL (VS Code inside WSL): Use direct binary or bash wrapper - Add troubleshooting section for ENOENT errors - Prevent confusion that leads to spawn failures in Remote-WSL Fixes #190 --- README.md | 50 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3667405..6d31a20 100644 --- a/README.md +++ b/README.md @@ -323,20 +323,52 @@ Once started, the MCP server will appear in the **Copilot Chat: Configure Tools* Try a prompt like *"List all my AKS clusters"*, which will start using tools from the AKS-MCP server. -Note: -For VS Code that is runing inside of WSL environment, starting AKS MCP server could fail with error "Connection state: Error spawn /home/path/.vs-kubernetes/tools/aks-mcp/v0.0.3/aks-mcp ENOENT", it means VS code cannot find this file. The resolution is modifying the mcp.json file as following to make it running with wsl. +#### WSL Configuration + +The MCP configuration differs depending on whether VS Code is running on Windows or inside WSL: + +**🪟 Windows Host (VS Code on Windows)**: Use `"command": "wsl"` to invoke the WSL binary from Windows: ```json -"AKS MCP": { - "command": "wsl", - "args": [ - "", - "--transport", - "stdio" - ] +{ + "servers": { + "aks-mcp": { + "type": "stdio", + "command": "wsl", + "args": [ + "--", + "/home/you/.vs-kubernetes/tools/aks-mcp/aks-mcp", + "--transport", + "stdio" + ] + } + } } ``` +**🐧 Remote-WSL (VS Code running inside WSL)**: Call the binary directly or use a shell wrapper: + +```json +{ + "servers": { + "aks-mcp": { + "type": "stdio", + "command": "bash", + "args": [ + "-c", + "/home/you/.vs-kubernetes/tools/aks-mcp/aks-mcp --transport stdio" + ] + } + } +} +``` + +**🔧 Troubleshooting ENOENT Errors** + +If you see "spawn ENOENT" errors, verify your VS Code environment: +- **Windows host**: Check if the WSL binary path is correct and accessible via `wsl -- ls /path/to/aks-mcp` +- **Remote-WSL**: Do NOT use `"command": "wsl"` - use direct paths or bash wrapper as shown above + > **💡 Benefits**: The AKS extension handles binary downloads, updates, and configuration automatically, ensuring you always have the latest version with optimal settings. From 1b96870eb408f7f7e41832c5575774b64262842e Mon Sep 17 00:00:00 2001 From: Qasim Sarfraz Date: Mon, 1 Sep 2025 04:02:19 +0200 Subject: [PATCH 05/10] chore: Add note for container_user (#194) Signed-off-by: Qasim Sarfraz --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d31a20..67b0753 100644 --- a/README.md +++ b/README.md @@ -510,7 +510,11 @@ You can enable the [AKS-MCP server directly from MCP Toolkit](https://hub.docker - **azure_dir** `[REQUIRED]`: Path to your Azure credentials directory e.g `/home/user/.azure` (must be absolute – without `$HOME` or `~`) - **kubeconfig** `[REQUIRED]`: Path to your kubeconfig file e.g `/home/user/.kube/config` (must be absolute – without `$HOME` or `~`) - **access_level** `[REQUIRED]`: Set to `readonly`, `readwrite`, or `admin` as needed -7. You are now ready to use the AKS-MCP server with your [preferred MCP client](https://hub.docker.com/mcp/server/aks/manual), see an example [here](https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/#install-an-mcp-client). + - **container_user** `[OPTIONAL]`: Username or UID to run the container as (default is `mcp`), e.g. use `1000` to match your host user ID (see note below). Only needed if you are using docker engine on Linux. +7. You are now ready to use the AKS-MCP server with your [preferred MCP client](https://hub.docker.com/mcp/server/aks/manual), see an example [here](https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/#install-an-mcp-client). (requires `>= v0.16.0` for MCP gateway) + +> **Note**: When running the MCP gateway using Docker Engine, you have to set the `container_user` to match your host user ID (e.g using `id -u`) to ensure proper file permissions for accessing mounted volumes. +> On Docker Desktop, this is handled automatically if you use `desktop-*` contexts, confirmed by running `docker context ls`. On **Windows**, the Azure credentials won't work by default, but you have two options: From f22002b2e6c94f14f91e65d4f3853905a9ef602c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 09:35:49 +0800 Subject: [PATCH 06/10] chore(deps): bump the all-gomod group across 1 directory with 7 updates (#196) Bumps the all-gomod group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [github.com/inspektor-gadget/inspektor-gadget](https://github.com/inspektor-gadget/inspektor-gadget) | `0.43.0` | `0.44.1` | | [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) | `0.38.0` | `0.39.1` | | [github.com/spf13/pflag](https://github.com/spf13/pflag) | `1.0.7` | `1.0.10` | | [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | `1.37.0` | `1.38.0` | | [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://github.com/open-telemetry/opentelemetry-go) | `1.37.0` | `1.38.0` | Updates `github.com/inspektor-gadget/inspektor-gadget` from 0.43.0 to 0.44.1 - [Release notes](https://github.com/inspektor-gadget/inspektor-gadget/releases) - [Commits](https://github.com/inspektor-gadget/inspektor-gadget/compare/v0.43.0...v0.44.1) Updates `github.com/mark3labs/mcp-go` from 0.38.0 to 0.39.1 - [Release notes](https://github.com/mark3labs/mcp-go/releases) - [Commits](https://github.com/mark3labs/mcp-go/compare/v0.38.0...v0.39.1) Updates `github.com/spf13/pflag` from 1.0.7 to 1.0.10 - [Release notes](https://github.com/spf13/pflag/releases) - [Commits](https://github.com/spf13/pflag/compare/v1.0.7...v1.0.10) Updates `go.opentelemetry.io/otel` from 1.37.0 to 1.38.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.37.0...v1.38.0) Updates `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc` from 1.37.0 to 1.38.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.37.0...v1.38.0) Updates `go.opentelemetry.io/otel/sdk` from 1.37.0 to 1.38.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.37.0...v1.38.0) Updates `go.opentelemetry.io/otel/trace` from 1.37.0 to 1.38.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.37.0...v1.38.0) --- updated-dependencies: - dependency-name: github.com/inspektor-gadget/inspektor-gadget dependency-version: 0.44.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod - dependency-name: github.com/mark3labs/mcp-go dependency-version: 0.39.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod - dependency-name: github.com/spf13/pflag dependency-version: 1.0.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-gomod - dependency-name: go.opentelemetry.io/otel dependency-version: 1.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod - dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc dependency-version: 1.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod - dependency-name: go.opentelemetry.io/otel/sdk dependency-version: 1.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod - dependency-name: go.opentelemetry.io/otel/trace dependency-version: 1.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-gomod ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 48 +++++++++++------------ go.sum | 122 +++++++++++++++++++++++++++++---------------------------- 2 files changed, 86 insertions(+), 84 deletions(-) diff --git a/go.mod b/go.mod index 923da8a..f44e095 100644 --- a/go.mod +++ b/go.mod @@ -13,14 +13,14 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v2 v2.2.1 github.com/Azure/mcp-kubernetes v0.0.9 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 - github.com/inspektor-gadget/inspektor-gadget v0.43.0 - github.com/mark3labs/mcp-go v0.38.0 + github.com/inspektor-gadget/inspektor-gadget v0.44.1 + github.com/mark3labs/mcp-go v0.39.1 github.com/microsoft/ApplicationInsights-Go v0.4.4 - github.com/spf13/pflag v1.0.7 - go.opentelemetry.io/otel v1.37.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 - go.opentelemetry.io/otel/sdk v1.37.0 - go.opentelemetry.io/otel/trace v1.37.0 + github.com/spf13/pflag v1.0.10 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 helm.sh/helm/v3 v3.18.6 k8s.io/apimachinery v0.34.0 k8s.io/cli-runtime v0.34.0 @@ -44,7 +44,7 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect github.com/cilium/ebpf v0.19.1-0.20250729164112-d994daa25101 // indirect github.com/containerd/containerd v1.7.28 // indirect @@ -78,7 +78,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/huandu/xstrings v1.5.0 // indirect @@ -132,32 +132,32 @@ require ( github.com/xlab/treeprint v1.2.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.40.0 // indirect + golang.org/x/crypto v0.41.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/net v0.42.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/term v0.33.0 // indirect - golang.org/x/text v0.27.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.12.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.0 // indirect - k8s.io/apiextensions-apiserver v0.33.3 // indirect - k8s.io/apiserver v0.33.3 // indirect - k8s.io/component-base v0.33.3 // indirect + k8s.io/apiextensions-apiserver v0.33.4 // indirect + k8s.io/apiserver v0.33.4 // indirect + k8s.io/component-base v0.33.4 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/kubectl v0.33.3 // indirect diff --git a/go.sum b/go.sum index 75b6404..691e0aa 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= @@ -86,11 +86,11 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.20 h1:VIPb/a2s17qNeQgDnkfZC35RScx+blkKF8GV68n80J4= -github.com/creack/pty v1.1.20/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -189,8 +189,8 @@ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrR github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -205,8 +205,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/inspektor-gadget/inspektor-gadget v0.43.0 h1:JNmrpMMVWDEDdJFdFDuW8XhHPmWRD1vAuRoYuys6+G0= -github.com/inspektor-gadget/inspektor-gadget v0.43.0/go.mod h1:c2dRyOye0ImZgmwMNaNFG1sH7WabrKHZTUSJgVp+jcg= +github.com/inspektor-gadget/inspektor-gadget v0.44.1 h1:Y9uk+GSsAXYtvOeK9N8LZ5luqTeoZNh2hJbRDUB7sEM= +github.com/inspektor-gadget/inspektor-gadget v0.44.1/go.mod h1:z0nlLT6zM2lKMBI8/AoSh46armnFXDqcyirVmUopcAg= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -244,8 +244,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mark3labs/mcp-go v0.38.0 h1:E5tmJiIXkhwlV0pLAwAT0O5ZjUZSISE/2Jxg+6vpq4I= -github.com/mark3labs/mcp-go v0.38.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.39.1 h1:2oPxk7aDbQhouakkYyKl2T4hKFU1c6FDaubWyGyVE1k= +github.com/mark3labs/mcp-go v0.39.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -317,8 +317,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= +github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= @@ -358,8 +358,8 @@ github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cA github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -369,8 +369,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= @@ -392,8 +392,8 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQ go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 h1:z6lNIajgEBVtQZHjfw2hAccPEBDs+nx58VemmXWa2ec= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0/go.mod h1:+kyc3bRx/Qkq05P6OCu3mTEIOxYRYzoIg+JsUp5X+PM= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= @@ -402,10 +402,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 h1:zG8 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0/go.mod h1:hOfBCz8kv/wuq73Mx2H2QnWokh/kHZxkh6SNF2bdKtw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= go.opentelemetry.io/otel/exporters/prometheus v0.59.1 h1:HcpSkTkJbggT8bjYP+BjyqPWlD17BH9C5CYNKeDzmcA= @@ -418,18 +418,18 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsu go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls= go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ= go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= -go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -441,8 +441,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -454,8 +454,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -475,34 +475,36 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -523,18 +525,18 @@ helm.sh/helm/v3 v3.18.6 h1:S/2CqcYnNfLckkHLI0VgQbxgcDaU3N4A/46E3n9wSNY= helm.sh/helm/v3 v3.18.6/go.mod h1:L/dXDR2r539oPlFP1PJqKAC1CUgqHJDLkxKpDGrWnyg= k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= -k8s.io/apiextensions-apiserver v0.33.3 h1:qmOcAHN6DjfD0v9kxL5udB27SRP6SG/MTopmge3MwEs= -k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8= +k8s.io/apiextensions-apiserver v0.33.4 h1:rtq5SeXiDbXmSwxsF0MLe2Mtv3SwprA6wp+5qh/CrOU= +k8s.io/apiextensions-apiserver v0.33.4/go.mod h1:mWXcZQkQV1GQyxeIjYApuqsn/081hhXPZwZ2URuJeSs= k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4= -k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E= +k8s.io/apiserver v0.33.4 h1:6N0TEVA6kASUS3owYDIFJjUH6lgN8ogQmzZvaFFj1/Y= +k8s.io/apiserver v0.33.4/go.mod h1:8ODgXMnOoSPLMUg1aAzMFx+7wTJM+URil+INjbTZCok= k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw= k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8= k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= -k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA= -k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4= +k8s.io/component-base v0.33.4 h1:Jvb/aw/tl3pfgnJ0E0qPuYLT0NwdYs1VXXYQmSuxJGY= +k8s.io/component-base v0.33.4/go.mod h1:567TeSdixWW2Xb1yYUQ7qk5Docp2kNznKL87eygY8Rc= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= From 8ad3e53606341facbef14e60bb946924a56bdd70 Mon Sep 17 00:00:00 2001 From: Pengfei Ni Date: Fri, 5 Sep 2025 10:01:06 +0800 Subject: [PATCH 07/10] fix: ensure error messages are included in tool output (#198) * fix: ensure error messages are included in tool output * test: enrich unit test and improve coverage --- internal/components/azaks/registry.go | 2 + internal/tools/handler.go | 8 + internal/tools/handler_test.go | 297 ++++++++++++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 internal/tools/handler_test.go diff --git a/internal/components/azaks/registry.go b/internal/components/azaks/registry.go index 3bb37e9..30dbe49 100644 --- a/internal/components/azaks/registry.go +++ b/internal/components/azaks/registry.go @@ -77,6 +77,8 @@ func generateToolDescription(accessLevel string) string { // Only show write operation examples if access level allows it if accessLevel == "readwrite" || accessLevel == "admin" { desc += "- Scale cluster: operation=\"scale\", args=\"--name myCluster --resource-group myRG --node-count 5\"\n" + desc += "- Update cluster (enable autoscaler): operation=\"update\", args=\"--name myCluster --resource-group myRG --enable-cluster-autoscaler --min-count 1 --max-count 5\"\n" + desc += "- Update cluster (disable autoscaler): operation=\"update\", args=\"--name myCluster --resource-group myRG --disable-cluster-autoscaler\"\n" } return desc diff --git a/internal/tools/handler.go b/internal/tools/handler.go index e266ee6..c66f216 100644 --- a/internal/tools/handler.go +++ b/internal/tools/handler.go @@ -59,6 +59,10 @@ func CreateToolHandler(executor CommandExecutor, cfg *config.ConfigData) func(ct } if err != nil { + // Include command output (often stderr) in the error for context + if result != "" { + return mcp.NewToolResultError(fmt.Sprintf("%s\n%s", err.Error(), result)), nil + } return mcp.NewToolResultError(err.Error()), nil } @@ -96,6 +100,10 @@ func CreateResourceHandler(handler ResourceHandler, cfg *config.ConfigData) func } if err != nil { + // Include handler output in the error message for better diagnostics + if result != "" { + return mcp.NewToolResultError(fmt.Sprintf("%s\n%s", err.Error(), result)), nil + } return mcp.NewToolResultError(err.Error()), nil } diff --git a/internal/tools/handler_test.go b/internal/tools/handler_test.go new file mode 100644 index 0000000..f629273 --- /dev/null +++ b/internal/tools/handler_test.go @@ -0,0 +1,297 @@ +package tools + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Azure/aks-mcp/internal/config" + "github.com/Azure/aks-mcp/internal/telemetry" + "github.com/mark3labs/mcp-go/mcp" +) + +// helper to extract first text content from result +func firstText(result *mcp.CallToolResult) string { + for _, c := range result.Content { + if tc, ok := mcp.AsTextContent(c); ok { + return tc.Text + } + } + return "" +} + +func TestCreateToolHandler_ErrorIncludesResultOutput(t *testing.T) { + cfg := config.NewConfig() + + // Fake executor returns stderr-like output with an error + exec := CommandExecutorFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return "ERROR: Azure CLI detailed message", errors.New("exit status 1") + }) + + handler := CreateToolHandler(exec, cfg) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_tool", + Arguments: map[string]any{"operation": "test"}, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil { + t.Fatalf("nil result returned") + } + if !res.IsError { + t.Fatalf("expected IsError=true on result") + } + msg := firstText(res) + if !strings.Contains(msg, "exit status 1") || !strings.Contains(msg, "ERROR: Azure CLI detailed message") { + t.Fatalf("expected combined error + output, got: %q", msg) + } +} + +func TestCreateToolHandler_ErrorWithoutOutput(t *testing.T) { + cfg := config.NewConfig() + + exec := CommandExecutorFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return "", errors.New("exit status 1") + }) + + handler := CreateToolHandler(exec, cfg) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_tool", + Arguments: map[string]any{"operation": "test"}, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("expected error result, got: %+v", res) + } + msg := firstText(res) + if msg != "exit status 1" { + t.Fatalf("expected only error text, got: %q", msg) + } +} + +func TestCreateResourceHandler_ErrorIncludesResultOutput(t *testing.T) { + cfg := config.NewConfig() + + rh := ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return "API: detailed failure context", errors.New("bad request") + }) + + handler := CreateResourceHandler(rh, cfg) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_resource", + Arguments: map[string]any{"operation": "test"}, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("expected error result, got: %+v", res) + } + msg := firstText(res) + if !strings.Contains(msg, "bad request") || !strings.Contains(msg, "API: detailed failure context") { + t.Fatalf("expected combined error + output, got: %q", msg) + } +} + +func TestCreateResourceHandler_ErrorWithoutOutput(t *testing.T) { + cfg := config.NewConfig() + + rh := ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return "", errors.New("bad request") + }) + + handler := CreateResourceHandler(rh, cfg) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_resource", + Arguments: map[string]any{"operation": "test"}, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("expected error result, got: %+v", res) + } + msg := firstText(res) + if msg != "bad request" { + t.Fatalf("expected only error text, got: %q", msg) + } +} + +func TestCreateToolHandler_Success_Verbose_Telemetry_LongResult(t *testing.T) { + cfg := config.NewConfig() + cfg.Verbose = true // exercise logToolCall + logToolResult + // Provide non-nil telemetry to exercise TrackToolInvocation path + cfg.TelemetryService = telemetry.NewService(telemetry.NewConfig("svc", "1.0")) + + long := strings.Repeat("x", 600) + exec := CommandExecutorFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return long, nil + }) + + handler := CreateToolHandler(exec, cfg) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_tool", + Arguments: map[string]any{ + "operation": "op", + }, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil || res.IsError { + t.Fatalf("expected success result, got: %+v", res) + } + if got := firstText(res); got != long { + t.Fatalf("unexpected result text length=%d", len(got)) + } +} + +func TestCreateToolHandler_InvalidArguments_Verbose_LogsFallback_TracksTelemetry(t *testing.T) { + cfg := config.NewConfig() + cfg.Verbose = true + cfg.TelemetryService = telemetry.NewService(telemetry.NewConfig("svc", "1.0")) + + exec := CommandExecutorFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return "should not run", nil + }) + + handler := CreateToolHandler(exec, cfg) + + // Use an argument type that fails json.Marshal to exercise logToolCall fallback branch + ch := make(chan int) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_tool", + Arguments: ch, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("expected error result, got: %+v", res) + } + msg := firstText(res) + if !strings.Contains(msg, "arguments must be a map[string]interface{}") { + t.Fatalf("unexpected error message: %q", msg) + } +} + +func TestCreateResourceHandler_ShortSuccess_Verbose_Telemetry(t *testing.T) { + cfg := config.NewConfig() + cfg.Verbose = true + cfg.TelemetryService = telemetry.NewService(telemetry.NewConfig("svc", "1.0")) + + rh := ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return "ok", nil + }) + + handler := CreateResourceHandler(rh, cfg) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_resource", + Arguments: map[string]any{"operation": "x"}, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil || res.IsError { + t.Fatalf("expected success result, got: %+v", res) + } + if got := firstText(res); got != "ok" { + t.Fatalf("unexpected text: %q", got) + } +} + +func TestCreateResourceHandler_InvalidArguments_Verbose_LogsFallback_TracksTelemetry(t *testing.T) { + cfg := config.NewConfig() + cfg.Verbose = true + cfg.TelemetryService = telemetry.NewService(telemetry.NewConfig("svc", "1.0")) + + rh := ResourceHandlerFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return "should not run", nil + }) + + handler := CreateResourceHandler(rh, cfg) + + // Unmarshalable type to drive logToolCall fallback branch + ch := make(chan int) + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_resource", + Arguments: ch, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("expected error result, got: %+v", res) + } + if msg := firstText(res); !strings.Contains(msg, "arguments must be a map[string]interface{}") { + t.Fatalf("unexpected error message: %q", msg) + } +} + +func TestCreateToolHandler_Error_Verbose_LogErrorBranch(t *testing.T) { + cfg := config.NewConfig() + cfg.Verbose = true + + exec := CommandExecutorFunc(func(params map[string]interface{}, _ *config.ConfigData) (string, error) { + return "", errors.New("boom") + }) + + handler := CreateToolHandler(exec, cfg) + + req := mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: "dummy_tool", + Arguments: map[string]any{"operation": "op"}, + }, + } + + res, err := handler(context.Background(), req) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("expected error result, got: %+v", res) + } +} From e956862d1c07fb47410c2537006d7fee59af0720 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:02:28 +0800 Subject: [PATCH 08/10] chore(deps): bump actions/setup-go from 5 to 6 in the all-gomod group (#201) Bumps the all-gomod group with 1 update: [actions/setup-go](https://github.com/actions/setup-go). Updates `actions/setup-go` from 5 to 6 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-gomod ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/lint.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52a6651..f5e7900 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.24' check-latest: true @@ -48,7 +48,7 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.24' check-latest: true @@ -71,7 +71,7 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.24' check-latest: true @@ -108,7 +108,7 @@ jobs: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: '1.24' check-latest: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 73f3fca..19347d8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} - name: golangci-lint From 12c6f63c9356fafd574216f555fb2e708afac6fd Mon Sep 17 00:00:00 2001 From: Simone Rodigari <32323373+SRodi@users.noreply.github.com> Date: Fri, 5 Sep 2025 03:14:39 +0100 Subject: [PATCH 09/10] Include hubble in additional tools (#200) * Include hubble in additional tools * update docs, adapter tests and validator --- README.md | 2 +- internal/config/config.go | 2 +- internal/config/validator.go | 6 ++++++ internal/k8s/adapter_test.go | 4 ++-- internal/server/server.go | 16 +++++++++++++++- internal/server/server_test.go | 9 +++++++-- prompts/aks-mcp-tool-consolidation.md | 4 +++- 7 files changed, 35 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 67b0753..30ad092 100644 --- a/README.md +++ b/README.md @@ -634,7 +634,7 @@ Command line arguments: ```sh Usage of ./aks-mcp: --access-level string Access level (readonly, readwrite, admin) (default "readonly") - --additional-tools string Comma-separated list of additional Kubernetes tools to support (kubectl is always enabled). Available: helm,cilium + --additional-tools string Comma-separated list of additional Kubernetes tools to support (kubectl is always enabled). Available: helm,cilium,hubble --allow-namespaces string Comma-separated list of allowed Kubernetes namespaces (empty means all namespaces) --host string Host to listen for the server (only used with transport sse or streamable-http) (default "127.0.0.1") --otlp-endpoint string OTLP endpoint for OpenTelemetry traces (e.g. localhost:4317, default "") diff --git a/internal/config/config.go b/internal/config/config.go index 967fd21..a653188 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,7 +71,7 @@ func (cfg *ConfigData) ParseFlags() { // Kubernetes-specific settings additionalTools := flag.String("additional-tools", "", - "Comma-separated list of additional Kubernetes tools to support (kubectl is always enabled). Available: helm,cilium") + "Comma-separated list of additional Kubernetes tools to support (kubectl is always enabled). Available: helm,cilium,hubble") flag.StringVar(&cfg.AllowNamespaces, "allow-namespaces", "", "Comma-separated list of allowed Kubernetes namespaces (empty means all namespaces)") diff --git a/internal/config/validator.go b/internal/config/validator.go index 89526b9..3499192 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -55,6 +55,12 @@ func (v *Validator) validateCli() bool { valid = false } + // hubble is optional - only validate if explicitly enabled + if v.config.AdditionalTools["hubble"] && !v.isCliInstalled("hubble") { + v.errors = append(v.errors, "hubble is not installed or not found in PATH (required when --additional-tools includes hubble)") + valid = false + } + return valid } diff --git a/internal/k8s/adapter_test.go b/internal/k8s/adapter_test.go index 703fdd6..a53b6fa 100644 --- a/internal/k8s/adapter_test.go +++ b/internal/k8s/adapter_test.go @@ -58,7 +58,7 @@ func TestConvertConfig_MapsFields(t *testing.T) { Host: "127.0.0.1", Port: 8000, AccessLevel: "readonly", - AdditionalTools: map[string]bool{"helm": true, "cilium": false}, + AdditionalTools: map[string]bool{"helm": true, "cilium": false, "hubble": false}, AllowNamespaces: "default,platform", OTLPEndpoint: "otel:4317", } @@ -220,7 +220,7 @@ func BenchmarkConvertConfig(b *testing.B) { Host: "127.0.0.1", Port: 8000, AccessLevel: "readonly", - AdditionalTools: map[string]bool{"helm": true, "cilium": false}, + AdditionalTools: map[string]bool{"helm": true, "cilium": false, "hubble": false}, AllowNamespaces: "default,platform", OTLPEndpoint: "otel:4317", } diff --git a/internal/server/server.go b/internal/server/server.go index f9942a5..555ca37 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/Azure/aks-mcp/internal/version" "github.com/Azure/mcp-kubernetes/pkg/cilium" "github.com/Azure/mcp-kubernetes/pkg/helm" + "github.com/Azure/mcp-kubernetes/pkg/hubble" "github.com/Azure/mcp-kubernetes/pkg/kubectl" k8stools "github.com/Azure/mcp-kubernetes/pkg/tools" "github.com/mark3labs/mcp-go/server" @@ -335,8 +336,11 @@ func (s *Service) registerOptionalKubernetesComponents() { // Register cilium if enabled s.registerCiliumComponent() + // Register hubble if enabled + s.registerHubbleComponent() + // Log if no optional components are enabled - if !s.cfg.AdditionalTools["helm"] && !s.cfg.AdditionalTools["cilium"] { + if !s.cfg.AdditionalTools["helm"] && !s.cfg.AdditionalTools["cilium"] && !s.cfg.AdditionalTools["hubble"] { log.Println("No optional Kubernetes components enabled") } } @@ -443,3 +447,13 @@ func (s *Service) registerCiliumComponent() { s.mcpServer.AddTool(ciliumTool, tools.CreateToolHandler(ciliumExecutor, s.cfg)) } } + +// registerHubbleComponent registers hubble tools if enabled +func (s *Service) registerHubbleComponent() { + if s.cfg.AdditionalTools["hubble"] { + log.Println("Registering Kubernetes tool: hubble") + hubbleTool := hubble.RegisterHubble() + hubbleExecutor := k8s.WrapK8sExecutor(hubble.NewExecutor()) + s.mcpServer.AddTool(hubbleTool, tools.CreateToolHandler(hubbleExecutor, s.cfg)) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6b863a7..6b8469f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -35,7 +35,7 @@ func (m *MockToolCounter) AddTool(toolName string) { // Categorize tools azureToolPrefixes := []string{"az_", "azure_", "get_aks_", "list_detectors", "run_detector", "inspektor_gadget_observability"} - k8sToolPrefixes := []string{"kubectl_", "k8s_", "helm", "cilium"} + k8sToolPrefixes := []string{"kubectl_", "k8s_", "helm", "cilium", "hubble"} isAzureTool := false for _, prefix := range azureToolPrefixes { @@ -119,6 +119,7 @@ func TestService(t *testing.T) { additionalTools: map[string]bool{ "helm": true, "cilium": true, + "hubble": true, }, expectedAzureTools: 8, // Same as readonly (Inspektor Gadget now included automatically) expectedK8sTools: 0, // Will be calculated + 2 optional tools @@ -146,6 +147,9 @@ func TestService(t *testing.T) { if tt.additionalTools["cilium"] { optionalToolsCount++ } + if tt.additionalTools["hubble"] { + optionalToolsCount++ + } expectedTotalK8sTools := expectedKubectlCount + optionalToolsCount @@ -242,6 +246,7 @@ func TestComponentToolCounts(t *testing.T) { t.Logf("Optional Kubernetes Components:") t.Logf(" - Helm: 1 tool (when enabled)") t.Logf(" - Cilium: 1 tool (when enabled)") + t.Logf(" - Hubble: 1 tool (when enabled)") t.Logf("Note: Inspektor Gadget is now automatically enabled as part of Azure Components") }) @@ -344,7 +349,7 @@ func TestExpectedToolsByAccessLevel(t *testing.T) { t.Logf("Kubernetes Tools:") t.Logf(" - Kubectl Tools: %d", k8sToolsCount) - t.Logf(" - Optional Tools: 0-2 (helm, cilium)") + t.Logf(" - Optional Tools: 0-3 (helm, cilium, hubble)") t.Logf("kubectl tools for %s:", level) for i, tool := range kubectlTools { diff --git a/prompts/aks-mcp-tool-consolidation.md b/prompts/aks-mcp-tool-consolidation.md index 0ad9fef..e259312 100644 --- a/prompts/aks-mcp-tool-consolidation.md +++ b/prompts/aks-mcp-tool-consolidation.md @@ -27,6 +27,7 @@ The current AKS-MCP server registers approximately 40+ individual tools, which c - kubectl commands - helm (optional) - cilium (optional) + - hubble (optional) 5. **Advisor Tools** (1 tool) - Advisor recommendations @@ -160,11 +161,12 @@ The current AKS-MCP server registers approximately 40+ individual tools, which c - kubectl commands - helm operations (if enabled) - cilium operations (if enabled) +- hubble commands (if enabled) **Parameters:** ```json { - "tool": "kubectl|helm|cilium", + "tool": "kubectl|helm|cilium|hubble", "command": "specific command", "args": "command arguments" } From d425db77f703e2e16893d4de0b2b6d50f657c653 Mon Sep 17 00:00:00 2001 From: gossion Date: Tue, 9 Sep 2025 15:37:32 +0800 Subject: [PATCH 10/10] Feat: support oauth (#202) * support oauth * lint * avoid hard-coded port * address comments --- docs/oauth-authentication.md | 474 +++++++++++ internal/auth/oauth/endpoints.go | 1021 ++++++++++++++++++++++++ internal/auth/oauth/endpoints_test.go | 601 ++++++++++++++ internal/auth/oauth/middleware.go | 299 +++++++ internal/auth/oauth/middleware_test.go | 253 ++++++ internal/auth/oauth/provider.go | 523 ++++++++++++ internal/auth/oauth/provider_test.go | 388 +++++++++ internal/auth/types.go | 140 ++++ internal/auth/types_test.go | 183 +++++ internal/config/config.go | 135 ++++ internal/config/config_test.go | 239 ++++++ internal/config/validator.go | 12 +- internal/server/server.go | 128 ++- 13 files changed, 4391 insertions(+), 5 deletions(-) create mode 100644 docs/oauth-authentication.md create mode 100644 internal/auth/oauth/endpoints.go create mode 100644 internal/auth/oauth/endpoints_test.go create mode 100644 internal/auth/oauth/middleware.go create mode 100644 internal/auth/oauth/middleware_test.go create mode 100644 internal/auth/oauth/provider.go create mode 100644 internal/auth/oauth/provider_test.go create mode 100644 internal/auth/types.go create mode 100644 internal/auth/types_test.go create mode 100644 internal/config/config_test.go diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md new file mode 100644 index 0000000..344279f --- /dev/null +++ b/docs/oauth-authentication.md @@ -0,0 +1,474 @@ +# OAuth Authentication for AKS-MCP + +This document describes how to configure and use OAuth authentication with AKS-MCP. + +## Overview + +AKS-MCP now supports OAuth 2.1 authentication using Azure Active Directory as the authorization server. When enabled, OAuth authentication provides secure access control for MCP endpoints using Bearer tokens. + +## Features + +- **Azure AD Integration**: Uses Azure Active Directory as the OAuth authorization server +- **JWT Token Validation**: Validates JWT tokens with Azure AD signing keys +- **OAuth 2.0 Metadata Endpoints**: Provides standard OAuth metadata discovery endpoints +- **Dynamic Client Registration**: Supports RFC 7591 dynamic client registration +- **Token Introspection**: Implements RFC 7662 token introspection +- **Transport Support**: Works with both SSE and HTTP Streamable transports +- **Flexible Configuration**: Supports environment variables and command-line configuration + +## Environment Setup and Azure AD Configuration + +### Prerequisites + +Before setting up OAuth authentication, ensure you have: + +- Azure CLI installed and configured (`az login`) +- An Azure subscription with appropriate permissions to create applications +- Azure Active Directory tenant access + +### Important: Environment Variables Shared Between OAuth and Azure CLI + +AKS-MCP uses the same environment variables (`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`) for both OAuth authentication and Azure CLI operations. This design provides configuration simplicity but requires careful permission setup: + +**When `AZURE_CLIENT_ID` is set:** +- OAuth: Used for validating user tokens accessing the MCP server +- Azure CLI: Used for managed identity/workload identity authentication to access Azure resources + +**Permission Requirements:** +- The Azure AD application must have both **OAuth permissions** (for user authentication) AND **Azure resource permissions** (for az CLI operations) +- Missing either set of permissions will cause authentication failures + +### Step 1: Create Azure AD Application + +#### Using Azure Portal (Recommended) + +1. **Navigate to Azure Portal** + - Go to https://portal.azure.com + - Sign in with your Azure account + +2. **Create App Registration** + ``` + Navigation: Azure Active Directory → App registrations → New registration + ``` + + Configure the following: + - **Name**: `AKS-MCP-OAuth` (or your preferred name) + - **Supported account types**: "Accounts in this organizational directory only" + - **Redirect URI Platform Options**: + +#### Supported Platform Types + +**✅ Mobile and desktop applications (Recommended)** +- **Platform**: "Mobile and desktop applications" +- **Redirect URIs**: + - `http://localhost:8000/oauth/callback` +- **Benefits**: + - Native support for PKCE (required by OAuth 2.1) + - No client secret required (public client) + - Better security for localhost redirects +- **Status**: ✅ **Confirmed working** + +**❌ Single-page application (SPA) - Not Recommended** +- **Platform**: "Single-page application (SPA)" +- **Redirect URIs**: Same as above +- **Benefits**: + - Designed for PKCE flow + - No client secret required +- **Critical Limitations**: + - **Token exchange restriction**: Azure AD error AADSTS9002327 - "Tokens issued for the 'Single-Page Application' client-type may only be redeemed via cross-origin requests" + - **Architecture mismatch**: SPA platform expects frontend JavaScript to handle token exchange, but AKS-MCP performs backend token exchange + - **CORS requirements**: Requires complex frontend-backend coordination for OAuth flow +- **Status**: ❌ **Not compatible with AKS-MCP's backend OAuth implementation** + +**❌ Web application** +- **Platform**: "Web" +- **Why not supported**: + - Requires client secret (confidential client) + - AKS-MCP implements public client flow without secrets + - PKCE handling may differ + +**Choose Platform Recommendation:** +1. **Primary**: Use "Mobile and desktop applications" (✅ confirmed working) +2. **Avoid**: "Single-page application" - incompatible with backend OAuth implementation (AADSTS9002327 error) +3. **Avoid**: "Web" platform due to client secret requirements + +3. **Record Essential Information** + From the "Overview" page, note: + - **Application (client) ID** - This is your `CLIENT_ID` + - **Directory (tenant) ID** - This is your `TENANT_ID` + +#### Using Azure CLI (Alternative) + +**For Mobile and desktop applications platform:** +```bash +# Create Azure AD application with public client platform +az ad app create --display-name "AKS-MCP-OAuth" \ + --public-client-redirect-uris "http://localhost:8000/oauth/callback" + +# Get application details +az ad app list --display-name "AKS-MCP-OAuth" --query "[0].{appId:appId,objectId:objectId}" + +# Get your tenant ID +az account show --query "tenantId" -o tsv +``` + +### Step 2: Configure API Permissions + +**Critical: Both OAuth and Azure CLI require proper permissions** + +1. **Add Required API Permissions** + ``` + Navigation: Azure Active Directory → App registrations → [Your App] → API permissions + ``` + +2. **Add Azure Service Management Permission (Required for OAuth)** + - Click "Add a permission" + - Select "Microsoft APIs" → "Azure Service Management" + - Choose "Delegated permissions" + - Select `user_impersonation` + - Click "Add permissions" + +3. **Add Azure Resource Management Permissions (Required for Azure CLI)** + + When `AZURE_CLIENT_ID` is set, Azure CLI will use this application for authentication. Add these permissions based on your AKS-MCP access level: + + **For readonly access:** + - Microsoft Graph → Application permissions → `Directory.Read.All` + - Azure Service Management → Delegated permissions → `user_impersonation` + + **For readwrite/admin access:** + - Microsoft Graph → Application permissions → `Directory.Read.All` + - Azure Service Management → Delegated permissions → `user_impersonation` + - Consider adding specific Azure resource permissions based on your needs + +4. **Grant Admin Consent (Required)** + - Click "Grant admin consent for [Your Organization]" + - Confirm the consent + +**⚠️ Important Notes:** +- Without proper Azure CLI permissions, you'll see "Insufficient privileges" errors when AKS-MCP tries to access Azure resources +- The same application serves both OAuth authentication (user access to MCP) and Azure CLI authentication (MCP access to Azure) +- Test both OAuth flow AND Azure resource access after permission changes + +### Step 3: Environment Configuration + +Set the required environment variables: + +```bash +# Replace with your actual values from Step 1 +export AZURE_TENANT_ID="your-tenant-id" +export AZURE_CLIENT_ID="your-client-id" +export AZURE_SUBSCRIPTION_ID="your-subscription-id" # Optional, for AKS operations +``` + +**⚠️ Important: Dual Authentication Impact** + +When you set `AZURE_CLIENT_ID`, it affects both OAuth and Azure CLI authentication: + +1. **OAuth Authentication**: Validates user tokens for MCP server access +2. **Azure CLI Authentication**: AKS-MCP uses this client ID for managed identity authentication when accessing Azure resources + +**Common Issues:** +- If you only configured OAuth permissions, Azure CLI operations will fail with "Insufficient privileges" +- If you only configured Azure resource permissions, OAuth token validation may fail +- Solution: Ensure your Azure AD application has BOTH sets of permissions (see Step 2) + +**Testing Both Authentication Paths:** +```bash +# Test OAuth (should work after proper setup) +curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/mcp + +# Test Azure CLI access (should work after proper permissions) +# This happens automatically when AKS-MCP tries to access Azure resources +./aks-mcp --oauth-enabled --access-level=readonly +``` + +### Step 4: Start AKS-MCP with OAuth + +```bash +# Using HTTP Streamable transport with OAuth (recommended) +./aks-mcp \ + --transport=streamable-http \ + --port=8000 \ + --oauth-enabled \ + --oauth-tenant-id="$AZURE_TENANT_ID" \ + --oauth-client-id="$AZURE_CLIENT_ID" \ + --oauth-redirects="http://localhost:8000/oauth/callback" \ + --access-level=readonly + +# Using SSE transport with OAuth (alternative) +./aks-mcp \ + --transport=sse \ + --port=8000 \ + --oauth-enabled \ + --oauth-tenant-id="$AZURE_TENANT_ID" \ + --oauth-client-id="$AZURE_CLIENT_ID" \ + --oauth-redirects="http://localhost:8000/oauth/callback" \ + --access-level=readonly + +# Environment variables are automatically used if set +# You can also just use: +./aks-mcp --transport=streamable-http --port=8000 --oauth-enabled --access-level=readonly +``` + +## Configuration Options + +### Command Line Flags + +- `--oauth-enabled`: Enable OAuth authentication (default: false) +- `--oauth-tenant-id`: Azure AD tenant ID (or use AZURE_TENANT_ID env var) +- `--oauth-client-id`: Azure AD client ID (or use AZURE_CLIENT_ID env var) +- `--oauth-redirects`: Comma-separated list of allowed redirect URIs (required when OAuth enabled) +- `--oauth-cors-origins`: Comma-separated list of allowed CORS origins for OAuth endpoints (e.g. http://localhost:6274 for MCP Inspector). If empty, no cross-origin requests are allowed for security + +**Note**: OAuth scopes are automatically configured to use `https://management.azure.com/.default` for optimal Azure AD compatibility. Custom scopes are not currently configurable via command line. + +### Example with Command Line Flags + +```bash +./aks-mcp --transport=sse --oauth-enabled=true \ + --oauth-tenant-id="12345678-1234-1234-1234-123456789012" \ + --oauth-client-id="87654321-4321-4321-4321-210987654321" +``` + +**Note**: Scopes are automatically set to `https://management.azure.com/.default` and cannot be customized via command line. + +## OAuth Endpoints + +When OAuth is enabled, the following endpoints are available: + +### Metadata Endpoints (Unauthenticated) + +- `GET /.well-known/oauth-protected-resource` - OAuth 2.0 Protected Resource Metadata (RFC 9728) +- `GET /.well-known/oauth-authorization-server` - OAuth 2.0 Authorization Server Metadata (RFC 8414) +- `GET /.well-known/openid-configuration` - OpenID Connect Discovery (alias for authorization server metadata) +- `GET /health` - Health check endpoint + +### OAuth Flow Endpoints (Unauthenticated) + +- `GET /oauth2/v2.0/authorize` - Authorization endpoint proxy to Azure AD +- `POST /oauth2/v2.0/token` - Token exchange endpoint proxy to Azure AD +- `GET /oauth/callback` - Authorization Code flow callback handler +- `POST /oauth/register` - Dynamic Client Registration (RFC 7591) + +### Token Management (Unauthenticated for simplicity) + +- `POST /oauth/introspect` - Token Introspection (RFC 7662) + +### Authenticated MCP Endpoints + +When OAuth is enabled, these endpoints require Bearer token authentication: + +- **SSE Transport**: `GET /sse`, `POST /message` +- **HTTP Streamable Transport**: `POST /mcp` + +## Client Integration + +### Obtaining an Access Token + +Use the Azure AD OAuth flow to obtain an access token: + +```bash +# Example using Azure CLI (for testing) +az account get-access-token --resource https://management.azure.com/ --query accessToken -o tsv +``` + +### Making Authenticated Requests + +Include the Bearer token in the Authorization header: + +```bash +# Example authenticated request to SSE endpoint +curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Accept: text/event-stream" \ + http://localhost:8000/sse + +# Example authenticated request to HTTP Streamable endpoint +curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST http://localhost:8000/mcp \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}' +``` + +## Testing OAuth Integration + +### 1. Test OAuth Metadata + +```bash +# Get protected resource metadata +curl http://localhost:8000/.well-known/oauth-protected-resource + +# Get authorization server metadata +curl http://localhost:8000/.well-known/oauth-authorization-server +``` + +### 2. Test Dynamic Client Registration + +```bash +curl -X POST http://localhost:8000/oauth/register \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": ["http://localhost:3000/oauth/callback"], + "client_name": "Test MCP Client", + "grant_types": ["authorization_code"] + }' +``` + +### 3. Test Token Introspection + +```bash +curl -X POST http://localhost:8000/oauth/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=YOUR_ACCESS_TOKEN" +``` + +## Security Considerations + +1. **HTTPS in Production**: Always use HTTPS in production environments +2. **Token Validation**: JWT tokens are validated against Azure AD signing keys +3. **Scope Validation**: Tokens must include required scopes +4. **Audience Validation**: Tokens must have the correct audience claim +5. **Redirect URI Validation**: Only configured redirect URIs are allowed + +## Troubleshooting + +### Common Issues + +#### Authentication and Token Issues +1. **Invalid Token**: Ensure the token is valid and not expired +2. **Wrong Audience**: Verify the token audience matches `https://management.azure.com` +3. **Missing Scopes**: Ensure the token includes `https://management.azure.com/.default` scope +4. **JWT Signature Validation Failed**: + - Check that Azure AD application platform is set correctly + - Verify tenant ID matches the issuer in the token + - Ensure token is using v2.0 format (from Azure Management API scope) + +#### Azure AD Application Configuration Issues +5. **Client ID Not Found**: Verify the Application (client) ID is correct +6. **Redirect URI Mismatch**: Ensure redirect URIs match exactly in Azure AD app registration +7. **Wrong Platform Type**: Use "Mobile and desktop applications", NOT "Web" or "Single-page application" +8. **Insufficient Permissions**: Verify both OAuth and Azure resource permissions are configured +9. **SPA Platform Incompatibility (AADSTS9002327)**: + - Error: "Tokens issued for the 'Single-Page Application' client-type may only be redeemed via cross-origin requests" + - Solution: Change Azure AD app platform to "Mobile and desktop applications" + - Cause: SPA platform requires frontend token exchange, incompatible with AKS-MCP's backend implementation + +#### Network and Endpoint Issues +10. **CORS Errors**: Check that redirect URIs are properly configured for localhost +11. **Network Issues**: Check connectivity to Azure AD endpoints +11. **Port Conflicts**: Ensure the configured port (default 8000) is available + +#### Scope and Permission Issues +12. **Scope Mixing Error**: + - Error: "scope can't be combined with resource-specific scopes" + - Solution: Our implementation automatically handles this by using only Azure Management API scope +13. **Resource Parameter Issues**: + - Azure AD doesn't support RFC 8707 resource parameter + - Our implementation works around this limitation automatically + +### Debug Logging + +Enable verbose logging for OAuth debugging: + +```bash +./aks-mcp --oauth-enabled=true --verbose +``` + +### Health Check + +Use the health endpoint to verify OAuth configuration: + +```bash +curl http://localhost:8000/health +``` + +Expected response with OAuth enabled: +```json +{ + "status": "healthy", + "oauth": { + "enabled": true + } +} +``` + +### Testing OAuth Flow Step by Step + +1. **Test Metadata Discovery**: +```bash +# Should return authorization server URLs +curl http://localhost:8000/.well-known/oauth-protected-resource + +# Should return PKCE support and endpoints +curl http://localhost:8000/.well-known/oauth-authorization-server +``` + +2. **Test Client Registration**: +```bash +curl -X POST http://localhost:8000/oauth/register \ + -H "Content-Type: application/json" \ + -d '{ + "redirect_uris": ["http://localhost:8000/oauth/callback"], + "client_name": "Test Client" + }' +``` + +3. **Test Authorization Flow**: + - Open browser to: `http://localhost:8000/oauth2/v2.0/authorize?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/oauth/callback&scope=https://management.azure.com/.default&code_challenge=CHALLENGE&code_challenge_method=S256&state=STATE` + +4. **Verify Token Validation**: +```bash +# Use a valid Azure AD token +curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8000/mcp +``` + +## Migration from Non-OAuth + +To migrate from a non-OAuth AKS-MCP deployment: + +1. Update clients to obtain and include Bearer tokens +2. Enable OAuth on the server with `--oauth-enabled=true` +3. Configure Azure AD application and credentials +4. Test with a subset of clients before full migration +5. Monitor logs for authentication errors + +## Integration with MCP Inspector + +The MCP Inspector tool can be used to test OAuth-enabled AKS-MCP servers. Configure the Inspector's OAuth settings to match your AKS-MCP OAuth configuration for testing. + +### Important: Redirect URI Configuration for MCP Inspector + +When using MCP Inspector with OAuth authentication, you need to add the Inspector's proxy redirect URI to your OAuth configuration: + +```bash +# Add Inspector's redirect URI (typically http://localhost:6274/oauth/callback) +./aks-mcp \ + --transport=streamable-http \ + --port=8000 \ + --oauth-enabled \ + --oauth-redirects="http://localhost:8000/oauth/callback,http://localhost:6274/oauth/callback" \ + --access-level=readonly +``` + +**Key Points:** +- MCP Inspector typically runs on port 6274 by default +- The Inspector creates a proxy redirect URI at `/oauth/callback` +- You must include both your server's redirect URI AND the Inspector's redirect URI +- You must also configure CORS origins to allow the Inspector's web interface to make requests +- Comma-separate multiple redirect URIs in the `--oauth-redirects` parameter +- Comma-separate multiple CORS origins in the `--oauth-cors-origins` parameter +- Without the Inspector's redirect URI, OAuth authentication will fail with "redirect_uri not registered" error +- Without the Inspector's CORS origin, the web interface will be blocked by browser CORS policy + +**Example with MCP Inspector configuration:** +```bash +./aks-mcp \ + --transport=streamable-http \ + --port=8000 \ + --oauth-enabled \ + --oauth-redirects="http://localhost:8000/oauth/callback,http://localhost:6274/oauth/callback" \ + --oauth-cors-origins="http://localhost:6274" \ + --access-level=readonly +``` + +For more information, see the MCP OAuth specification and Azure AD documentation. \ No newline at end of file diff --git a/internal/auth/oauth/endpoints.go b/internal/auth/oauth/endpoints.go new file mode 100644 index 0000000..af49899 --- /dev/null +++ b/internal/auth/oauth/endpoints.go @@ -0,0 +1,1021 @@ +package oauth + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Azure/aks-mcp/internal/auth" + "github.com/Azure/aks-mcp/internal/config" +) + +// validateAzureADURL validates that the URL is a legitimate Azure AD endpoint +func validateAzureADURL(tokenURL string) error { + parsedURL, err := url.Parse(tokenURL) + if err != nil { + return fmt.Errorf("invalid URL format: %w", err) + } + + // Only allow HTTPS for security + if parsedURL.Scheme != "https" { + return fmt.Errorf("only HTTPS URLs are allowed") + } + + // Only allow Azure AD endpoints + if parsedURL.Host != "login.microsoftonline.com" { + return fmt.Errorf("only Azure AD endpoints are allowed") + } + + // Validate path format for token endpoint (should be /{tenantId}/oauth2/v2.0/token) + if !strings.Contains(parsedURL.Path, "/oauth2/v2.0/token") { + return fmt.Errorf("invalid Azure AD token endpoint path") + } + + return nil +} + +// EndpointManager manages OAuth-related HTTP endpoints +type EndpointManager struct { + provider *AzureOAuthProvider + cfg *config.ConfigData +} + +// NewEndpointManager creates a new OAuth endpoint manager +func NewEndpointManager(provider *AzureOAuthProvider, cfg *config.ConfigData) *EndpointManager { + return &EndpointManager{ + provider: provider, + cfg: cfg, + } +} + +// setCORSHeaders sets CORS headers for OAuth endpoints with origin whitelisting +func (em *EndpointManager) setCORSHeaders(w http.ResponseWriter, r *http.Request) { + requestOrigin := r.Header.Get("Origin") + + // Check if the request origin is in the allowed list + var allowedOrigin string + for _, allowed := range em.provider.config.AllowedOrigins { + if requestOrigin == allowed { + allowedOrigin = requestOrigin + break + } + } + + // Only set CORS headers if origin is allowed + if allowedOrigin != "" { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-protocol-version") + w.Header().Set("Access-Control-Max-Age", "86400") // 24 hours + w.Header().Set("Access-Control-Allow-Credentials", "false") + } else if requestOrigin != "" { + log.Printf("CORS ERROR: Origin %s is not in the allowed list - cross-origin requests will be blocked for security", requestOrigin) + } +} + +// setCacheHeaders sets cache control headers based on EnableCache configuration +func (em *EndpointManager) setCacheHeaders(w http.ResponseWriter) { + if config.EnableCache { + // Enable caching for 1 hour when cache is enabled + w.Header().Set("Cache-Control", "max-age=3600") + } else { + // Disable all caching when cache is disabled (for debugging) + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + } +} + +// RegisterEndpoints registers OAuth endpoints with the provided HTTP mux +func (em *EndpointManager) RegisterEndpoints(mux *http.ServeMux) { + // OAuth 2.0 Protected Resource Metadata endpoint (RFC 9728) + mux.HandleFunc("/.well-known/oauth-protected-resource", em.protectedResourceMetadataHandler()) + + // OAuth 2.0 Authorization Server Metadata endpoint (RFC 8414) + // Note: This would typically be served by Azure AD, but we provide a proxy for convenience + mux.HandleFunc("/.well-known/oauth-authorization-server", em.authServerMetadataProxyHandler()) + + // OpenID Connect Discovery endpoint (compatibility with MCP Inspector) + mux.HandleFunc("/.well-known/openid-configuration", em.authServerMetadataProxyHandler()) + + // Authorization endpoint proxy to handle Azure AD compatibility + mux.HandleFunc("/oauth2/v2.0/authorize", em.authorizationProxyHandler()) + + // Dynamic Client Registration endpoint (RFC 7591) + mux.HandleFunc("/oauth/register", em.clientRegistrationHandler()) + + // Token introspection endpoint (RFC 7662) - optional + mux.HandleFunc("/oauth/introspect", em.tokenIntrospectionHandler()) + + // OAuth 2.0 callback endpoint for Authorization Code flow + mux.HandleFunc("/oauth/callback", em.callbackHandler()) + + // OAuth 2.0 token endpoint for Authorization Code exchange + mux.HandleFunc("/oauth2/v2.0/token", em.tokenHandler()) + + // Health check endpoint (unauthenticated) + mux.HandleFunc("/health", em.healthHandler()) +} + +// authServerMetadataProxyHandler proxies authorization server metadata from Azure AD +func (em *EndpointManager) authServerMetadataProxyHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Printf("OAuth DEBUG: Received request for authorization server metadata: %s %s", r.Method, r.URL.Path) + + // Set CORS headers for all requests + em.setCORSHeaders(w, r) + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodGet { + log.Printf("OAuth ERROR: Invalid method %s for metadata endpoint", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get metadata from Azure AD + provider := em.provider + + // Build server URL based on the request + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + // Use the Host header from the request + host := r.Host + if host == "" { + host = r.URL.Host + } + + serverURL := fmt.Sprintf("%s://%s", scheme, host) + + metadata, err := provider.GetAuthorizationServerMetadata(serverURL) + if err != nil { + log.Printf("Failed to fetch authorization server metadata: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to fetch authorization server metadata: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + em.setCacheHeaders(w) + + if err := json.NewEncoder(w).Encode(metadata); err != nil { + log.Printf("Failed to encode response: %v\n", err) + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + } +} + +// clientRegistrationHandler implements OAuth 2.0 Dynamic Client Registration (RFC 7591) +func (em *EndpointManager) clientRegistrationHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Printf("OAuth DEBUG: Received client registration request: %s %s", r.Method, r.URL.Path) + + // Set CORS headers for all requests + em.setCORSHeaders(w, r) + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodPost { + log.Printf("OAuth ERROR: Invalid method %s for client registration endpoint, only POST allowed", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse client registration request + var registrationRequest ClientRegistrationRequest + + if err := json.NewDecoder(r.Body).Decode(®istrationRequest); err != nil { + log.Printf("OAuth ERROR: Failed to parse client registration JSON: %v", err) + em.writeErrorResponse(w, "invalid_request", "Invalid JSON in request body", http.StatusBadRequest) + return + } + + log.Printf("OAuth DEBUG: Client registration request parsed - client_name: %s, redirect_uris: %v", registrationRequest.ClientName, registrationRequest.RedirectURIs) + + // Validate registration request + if err := em.validateClientRegistration(®istrationRequest); err != nil { + log.Printf("OAuth ERROR: Client registration validation failed: %v", err) + em.writeErrorResponse(w, "invalid_client_metadata", err.Error(), http.StatusBadRequest) + return + } + + // Use client-requested grant types if provided and valid, otherwise use defaults + grantTypes := registrationRequest.GrantTypes + if len(grantTypes) == 0 { + grantTypes = []string{"authorization_code", "refresh_token"} + } + + // Use client-requested response types if provided and valid, otherwise use defaults + responseTypes := registrationRequest.ResponseTypes + if len(responseTypes) == 0 { + responseTypes = []string{"code"} + } + + // For Azure AD compatibility, use the configured client ID + // In a full RFC 7591 implementation, each registration would get a unique ID + // But since Azure AD requires pre-registered client IDs, we return the configured one + clientID := em.cfg.OAuthConfig.ClientID + + clientInfo := map[string]interface{}{ + "client_id": clientID, // Use configured Azure AD client ID + "client_id_issued_at": time.Now().Unix(), // RFC 7591: timestamp of issuance + "redirect_uris": registrationRequest.RedirectURIs, + "token_endpoint_auth_method": "none", // Public client (PKCE required) + "grant_types": grantTypes, + "response_types": responseTypes, + "client_name": registrationRequest.ClientName, + "client_uri": registrationRequest.ClientURI, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + if err := json.NewEncoder(w).Encode(clientInfo); err != nil { + log.Printf("OAuth ERROR: Failed to encode client registration response: %v", err) + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + } +} + +// validateClientRegistration validates a client registration request +func (em *EndpointManager) validateClientRegistration(req *ClientRegistrationRequest) error { + // Validate redirect URIs - require at least one + if len(req.RedirectURIs) == 0 { + return fmt.Errorf("at least one redirect_uri is required") + } + + // Basic URL validation for redirect URIs + for _, redirectURI := range req.RedirectURIs { + if _, err := url.Parse(redirectURI); err != nil { + return fmt.Errorf("invalid redirect_uri format: %s", redirectURI) + } + } + + // Validate grant types + validGrantTypes := map[string]bool{ + "authorization_code": true, + "refresh_token": true, + } + + for _, grantType := range req.GrantTypes { + if !validGrantTypes[grantType] { + return fmt.Errorf("unsupported grant_type: %s", grantType) + } + } + + // Validate response types + validResponseTypes := map[string]bool{ + "code": true, + } + + for _, responseType := range req.ResponseTypes { + if !validResponseTypes[responseType] { + return fmt.Errorf("unsupported response_type: %s", responseType) + } + } + + return nil +} + +// validateRedirectURI validates that a redirect URI is registered and allowed +func (em *EndpointManager) validateRedirectURI(redirectURI string) error { + if len(em.cfg.OAuthConfig.RedirectURIs) == 0 { + return fmt.Errorf("no redirect URIs configured") + } + + for _, allowed := range em.cfg.OAuthConfig.RedirectURIs { + if redirectURI == allowed { + return nil + } + } + + log.Printf("OAuth SECURITY WARNING: Invalid redirect URI attempted: %s, allowed: %v", + redirectURI, em.cfg.OAuthConfig.RedirectURIs) + return fmt.Errorf("redirect_uri not registered: %s", redirectURI) +} + +// tokenIntrospectionHandler implements RFC 7662 OAuth 2.0 Token Introspection +func (em *EndpointManager) tokenIntrospectionHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers for all requests + em.setCORSHeaders(w, r) + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // This endpoint should be protected with client authentication + // For simplicity, we'll skip client auth in this implementation + + token := r.FormValue("token") + if token == "" { + em.writeErrorResponse(w, "invalid_request", "Missing token parameter", http.StatusBadRequest) + return + } + + // Validate the token + provider := em.provider + + tokenInfo, err := provider.ValidateToken(r.Context(), token) + if err != nil { + // Return inactive token response + response := map[string]interface{}{ + "active": false, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode introspection response: %v", err) + } + return + } + + // Return active token response + response := map[string]interface{}{ + "active": true, + "client_id": em.cfg.OAuthConfig.ClientID, + "scope": strings.Join(tokenInfo.Scope, " "), + "sub": tokenInfo.Subject, + "aud": tokenInfo.Audience, + "iss": tokenInfo.Issuer, + "exp": tokenInfo.ExpiresAt.Unix(), + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + } +} + +// healthHandler provides a simple health check endpoint +func (em *EndpointManager) healthHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Set CORS headers for all requests + em.setCORSHeaders(w, r) + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + response := map[string]interface{}{ + "status": "healthy", + "oauth": map[string]interface{}{ + "enabled": em.cfg.OAuthConfig.Enabled, + }, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + } +} + +// protectedResourceMetadataHandler handles OAuth 2.0 Protected Resource Metadata requests +func (em *EndpointManager) protectedResourceMetadataHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Printf("OAuth DEBUG: Received request for protected resource metadata: %s %s", r.Method, r.URL.Path) + + // Set CORS headers for all requests + em.setCORSHeaders(w, r) + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodGet { + log.Printf("OAuth ERROR: Invalid method %s for protected resource metadata endpoint", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Build resource URL based on the request + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + // Use the Host header from the request + host := r.Host + if host == "" { + host = r.URL.Host + } + + // Build the resource URL + resourceURL := fmt.Sprintf("%s://%s", scheme, host) + log.Printf("OAuth DEBUG: Building protected resource metadata for URL: %s", resourceURL) + + provider := em.provider + + metadata, err := provider.GetProtectedResourceMetadata(resourceURL) + if err != nil { + log.Printf("OAuth ERROR: Failed to get protected resource metadata: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + log.Printf("OAuth DEBUG: Successfully generated protected resource metadata with %d authorization servers", len(metadata.AuthorizationServers)) + + w.Header().Set("Content-Type", "application/json") + em.setCacheHeaders(w) + + if err := json.NewEncoder(w).Encode(metadata); err != nil { + log.Printf("OAuth ERROR: Failed to encode protected resource metadata response: %v", err) + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + } +} + +// writeErrorResponse writes an OAuth error response +func (em *EndpointManager) writeErrorResponse(w http.ResponseWriter, errorCode, description string, statusCode int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + response := map[string]interface{}{ + "error": errorCode, + "error_description": description, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode error response: %v", err) + } +} + +// authorizationProxyHandler proxies authorization requests to Azure AD with resource parameter filtering +func (em *EndpointManager) authorizationProxyHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Printf("OAuth DEBUG: Received authorization proxy request: %s %s", r.Method, r.URL.Path) + + // Set CORS headers for all requests + em.setCORSHeaders(w, r) + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodGet { + log.Printf("OAuth ERROR: Invalid method %s for authorization endpoint, only GET allowed", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse query parameters + query := r.URL.Query() + + // Validate redirect_uri parameter for security and better user experience + redirectURI := query.Get("redirect_uri") + if redirectURI == "" { + log.Printf("OAuth ERROR: Missing redirect_uri parameter in authorization request") + log.Printf("OAuth HELP: To fix this error, configure redirect URIs using --oauth-redirects flag") + log.Printf("OAuth HELP: For MCP Inspector, use: --oauth-redirects=\"http://localhost:8000/oauth/callback,http://localhost:6274/oauth/callback\"") + em.writeErrorResponse(w, "invalid_request", "redirect_uri parameter is required", http.StatusBadRequest) + return + } + + // Validate that the redirect_uri is registered and allowed + if err := em.validateRedirectURI(redirectURI); err != nil { + log.Printf("OAuth ERROR: redirect_uri %s not registered - requests will be blocked for security", redirectURI) + em.writeErrorResponse(w, "invalid_request", fmt.Sprintf("redirect_uri not registered: %s", redirectURI), http.StatusBadRequest) + return + } + + // Enforce PKCE for OAuth 2.1 compliance (MCP requirement) + codeChallenge := query.Get("code_challenge") + codeChallengeMethod := query.Get("code_challenge_method") + + if codeChallenge == "" { + log.Printf("OAuth ERROR: Missing PKCE code_challenge parameter (required for OAuth 2.1)") + em.writeErrorResponse(w, "invalid_request", "PKCE code_challenge is required", http.StatusBadRequest) + return + } + + if codeChallengeMethod == "" { + // Default to S256 if not specified + query.Set("code_challenge_method", "S256") + log.Printf("OAuth DEBUG: Setting default code_challenge_method to S256") + } else if codeChallengeMethod != "S256" { + log.Printf("OAuth ERROR: Unsupported code_challenge_method: %s (only S256 supported)", codeChallengeMethod) + em.writeErrorResponse(w, "invalid_request", "Only S256 code_challenge_method is supported", http.StatusBadRequest) + return + } + + // Resource parameter handling for MCP compliance + // requestedScopes := strings.Split(query.Get("scope"), " ") + + // Azure AD v2.0 doesn't support RFC 8707 Resource Indicators in authorization requests + // Remove the resource parameter if present for Azure AD compatibility + resourceParam := query.Get("resource") + if resourceParam != "" { + log.Printf("OAuth DEBUG: Removing resource parameter for Azure AD compatibility: %s", resourceParam) + query.Del("resource") + } + + // Use only server-required scopes for Azure AD compatibility + // Azure AD .default scopes cannot be mixed with OpenID Connect scopes + // We prioritize Azure Management API access over OpenID Connect user info + finalScopes := em.cfg.OAuthConfig.RequiredScopes + + finalScopeString := strings.Join(finalScopes, " ") + query.Set("scope", finalScopeString) + log.Printf("OAuth DEBUG: Setting final scope for Azure AD: %s", finalScopeString) + + // Build the Azure AD authorization URL + azureAuthURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", em.cfg.OAuthConfig.TenantID) + + // Create the redirect URL with filtered parameters + redirectURL := fmt.Sprintf("%s?%s", azureAuthURL, query.Encode()) + log.Printf("OAuth DEBUG: Redirecting to Azure AD authorization endpoint: %s", azureAuthURL) + + // Redirect to Azure AD + http.Redirect(w, r, redirectURL, http.StatusFound) + } +} + +// callbackHandler handles OAuth 2.0 Authorization Code flow callback +func (em *EndpointManager) callbackHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Printf("OAuth DEBUG: Received callback request: %s %s", r.Method, r.URL.Path) + + // Set CORS headers for all requests + em.setCORSHeaders(w, r) + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodGet { + log.Printf("OAuth ERROR: Invalid method %s for callback endpoint, only GET allowed", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse query parameters + query := r.URL.Query() + + // Check for error response from authorization server + if authError := query.Get("error"); authError != "" { + errorDesc := query.Get("error_description") + log.Printf("OAuth ERROR: Authorization server returned error: %s - %s", authError, errorDesc) + em.writeCallbackErrorResponse(w, fmt.Sprintf("Authorization failed: %s - %s", authError, errorDesc)) + return + } + + // Get authorization code + code := query.Get("code") + if code == "" { + log.Printf("OAuth ERROR: Missing authorization code in callback") + em.writeCallbackErrorResponse(w, "Missing authorization code") + return + } + + // Get state parameter for CSRF protection + state := query.Get("state") + if state == "" { + log.Printf("OAuth ERROR: Missing state parameter in callback") + em.writeCallbackErrorResponse(w, "Missing state parameter") + return + } + + log.Printf("OAuth DEBUG: Callback parameters validated - has_code: true, state: %s", state) + + // Validate redirect URI for security - construct expected URI and validate it + expectedRedirectURI := fmt.Sprintf("http://%s:%d/oauth/callback", em.cfg.Host, em.cfg.Port) + if err := em.validateRedirectURI(expectedRedirectURI); err != nil { + log.Printf("OAuth ERROR: Redirect URI validation failed: %v", err) + em.writeCallbackErrorResponse(w, "Invalid redirect URI") + return + } + + // Exchange authorization code for access token + tokenResponse, err := em.exchangeCodeForToken(code, state) + if err != nil { + log.Printf("OAuth ERROR: Failed to exchange authorization code for token: %v", err) + em.writeCallbackErrorResponse(w, fmt.Sprintf("Failed to exchange code for token: %v", err)) + return + } + + // Skip token validation in callback - validation happens during MCP requests + // Create minimal token info for callback success page + tokenInfo := &auth.TokenInfo{ + AccessToken: tokenResponse.AccessToken, + TokenType: "Bearer", + ExpiresAt: time.Now().Add(time.Hour), // Default 1 hour expiration + Scope: em.cfg.OAuthConfig.RequiredScopes, // Use configured scopes + Subject: "authenticated_user", // Placeholder + Audience: []string{fmt.Sprintf("https://sts.windows.net/%s/", em.cfg.OAuthConfig.TenantID)}, + Issuer: fmt.Sprintf("https://sts.windows.net/%s/", em.cfg.OAuthConfig.TenantID), + Claims: make(map[string]interface{}), + } + + // Return success response with token information + em.writeCallbackSuccessResponse(w, tokenResponse, tokenInfo) + } +} + +// TokenResponse represents the response from token exchange +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"` +} + +// exchangeCodeForToken exchanges authorization code for access token +func (em *EndpointManager) exchangeCodeForToken(code, state string) (*TokenResponse, error) { + // Prepare token exchange request + tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", em.cfg.OAuthConfig.TenantID) + + // Validate URL for security + if err := validateAzureADURL(tokenURL); err != nil { + return nil, fmt.Errorf("invalid token URL: %w", err) + } + + // Use default callback redirect URI for token exchange + redirectURI := fmt.Sprintf("http://%s:%d/oauth/callback", em.cfg.Host, em.cfg.Port) + + // Prepare form data + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", em.cfg.OAuthConfig.ClientID) + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + data.Set("scope", strings.Join(em.cfg.OAuthConfig.RequiredScopes, " ")) + + // Note: Azure AD v2.0 doesn't support the 'resource' parameter in token requests + // It uses scope-based resource identification instead + // For MCP compliance, we handle resource binding through audience validation + + // Make token exchange request + resp, err := http.PostForm(tokenURL, data) // #nosec G107 -- URL is validated above + if err != nil { + return nil, fmt.Errorf("token exchange request failed: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("Failed to close response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Parse token response + var tokenResponse TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + + return &tokenResponse, nil +} + +// writeCallbackErrorResponse writes an error response for callback +func (em *EndpointManager) writeCallbackErrorResponse(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + + html := fmt.Sprintf(` + + + + OAuth Authentication Error + + + +
+

Authentication Error

+

%s

+

Please try again or contact your administrator.

+
+ +`, message) + + if _, err := w.Write([]byte(html)); err != nil { + log.Printf("Failed to write error response: %v", err) + } +} + +// writeCallbackSuccessResponse writes a success response for callback +func (em *EndpointManager) writeCallbackSuccessResponse(w http.ResponseWriter, tokenResponse *TokenResponse, tokenInfo *auth.TokenInfo) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + // Generate a secure session token for the client to use + _, err := em.generateSessionToken() + if err != nil { + em.writeCallbackErrorResponse(w, "Failed to generate session token") + return + } + + html := fmt.Sprintf(` + + + + OAuth Authentication Success + + + +
+

Authentication Successful

+

You have been successfully authenticated with Azure AD.

+ +
+

Access Token (use as Bearer token):

+
%s
+ +
+ +
+

Token Information:

+
    +
  • Subject: %s
  • +
  • Audience: %s
  • +
  • Scope: %s
  • +
  • Expires: %s
  • +
+
+ +
+

For MCP Client Usage:

+

Use this token in the Authorization header:

+
Authorization: Bearer %s
+ +
+
+ + + +`, + tokenResponse.AccessToken, + tokenInfo.Subject, + strings.Join(tokenInfo.Audience, ", "), + strings.Join(tokenInfo.Scope, ", "), + tokenInfo.ExpiresAt.Format("2006-01-02 15:04:05 UTC"), + tokenResponse.AccessToken, + tokenResponse.AccessToken) + + if _, err := w.Write([]byte(html)); err != nil { + log.Printf("Failed to write success response: %v", err) + } +} + +// isValidClientID validates if a client ID is acceptable +func (em *EndpointManager) isValidClientID(clientID string) bool { + // Accept configured client ID (primary method for Azure AD) + if clientID == em.cfg.OAuthConfig.ClientID { + return true + } + + // For future extensibility, could accept other registered client IDs + // But for Azure AD integration, we primarily use the configured client ID + + return false +} + +// generateSessionToken generates a secure random session token +func (em *EndpointManager) generateSessionToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +// tokenHandler handles OAuth 2.0 token endpoint requests (Authorization Code exchange) +func (em *EndpointManager) tokenHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Printf("OAuth DEBUG: Received token endpoint request: %s %s", r.Method, r.URL.Path) + + // Set CORS headers for all requests + em.setCORSHeaders(w, r) + + // Handle preflight OPTIONS request + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + if r.Method != http.MethodPost { + log.Printf("OAuth ERROR: Invalid method %s for token endpoint, only POST allowed", r.Method) + em.writeErrorResponse(w, "invalid_request", "Only POST method is allowed", http.StatusMethodNotAllowed) + return + } + + // Parse form data + if err := r.ParseForm(); err != nil { + log.Printf("OAuth ERROR: Failed to parse form data: %v", err) + em.writeErrorResponse(w, "invalid_request", "Failed to parse form data", http.StatusBadRequest) + return + } + + // Validate grant type + grantType := r.FormValue("grant_type") + if grantType != "authorization_code" { + log.Printf("OAuth ERROR: Unsupported grant type: %s", grantType) + em.writeErrorResponse(w, "unsupported_grant_type", fmt.Sprintf("Unsupported grant type: %s", grantType), http.StatusBadRequest) + return + } + + // Extract required parameters + code := r.FormValue("code") + clientID := r.FormValue("client_id") + redirectURI := r.FormValue("redirect_uri") + codeVerifier := r.FormValue("code_verifier") // PKCE parameter + + if code == "" { + log.Printf("OAuth ERROR: Missing authorization code in token request") + em.writeErrorResponse(w, "invalid_request", "Missing authorization code", http.StatusBadRequest) + return + } + + if clientID == "" { + log.Printf("OAuth ERROR: Missing client_id in token request") + em.writeErrorResponse(w, "invalid_request", "Missing client_id", http.StatusBadRequest) + return + } + + if redirectURI == "" { + log.Printf("OAuth ERROR: Missing redirect_uri in token request") + em.writeErrorResponse(w, "invalid_request", "Missing redirect_uri", http.StatusBadRequest) + return + } + + // Enforce PKCE code_verifier for OAuth 2.1 compliance + if codeVerifier == "" { + log.Printf("OAuth ERROR: Missing PKCE code_verifier (required for OAuth 2.1)") + em.writeErrorResponse(w, "invalid_request", "PKCE code_verifier is required", http.StatusBadRequest) + return + } + + // Validate client ID (accept both configured and dynamically registered clients) + if !em.isValidClientID(clientID) { + log.Printf("OAuth ERROR: Invalid client_id: %s", clientID) + em.writeErrorResponse(w, "invalid_client", "Invalid client_id", http.StatusBadRequest) + return + } + + // Validate redirect URI for security + if err := em.validateRedirectURI(redirectURI); err != nil { + log.Printf("OAuth ERROR: Redirect URI validation failed in token endpoint: %v", err) + em.writeErrorResponse(w, "invalid_request", "Invalid redirect_uri", http.StatusBadRequest) + return + } + + // Extract scope from the token request (MCP client should send the same scope) + requestedScope := r.FormValue("scope") + if requestedScope == "" { + // Fallback to server required scopes if not provided + requestedScope = strings.Join(em.cfg.OAuthConfig.RequiredScopes, " ") + } + + log.Printf("OAuth DEBUG: Exchanging authorization code for access token with Azure AD, scope: %s", requestedScope) + + // Exchange authorization code for access token with Azure AD + tokenResponse, err := em.exchangeCodeForTokenDirect(code, redirectURI, codeVerifier, requestedScope) + if err != nil { + log.Printf("OAuth ERROR: Token exchange with Azure AD failed: %v", err) + em.writeErrorResponse(w, "invalid_grant", fmt.Sprintf("Authorization code exchange failed: %v", err), http.StatusBadRequest) + return + } + + // Return token response + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + + if err := json.NewEncoder(w).Encode(tokenResponse); err != nil { + log.Printf("OAuth ERROR: Failed to encode token response: %v", err) + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + } +} + +// exchangeCodeForTokenDirect exchanges authorization code for access token directly with Azure AD +func (em *EndpointManager) exchangeCodeForTokenDirect(code, redirectURI, codeVerifier, scope string) (*TokenResponse, error) { + // Prepare token exchange request to Azure AD + tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", em.cfg.OAuthConfig.TenantID) + + // Validate URL for security + if err := validateAzureADURL(tokenURL); err != nil { + return nil, fmt.Errorf("invalid token URL: %w", err) + } + + // Prepare form data + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", em.cfg.OAuthConfig.ClientID) + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + data.Set("scope", scope) // Use the scope provided by the client + + // Add PKCE code_verifier if present + if codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + log.Printf("Including PKCE code_verifier in Azure AD token request") + } else { + log.Printf("No PKCE code_verifier provided - this may cause PKCE verification to fail") + } + + // Note: Azure AD v2.0 doesn't support the 'resource' parameter in token requests + // It uses scope-based resource identification instead + // For MCP compliance, we handle resource binding through audience validation + log.Printf("Azure AD token request with scope: %s", scope) + + // Make token exchange request to Azure AD + resp, err := http.PostForm(tokenURL, data) // #nosec G107 -- URL is validated above + if err != nil { + return nil, fmt.Errorf("token exchange request failed: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("Failed to close response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Parse token response + var tokenResponse TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + + log.Printf("Token exchange successful: access_token received (length: %d)", len(tokenResponse.AccessToken)) + + return &tokenResponse, nil +} diff --git a/internal/auth/oauth/endpoints_test.go b/internal/auth/oauth/endpoints_test.go new file mode 100644 index 0000000..f457d5c --- /dev/null +++ b/internal/auth/oauth/endpoints_test.go @@ -0,0 +1,601 @@ +package oauth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Azure/aks-mcp/internal/auth" + "github.com/Azure/aks-mcp/internal/config" +) + +// createTestConfig creates a test ConfigData with OAuth configuration +func createTestConfig() *config.ConfigData { + cfg := config.NewConfig() + cfg.Host = "127.0.0.1" + cfg.Port = 8000 + cfg.OAuthConfig = &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client", + RequiredScopes: []string{"https://management.azure.com/.default"}, + RedirectURIs: []string{"http://127.0.0.1:8000/oauth/callback", "http://localhost:8000/oauth/callback"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: false, + ValidateAudience: false, + ExpectedAudience: "https://management.azure.com/", + }, + } + return cfg +} + +func TestEndpointManager_RegisterEndpoints(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + mux := http.NewServeMux() + manager.RegisterEndpoints(mux) + + // Test that endpoints are registered by making requests + testCases := []struct { + method string + path string + status int + }{ + {"GET", "/.well-known/oauth-protected-resource", http.StatusOK}, + {"GET", "/.well-known/oauth-authorization-server", http.StatusInternalServerError}, // Will fail without real Azure AD + {"POST", "/oauth/register", http.StatusBadRequest}, // Missing required data + {"POST", "/oauth/introspect", http.StatusBadRequest}, // Missing token param + {"GET", "/oauth/callback", http.StatusBadRequest}, // Missing required params + {"GET", "/health", http.StatusOK}, + } + + for _, tc := range testCases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + if w.Code != tc.status { + t.Errorf("Expected status %d for %s %s, got %d", tc.status, tc.method, tc.path, w.Code) + } + }) + } +} + +func TestProtectedResourceMetadataEndpoint(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + req := httptest.NewRequest("GET", "/.well-known/oauth-protected-resource", nil) + w := httptest.NewRecorder() + + handler := manager.protectedResourceMetadataHandler() + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var metadata ProtectedResourceMetadata + if err := json.Unmarshal(w.Body.Bytes(), &metadata); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + expectedAuthServer := "http://example.com" + if len(metadata.AuthorizationServers) != 1 || metadata.AuthorizationServers[0] != expectedAuthServer { + t.Errorf("Expected auth server %s, got %v", expectedAuthServer, metadata.AuthorizationServers) + } + + if len(metadata.ScopesSupported) != 1 || metadata.ScopesSupported[0] != "https://management.azure.com/.default" { + t.Errorf("Expected scopes %v, got %v", cfg.OAuthConfig.RequiredScopes, metadata.ScopesSupported) + } +} + +func TestClientRegistrationEndpoint(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + // Test valid registration request + registrationRequest := map[string]interface{}{ + "redirect_uris": []string{"http://localhost:3000/callback"}, + "token_endpoint_auth_method": "none", + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "scope": "https://management.azure.com/.default", + "client_name": "Test Client", + } + + reqBody, _ := json.Marshal(registrationRequest) + req := httptest.NewRequest("POST", "/oauth/register", strings.NewReader(string(reqBody))) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + handler := manager.clientRegistrationHandler() + handler(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response["client_id"] == "" { + t.Error("Expected client_id in response") + } + + redirectURIs, ok := response["redirect_uris"].([]interface{}) + if !ok || len(redirectURIs) != 1 { + t.Errorf("Expected redirect URIs in response") + } +} + +func TestTokenIntrospectionEndpoint(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + // Test with valid token (since JWT validation is disabled, any token works) + // Note: Must use a token that looks like a JWT (has dots) to pass initial format checks + req := httptest.NewRequest("POST", "/oauth/introspect", strings.NewReader("token=header.payload.signature")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + w := httptest.NewRecorder() + handler := manager.tokenIntrospectionHandler() + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if active, ok := response["active"].(bool); !ok || !active { + t.Error("Expected active token") + } +} + +func TestTokenIntrospectionEndpointMissingToken(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + // Test without token parameter + req := httptest.NewRequest("POST", "/oauth/introspect", strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + w := httptest.NewRecorder() + handler := manager.tokenIntrospectionHandler() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for missing token, got %d", w.Code) + } +} + +func TestHealthEndpoint(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + + handler := manager.healthHandler() + handler(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response["status"] != "healthy" { + t.Errorf("Expected status healthy, got %v", response["status"]) + } + + oauth, ok := response["oauth"].(map[string]interface{}) + if !ok { + t.Error("Expected oauth object in response") + } + + if oauth["enabled"] != true { + t.Errorf("Expected oauth enabled true, got %v", oauth["enabled"]) + } +} + +func TestValidateClientRegistration(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + tests := []struct { + name string + request map[string]interface{} + wantErr bool + }{ + { + name: "valid request", + request: map[string]interface{}{ + "redirect_uris": []string{"http://localhost:3000/callback"}, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + }, + wantErr: false, + }, + { + name: "missing redirect URIs", + request: map[string]interface{}{ + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + }, + wantErr: true, + }, + { + name: "invalid grant type", + request: map[string]interface{}{ + "redirect_uris": []string{"http://localhost:3000/callback"}, + "grant_types": []string{"client_credentials"}, + "response_types": []string{"code"}, + }, + wantErr: true, + }, + { + name: "invalid response type", + request: map[string]interface{}{ + "redirect_uris": []string{"http://localhost:3000/callback"}, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"token"}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Convert test request to the expected struct format + req := &ClientRegistrationRequest{} + + if redirectURIs, ok := tt.request["redirect_uris"].([]string); ok { + req.RedirectURIs = redirectURIs + } + if grantTypes, ok := tt.request["grant_types"].([]string); ok { + req.GrantTypes = grantTypes + } + if responseTypes, ok := tt.request["response_types"].([]string); ok { + req.ResponseTypes = responseTypes + } + + err := manager.validateClientRegistration(req) + if (err != nil) != tt.wantErr { + t.Errorf("validateClientRegistration() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestCallbackEndpointMissingCode(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + // Test callback without authorization code + req := httptest.NewRequest("GET", "/oauth/callback?state=test-state", nil) + w := httptest.NewRecorder() + + handler := manager.callbackHandler() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for missing code, got %d", w.Code) + } + + // Check that response contains HTML error page + contentType := w.Header().Get("Content-Type") + if !strings.Contains(contentType, "text/html") { + t.Errorf("Expected HTML content type, got %s", contentType) + } + + body := w.Body.String() + if !strings.Contains(body, "Missing authorization code") { + t.Error("Expected error message about missing authorization code") + } +} + +func TestCallbackEndpointMissingState(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + // Test callback without state parameter + req := httptest.NewRequest("GET", "/oauth/callback?code=test-code", nil) + w := httptest.NewRecorder() + + handler := manager.callbackHandler() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for missing state, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "Missing state parameter") { + t.Error("Expected error message about missing state parameter") + } +} + +func TestCallbackEndpointAuthError(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + // Test callback with authorization error + req := httptest.NewRequest("GET", "/oauth/callback?error=access_denied&error_description=User%20denied%20access", nil) + w := httptest.NewRecorder() + + handler := manager.callbackHandler() + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for auth error, got %d", w.Code) + } + + body := w.Body.String() + if !strings.Contains(body, "Authorization failed") { + t.Error("Expected error message about authorization failure") + } + if !strings.Contains(body, "access_denied") { + t.Error("Expected specific error code in response") + } +} + +func TestCallbackEndpointMethodNotAllowed(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + // Test callback with POST method (should only accept GET) + req := httptest.NewRequest("POST", "/oauth/callback", nil) + w := httptest.NewRecorder() + + handler := manager.callbackHandler() + handler(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected status 405 for POST method, got %d", w.Code) + } +} + +func TestValidateRedirectURI(t *testing.T) { + cfg := createTestConfig() + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + tests := []struct { + name string + redirectURI string + wantErr bool + }{ + { + name: "valid redirect URI - 127.0.0.1", + redirectURI: "http://127.0.0.1:8000/oauth/callback", + wantErr: false, + }, + { + name: "valid redirect URI - localhost", + redirectURI: "http://localhost:8000/oauth/callback", + wantErr: false, + }, + { + name: "invalid redirect URI - wrong port", + redirectURI: "http://127.0.0.1:9000/oauth/callback", + wantErr: true, + }, + { + name: "invalid redirect URI - wrong path", + redirectURI: "http://127.0.0.1:8000/oauth/malicious", + wantErr: true, + }, + { + name: "invalid redirect URI - external domain", + redirectURI: "http://malicious.com:8000/oauth/callback", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := manager.validateRedirectURI(tt.redirectURI) + if (err != nil) != tt.wantErr { + t.Errorf("validateRedirectURI() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + + // Test with empty redirect URIs configuration + cfgEmpty := createTestConfig() + cfgEmpty.OAuthConfig.RedirectURIs = []string{} + managerEmpty := NewEndpointManager(provider, cfgEmpty) + + err := managerEmpty.validateRedirectURI("http://127.0.0.1:8000/oauth/callback") + if err == nil { + t.Error("Expected error when no redirect URIs are configured") + } +} + +// TestAuthorizationProxyRedirectURIValidation tests the authorization endpoint redirect URI validation +func TestCORSHeaders(t *testing.T) { + cfg := createTestConfig() + cfg.OAuthConfig.AllowedOrigins = []string{"http://localhost:6274"} + + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + tests := []struct { + name string + origin string + expectCORSSet bool + expectOrigin string + }{ + { + name: "allowed origin", + origin: "http://localhost:6274", + expectCORSSet: true, + expectOrigin: "http://localhost:6274", + }, + { + name: "disallowed origin", + origin: "http://malicious.com", + expectCORSSet: false, + expectOrigin: "", + }, + { + name: "no origin header", + origin: "", + expectCORSSet: false, + expectOrigin: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + w := httptest.NewRecorder() + + handler := manager.healthHandler() + handler(w, req) + + corsOrigin := w.Header().Get("Access-Control-Allow-Origin") + if tt.expectCORSSet { + if corsOrigin != tt.expectOrigin { + t.Errorf("Expected CORS origin %s, got %s", tt.expectOrigin, corsOrigin) + } + } else { + if corsOrigin != "" { + t.Errorf("Expected no CORS headers, but got Access-Control-Allow-Origin: %s", corsOrigin) + } + } + }) + } +} + +func TestAuthorizationProxyRedirectURIValidation(t *testing.T) { + cfg := createTestConfig() + provider, _ := NewAzureOAuthProvider(cfg.OAuthConfig) + manager := NewEndpointManager(provider, cfg) + + tests := []struct { + name string + redirectURI string + expectError bool + expectCode int + }{ + { + name: "missing redirect_uri", + redirectURI: "", + expectError: true, + expectCode: http.StatusBadRequest, + }, + { + name: "valid redirect_uri - 127.0.0.1", + redirectURI: "http://127.0.0.1:8000/oauth/callback", + expectError: false, + expectCode: http.StatusFound, // Should redirect to Azure AD + }, + { + name: "valid redirect_uri - localhost", + redirectURI: "http://localhost:8000/oauth/callback", + expectError: false, + expectCode: http.StatusFound, // Should redirect to Azure AD + }, + { + name: "invalid redirect_uri - wrong port", + redirectURI: "http://127.0.0.1:9000/oauth/callback", + expectError: true, + expectCode: http.StatusBadRequest, + }, + { + name: "invalid redirect_uri - wrong path", + redirectURI: "http://127.0.0.1:8000/oauth/malicious", + expectError: true, + expectCode: http.StatusBadRequest, + }, + { + name: "invalid redirect_uri - external domain", + redirectURI: "http://malicious.com:8000/oauth/callback", + expectError: true, + expectCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create request URL with redirect_uri parameter if provided + requestURL := "/oauth2/v2.0/authorize?response_type=code&client_id=test-client&code_challenge=test&code_challenge_method=S256&state=test" + if tt.redirectURI != "" { + requestURL += "&redirect_uri=" + tt.redirectURI + } + + req := httptest.NewRequest("GET", requestURL, nil) + w := httptest.NewRecorder() + + handler := manager.authorizationProxyHandler() + handler(w, req) + + if tt.expectError { + if w.Code != tt.expectCode { + t.Errorf("Expected status code %d, got %d", tt.expectCode, w.Code) + } + + // Check that error response contains helpful information + body := w.Body.String() + if !strings.Contains(body, "redirect_uri") { + t.Errorf("Error response should mention redirect_uri, got: %s", body) + } + } else { + if w.Code != tt.expectCode { + t.Errorf("Expected status code %d, got %d", tt.expectCode, w.Code) + } + + // For successful cases, check redirect location contains expected parameters + location := w.Header().Get("Location") + if location == "" { + t.Errorf("Expected redirect location header, got empty") + } + if !strings.Contains(location, "login.microsoftonline.com") { + t.Errorf("Expected redirect to Azure AD, got: %s", location) + } + } + }) + } +} diff --git a/internal/auth/oauth/middleware.go b/internal/auth/oauth/middleware.go new file mode 100644 index 0000000..5170385 --- /dev/null +++ b/internal/auth/oauth/middleware.go @@ -0,0 +1,299 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "github.com/Azure/aks-mcp/internal/auth" +) + +// contextKey is a custom type for context keys to avoid collisions +type contextKey string + +const tokenInfoKey contextKey = "token_info" + +// AuthMiddleware handles OAuth authentication for HTTP requests +type AuthMiddleware struct { + provider *AzureOAuthProvider + serverURL string +} + +// setCORSHeaders sets CORS headers for OAuth endpoints with origin whitelisting +func (m *AuthMiddleware) setCORSHeaders(w http.ResponseWriter, r *http.Request) { + requestOrigin := r.Header.Get("Origin") + + // Check if the request origin is in the allowed list + var allowedOrigin string + for _, allowed := range m.provider.config.AllowedOrigins { + if requestOrigin == allowed { + allowedOrigin = requestOrigin + break + } + } + + // Only set CORS headers if origin is allowed + if allowedOrigin != "" { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, mcp-protocol-version") + w.Header().Set("Access-Control-Max-Age", "86400") // 24 hours + w.Header().Set("Access-Control-Allow-Credentials", "false") + } else if requestOrigin != "" { + log.Printf("CORS ERROR: Origin %s is not in the allowed list - cross-origin requests will be blocked for security", requestOrigin) + } +} + +// NewAuthMiddleware creates a new authentication middleware +func NewAuthMiddleware(provider *AzureOAuthProvider, serverURL string) *AuthMiddleware { + return &AuthMiddleware{ + provider: provider, + serverURL: serverURL, + } +} + +// Middleware returns an HTTP middleware function for OAuth authentication +func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // Skip authentication for specific endpoints + if m.shouldSkipAuth(r) { + log.Printf("Skipping auth for path: %s\n", r.URL.Path) + next.ServeHTTP(w, r) + return + } + + // Perform authentication + authResult := m.authenticateRequest(r) + + if !authResult.Authenticated { + log.Printf("Authentication FAILED - handling error\n") + m.handleAuthError(w, r, authResult) + return + } + + // Add token info to request context + ctx := context.WithValue(r.Context(), tokenInfoKey, authResult.TokenInfo) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + }) +} + +// shouldSkipAuth determines if authentication should be skipped for this request +func (m *AuthMiddleware) shouldSkipAuth(r *http.Request) bool { + // Skip auth for OAuth metadata endpoints + path := r.URL.Path + + skipPaths := []string{ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", + "/oauth2/v2.0/authorize", + "/oauth/register", + "/oauth/callback", + "/oauth2/v2.0/token", + "/oauth/introspect", + "/health", + "/ping", + } + + for _, skipPath := range skipPaths { + if path == skipPath { + return true + } + } + + return false +} + +// authenticateRequest performs OAuth authentication on the request +func (m *AuthMiddleware) authenticateRequest(r *http.Request) *auth.AuthResult { + // Extract Bearer token from Authorization header + authHeader := r.Header.Get("Authorization") + + if authHeader == "" { + log.Printf("OAuth DEBUG - Missing authorization header for %s %s\n", r.Method, r.URL.Path) + log.Printf("OAuth DEBUG - Request headers: %+v\n", r.Header) + return &auth.AuthResult{ + Authenticated: false, + Error: "missing authorization header", + StatusCode: http.StatusUnauthorized, + } + } + + // Check for Bearer token format + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + log.Printf("FAILED - Invalid authorization header format (missing Bearer prefix)\n") + return &auth.AuthResult{ + Authenticated: false, + Error: "invalid authorization header format", + StatusCode: http.StatusUnauthorized, + } + } + + token := strings.TrimPrefix(authHeader, bearerPrefix) + if token == "" { + log.Printf("FAILED - Empty bearer token\n") + return &auth.AuthResult{ + Authenticated: false, + Error: "empty bearer token", + StatusCode: http.StatusUnauthorized, + } + } + + // Basic JWT structure validation + tokenParts := strings.Split(token, ".") + if len(tokenParts) != 3 { + log.Printf("FAILED - JWT structure validation (has %d parts, expected 3)\n", len(tokenParts)) + return &auth.AuthResult{ + Authenticated: false, + Error: "invalid JWT structure", + StatusCode: http.StatusUnauthorized, + } + } + + // Validate the token + tokenInfo, err := m.provider.ValidateToken(r.Context(), token) + if err != nil { + log.Printf("FAILED - Provider token validation failed: %v\n", err) + return &auth.AuthResult{ + Authenticated: false, + Error: fmt.Sprintf("token validation failed: %v", err), + StatusCode: http.StatusUnauthorized, + } + } + + // Validate required scopes - strict enforcement for security + if !m.validateScopes(tokenInfo.Scope) { + log.Printf("SCOPE ERROR: Token scopes %v don't match required scopes %v", tokenInfo.Scope, m.provider.config.RequiredScopes) + return &auth.AuthResult{ + Authenticated: false, + Error: "insufficient scope", + StatusCode: http.StatusForbidden, + } + } + + return &auth.AuthResult{ + Authenticated: true, + TokenInfo: tokenInfo, + StatusCode: http.StatusOK, + } +} + +// validateScopes checks if the token has required scopes +func (m *AuthMiddleware) validateScopes(tokenScopes []string) bool { + requiredScopes := m.provider.config.RequiredScopes + if len(requiredScopes) == 0 { + return true // No scopes required + } + + // Check if token has at least one required scope + for _, required := range requiredScopes { + if m.hasScopePermission(required, tokenScopes) { + return true + } + } + + return false +} + +// hasScopePermission checks if the token scopes satisfy the required scope +func (m *AuthMiddleware) hasScopePermission(requiredScope string, tokenScopes []string) bool { + // Direct scope match + for _, tokenScope := range tokenScopes { + if tokenScope == requiredScope { + return true + } + } + + // Azure resource scope mapping + azureResourceMappings := map[string][]string{ + "https://management.azure.com/.default": { + "user_impersonation", + "https://management.azure.com/user_impersonation", + "https://management.azure.com/.default", + "https://management.core.windows.net/", + "https://management.azure.com/", + }, + "https://graph.microsoft.com/.default": { + "User.Read", + "https://graph.microsoft.com/User.Read", + }, + } + + if allowedScopes, exists := azureResourceMappings[requiredScope]; exists { + for _, allowedScope := range allowedScopes { + for _, tokenScope := range tokenScopes { + if tokenScope == allowedScope { + return true + } + } + } + } + + return false +} + +// handleAuthError handles authentication errors +func (m *AuthMiddleware) handleAuthError(w http.ResponseWriter, r *http.Request, authResult *auth.AuthResult) { + // Set CORS headers + m.setCORSHeaders(w, r) + w.Header().Set("Content-Type", "application/json") + + // Add WWW-Authenticate header for 401 responses (RFC 9728 Section 5.1) + if authResult.StatusCode == http.StatusUnauthorized { + // Build the resource metadata URL + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + host := r.Host + if host == "" { + host = r.URL.Host + } + serverURL := fmt.Sprintf("%s://%s", scheme, host) + resourceMetadataURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", serverURL) + + // RFC 9728 compliant WWW-Authenticate header + wwwAuth := fmt.Sprintf(`Bearer realm="%s", resource_metadata="%s"`, serverURL, resourceMetadataURL) + + // Add error information if available + if authResult.Error != "" { + wwwAuth += fmt.Sprintf(`, error="invalid_token", error_description="%s"`, authResult.Error) + } + + w.Header().Set("WWW-Authenticate", wwwAuth) + } + + w.WriteHeader(authResult.StatusCode) + + errorResponse := map[string]interface{}{ + "error": getOAuthErrorCode(authResult.StatusCode), + "error_description": authResult.Error, + } + + if err := json.NewEncoder(w).Encode(errorResponse); err != nil { + log.Printf("MIDDLEWARE ERROR: Failed to encode error response: %v\n", err) + } else { + log.Printf("MIDDLEWARE ERROR: Error response sent\n") + } +} + +// getOAuthErrorCode returns appropriate OAuth error code for HTTP status +func getOAuthErrorCode(statusCode int) string { + switch statusCode { + case http.StatusUnauthorized: + return "invalid_token" + case http.StatusForbidden: + return "insufficient_scope" + case http.StatusBadRequest: + return "invalid_request" + default: + return "server_error" + } +} diff --git a/internal/auth/oauth/middleware_test.go b/internal/auth/oauth/middleware_test.go new file mode 100644 index 0000000..358cb1e --- /dev/null +++ b/internal/auth/oauth/middleware_test.go @@ -0,0 +1,253 @@ +package oauth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Azure/aks-mcp/internal/auth" +) + +// GetTokenInfo extracts token information from request context (test helper) +func GetTokenInfo(r *http.Request) (*auth.TokenInfo, bool) { + tokenInfo, ok := r.Context().Value(tokenInfoKey).(*auth.TokenInfo) + return tokenInfo, ok +} + +func TestAuthMiddleware(t *testing.T) { + // Create test config with minimal required scopes for testing + // Note: We cannot test with empty RequiredScopes because the OAuth configuration + // validation now requires at least one scope to be specified. This is intentional + // to prevent security misconfigurations in production environments. + config := &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client", + RequiredScopes: []string{"https://management.azure.com/.default"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: false, + ValidateAudience: false, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + } + + provider, err := NewAzureOAuthProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + middleware := NewAuthMiddleware(provider, "http://localhost:8000") + + // Create a test handler + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("success")); err != nil { + t.Errorf("Failed to write test response: %v", err) + } + }) + + wrappedHandler := middleware.Middleware(testHandler) + + tests := []struct { + name string + authHeader string + expectedStatus int + path string + }{ + { + name: "valid bearer token", + authHeader: "Bearer header.payload.signature", + expectedStatus: http.StatusOK, + path: "/test", + }, + { + name: "missing authorization header", + authHeader: "", + expectedStatus: http.StatusUnauthorized, + path: "/test", + }, + { + name: "invalid token format", + authHeader: "InvalidFormat", + expectedStatus: http.StatusUnauthorized, + path: "/test", + }, + { + name: "non-bearer token", + authHeader: "Basic dXNlcjpwYXNz", + expectedStatus: http.StatusUnauthorized, + path: "/test", + }, + { + name: "skip auth for metadata endpoint", + authHeader: "", + expectedStatus: http.StatusOK, + path: "/.well-known/oauth-protected-resource", + }, + { + name: "skip auth for health endpoint", + authHeader: "", + expectedStatus: http.StatusOK, + path: "/health", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + + // Check WWW-Authenticate header for 401 responses + if w.Code == http.StatusUnauthorized { + wwwAuth := w.Header().Get("WWW-Authenticate") + if wwwAuth == "" { + t.Error("Expected WWW-Authenticate header for 401 response") + } + } + }) + } +} + +func TestAuthMiddlewareContextPropagation(t *testing.T) { + // Note: We cannot test with empty RequiredScopes because the OAuth configuration + // validation now requires at least one scope to be specified. + config := &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client", + RequiredScopes: []string{"https://management.azure.com/.default"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: false, + ValidateAudience: false, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + } + + provider, err := NewAzureOAuthProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + middleware := NewAuthMiddleware(provider, "http://localhost:8000") + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if token info is available in context + tokenInfo, ok := GetTokenInfo(r) + if !ok { + t.Error("Token info not found in context") + return + } + + if tokenInfo.AccessToken != "header.payload.signature" { + t.Errorf("Expected token header.payload.signature, got %s", tokenInfo.AccessToken) + } + + w.WriteHeader(http.StatusOK) + }) + + wrappedHandler := middleware.Middleware(testHandler) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer header.payload.signature") + + w := httptest.NewRecorder() + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} + +func TestShouldSkipAuth(t *testing.T) { + // Note: We cannot test with empty RequiredScopes because the OAuth configuration + // validation now requires at least one scope to be specified. + config := &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client", + RequiredScopes: []string{"https://management.azure.com/.default"}, // Minimal scope for testing + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: false, + ValidateAudience: false, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + } + + provider, err := NewAzureOAuthProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + middleware := NewAuthMiddleware(provider, "http://localhost:8000") + + tests := []struct { + path string + expected bool + }{ + {"/.well-known/oauth-protected-resource", true}, + {"/.well-known/oauth-authorization-server", true}, + {"/.well-known/openid-configuration", true}, + {"/oauth2/v2.0/authorize", true}, + {"/oauth/register", true}, + {"/oauth/callback", true}, + {"/oauth2/v2.0/token", true}, + {"/oauth/introspect", true}, + {"/health", true}, + {"/ping", true}, + {"/test", false}, + {"/mcp", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + req := httptest.NewRequest("GET", tt.path, nil) + result := middleware.shouldSkipAuth(req) + if result != tt.expected { + t.Errorf("Expected %v for path %s, got %v", tt.expected, tt.path, result) + } + }) + } +} + +func TestGetTokenInfo(t *testing.T) { + // Test with valid token info + tokenInfo := &auth.TokenInfo{ + AccessToken: "test-token", + TokenType: "Bearer", + Subject: "user123", + } + + ctx := context.WithValue(context.Background(), tokenInfoKey, tokenInfo) + req := httptest.NewRequest("GET", "/test", nil) + req = req.WithContext(ctx) + + retrievedTokenInfo, ok := GetTokenInfo(req) + if !ok { + t.Error("Expected to find token info in context") + } + + if retrievedTokenInfo.AccessToken != "test-token" { + t.Errorf("Expected access token test-token, got %s", retrievedTokenInfo.AccessToken) + } + + // Test without token info + req = httptest.NewRequest("GET", "/test", nil) + _, ok = GetTokenInfo(req) + if ok { + t.Error("Expected not to find token info in empty context") + } +} diff --git a/internal/auth/oauth/provider.go b/internal/auth/oauth/provider.go new file mode 100644 index 0000000..d6ec9bc --- /dev/null +++ b/internal/auth/oauth/provider.go @@ -0,0 +1,523 @@ +package oauth + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/Azure/aks-mcp/internal/auth" + internalConfig "github.com/Azure/aks-mcp/internal/config" + "github.com/golang-jwt/jwt/v5" +) + +// AzureOAuthProvider implements OAuth authentication for Azure AD +type AzureOAuthProvider struct { + config *auth.OAuthConfig + httpClient *http.Client + keyCache *keyCache + enableCache bool +} + +// keyCache caches Azure AD signing keys +type keyCache struct { + keys map[string]*rsa.PublicKey + expiresAt time.Time + mu sync.RWMutex +} + +// AzureADMetadata represents Azure AD OAuth metadata +type AzureADMetadata 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"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +// ProtectedResourceMetadata represents MCP protected resource metadata (RFC 9728 compliant) +type ProtectedResourceMetadata struct { + AuthorizationServers []string `json:"authorization_servers"` + Resource string `json:"resource"` + ScopesSupported []string `json:"scopes_supported"` +} + +// ClientRegistrationRequest represents OAuth 2.0 Dynamic Client Registration request (RFC 7591) +type ClientRegistrationRequest struct { + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + GrantTypes []string `json:"grant_types"` + ResponseTypes []string `json:"response_types"` + ClientName string `json:"client_name"` + ClientURI string `json:"client_uri"` + Scope string `json:"scope"` +} + +// NewAzureOAuthProvider creates a new Azure OAuth provider +func NewAzureOAuthProvider(config *auth.OAuthConfig) (*AzureOAuthProvider, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid OAuth config: %w", err) + } + + return &AzureOAuthProvider{ + config: config, + enableCache: internalConfig.EnableCache, // Use config constant for cache control + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + keyCache: &keyCache{ + keys: make(map[string]*rsa.PublicKey), + }, + }, nil +} + +// GetProtectedResourceMetadata returns OAuth 2.0 Protected Resource Metadata (RFC 9728) +func (p *AzureOAuthProvider) GetProtectedResourceMetadata(serverURL string) (*ProtectedResourceMetadata, error) { + // For MCP compliance, point to our local authorization server proxy + // which properly advertises PKCE support + parsedURL, err := url.Parse(serverURL) + if err != nil { + return nil, fmt.Errorf("invalid server URL: %v", err) + } + + // Use the same scheme and host as the server URL + authServerURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + + // RFC 9728 requires the resource field to identify this MCP server + return &ProtectedResourceMetadata{ + AuthorizationServers: []string{authServerURL}, + Resource: serverURL, // Required by MCP spec + ScopesSupported: p.config.RequiredScopes, + }, nil +} + +// GetAuthorizationServerMetadata returns OAuth 2.0 Authorization Server Metadata (RFC 8414) +func (p *AzureOAuthProvider) GetAuthorizationServerMetadata(serverURL string) (*AzureADMetadata, error) { + metadataURL := fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0/.well-known/openid-configuration", p.config.TenantID) + log.Printf("OAuth DEBUG: Fetching Azure AD metadata from: %s", metadataURL) + + resp, err := p.httpClient.Get(metadataURL) + if err != nil { + log.Printf("OAuth ERROR: Failed to fetch metadata from %s: %v", metadataURL, err) + return nil, fmt.Errorf("failed to fetch metadata from %s: %w", metadataURL, err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("Failed to close response body: %v", err) + } + }() + + if resp.StatusCode == http.StatusNotFound { + log.Printf("OAuth ERROR: Tenant ID '%s' not found (HTTP 404)", p.config.TenantID) + return nil, fmt.Errorf("tenant ID '%s' not found (HTTP 404). Please verify your Azure AD tenant ID is correct", p.config.TenantID) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("OAuth ERROR: Metadata endpoint returned status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("metadata endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("OAuth ERROR: Failed to read metadata response: %v", err) + return nil, fmt.Errorf("failed to read metadata response: %w", err) + } + + var metadata AzureADMetadata + if err := json.Unmarshal(body, &metadata); err != nil { + log.Printf("OAuth ERROR: Failed to parse metadata JSON: %v", err) + return nil, fmt.Errorf("failed to parse metadata: %w", err) + } + + log.Printf("OAuth DEBUG: Successfully parsed Azure AD metadata, original grant_types_supported: %v", metadata.GrantTypesSupported) + + // Ensure grant_types_supported is populated for MCP Inspector compatibility + if len(metadata.GrantTypesSupported) == 0 { + log.Printf("OAuth DEBUG: Setting default grant_types_supported (was empty/nil)") + metadata.GrantTypesSupported = []string{"authorization_code", "refresh_token"} + } + + // Ensure response_types_supported is populated for MCP Inspector compatibility + if len(metadata.ResponseTypesSupported) == 0 { + log.Printf("OAuth DEBUG: Setting default response_types_supported (was empty/nil)") + metadata.ResponseTypesSupported = []string{"code"} + } + + // Ensure subject_types_supported is populated for MCP Inspector compatibility + if len(metadata.SubjectTypesSupported) == 0 { + log.Printf("OAuth DEBUG: Setting default subject_types_supported (was empty/nil)") + metadata.SubjectTypesSupported = []string{"public"} + } + + // Ensure token_endpoint_auth_methods_supported is populated for MCP Inspector compatibility + if len(metadata.TokenEndpointAuthMethodsSupported) == 0 { + log.Printf("OAuth DEBUG: Setting default token_endpoint_auth_methods_supported (was empty/nil)") + metadata.TokenEndpointAuthMethodsSupported = []string{"none"} + } + + // Add S256 code challenge method support (Azure AD supports this but may not advertise it) + // MCP specification requires S256 support, so we always ensure it's present + log.Printf("OAuth DEBUG: Enforcing S256 code challenge method support (MCP requirement)") + metadata.CodeChallengeMethodsSupported = []string{"S256"} + + // Azure AD v2.0 has limited support for RFC 8707 Resource Indicators + // - Authorization endpoint: doesn't support resource parameter + // - Token endpoint: doesn't support resource parameter + // - Uses scope-based resource identification instead + // Our proxy handles MCP resource parameter translation + parsedURL, err := url.Parse(serverURL) + if err == nil { + // If the server URL includes /mcp path, include it in the proxy endpoint + proxyPath := "/oauth2/v2.0/authorize" + tokenPath := "/oauth2/v2.0/token" // #nosec G101 -- This is an OAuth endpoint path, not credentials + registrationPath := "/oauth/register" + proxyAuthURL := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, proxyPath) + tokenURL := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, tokenPath) + registrationURL := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, registrationPath) + + metadata.AuthorizationEndpoint = proxyAuthURL + metadata.TokenEndpoint = tokenURL + // Add dynamic client registration endpoint + metadata.RegistrationEndpoint = registrationURL + } + + log.Printf("OAuth DEBUG: Final metadata prepared - grant_types_supported: %v, response_types_supported: %v, code_challenge_methods_supported: %v", + metadata.GrantTypesSupported, metadata.ResponseTypesSupported, metadata.CodeChallengeMethodsSupported) + + return &metadata, nil +} + +// ValidateToken validates an OAuth access token +func (p *AzureOAuthProvider) ValidateToken(ctx context.Context, tokenString string) (*auth.TokenInfo, error) { + // JWTs have three parts (header.payload.signature) separated by two dots. + const jwtExpectedDotCount = 2 + + dotCount := strings.Count(tokenString, ".") + if dotCount != jwtExpectedDotCount { + return nil, fmt.Errorf("invalid JWT token format: expected 3 parts separated by dots, got %d dots", dotCount) + } + + // SECURITY WARNING: JWT validation bypass - for development and testing ONLY + // ValidateJWT should ALWAYS be true in production environments + // This bypass creates a significant security vulnerability if enabled in production + if !p.config.TokenValidation.ValidateJWT { + log.Printf("WARNING: JWT validation is DISABLED - this should ONLY be used in development/testing") + return &auth.TokenInfo{ + AccessToken: tokenString, + TokenType: "Bearer", + ExpiresAt: time.Now().Add(time.Hour), // Default 1 hour expiration + Scope: p.config.RequiredScopes, // Use configured scopes + Subject: "unknown", // Cannot extract without parsing + Audience: []string{p.config.TokenValidation.ExpectedAudience}, + Issuer: fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", p.config.TenantID), + Claims: make(map[string]interface{}), + }, nil + } + + // Parse and validate JWT token + + // Parse token structure and check expiration + parserUnsafe := jwt.NewParser(jwt.WithoutClaimsValidation()) + tokenUnsafe, _, err := parserUnsafe.ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return nil, fmt.Errorf("invalid token structure: %w", err) + } + + // Check claims and expiration + if claims, ok := tokenUnsafe.Claims.(jwt.MapClaims); ok { + if exp, ok := claims["exp"].(float64); ok { + expTime := time.Unix(int64(exp), 0) + if time.Now().After(expTime) { + return nil, fmt.Errorf("token expired at %v", expTime) + } + } + } + + // JWT signature validation + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, err := parser.ParseWithClaims(tokenString, jwt.MapClaims{}, p.getKeyFunc) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid token claims") + } + + // Validate issuer - with Azure Management API scope, we should get v2.0 format + issuer, ok := claims["iss"].(string) + if !ok { + return nil, fmt.Errorf("missing issuer claim") + } + + expectedIssuerV2 := fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", p.config.TenantID) + expectedIssuerV1 := fmt.Sprintf("https://sts.windows.net/%s/", p.config.TenantID) + + if issuer != expectedIssuerV2 && issuer != expectedIssuerV1 { + return nil, fmt.Errorf("invalid issuer: expected %s (preferred) or %s (fallback), got %s", expectedIssuerV2, expectedIssuerV1, issuer) + } + + // Azure AD may return v1.0 or v2.0 issuer format depending on token scope + + // Validate audience and resource binding + if p.config.TokenValidation.ValidateAudience { + if err := p.validateAudience(claims); err != nil { + return nil, err + } + } + + // Extract token information + tokenInfo := &auth.TokenInfo{ + AccessToken: tokenString, + TokenType: "Bearer", + Claims: claims, + } + + // Extract subject + if sub, ok := claims["sub"].(string); ok { + tokenInfo.Subject = sub + } + + // Extract audience + if aud, ok := claims["aud"].(string); ok { + tokenInfo.Audience = []string{aud} + } else if audSlice, ok := claims["aud"].([]interface{}); ok { + for _, a := range audSlice { + if audStr, ok := a.(string); ok { + tokenInfo.Audience = append(tokenInfo.Audience, audStr) + } + } + } + + // Extract scope from Azure AD token + // Check for 'scp' claim (Azure AD v2.0) + if scp, ok := claims["scp"].(string); ok { + tokenInfo.Scope = strings.Split(scp, " ") + } else if scope, ok := claims["scope"].(string); ok { + // Check for 'scope' claim (alternative) + tokenInfo.Scope = strings.Split(scope, " ") + } + + // Check for 'roles' claim (Azure AD app roles) + if roles, ok := claims["roles"].([]interface{}); ok { + for _, role := range roles { + if roleStr, ok := role.(string); ok { + tokenInfo.Scope = append(tokenInfo.Scope, roleStr) + } + } + } + + // Extract expiration + if exp, ok := claims["exp"].(float64); ok { + tokenInfo.ExpiresAt = time.Unix(int64(exp), 0) + } + + // Set issuer + tokenInfo.Issuer = issuer + + return tokenInfo, nil +} + +// validateAudience validates the audience claim and resource binding (RFC 8707) +func (p *AzureOAuthProvider) validateAudience(claims jwt.MapClaims) error { + expectedAudience := p.config.TokenValidation.ExpectedAudience + + // Normalize expected audience - remove trailing slash for comparison + normalizedExpected := strings.TrimSuffix(expectedAudience, "/") + + // Check single audience + if aud, ok := claims["aud"].(string); ok { + normalizedAud := strings.TrimSuffix(aud, "/") + if normalizedAud == normalizedExpected || aud == p.config.ClientID { + return nil + } + return fmt.Errorf("invalid audience: expected %s or %s, got %s", expectedAudience, p.config.ClientID, aud) + } + + // Check audience array + if audSlice, ok := claims["aud"].([]interface{}); ok { + for _, a := range audSlice { + if audStr, ok := a.(string); ok { + normalizedAud := strings.TrimSuffix(audStr, "/") + if normalizedAud == normalizedExpected || audStr == p.config.ClientID { + return nil + } + } + } + return fmt.Errorf("invalid audience: expected %s or %s in audience list", expectedAudience, p.config.ClientID) + } + + return fmt.Errorf("missing audience claim") +} + +// getKeyFunc returns a function to retrieve JWT signing keys +func (p *AzureOAuthProvider) getKeyFunc(token *jwt.Token) (interface{}, error) { + // Validate signing method + if token.Method.Alg() != "RS256" { + return nil, fmt.Errorf("unexpected signing method: expected RS256, got %v", token.Method.Alg()) + } + + // Also verify it's an RSA method + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("signing method is not RSA: %T", token.Method) + } + + // Get key ID from token header + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing key ID in token header") + } + + // Extract issuer from token to determine the correct JWKS endpoint + var issuer string + if claims, ok := token.Claims.(jwt.MapClaims); ok { + if iss, ok := claims["iss"].(string); ok { + issuer = iss + } + } + + // Get the public key for this key ID using the appropriate issuer + key, err := p.getPublicKey(kid, issuer) + if err != nil { + log.Printf("PUBLIC KEY RETRIEVAL FAILED: %s\n", err) + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + return key, nil +} + +// getPublicKey retrieves and caches Azure AD public keys +func (p *AzureOAuthProvider) getPublicKey(kid string, issuer string) (*rsa.PublicKey, error) { + // Generate cache key based on both kid and issuer to avoid conflicts between v1.0 and v2.0 keys + cacheKey := fmt.Sprintf("%s_%s", kid, issuer) + + // Check cache first if caching is enabled + if p.enableCache { + p.keyCache.mu.RLock() + if key, exists := p.keyCache.keys[cacheKey]; exists && time.Now().Before(p.keyCache.expiresAt) { + p.keyCache.mu.RUnlock() + return key, nil + } + p.keyCache.mu.RUnlock() + } + + // With Azure Management API scope, we should always get v2.0 format tokens + // Force using v2.0 JWKS endpoint for consistency + jwksURL := fmt.Sprintf("https://login.microsoftonline.com/%s/discovery/v2.0/keys", p.config.TenantID) + + resp, err := p.httpClient.Get(jwksURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch JWKS from %s: %w", jwksURL, err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("Failed to close response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read JWKS response: %w", err) + } + + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + Kty string `json:"kty"` + } `json:"keys"` + } + + if err := json.Unmarshal(body, &jwks); err != nil { + return nil, fmt.Errorf("failed to parse JWKS: %w", err) + } + + log.Printf("JWKS Contains %d keys, searching for kid=%s\n", len(jwks.Keys), kid) + + // Parse keys and find the target key + var targetKey *rsa.PublicKey + var foundKeyIds []string + + for _, key := range jwks.Keys { + foundKeyIds = append(foundKeyIds, key.Kid) + + if key.Kty == "RSA" && key.Kid == kid { + pubKey, err := parseRSAPublicKey(key.N, key.E) + if err != nil { + log.Printf("JWKS Failed to parse RSA key %s: %v\n", key.Kid, err) + continue + } + targetKey = pubKey + break + } + } + + // Cache the retrieved key and return it (only if caching is enabled) + if targetKey != nil { + if p.enableCache { + p.keyCache.mu.Lock() + if p.keyCache.keys == nil { + p.keyCache.keys = make(map[string]*rsa.PublicKey) + } + p.keyCache.keys[cacheKey] = targetKey + p.keyCache.expiresAt = time.Now().Add(24 * time.Hour) // Cache for 24 hours + p.keyCache.mu.Unlock() + } + return targetKey, nil + } + + return nil, fmt.Errorf("key with ID %s not found in JWKS (available: %v)", kid, foundKeyIds) +} + +// parseRSAPublicKey parses RSA public key from JWK format +func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { + // Decode base64url-encoded modulus + nBytes, err := base64.RawURLEncoding.DecodeString(nStr) + if err != nil { + return nil, fmt.Errorf("failed to decode modulus: %w", err) + } + + // Decode base64url-encoded exponent + eBytes, err := base64.RawURLEncoding.DecodeString(eStr) + if err != nil { + return nil, fmt.Errorf("failed to decode exponent: %w", err) + } + + // Convert bytes to big integers + n := new(big.Int).SetBytes(nBytes) + e := new(big.Int).SetBytes(eBytes) + + // Create RSA public key + pubKey := &rsa.PublicKey{ + N: n, + E: int(e.Int64()), + } + + return pubKey, nil +} diff --git a/internal/auth/oauth/provider_test.go b/internal/auth/oauth/provider_test.go new file mode 100644 index 0000000..657f2ba --- /dev/null +++ b/internal/auth/oauth/provider_test.go @@ -0,0 +1,388 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Azure/aks-mcp/internal/auth" +) + +func TestNewAzureOAuthProvider(t *testing.T) { + tests := []struct { + name string + config *auth.OAuthConfig + wantErr bool + }{ + { + name: "valid config should create provider", + config: &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client", + RequiredScopes: []string{"https://management.azure.com/.default"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: true, + ValidateAudience: true, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + }, + wantErr: false, + }, + { + name: "invalid config should fail", + config: &auth.OAuthConfig{ + Enabled: true, + // Missing required fields + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := NewAzureOAuthProvider(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("NewAzureOAuthProvider() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && provider == nil { + t.Error("NewAzureOAuthProvider() returned nil provider") + } + }) + } +} + +func TestGetProtectedResourceMetadata(t *testing.T) { + config := &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant-id", + ClientID: "test-client-id", + RequiredScopes: []string{"https://management.azure.com/.default"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: true, + ValidateAudience: true, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + } + + provider, err := NewAzureOAuthProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + serverURL := "http://localhost:8000" + metadata, err := provider.GetProtectedResourceMetadata(serverURL) + if err != nil { + t.Fatalf("GetProtectedResourceMetadata() error = %v", err) + } + + expectedAuthServer := "http://localhost:8000" + if len(metadata.AuthorizationServers) != 1 || metadata.AuthorizationServers[0] != expectedAuthServer { + t.Errorf("Expected authorization server %s, got %v", expectedAuthServer, metadata.AuthorizationServers) + } + + // Note: AzureADProtectedResourceMetadata doesn't include a Resource field. + // The resource URL is implied by the context of the request endpoint. + + if len(metadata.ScopesSupported) != 1 || metadata.ScopesSupported[0] != "https://management.azure.com/.default" { + t.Errorf("Expected scopes %v, got %v", config.RequiredScopes, metadata.ScopesSupported) + } +} + +func TestGetAuthorizationServerMetadataWithDefaults(t *testing.T) { + // Create a mock Azure AD metadata endpoint that's missing some fields + // This simulates the case where Azure AD doesn't provide all required fields + mockMetadata := AzureADMetadata{ + Issuer: "https://login.microsoftonline.com/test-tenant/v2.0", + AuthorizationEndpoint: "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize", + TokenEndpoint: "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/token", + JWKSUri: "https://login.microsoftonline.com/test-tenant/discovery/v2.0/keys", + ScopesSupported: []string{"openid", "profile", "email"}, + // Intentionally omit GrantTypesSupported, ResponseTypesSupported, etc. + // to test our default value logic + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(mockMetadata); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } + })) + defer server.Close() + + config := &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client", + RequiredScopes: []string{"https://management.azure.com/.default"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: true, + ValidateAudience: true, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + } + + provider, err := NewAzureOAuthProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + // Override the HTTP client to use our test server + provider.httpClient = &http.Client{ + Transport: &roundTripperFunc{ + fn: func(req *http.Request) (*http.Response, error) { + // Redirect all requests to our test server + req.URL.Scheme = "http" + req.URL.Host = server.URL[7:] // Remove "http://" + req.URL.Path = "/" + return http.DefaultTransport.RoundTrip(req) + }, + }, + } + + metadata, err := provider.GetAuthorizationServerMetadata(server.URL) + if err != nil { + t.Fatalf("GetAuthorizationServerMetadata() error = %v", err) + } + + // Verify that default values were populated for missing fields + expectedGrantTypes := []string{"authorization_code", "refresh_token"} + if len(metadata.GrantTypesSupported) != len(expectedGrantTypes) { + t.Errorf("Expected %d grant types, got %d", len(expectedGrantTypes), len(metadata.GrantTypesSupported)) + } + for i, expected := range expectedGrantTypes { + if i >= len(metadata.GrantTypesSupported) || metadata.GrantTypesSupported[i] != expected { + t.Errorf("Expected grant type %s at index %d, got %v", expected, i, metadata.GrantTypesSupported) + } + } + + expectedResponseTypes := []string{"code"} + if len(metadata.ResponseTypesSupported) != len(expectedResponseTypes) { + t.Errorf("Expected %d response types, got %d", len(expectedResponseTypes), len(metadata.ResponseTypesSupported)) + } + if len(metadata.ResponseTypesSupported) > 0 && metadata.ResponseTypesSupported[0] != "code" { + t.Errorf("Expected response type 'code', got %s", metadata.ResponseTypesSupported[0]) + } + + expectedSubjectTypes := []string{"public"} + if len(metadata.SubjectTypesSupported) != len(expectedSubjectTypes) { + t.Errorf("Expected %d subject types, got %d", len(expectedSubjectTypes), len(metadata.SubjectTypesSupported)) + } + if len(metadata.SubjectTypesSupported) > 0 && metadata.SubjectTypesSupported[0] != "public" { + t.Errorf("Expected subject type 'public', got %s", metadata.SubjectTypesSupported[0]) + } + + expectedTokenEndpointAuthMethods := []string{"none"} + if len(metadata.TokenEndpointAuthMethodsSupported) != len(expectedTokenEndpointAuthMethods) { + t.Errorf("Expected %d auth methods, got %d", len(expectedTokenEndpointAuthMethods), len(metadata.TokenEndpointAuthMethodsSupported)) + } + if len(metadata.TokenEndpointAuthMethodsSupported) > 0 && metadata.TokenEndpointAuthMethodsSupported[0] != "none" { + t.Errorf("Expected auth method 'none', got %s", metadata.TokenEndpointAuthMethodsSupported[0]) + } + + // Verify that PKCE is properly configured + expectedCodeChallengeMethods := []string{"S256"} + if len(metadata.CodeChallengeMethodsSupported) != len(expectedCodeChallengeMethods) { + t.Errorf("Expected %d code challenge methods, got %d", len(expectedCodeChallengeMethods), len(metadata.CodeChallengeMethodsSupported)) + } + if len(metadata.CodeChallengeMethodsSupported) > 0 && metadata.CodeChallengeMethodsSupported[0] != "S256" { + t.Errorf("Expected code challenge method 'S256', got %s", metadata.CodeChallengeMethodsSupported[0]) + } +} + +func TestGetAuthorizationServerMetadata(t *testing.T) { + // Create a mock Azure AD metadata endpoint + mockMetadata := AzureADMetadata{ + Issuer: "https://login.microsoftonline.com/test-tenant/v2.0", + AuthorizationEndpoint: "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/authorize", + TokenEndpoint: "https://login.microsoftonline.com/test-tenant/oauth2/v2.0/token", + JWKSUri: "https://login.microsoftonline.com/test-tenant/discovery/v2.0/keys", + ScopesSupported: []string{"openid", "profile", "email"}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(mockMetadata); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } + })) + defer server.Close() + + config := &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client", + RequiredScopes: []string{"https://management.azure.com/.default"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: true, + ValidateAudience: true, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + } + + provider, err := NewAzureOAuthProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + // Override the HTTP client to use our test server + provider.httpClient = &http.Client{ + Transport: &roundTripperFunc{ + fn: func(req *http.Request) (*http.Response, error) { + // Redirect all requests to our test server + req.URL.Scheme = "http" + req.URL.Host = server.URL[7:] // Remove "http://" + req.URL.Path = "/" + return http.DefaultTransport.RoundTrip(req) + }, + }, + } + + metadata, err := provider.GetAuthorizationServerMetadata(server.URL) + if err != nil { + t.Fatalf("GetAuthorizationServerMetadata() error = %v", err) + } + + if metadata.Issuer != mockMetadata.Issuer { + t.Errorf("Expected issuer %s, got %s", mockMetadata.Issuer, metadata.Issuer) + } + + expectedAuthEndpoint := fmt.Sprintf("%s/oauth2/v2.0/authorize", server.URL) + if metadata.AuthorizationEndpoint != expectedAuthEndpoint { + t.Errorf("Expected auth endpoint %s, got %s", expectedAuthEndpoint, metadata.AuthorizationEndpoint) + } +} + +func TestValidateTokenWithoutJWT(t *testing.T) { + // SECURITY WARNING: This test verifies the JWT validation bypass functionality + // ValidateJWT=false should ONLY be used in development/testing environments + // This functionality should NEVER be enabled in production + config := &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client", + RequiredScopes: []string{"https://management.azure.com/.default"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: false, // Disable JWT validation + ValidateAudience: false, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + } + + provider, err := NewAzureOAuthProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + ctx := context.Background() + // Use a token that looks like a JWT to pass initial format checks + testToken := "header.payload.signature" + tokenInfo, err := provider.ValidateToken(ctx, testToken) + if err != nil { + t.Fatalf("ValidateToken() error = %v", err) + } + + if tokenInfo.AccessToken != testToken { + t.Errorf("Expected access token %s, got %s", testToken, tokenInfo.AccessToken) + } + + if tokenInfo.TokenType != "Bearer" { + t.Errorf("Expected token type Bearer, got %s", tokenInfo.TokenType) + } +} + +func TestValidateAudience(t *testing.T) { + config := &auth.OAuthConfig{ + Enabled: true, + TenantID: "test-tenant", + ClientID: "test-client-id", + RequiredScopes: []string{"https://management.azure.com/.default"}, + TokenValidation: auth.TokenValidationConfig{ + ValidateJWT: true, + ValidateAudience: true, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: 5 * time.Minute, + ClockSkew: 1 * time.Minute, + }, + } + + provider, err := NewAzureOAuthProvider(config) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + tests := []struct { + name string + claims map[string]interface{} + wantErr bool + }{ + { + name: "valid audience string", + claims: map[string]interface{}{ + "aud": "https://management.azure.com/", + }, + wantErr: false, + }, + { + name: "valid client ID audience", + claims: map[string]interface{}{ + "aud": "test-client-id", + }, + wantErr: false, + }, + { + name: "valid audience array", + claims: map[string]interface{}{ + "aud": []interface{}{"https://management.azure.com/", "other-aud"}, + }, + wantErr: false, + }, + { + name: "invalid audience", + claims: map[string]interface{}{ + "aud": "invalid-audience", + }, + wantErr: true, + }, + { + name: "missing audience", + claims: map[string]interface{}{ + "sub": "user123", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := provider.validateAudience(tt.claims) + if (err != nil) != tt.wantErr { + t.Errorf("validateAudience() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// roundTripperFunc is a helper type for creating custom HTTP transports in tests +type roundTripperFunc struct { + fn func(*http.Request) (*http.Response, error) +} + +func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f.fn(req) +} diff --git a/internal/auth/types.go b/internal/auth/types.go new file mode 100644 index 0000000..d89cca6 --- /dev/null +++ b/internal/auth/types.go @@ -0,0 +1,140 @@ +package auth + +import ( + "fmt" + "time" +) + +// OAuthConfig represents OAuth configuration for AKS-MCP +type OAuthConfig struct { + // Enable OAuth authentication + Enabled bool `json:"enabled"` + + // Azure AD tenant ID + TenantID string `json:"tenant_id"` + + // Azure AD application (client) ID + ClientID string `json:"client_id"` + + // Required OAuth scopes for accessing AKS-MCP + RequiredScopes []string `json:"required_scopes"` + + // Allowed redirect URIs for OAuth callback + RedirectURIs []string `json:"redirect_uris"` + + // Allowed CORS origins for OAuth endpoints (for security, wildcard "*" should be avoided) + AllowedOrigins []string `json:"allowed_origins"` + + // Token validation settings + TokenValidation TokenValidationConfig `json:"token_validation"` +} + +// TokenValidationConfig represents token validation configuration +type TokenValidationConfig struct { + // SECURITY CRITICAL: Enable JWT token validation + // Setting this to false creates a security vulnerability - for development/testing ONLY + // MUST be true in production environments + ValidateJWT bool `json:"validate_jwt"` + + // Enable audience validation + ValidateAudience bool `json:"validate_audience"` + + // Expected audience for tokens + ExpectedAudience string `json:"expected_audience"` + + // Token cache TTL + CacheTTL time.Duration `json:"cache_ttl"` + + // Clock skew tolerance for token validation + ClockSkew time.Duration `json:"clock_skew"` +} + +// TokenInfo represents validated token information +type TokenInfo struct { + // Access token + AccessToken string `json:"access_token"` + + // Token type (usually "Bearer") + TokenType string `json:"token_type"` + + // Token expiration time + ExpiresAt time.Time `json:"expires_at"` + + // Token scope + Scope []string `json:"scope"` + + // Subject (user ID) + Subject string `json:"subject"` + + // Audience + Audience []string `json:"audience"` + + // Issuer + Issuer string `json:"issuer"` + + // Additional claims + Claims map[string]interface{} `json:"claims"` +} + +// AuthResult represents the result of authentication +type AuthResult struct { + // Whether authentication was successful + Authenticated bool `json:"authenticated"` + + // Token information (if authenticated) + TokenInfo *TokenInfo `json:"token_info,omitempty"` + + // Error message (if authentication failed) + Error string `json:"error,omitempty"` + + // HTTP status code to return + StatusCode int `json:"status_code"` +} + +// Default OAuth configuration values +const ( + DefaultTokenCacheTTL = 5 * time.Minute + DefaultClockSkew = 1 * time.Minute + DefaultExpectedAudience = "https://management.azure.com" + AzureADScope = "https://management.azure.com/.default" +) + +// NewDefaultOAuthConfig creates a default OAuth configuration +func NewDefaultOAuthConfig() *OAuthConfig { + return &OAuthConfig{ + Enabled: false, + // Use Azure Management API scope to get v2.0 format tokens + // This ensures we get v2.0 issuer format which works with v2.0 JWKS endpoints + RequiredScopes: []string{AzureADScope}, // "https://management.azure.com/.default" + // RedirectURIs will be populated dynamically based on host/port configuration + RedirectURIs: []string{}, + TokenValidation: TokenValidationConfig{ + ValidateJWT: true, // SECURITY CRITICAL: Always true in production + ValidateAudience: true, // Re-enabled with correct audience + ExpectedAudience: DefaultExpectedAudience, // "https://management.azure.com" + CacheTTL: DefaultTokenCacheTTL, + ClockSkew: DefaultClockSkew, + }, + } +} + +// Validate validates the OAuth configuration +func (cfg *OAuthConfig) Validate() error { + if !cfg.Enabled { + return nil + } + + if cfg.TenantID == "" { + return fmt.Errorf("tenant_id is required when OAuth is enabled") + } + + if cfg.ClientID == "" { + return fmt.Errorf("client_id is required when OAuth is enabled") + } + + // if len(cfg.RequiredScopes) == 0 { + // return fmt.Errorf("at least one required scope must be specified") + // } + + return nil +} diff --git a/internal/auth/types_test.go b/internal/auth/types_test.go new file mode 100644 index 0000000..d6becde --- /dev/null +++ b/internal/auth/types_test.go @@ -0,0 +1,183 @@ +package auth + +import ( + "os" + "testing" + "time" +) + +func TestOAuthConfigValidation(t *testing.T) { + tests := []struct { + name string + config *OAuthConfig + wantErr bool + }{ + { + name: "disabled OAuth should pass validation", + config: &OAuthConfig{ + Enabled: false, + }, + wantErr: false, + }, + { + name: "enabled OAuth with missing tenant ID should fail", + config: &OAuthConfig{ + Enabled: true, + ClientID: "test-client-id", + RequiredScopes: []string{"scope1"}, + }, + wantErr: true, + }, + { + name: "enabled OAuth with missing client ID should fail", + config: &OAuthConfig{ + Enabled: true, + TenantID: "test-tenant-id", + RequiredScopes: []string{"scope1"}, + }, + wantErr: true, + }, + { + name: "enabled OAuth with empty scopes should pass", + config: &OAuthConfig{ + Enabled: true, + TenantID: "test-tenant-id", + ClientID: "test-client-id", + RequiredScopes: []string{}, + }, + wantErr: false, + }, + { + name: "valid enabled OAuth config should pass", + config: &OAuthConfig{ + Enabled: true, + TenantID: "test-tenant-id", + ClientID: "test-client-id", + RequiredScopes: []string{"scope1"}, + }, + wantErr: false, + }, + { + name: "valid enabled OAuth config with full token validation should pass", + config: &OAuthConfig{ + Enabled: true, + TenantID: "test-tenant-id", + ClientID: "test-client-id", + RequiredScopes: []string{"scope1"}, + TokenValidation: TokenValidationConfig{ + ValidateJWT: true, + ValidateAudience: true, + ExpectedAudience: "https://management.azure.com/", + CacheTTL: DefaultTokenCacheTTL, + ClockSkew: DefaultClockSkew, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("OAuthConfig.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestNewDefaultOAuthConfig(t *testing.T) { + config := NewDefaultOAuthConfig() + + if config.Enabled { + t.Error("Default config should have OAuth disabled") + } + + if len(config.RequiredScopes) != 1 || config.RequiredScopes[0] != AzureADScope { + t.Errorf("Default config should have Azure AD scope, got %v", config.RequiredScopes) + } + + if !config.TokenValidation.ValidateJWT { + t.Error("Default config should enable JWT validation for production") + } + + if !config.TokenValidation.ValidateAudience { + t.Error("Default config should enable audience validation for security") + } + + if config.TokenValidation.ExpectedAudience != DefaultExpectedAudience { + t.Errorf("Default config should have correct expected audience, got %s", config.TokenValidation.ExpectedAudience) + } + + if config.TokenValidation.CacheTTL != DefaultTokenCacheTTL { + t.Errorf("Default config should have correct cache TTL, got %v", config.TokenValidation.CacheTTL) + } + + if config.TokenValidation.ClockSkew != DefaultClockSkew { + t.Errorf("Default config should have correct clock skew, got %v", config.TokenValidation.ClockSkew) + } +} + +func TestOAuthConfigConstants(t *testing.T) { + if DefaultTokenCacheTTL != 5*time.Minute { + t.Errorf("DefaultTokenCacheTTL should be 5 minutes, got %v", DefaultTokenCacheTTL) + } + + if DefaultClockSkew != 1*time.Minute { + t.Errorf("DefaultClockSkew should be 1 minute, got %v", DefaultClockSkew) + } + + if DefaultExpectedAudience != "https://management.azure.com" { + t.Errorf("DefaultExpectedAudience should be Azure management, got %s", DefaultExpectedAudience) + } + + if AzureADScope != "https://management.azure.com/.default" { + t.Errorf("AzureADScope should be Azure management default, got %s", AzureADScope) + } +} + +func TestOAuthConfigEnvironmentVariables(t *testing.T) { + // Test that environment variables are respected + oldTenantID := os.Getenv("AZURE_TENANT_ID") + oldClientID := os.Getenv("AZURE_CLIENT_ID") + + defer func() { + if err := os.Setenv("AZURE_TENANT_ID", oldTenantID); err != nil { + t.Logf("Failed to restore AZURE_TENANT_ID: %v", err) + } + if err := os.Setenv("AZURE_CLIENT_ID", oldClientID); err != nil { + t.Logf("Failed to restore AZURE_CLIENT_ID: %v", err) + } + }() + + if err := os.Setenv("AZURE_TENANT_ID", "env-tenant-id"); err != nil { + t.Fatalf("Failed to set AZURE_TENANT_ID: %v", err) + } + if err := os.Setenv("AZURE_CLIENT_ID", "env-client-id"); err != nil { + t.Fatalf("Failed to set AZURE_CLIENT_ID: %v", err) + } + + config := NewDefaultOAuthConfig() + config.Enabled = true + + // Simulate the environment variable loading that happens in config parsing + if config.TenantID == "" { + config.TenantID = os.Getenv("AZURE_TENANT_ID") + } + if config.ClientID == "" { + config.ClientID = os.Getenv("AZURE_CLIENT_ID") + } + + if config.TenantID != "env-tenant-id" { + t.Errorf("Expected tenant ID from environment, got %s", config.TenantID) + } + + if config.ClientID != "env-client-id" { + t.Errorf("Expected client ID from environment, got %s", config.ClientID) + } + + // Should pass validation with environment variables + if err := config.Validate(); err != nil { + t.Errorf("Config with environment variables should be valid, got error: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a653188..b60f068 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,15 +5,37 @@ import ( "fmt" "log" "os" + "regexp" "strings" "time" + "github.com/Azure/aks-mcp/internal/auth" "github.com/Azure/aks-mcp/internal/security" "github.com/Azure/aks-mcp/internal/telemetry" "github.com/Azure/aks-mcp/internal/version" flag "github.com/spf13/pflag" ) +// EnableCache controls whether caching is enabled globally +// Cache is enabled by default for production performance +// This affects both web cache headers and AzureOAuthProvider cache +// Can be disabled via DISABLE_CACHE environment variable +var EnableCache = os.Getenv("DISABLE_CACHE") != "true" + +// validateGUID validates that a value is in valid GUID format +func validateGUID(value, name string) error { + if value == "" { + return nil // Empty values are allowed (will be handled by OAuth validation) + } + + // GUID pattern: 8-4-4-4-12 hexadecimal digits with hyphens + guidRegex := regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + if !guidRegex.MatchString(value) { + return fmt.Errorf("%s must be a valid GUID format (e.g., 12345678-1234-1234-1234-123456789abc), got: %s", name, value) + } + return nil +} + // ConfigData holds the global configuration type ConfigData struct { // Command execution timeout in seconds @@ -22,6 +44,8 @@ type ConfigData struct { CacheTimeout time.Duration // Security configuration SecurityConfig *security.SecurityConfig + // OAuth configuration + OAuthConfig *auth.OAuthConfig // Command-line specific options Transport string @@ -51,6 +75,7 @@ func NewConfig() *ConfigData { Timeout: 60, CacheTimeout: 1 * time.Minute, SecurityConfig: security.NewSecurityConfig(), + OAuthConfig: auth.NewDefaultOAuthConfig(), Transport: "stdio", Port: 8000, AccessLevel: "readonly", @@ -66,9 +91,23 @@ func (cfg *ConfigData) ParseFlags() { flag.StringVar(&cfg.Host, "host", "127.0.0.1", "Host to listen for the server (only used with transport sse or streamable-http)") flag.IntVar(&cfg.Port, "port", 8000, "Port to listen for the server (only used with transport sse or streamable-http)") flag.IntVar(&cfg.Timeout, "timeout", 600, "Timeout for command execution in seconds, default is 600s") + // Security settings flag.StringVar(&cfg.AccessLevel, "access-level", "readonly", "Access level (readonly, readwrite, admin)") + // OAuth configuration + flag.BoolVar(&cfg.OAuthConfig.Enabled, "oauth-enabled", false, "Enable OAuth authentication") + flag.StringVar(&cfg.OAuthConfig.TenantID, "oauth-tenant-id", "", "Azure AD tenant ID for OAuth (fallback to AZURE_TENANT_ID env var)") + flag.StringVar(&cfg.OAuthConfig.ClientID, "oauth-client-id", "", "Azure AD client ID for OAuth (fallback to AZURE_CLIENT_ID env var)") + + // OAuth redirect URIs configuration + additionalRedirectURIs := flag.String("oauth-redirects", "", + "Comma-separated list of additional OAuth redirect URIs (e.g. http://localhost:8000/oauth/callback,http://localhost:6274/oauth/callback)") + + // OAuth CORS origins configuration + allowedCORSOrigins := flag.String("oauth-cors-origins", "", + "Comma-separated list of allowed CORS origins for OAuth endpoints (e.g. http://localhost:6274). If empty, no cross-origin requests are allowed for security") + // Kubernetes-specific settings additionalTools := flag.String("additional-tools", "", "Comma-separated list of additional Kubernetes tools to support (kubectl is always enabled). Available: helm,cilium,hubble") @@ -113,6 +152,12 @@ func (cfg *ConfigData) ParseFlags() { cfg.SecurityConfig.AccessLevel = cfg.AccessLevel cfg.SecurityConfig.AllowedNamespaces = cfg.AllowNamespaces + // Parse OAuth configuration + if err := cfg.parseOAuthConfig(*additionalRedirectURIs, *allowedCORSOrigins); err != nil { + fmt.Printf("OAuth configuration error: %v\n", err) + os.Exit(1) + } + // Parse additional tools if *additionalTools != "" { tools := strings.Split(*additionalTools, ",") @@ -122,6 +167,96 @@ func (cfg *ConfigData) ParseFlags() { } } +// parseOAuthConfig parses OAuth-related command line arguments +func (cfg *ConfigData) parseOAuthConfig(additionalRedirectURIs, allowedCORSOrigins string) error { + // Note: OAuth scopes are automatically configured to use "https://management.azure.com/.default" + // and are not configurable via command line per design + + // Track configuration sources for logging + var tenantIDSource, clientIDSource string + + // Load OAuth configuration from environment variables if not set via CLI + if cfg.OAuthConfig.TenantID == "" { + if tenantID := os.Getenv("AZURE_TENANT_ID"); tenantID != "" { + cfg.OAuthConfig.TenantID = tenantID + tenantIDSource = "environment variable AZURE_TENANT_ID" + log.Printf("OAuth Config: Using tenant ID from environment variable AZURE_TENANT_ID") + } + } else { + tenantIDSource = "command line flag --oauth-tenant-id" + log.Printf("OAuth Config: Using tenant ID from command line flag --oauth-tenant-id") + } + + if cfg.OAuthConfig.ClientID == "" { + if clientID := os.Getenv("AZURE_CLIENT_ID"); clientID != "" { + cfg.OAuthConfig.ClientID = clientID + clientIDSource = "environment variable AZURE_CLIENT_ID" + log.Printf("OAuth Config: Using client ID from environment variable AZURE_CLIENT_ID") + } + } else { + clientIDSource = "command line flag --oauth-client-id" + log.Printf("OAuth Config: Using client ID from command line flag --oauth-client-id") + } + + // Validate GUID formats for tenant ID and client ID + if err := validateGUID(cfg.OAuthConfig.TenantID, "OAuth tenant ID"); err != nil { + return fmt.Errorf("invalid OAuth tenant ID from %s: %w", tenantIDSource, err) + } + + if err := validateGUID(cfg.OAuthConfig.ClientID, "OAuth client ID"); err != nil { + return fmt.Errorf("invalid OAuth client ID from %s: %w", clientIDSource, err) + } + + // Set redirect URIs based on configured host and port + if cfg.OAuthConfig.Enabled { + redirectURI := fmt.Sprintf("http://%s:%d/oauth/callback", cfg.Host, cfg.Port) + cfg.OAuthConfig.RedirectURIs = []string{redirectURI} + + // Add localhost variant if using 127.0.0.1 + if cfg.Host == "127.0.0.1" { + localhostURI := fmt.Sprintf("http://localhost:%d/oauth/callback", cfg.Port) + cfg.OAuthConfig.RedirectURIs = append(cfg.OAuthConfig.RedirectURIs, localhostURI) + } + + // Add additional redirect URIs from command line flag + if additionalRedirectURIs != "" { + additionalURIs := strings.Split(additionalRedirectURIs, ",") + for _, uri := range additionalURIs { + trimmedURI := strings.TrimSpace(uri) + if trimmedURI != "" { + cfg.OAuthConfig.RedirectURIs = append(cfg.OAuthConfig.RedirectURIs, trimmedURI) + } + } + } + } + + // Parse allowed CORS origins for OAuth endpoints + if allowedCORSOrigins != "" { + log.Printf("OAuth Config: Setting allowed CORS origins from command line flag --oauth-cors-origins") + origins := strings.Split(allowedCORSOrigins, ",") + for _, origin := range origins { + trimmedOrigin := strings.TrimSpace(origin) + if trimmedOrigin != "" { + cfg.OAuthConfig.AllowedOrigins = append(cfg.OAuthConfig.AllowedOrigins, trimmedOrigin) + } + } + } else { + log.Printf("OAuth Config: No CORS origins configured - cross-origin requests will be blocked for security") + } + + return nil +} + +// ValidateConfig validates the configuration for incompatible settings +func (cfg *ConfigData) ValidateConfig() error { + // Validate OAuth + transport compatibility + if cfg.OAuthConfig.Enabled && cfg.Transport == "stdio" { + return fmt.Errorf("OAuth authentication is not supported with stdio transport per MCP specification") + } + + return nil +} + // InitializeTelemetry initializes the telemetry service func (cfg *ConfigData) InitializeTelemetry(ctx context.Context, serviceName, serviceVersion string) { // Create telemetry configuration diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..56ad2e4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,239 @@ +package config + +import ( + "testing" +) + +func TestBasicOAuthConfig(t *testing.T) { + // Test basic OAuth configuration parsing with valid GUIDs + cfg := NewConfig() + cfg.OAuthConfig.Enabled = true + cfg.OAuthConfig.TenantID = "12345678-1234-1234-1234-123456789abc" + cfg.OAuthConfig.ClientID = "87654321-4321-4321-4321-cba987654321" + + // Parse OAuth configuration + if err := cfg.parseOAuthConfig("", ""); err != nil { + t.Fatalf("Unexpected error in parseOAuthConfig: %v", err) + } + + // Verify basic configuration is preserved + if !cfg.OAuthConfig.Enabled { + t.Error("Expected OAuth to be enabled") + } + if cfg.OAuthConfig.TenantID != "12345678-1234-1234-1234-123456789abc" { + t.Errorf("Expected tenant ID '12345678-1234-1234-1234-123456789abc', got %s", cfg.OAuthConfig.TenantID) + } + if cfg.OAuthConfig.ClientID != "87654321-4321-4321-4321-cba987654321" { + t.Errorf("Expected client ID '87654321-4321-4321-4321-cba987654321', got %s", cfg.OAuthConfig.ClientID) + } +} + +func TestOAuthRedirectURIsConfig(t *testing.T) { + // Test OAuth redirect URIs configuration with additional URIs + cfg := NewConfig() + cfg.OAuthConfig.Enabled = true + cfg.Host = "127.0.0.1" + cfg.Port = 8081 + + // Test with additional redirect URIs + additionalRedirectURIs := "http://localhost:6274/oauth/callback,http://localhost:8080/oauth/callback" + if err := cfg.parseOAuthConfig(additionalRedirectURIs, ""); err != nil { + t.Fatalf("Unexpected error in parseOAuthConfig: %v", err) + } + + // Should have default URIs plus additional ones + expectedURIs := []string{ + "http://127.0.0.1:8081/oauth/callback", + "http://localhost:8081/oauth/callback", + "http://localhost:6274/oauth/callback", + "http://localhost:8080/oauth/callback", + } + + if len(cfg.OAuthConfig.RedirectURIs) != len(expectedURIs) { + t.Errorf("Expected %d redirect URIs, got %d", len(expectedURIs), len(cfg.OAuthConfig.RedirectURIs)) + } + + for i, expected := range expectedURIs { + if i >= len(cfg.OAuthConfig.RedirectURIs) || cfg.OAuthConfig.RedirectURIs[i] != expected { + t.Errorf("Expected redirect URI '%s' at index %d, got '%s'", expected, i, + func() string { + if i < len(cfg.OAuthConfig.RedirectURIs) { + return cfg.OAuthConfig.RedirectURIs[i] + } + return "missing" + }()) + } + } +} + +func TestOAuthRedirectURIsEmptyAdditional(t *testing.T) { + // Test OAuth redirect URIs configuration without additional URIs + cfg := NewConfig() + cfg.OAuthConfig.Enabled = true + cfg.Host = "127.0.0.1" + cfg.Port = 8081 + + // Test with empty additional redirect URIs + if err := cfg.parseOAuthConfig("", ""); err != nil { + t.Fatalf("Unexpected error in parseOAuthConfig: %v", err) + } + + // Should have only default URIs + expectedURIs := []string{ + "http://127.0.0.1:8081/oauth/callback", + "http://localhost:8081/oauth/callback", + } + + if len(cfg.OAuthConfig.RedirectURIs) != len(expectedURIs) { + t.Errorf("Expected %d redirect URIs, got %d", len(expectedURIs), len(cfg.OAuthConfig.RedirectURIs)) + } + + for i, expected := range expectedURIs { + if cfg.OAuthConfig.RedirectURIs[i] != expected { + t.Errorf("Expected redirect URI '%s' at index %d, got '%s'", expected, i, cfg.OAuthConfig.RedirectURIs[i]) + } + } +} + +func TestValidateGUID(t *testing.T) { + tests := []struct { + name string + value string + fieldName string + wantErr bool + }{ + { + name: "valid GUID", + value: "12345678-1234-1234-1234-123456789abc", + fieldName: "test field", + wantErr: false, + }, + { + name: "valid GUID uppercase", + value: "12345678-1234-1234-1234-123456789ABC", + fieldName: "test field", + wantErr: false, + }, + { + name: "empty value allowed", + value: "", + fieldName: "test field", + wantErr: false, + }, + { + name: "invalid format - missing hyphens", + value: "123456781234123412341234567890ab", + fieldName: "test field", + wantErr: true, + }, + { + name: "invalid format - wrong length", + value: "12345678-1234-1234-1234-123456789", + fieldName: "test field", + wantErr: true, + }, + { + name: "invalid format - non-hex characters", + value: "12345678-1234-1234-1234-123456789abg", + fieldName: "test field", + wantErr: true, + }, + { + name: "invalid format - extra hyphens", + value: "12345678-1234-1234-1234-1234-56789abc", + fieldName: "test field", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateGUID(tt.value, tt.fieldName) + if (err != nil) != tt.wantErr { + t.Errorf("validateGUID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && err != nil { + // Verify error message contains the field name and value + errorMsg := err.Error() + if !contains(errorMsg, tt.fieldName) { + t.Errorf("Error message should contain field name '%s', got: %s", tt.fieldName, errorMsg) + } + if tt.value != "" && !contains(errorMsg, tt.value) { + t.Errorf("Error message should contain value '%s', got: %s", tt.value, errorMsg) + } + } + }) + } +} + +func TestOAuthGUIDValidation(t *testing.T) { + tests := []struct { + name string + tenantID string + clientID string + wantErr bool + }{ + { + name: "valid GUIDs", + tenantID: "12345678-1234-1234-1234-123456789abc", + clientID: "87654321-4321-4321-4321-cba987654321", + wantErr: false, + }, + { + name: "empty values allowed", + tenantID: "", + clientID: "", + wantErr: false, + }, + { + name: "invalid tenant ID", + tenantID: "invalid-tenant-id", + clientID: "87654321-4321-4321-4321-cba987654321", + wantErr: true, + }, + { + name: "invalid client ID", + tenantID: "12345678-1234-1234-1234-123456789abc", + clientID: "invalid-client-id", + wantErr: true, + }, + { + name: "both invalid", + tenantID: "invalid-tenant", + clientID: "invalid-client", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewConfig() + cfg.OAuthConfig.Enabled = true + cfg.OAuthConfig.TenantID = tt.tenantID + cfg.OAuthConfig.ClientID = tt.clientID + cfg.Host = "127.0.0.1" + cfg.Port = 8081 + + err := cfg.parseOAuthConfig("", "") + if (err != nil) != tt.wantErr { + t.Errorf("parseOAuthConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +// contains is a helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/config/validator.go b/internal/config/validator.go index 3499192..2f5214f 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -64,12 +64,22 @@ func (v *Validator) validateCli() bool { return valid } +// validateConfig checks configuration compatibility +func (v *Validator) validateConfig() bool { + if err := v.config.ValidateConfig(); err != nil { + v.errors = append(v.errors, err.Error()) + return false + } + return true +} + // Validate runs all validation checks func (v *Validator) Validate() bool { // Run all validation checks validCli := v.validateCli() + validConfig := v.validateConfig() - return validCli + return validCli && validConfig } // GetErrors returns all errors found during validation diff --git a/internal/server/server.go b/internal/server/server.go index 555ca37..39b4513 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "github.com/Azure/aks-mcp/internal/auth/oauth" "github.com/Azure/aks-mcp/internal/azcli" "github.com/Azure/aks-mcp/internal/azureclient" "github.com/Azure/aks-mcp/internal/components/advisor" @@ -36,6 +37,9 @@ type Service struct { mcpServer *server.MCPServer azClient *azureclient.AzureClient azcliProcFactory func(timeout int) azcli.Proc + oauthProvider *oauth.AzureOAuthProvider + authMiddleware *oauth.AuthMiddleware + endpointManager *oauth.EndpointManager } // ServiceOption defines a function that configures the AKS MCP service @@ -83,6 +87,14 @@ func (s *Service) initializeInfrastructure() error { s.azClient = azClient log.Println("Azure client initialized successfully") + // Initialize OAuth components if enabled and transport is not stdio + // OAuth is not supported with stdio transport per MCP specification + if s.cfg.OAuthConfig.Enabled && s.cfg.Transport != "stdio" { + if err := s.initializeOAuth(); err != nil { + return fmt.Errorf("failed to initialize OAuth: %w", err) + } + } + // Ensure Azure CLI exists and is logged in if s.azcliProcFactory != nil { // Use injected factory to create an azcli.Proc @@ -114,6 +126,35 @@ func (s *Service) initializeInfrastructure() error { return nil } +// initializeOAuth initializes OAuth authentication components +func (s *Service) initializeOAuth() error { + log.Println("Initializing OAuth authentication...") + + // Validate OAuth configuration + if err := s.cfg.OAuthConfig.Validate(); err != nil { + return fmt.Errorf("invalid OAuth configuration: %w", err) + } + + // Create OAuth provider + provider, err := oauth.NewAzureOAuthProvider(s.cfg.OAuthConfig) + if err != nil { + return fmt.Errorf("failed to create OAuth provider: %w", err) + } + s.oauthProvider = provider + + // Create server URL for OAuth metadata + serverURL := fmt.Sprintf("http://%s:%d", s.cfg.Host, s.cfg.Port) + + // Create auth middleware + s.authMiddleware = oauth.NewAuthMiddleware(provider, serverURL) + + // Create endpoint manager + s.endpointManager = oauth.NewEndpointManager(provider, s.cfg) + + log.Printf("OAuth authentication initialized with tenant: %s", s.cfg.OAuthConfig.TenantID) + return nil +} + // registerAllComponents registers all component tools organized by category func (s *Service) registerAllComponents() { // Azure Components @@ -142,6 +183,15 @@ func (s *Service) registerPrompts() { func (s *Service) createCustomHTTPServerWithHelp404(addr string) *http.Server { mux := http.NewServeMux() + // Register OAuth endpoints if OAuth is enabled + if s.cfg.OAuthConfig.Enabled { + if s.endpointManager == nil { + log.Fatal("OAuth is enabled but endpoint manager is not initialized - this indicates a bug in server initialization") + } + log.Println("Registering OAuth endpoints...") + s.endpointManager.RegisterEndpoints(mux) + } + // Handle all other paths with a helpful 404 response mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/mcp" { @@ -159,6 +209,20 @@ func (s *Service) createCustomHTTPServerWithHelp404(addr string) *http.Server { }, } + // Add OAuth endpoints to the response if enabled + if s.cfg.OAuthConfig.Enabled { + oauthEndpoints := map[string]string{ + "oauth-metadata": "GET /.well-known/oauth-protected-resource - OAuth metadata", + "auth-server-metadata": "GET /.well-known/oauth-authorization-server - Authorization server metadata", + "client-registration": "POST /oauth/register - Dynamic client registration", + "token-introspection": "POST /oauth/introspect - Token introspection", + "health": "GET /health - Health check", + } + for k, v := range oauthEndpoints { + response["endpoints"].(map[string]string)[k] = v + } + } + if err := json.NewEncoder(w).Encode(response); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) } @@ -177,9 +241,28 @@ func (s *Service) createCustomHTTPServerWithHelp404(addr string) *http.Server { func (s *Service) createCustomSSEServerWithHelp404(sseServer *server.SSEServer, addr string) *http.Server { mux := http.NewServeMux() - // Register SSE and Message handlers - mux.Handle("/sse", sseServer.SSEHandler()) - mux.Handle("/message", sseServer.MessageHandler()) + // Register OAuth endpoints if OAuth is enabled + if s.cfg.OAuthConfig.Enabled { + if s.endpointManager == nil { + log.Fatal("OAuth is enabled but endpoint manager is not initialized - this indicates a bug in server initialization") + } + log.Println("Registering OAuth endpoints for SSE server...") + s.endpointManager.RegisterEndpoints(mux) + } + + // Register SSE and Message handlers with authentication if enabled + if s.cfg.OAuthConfig.Enabled { + if s.authMiddleware == nil { + log.Fatal("OAuth is enabled but auth middleware is not initialized - this indicates a bug in server initialization") + } + // Apply authentication middleware to SSE and Message endpoints + mux.Handle("/sse", s.authMiddleware.Middleware(sseServer.SSEHandler())) + mux.Handle("/message", s.authMiddleware.Middleware(sseServer.MessageHandler())) + } else { + // Register without authentication + mux.Handle("/sse", sseServer.SSEHandler()) + mux.Handle("/message", sseServer.MessageHandler()) + } // Handle all other paths with a helpful 404 response mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { @@ -196,6 +279,26 @@ func (s *Service) createCustomSSEServerWithHelp404(sseServer *server.SSEServer, }, } + // Add OAuth endpoints and authentication info if enabled + if s.cfg.OAuthConfig.Enabled { + response["authentication"] = map[string]interface{}{ + "required": true, + "type": "Bearer", + "note": "Include 'Authorization: Bearer ' header for authenticated endpoints", + } + + oauthEndpoints := map[string]string{ + "oauth-metadata": "GET /.well-known/oauth-protected-resource - OAuth metadata", + "auth-server-metadata": "GET /.well-known/oauth-authorization-server - Authorization server metadata", + "client-registration": "POST /oauth/register - Dynamic client registration", + "token-introspection": "POST /oauth/introspect - Token introspection", + "health": "GET /health - Health check", + } + for k, v := range oauthEndpoints { + response["endpoints"].(map[string]string)[k] = v + } + } + if err := json.NewEncoder(w).Encode(response); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) } @@ -231,6 +334,10 @@ func (s *Service) Run() error { log.Printf("SSE endpoint available at: http://%s/sse", addr) log.Printf("Message endpoint available at: http://%s/message", addr) log.Printf("Connect to /sse for real-time events, send JSON-RPC to /message") + if s.cfg.OAuthConfig.Enabled { + log.Printf("OAuth authentication enabled - Bearer token required for SSE and Message endpoints") + log.Printf("OAuth metadata available at: http://%s/.well-known/oauth-protected-resource", addr) + } return customServer.ListenAndServe() case "streamable-http": @@ -247,12 +354,25 @@ func (s *Service) Run() error { // Update the mux to use the actual streamable server as the MCP handler if mux, ok := customServer.Handler.(*http.ServeMux); ok { - mux.Handle("/mcp", streamableServer) + if s.cfg.OAuthConfig.Enabled { + if s.authMiddleware == nil { + log.Fatal("OAuth is enabled but auth middleware is not initialized - this indicates a bug in server initialization") + } + // Apply authentication middleware to MCP endpoint + mux.Handle("/mcp", s.authMiddleware.Middleware(streamableServer)) + } else { + // Register without authentication + mux.Handle("/mcp", streamableServer) + } } log.Printf("Streamable HTTP server listening on %s", addr) log.Printf("MCP endpoint available at: http://%s/mcp", addr) log.Printf("Send POST requests to /mcp to initialize session and obtain Mcp-Session-Id") + if s.cfg.OAuthConfig.Enabled { + log.Printf("OAuth authentication enabled - Bearer token required for MCP endpoint") + log.Printf("OAuth metadata available at: http://%s/.well-known/oauth-protected-resource", addr) + } return customServer.ListenAndServe() default: