diff --git a/.tekton/kubernetes-mcp-server-ols-pull-request.yaml b/.tekton/kubernetes-mcp-server-ols-pull-request.yaml deleted file mode 100644 index bef5679b..00000000 --- a/.tekton/kubernetes-mcp-server-ols-pull-request.yaml +++ /dev/null @@ -1,608 +0,0 @@ -apiVersion: tekton.dev/v1 -kind: PipelineRun -metadata: - annotations: - build.appstudio.openshift.io/repo: https://github.com/containers/kubernetes-mcp-server?rev={{revision}} - build.appstudio.redhat.com/commit_sha: '{{revision}}' - build.appstudio.redhat.com/pull_request_number: '{{pull_request_number}}' - build.appstudio.redhat.com/target_branch: '{{target_branch}}' - pipelinesascode.tekton.dev/cancel-in-progress: "true" - pipelinesascode.tekton.dev/max-keep-runs: "3" - pipelinesascode.tekton.dev/on-cel-expression: event == "pull_request" && target_branch - == "main" - creationTimestamp: null - labels: - appstudio.openshift.io/application: ols - appstudio.openshift.io/component: kubernetes-mcp-server-ols - pipelines.appstudio.openshift.io/type: build - name: kubernetes-mcp-server-ols-on-pull-request - namespace: crt-nshift-lightspeed-tenant -spec: - params: - - name: git-url - value: '{{source_url}}' - - name: revision - value: '{{revision}}' - - name: output-image - value: quay.io/redhat-user-workloads/crt-nshift-lightspeed-tenant/kubernetes-mcp-server-ols:on-pr-{{revision}} - - name: image-expires-after - value: 5d - - name: dockerfile - value: Dockerfile - - name: path-context - value: . - pipelineSpec: - description: | - This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. - - _Uses `buildah` to create a container image leveraging [trusted artifacts](https://konflux-ci.dev/architecture/ADR/0036-trusted-artifacts.html). It also optionally creates a source image and runs some build-time tests. Information is shared between tasks using OCI artifacts instead of PVCs. EC will pass the [`trusted_task.trusted`](https://conforma.dev/docs/policy/packages/release_trusted_task.html#trusted_task__trusted) policy as long as all data used to build the artifact is generated from trusted tasks. - This pipeline is pushed as a Tekton bundle to [quay.io](https://quay.io/repository/konflux-ci/tekton-catalog/pipeline-docker-build-oci-ta?tab=tags)_ - finally: - - name: show-sbom - params: - - name: IMAGE_URL - value: $(tasks.build-image-index.results.IMAGE_URL) - taskRef: - params: - - name: name - value: show-sbom - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-show-sbom:0.1@sha256:86c069cac0a669797e8049faa8aa4088e70ff7fcd579d5bdc37626a9e0488a05 - - name: kind - value: task - resolver: bundles - params: - - description: Source Repository URL - name: git-url - type: string - - default: "" - description: Revision of the Source Repository - name: revision - type: string - - description: Fully Qualified Output Image - name: output-image - type: string - - default: . - description: Path to the source code of an application's component from where - to build image. - name: path-context - type: string - - default: Dockerfile - description: Path to the Dockerfile inside the context specified by parameter - path-context - name: dockerfile - type: string - - default: "false" - description: Force rebuild image - name: rebuild - type: string - - default: "false" - description: Skip checks against built image - name: skip-checks - type: string - - default: "false" - description: Execute the build with network isolation - name: hermetic - type: string - - default: "" - description: Build dependencies to be prefetched by Cachi2 - name: prefetch-input - type: string - - default: "" - description: Image tag expiration time, time values could be something like - 1h, 2d, 3w for hours, days, and weeks, respectively. - name: image-expires-after - type: string - - default: "false" - description: Build a source image. - name: build-source-image - type: string - - default: "false" - description: Add built image into an OCI image index - name: build-image-index - type: string - - default: [] - description: Array of --build-arg values ("arg=value" strings) for buildah - name: build-args - type: array - - default: "" - description: Path to a file with build arguments for buildah, see https://www.mankier.com/1/buildah-build#--build-arg-file - name: build-args-file - type: string - - default: "false" - description: Whether to enable privileged mode, should be used only with remote - VMs - name: privileged-nested - type: string - results: - - description: "" - name: IMAGE_URL - value: $(tasks.build-image-index.results.IMAGE_URL) - - description: "" - name: IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - description: "" - name: CHAINS-GIT_URL - value: $(tasks.clone-repository.results.url) - - description: "" - name: CHAINS-GIT_COMMIT - value: $(tasks.clone-repository.results.commit) - tasks: - - name: init - params: - - name: image-url - value: $(params.output-image) - - name: rebuild - value: $(params.rebuild) - - name: skip-checks - value: $(params.skip-checks) - taskRef: - params: - - name: name - value: init - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:1d8221c84f91b923d89de50bf16481ea729e3b68ea04a9a7cbe8485ddbb27ee6 - - name: kind - value: task - resolver: bundles - - name: clone-repository - params: - - name: url - value: $(params.git-url) - - name: revision - value: $(params.revision) - - name: ociStorage - value: $(params.output-image).git - - name: ociArtifactExpiresAfter - value: $(params.image-expires-after) - runAfter: - - init - taskRef: - params: - - name: name - value: git-clone-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.1@sha256:d35e5d501cb5f5f88369511f76249857cb5ac30250e1dcf086939321964ff6b9 - - name: kind - value: task - resolver: bundles - when: - - input: $(tasks.init.results.build) - operator: in - values: - - "true" - workspaces: - - name: basic-auth - workspace: git-auth - - name: prefetch-dependencies - params: - - name: input - value: $(params.prefetch-input) - - name: SOURCE_ARTIFACT - value: $(tasks.clone-repository.results.SOURCE_ARTIFACT) - - name: ociStorage - value: $(params.output-image).prefetch - - name: ociArtifactExpiresAfter - value: $(params.image-expires-after) - runAfter: - - clone-repository - taskRef: - params: - - name: name - value: prefetch-dependencies-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.2@sha256:092491ac0f6e1009d10c58a1319d1029371bf637cc1293cceba53c6da5314ed1 - - name: kind - value: task - resolver: bundles - workspaces: - - name: git-basic-auth - workspace: git-auth - - name: netrc - workspace: netrc - - name: build-container - params: - - name: IMAGE - value: $(params.output-image) - - name: DOCKERFILE - value: $(params.dockerfile) - - name: CONTEXT - value: $(params.path-context) - - name: HERMETIC - value: $(params.hermetic) - - name: PREFETCH_INPUT - value: $(params.prefetch-input) - - name: IMAGE_EXPIRES_AFTER - value: $(params.image-expires-after) - - name: COMMIT_SHA - value: $(tasks.clone-repository.results.commit) - - name: BUILD_ARGS - value: - - $(params.build-args[*]) - - name: BUILD_ARGS_FILE - value: $(params.build-args-file) - - name: PRIVILEGED_NESTED - value: $(params.privileged-nested) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - prefetch-dependencies - taskRef: - params: - - name: name - value: buildah-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.4@sha256:9e9bac2044d6231b44114046b9d528c135388699365f0f210ee810c01bd4d702 - - name: kind - value: task - resolver: bundles - when: - - input: $(tasks.init.results.build) - operator: in - values: - - "true" - - name: build-image-index - params: - - name: IMAGE - value: $(params.output-image) - - name: COMMIT_SHA - value: $(tasks.clone-repository.results.commit) - - name: IMAGE_EXPIRES_AFTER - value: $(params.image-expires-after) - - name: ALWAYS_BUILD_INDEX - value: $(params.build-image-index) - - name: IMAGES - value: - - $(tasks.build-container.results.IMAGE_URL)@$(tasks.build-container.results.IMAGE_DIGEST) - runAfter: - - build-container - taskRef: - params: - - name: name - value: build-image-index - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.1@sha256:3499772af90aad0d3935629be6d37dd9292195fb629e6f43ec839c7f545a0faa - - name: kind - value: task - resolver: bundles - when: - - input: $(tasks.init.results.build) - operator: in - values: - - "true" - - name: build-source-image - params: - - name: BINARY_IMAGE - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: BINARY_IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: source-build-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3@sha256:b1eb49583b41872b27356fee20d5f0eb6ff7f5cdeacde7ffb39655f031104728 - - name: kind - value: task - resolver: bundles - when: - - input: $(tasks.init.results.build) - operator: in - values: - - "true" - - input: $(params.build-source-image) - operator: in - values: - - "true" - - name: deprecated-base-image-check - params: - - name: IMAGE_URL - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: deprecated-image-check - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:3c8b81fa868e27c6266e7660a4bfb4c822846dcf4304606e71e20893b0d3e515 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: clair-scan - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: clair-scan - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.2@sha256:417f44117f8d87a4a62fea6589b5746612ac61640b454dbd88f74892380411f2 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: ecosystem-cert-preflight-checks - params: - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: ecosystem-cert-preflight-checks - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:f99d2bdb02f13223d494077a2cde31418d09369f33c02134a8e7e5fad2f61eda - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: sast-snyk-check - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: sast-snyk-check-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.4@sha256:fe5e5ba3a72632cd505910de2eacd62c9d11ed570c325173188f8d568ac60771 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: clamav-scan - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: clamav-scan - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.2@sha256:7749146f7e4fe530846f1b15c9366178ec9f44776ef1922a60d3e7e2b8c6426b - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: sast-coverity-check - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: IMAGE - value: $(params.output-image) - - name: DOCKERFILE - value: $(params.dockerfile) - - name: CONTEXT - value: $(params.path-context) - - name: HERMETIC - value: $(params.hermetic) - - name: PREFETCH_INPUT - value: $(params.prefetch-input) - - name: IMAGE_EXPIRES_AFTER - value: $(params.image-expires-after) - - name: COMMIT_SHA - value: $(tasks.clone-repository.results.commit) - - name: BUILD_ARGS - value: - - $(params.build-args[*]) - - name: BUILD_ARGS_FILE - value: $(params.build-args-file) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - coverity-availability-check - taskRef: - params: - - name: name - value: sast-coverity-check-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-coverity-check-oci-ta:0.3@sha256:f9ca942208dc2e63b479384ccc56a611cc793397ecc837637b5b9f89c2ecbefe - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - input: $(tasks.coverity-availability-check.results.STATUS) - operator: in - values: - - success - - name: coverity-availability-check - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: coverity-availability-check - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-coverity-availability-check:0.2@sha256:db2b267dc15e4ed17f704ee91b8e9b38068e1a35b1018a328fdca621819d74c6 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: sast-shell-check - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: sast-shell-check-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:bf7bdde00b7212f730c1356672290af6f38d070da2c8a316987b5c32fd49e0b9 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: sast-unicode-check - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: sast-unicode-check-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.2@sha256:3f99dc4634a62e1530324cd565d12323ca82be3cfa8a031a36b210becfa7b552 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: apply-tags - params: - - name: IMAGE_URL - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: apply-tags - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.2@sha256:517a51e260c0b59654a9d7b842e1ab07d76bce15ca7ce9c8fd2489a19be6463d - - name: kind - value: task - resolver: bundles - - name: push-dockerfile - params: - - name: IMAGE - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: DOCKERFILE - value: $(params.dockerfile) - - name: CONTEXT - value: $(params.path-context) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: push-dockerfile-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.1@sha256:8c75c4a747e635e5f3e12266a3bb6e5d3132bf54e37eaa53d505f89897dd8eca - - name: kind - value: task - resolver: bundles - - name: rpms-signature-scan - params: - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: rpms-signature-scan - - name: bundle - value: quay.io/konflux-ci/konflux-vanguard/task-rpms-signature-scan:0.2@sha256:7d1c087d7d33dd97effb3b4c9f3788e4c3138da2032040d69da6929e9a3aaceb - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - workspaces: - - name: git-auth - optional: true - - name: netrc - optional: true - taskRunTemplate: - serviceAccountName: build-pipeline-kubernetes-mcp-server-ols - workspaces: - - name: git-auth - secret: - secretName: '{{ git_auth_secret }}' -status: {} diff --git a/.tekton/kubernetes-mcp-server-ols-push.yaml b/.tekton/kubernetes-mcp-server-ols-push.yaml deleted file mode 100644 index eb83bfce..00000000 --- a/.tekton/kubernetes-mcp-server-ols-push.yaml +++ /dev/null @@ -1,605 +0,0 @@ -apiVersion: tekton.dev/v1 -kind: PipelineRun -metadata: - annotations: - build.appstudio.openshift.io/repo: https://github.com/containers/kubernetes-mcp-server?rev={{revision}} - build.appstudio.redhat.com/commit_sha: '{{revision}}' - build.appstudio.redhat.com/target_branch: '{{target_branch}}' - pipelinesascode.tekton.dev/cancel-in-progress: "false" - pipelinesascode.tekton.dev/max-keep-runs: "3" - pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch - == "main" - creationTimestamp: null - labels: - appstudio.openshift.io/application: ols - appstudio.openshift.io/component: kubernetes-mcp-server-ols - pipelines.appstudio.openshift.io/type: build - name: kubernetes-mcp-server-ols-on-push - namespace: crt-nshift-lightspeed-tenant -spec: - params: - - name: git-url - value: '{{source_url}}' - - name: revision - value: '{{revision}}' - - name: output-image - value: quay.io/redhat-user-workloads/crt-nshift-lightspeed-tenant/kubernetes-mcp-server-ols:{{revision}} - - name: dockerfile - value: Dockerfile - - name: path-context - value: . - pipelineSpec: - description: | - This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. - - _Uses `buildah` to create a container image leveraging [trusted artifacts](https://konflux-ci.dev/architecture/ADR/0036-trusted-artifacts.html). It also optionally creates a source image and runs some build-time tests. Information is shared between tasks using OCI artifacts instead of PVCs. EC will pass the [`trusted_task.trusted`](https://conforma.dev/docs/policy/packages/release_trusted_task.html#trusted_task__trusted) policy as long as all data used to build the artifact is generated from trusted tasks. - This pipeline is pushed as a Tekton bundle to [quay.io](https://quay.io/repository/konflux-ci/tekton-catalog/pipeline-docker-build-oci-ta?tab=tags)_ - finally: - - name: show-sbom - params: - - name: IMAGE_URL - value: $(tasks.build-image-index.results.IMAGE_URL) - taskRef: - params: - - name: name - value: show-sbom - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-show-sbom:0.1@sha256:86c069cac0a669797e8049faa8aa4088e70ff7fcd579d5bdc37626a9e0488a05 - - name: kind - value: task - resolver: bundles - params: - - description: Source Repository URL - name: git-url - type: string - - default: "" - description: Revision of the Source Repository - name: revision - type: string - - description: Fully Qualified Output Image - name: output-image - type: string - - default: . - description: Path to the source code of an application's component from where - to build image. - name: path-context - type: string - - default: Dockerfile - description: Path to the Dockerfile inside the context specified by parameter - path-context - name: dockerfile - type: string - - default: "false" - description: Force rebuild image - name: rebuild - type: string - - default: "false" - description: Skip checks against built image - name: skip-checks - type: string - - default: "false" - description: Execute the build with network isolation - name: hermetic - type: string - - default: "" - description: Build dependencies to be prefetched by Cachi2 - name: prefetch-input - type: string - - default: "" - description: Image tag expiration time, time values could be something like - 1h, 2d, 3w for hours, days, and weeks, respectively. - name: image-expires-after - type: string - - default: "false" - description: Build a source image. - name: build-source-image - type: string - - default: "false" - description: Add built image into an OCI image index - name: build-image-index - type: string - - default: [] - description: Array of --build-arg values ("arg=value" strings) for buildah - name: build-args - type: array - - default: "" - description: Path to a file with build arguments for buildah, see https://www.mankier.com/1/buildah-build#--build-arg-file - name: build-args-file - type: string - - default: "false" - description: Whether to enable privileged mode, should be used only with remote - VMs - name: privileged-nested - type: string - results: - - description: "" - name: IMAGE_URL - value: $(tasks.build-image-index.results.IMAGE_URL) - - description: "" - name: IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - description: "" - name: CHAINS-GIT_URL - value: $(tasks.clone-repository.results.url) - - description: "" - name: CHAINS-GIT_COMMIT - value: $(tasks.clone-repository.results.commit) - tasks: - - name: init - params: - - name: image-url - value: $(params.output-image) - - name: rebuild - value: $(params.rebuild) - - name: skip-checks - value: $(params.skip-checks) - taskRef: - params: - - name: name - value: init - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.2@sha256:1d8221c84f91b923d89de50bf16481ea729e3b68ea04a9a7cbe8485ddbb27ee6 - - name: kind - value: task - resolver: bundles - - name: clone-repository - params: - - name: url - value: $(params.git-url) - - name: revision - value: $(params.revision) - - name: ociStorage - value: $(params.output-image).git - - name: ociArtifactExpiresAfter - value: $(params.image-expires-after) - runAfter: - - init - taskRef: - params: - - name: name - value: git-clone-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.1@sha256:d35e5d501cb5f5f88369511f76249857cb5ac30250e1dcf086939321964ff6b9 - - name: kind - value: task - resolver: bundles - when: - - input: $(tasks.init.results.build) - operator: in - values: - - "true" - workspaces: - - name: basic-auth - workspace: git-auth - - name: prefetch-dependencies - params: - - name: input - value: $(params.prefetch-input) - - name: SOURCE_ARTIFACT - value: $(tasks.clone-repository.results.SOURCE_ARTIFACT) - - name: ociStorage - value: $(params.output-image).prefetch - - name: ociArtifactExpiresAfter - value: $(params.image-expires-after) - runAfter: - - clone-repository - taskRef: - params: - - name: name - value: prefetch-dependencies-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.2@sha256:092491ac0f6e1009d10c58a1319d1029371bf637cc1293cceba53c6da5314ed1 - - name: kind - value: task - resolver: bundles - workspaces: - - name: git-basic-auth - workspace: git-auth - - name: netrc - workspace: netrc - - name: build-container - params: - - name: IMAGE - value: $(params.output-image) - - name: DOCKERFILE - value: $(params.dockerfile) - - name: CONTEXT - value: $(params.path-context) - - name: HERMETIC - value: $(params.hermetic) - - name: PREFETCH_INPUT - value: $(params.prefetch-input) - - name: IMAGE_EXPIRES_AFTER - value: $(params.image-expires-after) - - name: COMMIT_SHA - value: $(tasks.clone-repository.results.commit) - - name: BUILD_ARGS - value: - - $(params.build-args[*]) - - name: BUILD_ARGS_FILE - value: $(params.build-args-file) - - name: PRIVILEGED_NESTED - value: $(params.privileged-nested) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - prefetch-dependencies - taskRef: - params: - - name: name - value: buildah-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.4@sha256:9e9bac2044d6231b44114046b9d528c135388699365f0f210ee810c01bd4d702 - - name: kind - value: task - resolver: bundles - when: - - input: $(tasks.init.results.build) - operator: in - values: - - "true" - - name: build-image-index - params: - - name: IMAGE - value: $(params.output-image) - - name: COMMIT_SHA - value: $(tasks.clone-repository.results.commit) - - name: IMAGE_EXPIRES_AFTER - value: $(params.image-expires-after) - - name: ALWAYS_BUILD_INDEX - value: $(params.build-image-index) - - name: IMAGES - value: - - $(tasks.build-container.results.IMAGE_URL)@$(tasks.build-container.results.IMAGE_DIGEST) - runAfter: - - build-container - taskRef: - params: - - name: name - value: build-image-index - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.1@sha256:3499772af90aad0d3935629be6d37dd9292195fb629e6f43ec839c7f545a0faa - - name: kind - value: task - resolver: bundles - when: - - input: $(tasks.init.results.build) - operator: in - values: - - "true" - - name: build-source-image - params: - - name: BINARY_IMAGE - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: BINARY_IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: source-build-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3@sha256:b1eb49583b41872b27356fee20d5f0eb6ff7f5cdeacde7ffb39655f031104728 - - name: kind - value: task - resolver: bundles - when: - - input: $(tasks.init.results.build) - operator: in - values: - - "true" - - input: $(params.build-source-image) - operator: in - values: - - "true" - - name: deprecated-base-image-check - params: - - name: IMAGE_URL - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: deprecated-image-check - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:3c8b81fa868e27c6266e7660a4bfb4c822846dcf4304606e71e20893b0d3e515 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: clair-scan - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: clair-scan - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clair-scan:0.2@sha256:417f44117f8d87a4a62fea6589b5746612ac61640b454dbd88f74892380411f2 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: ecosystem-cert-preflight-checks - params: - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: ecosystem-cert-preflight-checks - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:f99d2bdb02f13223d494077a2cde31418d09369f33c02134a8e7e5fad2f61eda - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: sast-snyk-check - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: sast-snyk-check-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.4@sha256:fe5e5ba3a72632cd505910de2eacd62c9d11ed570c325173188f8d568ac60771 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: clamav-scan - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: clamav-scan - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.2@sha256:7749146f7e4fe530846f1b15c9366178ec9f44776ef1922a60d3e7e2b8c6426b - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: sast-coverity-check - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: IMAGE - value: $(params.output-image) - - name: DOCKERFILE - value: $(params.dockerfile) - - name: CONTEXT - value: $(params.path-context) - - name: HERMETIC - value: $(params.hermetic) - - name: PREFETCH_INPUT - value: $(params.prefetch-input) - - name: IMAGE_EXPIRES_AFTER - value: $(params.image-expires-after) - - name: COMMIT_SHA - value: $(tasks.clone-repository.results.commit) - - name: BUILD_ARGS - value: - - $(params.build-args[*]) - - name: BUILD_ARGS_FILE - value: $(params.build-args-file) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - coverity-availability-check - taskRef: - params: - - name: name - value: sast-coverity-check-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-coverity-check-oci-ta:0.3@sha256:f9ca942208dc2e63b479384ccc56a611cc793397ecc837637b5b9f89c2ecbefe - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - input: $(tasks.coverity-availability-check.results.STATUS) - operator: in - values: - - success - - name: coverity-availability-check - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: coverity-availability-check - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-coverity-availability-check:0.2@sha256:db2b267dc15e4ed17f704ee91b8e9b38068e1a35b1018a328fdca621819d74c6 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: sast-shell-check - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: sast-shell-check-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:bf7bdde00b7212f730c1356672290af6f38d070da2c8a316987b5c32fd49e0b9 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: sast-unicode-check - params: - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: sast-unicode-check-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.2@sha256:3f99dc4634a62e1530324cd565d12323ca82be3cfa8a031a36b210becfa7b552 - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - - name: apply-tags - params: - - name: IMAGE_URL - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: apply-tags - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.2@sha256:517a51e260c0b59654a9d7b842e1ab07d76bce15ca7ce9c8fd2489a19be6463d - - name: kind - value: task - resolver: bundles - - name: push-dockerfile - params: - - name: IMAGE - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: IMAGE_DIGEST - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - - name: DOCKERFILE - value: $(params.dockerfile) - - name: CONTEXT - value: $(params.path-context) - - name: SOURCE_ARTIFACT - value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: push-dockerfile-oci-ta - - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.1@sha256:8c75c4a747e635e5f3e12266a3bb6e5d3132bf54e37eaa53d505f89897dd8eca - - name: kind - value: task - resolver: bundles - - name: rpms-signature-scan - params: - - name: image-url - value: $(tasks.build-image-index.results.IMAGE_URL) - - name: image-digest - value: $(tasks.build-image-index.results.IMAGE_DIGEST) - runAfter: - - build-image-index - taskRef: - params: - - name: name - value: rpms-signature-scan - - name: bundle - value: quay.io/konflux-ci/konflux-vanguard/task-rpms-signature-scan:0.2@sha256:7d1c087d7d33dd97effb3b4c9f3788e4c3138da2032040d69da6929e9a3aaceb - - name: kind - value: task - resolver: bundles - when: - - input: $(params.skip-checks) - operator: in - values: - - "false" - workspaces: - - name: git-auth - optional: true - - name: netrc - optional: true - taskRunTemplate: - serviceAccountName: build-pipeline-kubernetes-mcp-server-ols - workspaces: - - name: git-auth - secret: - secretName: '{{ git_auth_secret }}' -status: {} diff --git a/go.mod b/go.mod index 2ed1805b..1087161f 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,12 @@ require ( github.com/coreos/go-oidc/v3 v3.15.0 github.com/fsnotify/fsnotify v1.9.0 github.com/go-jose/go-jose/v4 v4.1.2 - github.com/mark3labs/mcp-go v0.36.0 + github.com/mark3labs/mcp-go v0.37.0 github.com/pkg/errors v0.9.1 github.com/spf13/afero v1.14.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.7 + golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.16.0 helm.sh/helm/v3 v3.18.4 k8s.io/api v0.33.3 @@ -116,7 +117,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/net v0.42.0 // indirect - golang.org/x/oauth2 v0.30.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 diff --git a/go.sum b/go.sum index 87473834..b75663f0 100644 --- a/go.sum +++ b/go.sum @@ -187,8 +187,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.36.0 h1:rIZaijrRYPeSbJG8/qNDe0hWlGrCJ7FWHNMz2SQpTis= -github.com/mark3labs/mcp-go v0.36.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mark3labs/mcp-go v0.37.0 h1:BywvZLPRT6Zx6mMG/MJfxLSZQkTGIcJSEGKsvr4DsoQ= +github.com/mark3labs/mcp-go v0.37.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/pkg/mcp/mock_server_test.go b/internal/test/mock_server.go similarity index 69% rename from pkg/mcp/mock_server_test.go rename to internal/test/mock_server.go index 124e5ab5..b5f9047a 100644 --- a/pkg/mcp/mock_server_test.go +++ b/internal/test/mock_server.go @@ -1,9 +1,12 @@ -package mcp +package test import ( "encoding/json" "errors" "io" + "net/http" + "net/http/httptest" + v1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -11,8 +14,7 @@ import ( "k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/httpstream/spdy" "k8s.io/client-go/rest" - "net/http" - "net/http/httptest" + "k8s.io/client-go/tools/clientcmd/api" ) type MockServer struct { @@ -51,7 +53,26 @@ func (m *MockServer) Handle(handler http.Handler) { m.restHandlers = append(m.restHandlers, handler.ServeHTTP) } -func writeObject(w http.ResponseWriter, obj runtime.Object) { +func (m *MockServer) Config() *rest.Config { + return m.config +} + +func (m *MockServer) KubeConfig() *api.Config { + fakeConfig := api.NewConfig() + fakeConfig.Clusters["fake"] = api.NewCluster() + fakeConfig.Clusters["fake"].Server = m.config.Host + fakeConfig.Clusters["fake"].CertificateAuthorityData = m.config.CAData + fakeConfig.AuthInfos["fake"] = api.NewAuthInfo() + fakeConfig.AuthInfos["fake"].ClientKeyData = m.config.KeyData + fakeConfig.AuthInfos["fake"].ClientCertificateData = m.config.CertData + fakeConfig.Contexts["fake-context"] = api.NewContext() + fakeConfig.Contexts["fake-context"].Cluster = "fake" + fakeConfig.Contexts["fake-context"].AuthInfo = "fake" + fakeConfig.CurrentContext = "fake-context" + return fakeConfig +} + +func WriteObject(w http.ResponseWriter, obj runtime.Object) { w.Header().Set("Content-Type", runtime.ContentTypeJSON) if err := json.NewEncoder(w).Encode(obj); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -63,11 +84,11 @@ type streamAndReply struct { replySent <-chan struct{} } -type streamContext struct { - conn io.Closer - stdinStream io.ReadCloser - stdoutStream io.WriteCloser - stderrStream io.WriteCloser +type StreamContext struct { + Closer io.Closer + StdinStream io.ReadCloser + StdoutStream io.WriteCloser + StderrStream io.WriteCloser writeStatus func(status *apierrors.StatusError) error } @@ -87,7 +108,7 @@ func v4WriteStatusFunc(stream io.Writer) func(status *apierrors.StatusError) err return err } } -func createHTTPStreams(w http.ResponseWriter, req *http.Request, opts *StreamOptions) (*streamContext, error) { +func CreateHTTPStreams(w http.ResponseWriter, req *http.Request, opts *StreamOptions) (*StreamContext, error) { _, err := httpstream.Handshake(req, w, []string{"v4.channel.k8s.io"}) if err != nil { return nil, err @@ -95,12 +116,12 @@ func createHTTPStreams(w http.ResponseWriter, req *http.Request, opts *StreamOpt upgrader := spdy.NewResponseUpgrader() streamCh := make(chan streamAndReply) - conn := upgrader.UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error { + connection := upgrader.UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error { streamCh <- streamAndReply{Stream: stream, replySent: replySent} return nil }) - ctx := &streamContext{ - conn: conn, + ctx := &StreamContext{ + Closer: connection, } // wait for stream @@ -128,13 +149,13 @@ WaitForStreams: ctx.writeStatus = v4WriteStatusFunc(stream) case v1.StreamTypeStdout: replyChan <- struct{}{} - ctx.stdoutStream = stream + ctx.StdoutStream = stream case v1.StreamTypeStdin: replyChan <- struct{}{} - ctx.stdinStream = stream + ctx.StdinStream = stream case v1.StreamTypeStderr: replyChan <- struct{}{} - ctx.stderrStream = stream + ctx.StderrStream = stream default: // add other stream ... return nil, errors.New("unimplemented stream type") diff --git a/pkg/config/config.go b/pkg/config/config.go index 970d8753..6e797971 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -19,12 +19,28 @@ type StaticConfig struct { // When true, expose only tools annotated with readOnlyHint=true ReadOnly bool `toml:"read_only,omitempty"` // When true, disable tools annotated with destructiveHint=true - DisableDestructive bool `toml:"disable_destructive,omitempty"` - EnabledTools []string `toml:"enabled_tools,omitempty"` - DisabledTools []string `toml:"disabled_tools,omitempty"` - RequireOAuth bool `toml:"require_oauth,omitempty"` - AuthorizationURL string `toml:"authorization_url,omitempty"` - JwksURL string `toml:"jwks_url,omitempty"` + DisableDestructive bool `toml:"disable_destructive,omitempty"` + EnabledTools []string `toml:"enabled_tools,omitempty"` + DisabledTools []string `toml:"disabled_tools,omitempty"` + + // Authorization-related fields + // RequireOAuth indicates whether the server requires OAuth for authentication. + RequireOAuth bool `toml:"require_oauth,omitempty"` + // OAuthAudience is the valid audience for the OAuth tokens, used for offline JWT claim validation. + OAuthAudience string `toml:"oauth_audience,omitempty"` + // ValidateToken indicates whether the server should validate the token against the Kubernetes API Server using TokenReview. + ValidateToken bool `toml:"validate_token,omitempty"` + // AuthorizationURL is the URL of the OIDC authorization server. + // It is used for token validation and for STS token exchange. + AuthorizationURL string `toml:"authorization_url,omitempty"` + // StsClientId is the OAuth client ID used for backend token exchange + StsClientId string `toml:"sts_client_id,omitempty"` + // StsClientSecret is the OAuth client secret used for backend token exchange + StsClientSecret string `toml:"sts_client_secret,omitempty"` + // StsAudience is the audience for the STS token exchange. + StsAudience string `toml:"sts_audience,omitempty"` + // StsScopes is the scopes for the STS token exchange. + StsScopes []string `toml:"sts_scopes,omitempty"` CertificateAuthority string `toml:"certificate_authority,omitempty"` ServerURL string `toml:"server_url,omitempty"` } diff --git a/pkg/http/authorization.go b/pkg/http/authorization.go index 2b3152b8..6fa81f08 100644 --- a/pkg/http/authorization.go +++ b/pkg/http/authorization.go @@ -9,108 +9,130 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" + "golang.org/x/oauth2" + authenticationapiv1 "k8s.io/api/authentication/v1" "k8s.io/klog/v2" + "k8s.io/utils/strings/slices" + "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/containers/kubernetes-mcp-server/pkg/mcp" ) -const ( - Audience = "kubernetes-mcp-server" -) +type KubernetesApiTokenVerifier interface { + // KubernetesApiVerifyToken TODO: clarify proper implementation + KubernetesApiVerifyToken(ctx context.Context, token, audience string) (*authenticationapiv1.UserInfo, []string, error) +} -// AuthorizationMiddleware validates the OAuth flow using Kubernetes TokenReview API -func AuthorizationMiddleware(requireOAuth bool, serverURL string, oidcProvider *oidc.Provider, mcpServer *mcp.Server) func(http.Handler) http.Handler { +// AuthorizationMiddleware validates the OAuth flow for protected resources. +// +// The flow is skipped for unprotected resources, such as health checks and well-known endpoints. +// +// There are several auth scenarios supported by this middleware: +// +// 1. requireOAuth is false: +// +// - The OAuth flow is skipped, and the server is effectively unprotected. +// - The request is passed to the next handler without any validation. +// +// see TestAuthorizationRequireOAuthFalse +// +// 2. requireOAuth is set to true, server is protected: +// +// 2.1. Raw Token Validation (oidcProvider is nil): +// - The token is validated offline for basic sanity checks (expiration). +// - If OAuthAudience is set, the token is validated against the audience. +// - If ValidateToken is set, the token is then used against the Kubernetes API Server for TokenReview. +// +// see TestAuthorizationRawToken +// +// 2.2. OIDC Provider Validation (oidcProvider is not nil): +// - The token is validated offline for basic sanity checks (audience and expiration). +// - If OAuthAudience is set, the token is validated against the audience. +// - The token is then validated against the OIDC Provider. +// - If ValidateToken is set, the token is then used against the Kubernetes API Server for TokenReview. +// +// see TestAuthorizationOidcToken +// +// 2.3. OIDC Token Exchange (oidcProvider is not nil, StsClientId and StsAudience are set): +// - The token is validated offline for basic sanity checks (audience and expiration). +// - If OAuthAudience is set, the token is validated against the audience. +// - The token is then validated against the OIDC Provider. +// - If the token is valid, an external account token exchange is performed using +// the OIDC Provider to obtain a new token with the specified audience and scopes. +// - If ValidateToken is set, the exchanged token is then used against the Kubernetes API Server for TokenReview. +// +// see TestAuthorizationOidcTokenExchange +func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oidc.Provider, verifier KubernetesApiTokenVerifier) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == healthEndpoint || r.URL.Path == oauthProtectedResourceEndpoint { + if r.URL.Path == healthEndpoint || slices.Contains(WellKnownEndpoints, r.URL.EscapedPath()) { next.ServeHTTP(w, r) return } - if !requireOAuth { + if !staticConfig.RequireOAuth { next.ServeHTTP(w, r) return } - audience := Audience - if serverURL != "" { - audience = serverURL + wwwAuthenticateHeader := "Bearer realm=\"Kubernetes MCP Server\"" + if staticConfig.OAuthAudience != "" { + wwwAuthenticateHeader += fmt.Sprintf(`, audience="%s"`, staticConfig.OAuthAudience) } authHeader := r.Header.Get("Authorization") if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { klog.V(1).Infof("Authentication failed - missing or invalid bearer token: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) - if serverURL == "" { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="missing_token"`, audience)) - } else { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="missing_token"`, audience, serverURL, oauthProtectedResourceEndpoint)) - } + w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+", error=\"missing_token\"") http.Error(w, "Unauthorized: Bearer token required", http.StatusUnauthorized) return } token := strings.TrimPrefix(authHeader, "Bearer ") - // Validate the token offline for simple sanity check - // Because missing expected audience and expired tokens must be - // rejected already. claims, err := ParseJWTClaims(token) - if err == nil && claims != nil { - err = claims.Validate(audience) + if err == nil && claims == nil { + // Impossible case, but just in case + err = fmt.Errorf("failed to parse JWT claims from token") } - if err != nil { - klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err) - - if serverURL == "" { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience)) - } else { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint)) - } - http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized) - return + // Offline validation + if err == nil { + err = claims.ValidateOffline(staticConfig.OAuthAudience) } - - if oidcProvider != nil { - // If OIDC Provider is configured, this token must be validated against it. - if err := validateTokenWithOIDC(r.Context(), oidcProvider, token, audience); err != nil { - klog.V(1).Infof("Authentication failed - OIDC token validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err) - - if serverURL == "" { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience)) - } else { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint)) - } - http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized) - return + // Online OIDC provider validation + if err == nil { + err = claims.ValidateWithProvider(r.Context(), staticConfig.OAuthAudience, oidcProvider) + } + // Scopes propagation, they are likely to be used for authorization. + if err == nil { + scopes := claims.GetScopes() + klog.V(2).Infof("JWT token validated - Scopes: %v", scopes) + r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes)) + } + // Token exchange with OIDC provider + sts := NewFromConfig(staticConfig, oidcProvider) + if err == nil && sts.IsEnabled() { + var exchangedToken *oauth2.Token + // If the token is valid, we can exchange it for a new token with the specified audience and scopes. + exchangedToken, err = sts.ExternalAccountTokenExchange(r.Context(), &oauth2.Token{ + AccessToken: claims.Token, + TokenType: "Bearer", + }) + if err == nil { + // Replace the original token with the exchanged token + token = exchangedToken.AccessToken + claims, err = ParseJWTClaims(token) + r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // TODO: Implement test to verify, THIS IS A CRITICAL PART } } - - // Scopes are likely to be used for authorization. - scopes := claims.GetScopes() - klog.V(2).Infof("JWT token validated - Scopes: %v", scopes) - r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes)) - - // Now, there are a couple of options: - // 1. If there is no authorization url configured for this MCP Server, - // that means this token will be used against the Kubernetes API Server. - // So that we need to validate the token using Kubernetes TokenReview API beforehand. - // 2. If there is an authorization url configured for this MCP Server, - // that means up to this point, the token is validated against the OIDC Provider already. - // 2. a. If this is the only token in the headers, this validated token - // is supposed to be used against the Kubernetes API Server as well. Therefore, - // TokenReview request must succeed. - // 2. b. If this is not the only token in the headers, the token in here is used - // only for authentication and authorization. Therefore, we need to send TokenReview request - // with the other token in the headers (TODO: still need to validate aud and exp of this token separately). - _, _, err = mcpServer.VerifyTokenAPIServer(r.Context(), token, audience) + // Kubernetes API Server TokenReview validation + if err == nil && staticConfig.ValidateToken { + err = claims.ValidateWithKubernetesApi(r.Context(), staticConfig.OAuthAudience, verifier) + } if err != nil { - klog.V(1).Infof("Authentication failed - API Server token validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err) + klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err) - if serverURL == "" { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s", error="invalid_token"`, audience)) - } else { - w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="Kubernetes MCP Server", audience="%s"", resource_metadata="%s%s", error="invalid_token"`, audience, serverURL, oauthProtectedResourceEndpoint)) - } + w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+", error=\"invalid_token\"") http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized) return } @@ -138,6 +160,7 @@ var allSignatureAlgorithms = []jose.SignatureAlgorithm{ type JWTClaims struct { jwt.Claims + Token string `json:"-"` Scope string `json:"scope,omitempty"` } @@ -148,11 +171,40 @@ func (c *JWTClaims) GetScopes() []string { return strings.Fields(c.Scope) } -// Validate Checks if the JWT claims are valid and if the audience matches the expected one. -func (c *JWTClaims) Validate(audience string) error { - return c.Claims.Validate(jwt.Expected{ - AnyAudience: jwt.Audience{audience}, - }) +// ValidateOffline Checks if the JWT claims are valid and if the audience matches the expected one. +func (c *JWTClaims) ValidateOffline(audience string) error { + expected := jwt.Expected{} + if audience != "" { + expected.AnyAudience = jwt.Audience{audience} + } + if err := c.Validate(expected); err != nil { + return fmt.Errorf("JWT token validation error: %v", err) + } + return nil +} + +// ValidateWithProvider validates the JWT claims against the OIDC provider. +func (c *JWTClaims) ValidateWithProvider(ctx context.Context, audience string, provider *oidc.Provider) error { + if provider != nil { + verifier := provider.Verifier(&oidc.Config{ + ClientID: audience, + }) + _, err := verifier.Verify(ctx, c.Token) + if err != nil { + return fmt.Errorf("OIDC token validation error: %v", err) + } + } + return nil +} + +func (c *JWTClaims) ValidateWithKubernetesApi(ctx context.Context, audience string, verifier KubernetesApiTokenVerifier) error { + if verifier != nil { + _, _, err := verifier.KubernetesApiVerifyToken(ctx, c.Token, audience) + if err != nil { + return fmt.Errorf("kubernetes API token validation error: %v", err) + } + } + return nil } func ParseJWTClaims(token string) (*JWTClaims, error) { @@ -162,18 +214,6 @@ func ParseJWTClaims(token string) (*JWTClaims, error) { } claims := &JWTClaims{} err = tkn.UnsafeClaimsWithoutVerification(claims) + claims.Token = token return claims, err } - -func validateTokenWithOIDC(ctx context.Context, provider *oidc.Provider, token, audience string) error { - verifier := provider.Verifier(&oidc.Config{ - ClientID: audience, - }) - - _, err := verifier.Verify(ctx, token) - if err != nil { - return fmt.Errorf("JWT token verification failed: %v", err) - } - - return nil -} diff --git a/pkg/http/authorization_test.go b/pkg/http/authorization_test.go index 31ad8041..e4c37caa 100644 --- a/pkg/http/authorization_test.go +++ b/pkg/http/authorization_test.go @@ -8,12 +8,12 @@ import ( ) const ( - // https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoyNTM0MDIyOTcxOTksImlhdCI6MCwiaXNzIjoiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJqdGkiOiI5OTIyMmQ1Ni0zNDBlLTRlYjYtODU4OC0yNjE0MTFmMzVkMjYiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoiZGVmYXVsdCIsInVpZCI6ImVhY2I2YWQyLTgwYjctNDE3OS04NDNkLTkyZWIxZTZiYmJhNiJ9fSwibmJmIjowLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0In0.0363P6xGmWpU-O9TAVkcOd95lPXxhI-_k5NKbHGNQeL--B8XMAz2vC8hpKnyC6rKOGifRTSR2XNHx_5fjd7lEA // notsecret - tokenBasicNotExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoyNTM0MDIyOTcxOTksImlhdCI6MCwiaXNzIjoiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJqdGkiOiI5OTIyMmQ1Ni0zNDBlLTRlYjYtODU4OC0yNjE0MTFmMzVkMjYiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoiZGVmYXVsdCIsInVpZCI6ImVhY2I2YWQyLTgwYjctNDE3OS04NDNkLTkyZWIxZTZiYmJhNiJ9fSwibmJmIjowLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0In0.0363P6xGmWpU-O9TAVkcOd95lPXxhI-_k5NKbHGNQeL--B8XMAz2vC8hpKnyC6rKOGifRTSR2XNHx_5fjd7lEA" // notsecret - // https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoxLCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.USsuGLsB_7MwG9i0__cFkVVZa0djtmQpc8Vwi56GrapAgVAcyTfmae3s83XMDP5AwcFnxhYxLCfiZWRJri6GTA // notsecret - tokenBasicExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoxLCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.USsuGLsB_7MwG9i0__cFkVVZa0djtmQpc8Vwi56GrapAgVAcyTfmae3s83XMDP5AwcFnxhYxLCfiZWRJri6GTA" // notsecret - // https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoyNTM0MDIyOTcxOTksImlhdCI6MCwiaXNzIjoiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJqdGkiOiI5OTIyMmQ1Ni0zNDBlLTRlYjYtODU4OC0yNjE0MTFmMzVkMjYiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoiZGVmYXVsdCIsInVpZCI6ImVhY2I2YWQyLTgwYjctNDE3OS04NDNkLTkyZWIxZTZiYmJhNiJ9fSwibmJmIjowLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0Iiwic2NvcGUiOiJyZWFkIHdyaXRlIn0.vl5se9BuxoVDhvR7M5wGfkLoyMSYUiORMZVxl0CQ7jw3x53mZfGEkU_kkIVIl9Ui371qCCVVxdvuZPcAgbM6pQ // notsecret - tokenMultipleAudienceNotExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJrdWJlcm5ldGVzLW1jcC1zZXJ2ZXIiXSwiZXhwIjoyNTM0MDIyOTcxOTksImlhdCI6MCwiaXNzIjoiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJqdGkiOiI5OTIyMmQ1Ni0zNDBlLTRlYjYtODU4OC0yNjE0MTFmMzVkMjYiLCJrdWJlcm5ldGVzLmlvIjp7Im5hbWVzcGFjZSI6ImRlZmF1bHQiLCJzZXJ2aWNlYWNjb3VudCI6eyJuYW1lIjoiZGVmYXVsdCIsInVpZCI6ImVhY2I2YWQyLTgwYjctNDE3OS04NDNkLTkyZWIxZTZiYmJhNiJ9fSwibmJmIjowLCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDpkZWZhdWx0Iiwic2NvcGUiOiJyZWFkIHdyaXRlIn0.vl5se9BuxoVDhvR7M5wGfkLoyMSYUiORMZVxl0CQ7jw3x53mZfGEkU_kkIVIl9Ui371qCCVVxdvuZPcAgbM6pQ" // notsecret + // https://jwt.io/#token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.ld9aJaQX5k44KOV1bv8MCY2RceAZ9jAjN2vKswKmINNiOpRMl0f8Y0trrq7gdRlKwGLsCUjz8hbHsGcM43QtNrcwfvH5imRnlAKANPUgswwEadCTjASihlo6ADsn9fjAWB4viplFwq8VdzcwpcyActYJi2TBFoRq204STZJIcAW_B40HOuCB2XxQ81V4_XWLzL03Bt-YmYUhliiiE5YSKS1WEEWIbdel--b7Gvp-VS1I2eeiOqV3SelMBHbF9EwKGAkyObg0JhGqr5XHLd6WOmhvLus4eCkyakQMgr2tZIdvbt2yEUDiId6r27tlgAPLmqlyYMEhyiM212_Sth3T3Q // notsecret + tokenBasicNotExpired = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.ld9aJaQX5k44KOV1bv8MCY2RceAZ9jAjN2vKswKmINNiOpRMl0f8Y0trrq7gdRlKwGLsCUjz8hbHsGcM43QtNrcwfvH5imRnlAKANPUgswwEadCTjASihlo6ADsn9fjAWB4viplFwq8VdzcwpcyActYJi2TBFoRq204STZJIcAW_B40HOuCB2XxQ81V4_XWLzL03Bt-YmYUhliiiE5YSKS1WEEWIbdel--b7Gvp-VS1I2eeiOqV3SelMBHbF9EwKGAkyObg0JhGqr5XHLd6WOmhvLus4eCkyakQMgr2tZIdvbt2yEUDiId6r27tlgAPLmqlyYMEhyiM212_Sth3T3Q" // notsecret + // https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MSwiaWF0IjowLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbCIsImp0aSI6Ijk5MjIyZDU2LTM0MGUtNGViNi04NTg4LTI2MTQxMWYzNWQyNiIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiZGVmYXVsdCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZWFjYjZhZDItODBiNy00MTc5LTg0M2QtOTJlYjFlNmJiYmE2In19LCJuYmYiOjAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlZmF1bHQifQ.iVrxt6glbY3Qe_mEtK-lYpx4Z3VC1a7zgGRSmfu29pMmnKhlTk56y0Wx45DQ4PSYCTwC6CJnGGZNbJyr4JS8PQ // notsecret + tokenBasicExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MSwiaWF0IjowLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbCIsImp0aSI6Ijk5MjIyZDU2LTM0MGUtNGViNi04NTg4LTI2MTQxMWYzNWQyNiIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiZGVmYXVsdCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZWFjYjZhZDItODBiNy00MTc5LTg0M2QtOTJlYjFlNmJiYmE2In19LCJuYmYiOjAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlZmF1bHQifQ.iVrxt6glbY3Qe_mEtK-lYpx4Z3VC1a7zgGRSmfu29pMmnKhlTk56y0Wx45DQ4PSYCTwC6CJnGGZNbJyr4JS8PQ" // notsecret + // https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.m5mFXp0TDSvgLevQ76nX65N14w1RxTClMaannLLOuBIUEsmXhMYZjGtf5mWMcxVOkSh65rLFiKugaMXgv877Mg // notsecret + tokenMultipleAudienceNotExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.m5mFXp0TDSvgLevQ76nX65N14w1RxTClMaannLLOuBIUEsmXhMYZjGtf5mWMcxVOkSh65rLFiKugaMXgv877Mg" // notsecret ) func TestParseJWTClaimsPayloadValid(t *testing.T) { @@ -32,7 +32,7 @@ func TestParseJWTClaimsPayloadValid(t *testing.T) { } }) t.Run("Parses audience", func(t *testing.T) { - expectedAudiences := []string{"https://kubernetes.default.svc.cluster.local", "kubernetes-mcp-server"} + expectedAudiences := []string{"https://kubernetes.default.svc.cluster.local", "mcp-server"} for _, expected := range expectedAudiences { if !basicClaims.Audience.Contains(expected) { t.Errorf("expected audience to contain %s", expected) @@ -91,7 +91,7 @@ func TestParseJWTClaimsPayloadInvalid(t *testing.T) { } }) t.Run("invalid base64 payload", func(t *testing.T) { - invalidPayload := "invalid_base64" + tokenBasicNotExpired + invalidPayload := strings.ReplaceAll(tokenBasicNotExpired, ".", ".invalid") _, err := ParseJWTClaims(invalidPayload) if err == nil { @@ -104,14 +104,14 @@ func TestParseJWTClaimsPayloadInvalid(t *testing.T) { }) } -func TestJWTTokenValidate(t *testing.T) { +func TestJWTTokenValidateOffline(t *testing.T) { t.Run("expired token returns error", func(t *testing.T) { claims, err := ParseJWTClaims(tokenBasicExpired) if err != nil { t.Fatalf("expected no error for expired token parsing, got %v", err) } - err = claims.Validate("kubernetes-mcp-server") + err = claims.ValidateOffline("mcp-server") if err == nil { t.Fatalf("expected error for expired token, got nil") } @@ -130,7 +130,7 @@ func TestJWTTokenValidate(t *testing.T) { t.Fatalf("expected claims to be returned, got nil") } - err = claims.Validate("kubernetes-mcp-server") + err = claims.ValidateOffline("mcp-server") if err != nil { t.Fatalf("expected no error for valid audience, got %v", err) } @@ -145,7 +145,7 @@ func TestJWTTokenValidate(t *testing.T) { t.Fatalf("expected claims to be returned, got nil") } - err = claims.Validate("missing-audience") + err = claims.ValidateOffline("missing-audience") if err == nil { t.Fatalf("expected error for token with wrong audience, got nil") } diff --git a/pkg/http/http.go b/pkg/http/http.go index e5dfb2ba..3f74c09f 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -2,7 +2,6 @@ package http import ( "context" - "encoding/json" "errors" "net/http" "os" @@ -19,18 +18,17 @@ import ( ) const ( - oauthProtectedResourceEndpoint = "/.well-known/oauth-protected-resource" - healthEndpoint = "/healthz" - mcpEndpoint = "/mcp" - sseEndpoint = "/sse" - sseMessageEndpoint = "/message" + healthEndpoint = "/healthz" + mcpEndpoint = "/mcp" + sseEndpoint = "/sse" + sseMessageEndpoint = "/message" ) func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.StaticConfig, oidcProvider *oidc.Provider) error { mux := http.NewServeMux() wrappedMux := RequestMiddleware( - AuthorizationMiddleware(staticConfig.RequireOAuth, staticConfig.ServerURL, oidcProvider, mcpServer)(mux), + AuthorizationMiddleware(staticConfig, oidcProvider, mcpServer)(mux), ) httpServer := &http.Server{ @@ -46,39 +44,7 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat mux.HandleFunc(healthEndpoint, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - mux.HandleFunc(oauthProtectedResourceEndpoint, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - var authServers []string - if staticConfig.AuthorizationURL != "" { - authServers = []string{staticConfig.AuthorizationURL} - } else { - // Fallback to Kubernetes API server host if authorization_server is not configured - if apiServerHost := mcpServer.GetKubernetesAPIServerHost(); apiServerHost != "" { - authServers = []string{apiServerHost} - } - } - - response := map[string]interface{}{ - "authorization_servers": authServers, - "authorization_server": authServers[0], - "scopes_supported": mcpServer.GetEnabledTools(), - "bearer_methods_supported": []string{"header"}, - } - - if staticConfig.ServerURL != "" { - response["resource"] = staticConfig.ServerURL - } - - if staticConfig.JwksURL != "" { - response["jwks_uri"] = staticConfig.JwksURL - } - - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(response); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - }) + mux.Handle("/.well-known/", WellKnownHandler(staticConfig)) ctx, cancel := context.WithCancel(ctx) defer cancel() diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 89e091fe..04cccf47 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -19,11 +19,11 @@ import ( "testing" "time" + "github.com/containers/kubernetes-mcp-server/internal/test" "github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc/oidctest" "golang.org/x/sync/errgroup" "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/tools/clientcmd/api" "k8s.io/klog/v2" "k8s.io/klog/v2/textlogger" @@ -33,6 +33,7 @@ import ( type httpContext struct { klogState klog.State + mockServer *test.MockServer LogBuffer bytes.Buffer HttpAddress string // HTTP server address timeoutCancel context.CancelFunc // Release resources if test completes before the timeout @@ -42,21 +43,31 @@ type httpContext struct { OidcProvider *oidc.Provider } +const tokenReviewSuccessful = ` + { + "kind": "TokenReview", + "apiVersion": "authentication.k8s.io/v1", + "spec": {"token": "valid-token"}, + "status": { + "authenticated": true, + "user": { + "username": "test-user", + "groups": ["system:authenticated"] + } + } + }` + func (c *httpContext) beforeEach(t *testing.T) { t.Helper() http.DefaultClient.Timeout = 10 * time.Second if c.StaticConfig == nil { c.StaticConfig = &config.StaticConfig{} } + c.mockServer = test.NewMockServer() // Fake Kubernetes configuration - fakeConfig := api.NewConfig() - fakeConfig.Clusters["fake"] = api.NewCluster() - fakeConfig.Clusters["fake"].Server = "https://example.com" - fakeConfig.Contexts["fake-context"] = api.NewContext() - fakeConfig.Contexts["fake-context"].Cluster = "fake" - fakeConfig.CurrentContext = "fake-context" + mockKubeConfig := c.mockServer.KubeConfig() kubeConfig := filepath.Join(t.TempDir(), "config") - _ = clientcmd.WriteToFile(*fakeConfig, kubeConfig) + _ = clientcmd.WriteToFile(*mockKubeConfig, kubeConfig) _ = os.Setenv("KUBECONFIG", kubeConfig) // Capture logging c.klogState = klog.CaptureState() @@ -100,6 +111,7 @@ func (c *httpContext) beforeEach(t *testing.T) { func (c *httpContext) afterEach(t *testing.T) { t.Helper() + c.mockServer.Close() c.StopServer() err := c.WaitForShutdown() if err != nil { @@ -120,24 +132,40 @@ func testCaseWithContext(t *testing.T, httpCtx *httpContext, test func(c *httpCo test(httpCtx) } -func NewOidcTestServer(t *testing.T) (privateKey *rsa.PrivateKey, oidcProvider *oidc.Provider, httpServer *httptest.Server) { +type OidcTestServer struct { + *rsa.PrivateKey + *oidc.Provider + *httptest.Server + TokenEndpointHandler http.HandlerFunc +} + +func NewOidcTestServer(t *testing.T) (oidcTestServer *OidcTestServer) { t.Helper() - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + var err error + oidcTestServer = &OidcTestServer{} + oidcTestServer.PrivateKey, err = rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("failed to generate private key for oidc: %v", err) } oidcServer := &oidctest.Server{ + Algorithms: []string{oidc.RS256, oidc.ES256}, PublicKeys: []oidctest.PublicKey{ { - PublicKey: privateKey.Public(), + PublicKey: oidcTestServer.Public(), KeyID: "test-oidc-key-id", Algorithm: oidc.RS256, }, }, } - httpServer = httptest.NewServer(oidcServer) - oidcServer.SetIssuer(httpServer.URL) - oidcProvider, err = oidc.NewProvider(t.Context(), httpServer.URL) + oidcTestServer.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" && oidcTestServer.TokenEndpointHandler != nil { + oidcTestServer.TokenEndpointHandler.ServeHTTP(w, r) + return + } + oidcServer.ServeHTTP(w, r) + })) + oidcServer.SetIssuer(oidcTestServer.URL) + oidcTestServer.Provider, err = oidc.NewProvider(t.Context(), oidcTestServer.URL) if err != nil { t.Fatalf("failed to create OIDC provider: %v", err) } @@ -271,7 +299,7 @@ func TestHealthCheck(t *testing.T) { }) }) // Health exposed even when require Authorization - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}}, func(ctx *httpContext) { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) { resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress)) if err != nil { t.Fatalf("Failed to get health check endpoint with OAuth: %v", err) @@ -285,23 +313,55 @@ func TestHealthCheck(t *testing.T) { }) } -func TestWellKnownOAuthProtectedResource(t *testing.T) { - testCase(t, func(ctx *httpContext) { - resp, err := http.Get(fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", ctx.HttpAddress)) - t.Cleanup(func() { _ = resp.Body.Close() }) - t.Run("Exposes .well-known/oauth-protected-resource endpoint", func(t *testing.T) { - if err != nil { - t.Fatalf("Failed to get .well-known/oauth-protected-resource endpoint: %v", err) - } - if resp.StatusCode != http.StatusOK { - t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) - } - }) - t.Run(".well-known/oauth-protected-resource returns application/json content type", func(t *testing.T) { - if resp.Header.Get("Content-Type") != "application/json" { - t.Errorf("Expected Content-Type application/json, got %s", resp.Header.Get("Content-Type")) - } - }) +func TestWellKnownReverseProxy(t *testing.T) { + cases := []string{ + ".well-known/oauth-authorization-server", + ".well-known/oauth-protected-resource", + ".well-known/openid-configuration", + } + // With No Authorization URL configured + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) { + for _, path := range cases { + resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path)) + t.Cleanup(func() { _ = resp.Body.Close() }) + t.Run("Protected resource '"+path+"' without Authorization URL returns 404 - Not Found", func(t *testing.T) { + if err != nil { + t.Fatalf("Failed to get %s endpoint: %v", path, err) + } + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected HTTP 404 Not Found, got %d", resp.StatusCode) + } + }) + } + }) + // With Authorization URL configured + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issuer": "https://example.com","scopes_supported":["mcp-server"]}`)) + })) + t.Cleanup(testServer.Close) + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{AuthorizationURL: testServer.URL, RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) { + for _, path := range cases { + resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path)) + t.Cleanup(func() { _ = resp.Body.Close() }) + t.Run("Exposes "+path+" endpoint", func(t *testing.T) { + if err != nil { + t.Fatalf("Failed to get %s endpoint: %v", path, err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) + } + }) + t.Run(path+" returns application/json content type", func(t *testing.T) { + if resp.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", resp.Header.Get("Content-Type")) + } + }) + } }) } @@ -309,12 +369,12 @@ func TestMiddlewareLogging(t *testing.T) { testCase(t, func(ctx *httpContext) { _, _ = http.Get(fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", ctx.HttpAddress)) t.Run("Logs HTTP requests and responses", func(t *testing.T) { - if !strings.Contains(ctx.LogBuffer.String(), "GET /.well-known/oauth-protected-resource 200") { + if !strings.Contains(ctx.LogBuffer.String(), "GET /.well-known/oauth-protected-resource 404") { t.Errorf("Expected log entry for GET /.well-known/oauth-protected-resource, got: %s", ctx.LogBuffer.String()) } }) t.Run("Logs HTTP request duration", func(t *testing.T) { - expected := `"GET /.well-known/oauth-protected-resource 200 (.+)"` + expected := `"GET /.well-known/oauth-protected-resource 404 (.+)"` m := regexp.MustCompile(expected).FindStringSubmatch(ctx.LogBuffer.String()) if len(m) != 2 { t.Fatalf("Expected log entry to contain duration, got %s", ctx.LogBuffer.String()) @@ -332,7 +392,7 @@ func TestMiddlewareLogging(t *testing.T) { func TestAuthorizationUnauthorized(t *testing.T) { // Missing Authorization header - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}}, func(ctx *httpContext) { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) { resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress)) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) @@ -345,7 +405,7 @@ func TestAuthorizationUnauthorized(t *testing.T) { }) t.Run("Protected resource with MISSING Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") - expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="missing_token"` + expected := `Bearer realm="Kubernetes MCP Server", error="missing_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } @@ -357,7 +417,7 @@ func TestAuthorizationUnauthorized(t *testing.T) { }) }) // Authorization header without Bearer prefix - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}}, func(ctx *httpContext) { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) @@ -370,7 +430,7 @@ func TestAuthorizationUnauthorized(t *testing.T) { t.Cleanup(func() { _ = resp.Body.Close }) t.Run("Protected resource with INCOMPATIBLE Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") - expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="missing_token"` + expected := `Bearer realm="Kubernetes MCP Server", error="missing_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } @@ -382,12 +442,12 @@ func TestAuthorizationUnauthorized(t *testing.T) { }) }) // Invalid Authorization header - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}}, func(ctx *httpContext) { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } - req.Header.Set("Authorization", "Bearer invalid_base64"+tokenBasicNotExpired) + req.Header.Set("Authorization", "Bearer "+strings.ReplaceAll(tokenBasicNotExpired, ".", ".invalid")) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("Failed to get protected endpoint: %v", err) @@ -400,20 +460,20 @@ func TestAuthorizationUnauthorized(t *testing.T) { }) t.Run("Protected resource with INVALID Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") - expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="invalid_token"` + expected := `Bearer realm="Kubernetes MCP Server", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with INVALID Authorization header logs error", func(t *testing.T) { - if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") && + if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || !strings.Contains(ctx.LogBuffer.String(), "error: failed to parse JWT token: illegal base64 data") { t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String()) } }) }) // Expired Authorization Bearer token - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}}, func(ctx *httpContext) { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true}}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) @@ -431,22 +491,53 @@ func TestAuthorizationUnauthorized(t *testing.T) { }) t.Run("Protected resource with EXPIRED Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") - expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="invalid_token"` + expected := `Bearer realm="Kubernetes MCP Server", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with EXPIRED Authorization header logs error", func(t *testing.T) { - if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") && + if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || !strings.Contains(ctx.LogBuffer.String(), "validation failed, token is expired (exp)") { t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String()) } }) }) + // Invalid audience claim Bearer token + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "expected-audience", ValidateToken: true}}, func(ctx *httpContext) { + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+tokenBasicExpired) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to get protected endpoint: %v", err) + } + t.Cleanup(func() { _ = resp.Body.Close }) + t.Run("Protected resource with INVALID AUDIENCE Authorization header returns 401 - Unauthorized", func(t *testing.T) { + if resp.StatusCode != 401 { + t.Errorf("Expected HTTP 401, got %d", resp.StatusCode) + } + }) + t.Run("Protected resource with INVALID AUDIENCE Authorization header returns WWW-Authenticate header", func(t *testing.T) { + authHeader := resp.Header.Get("WWW-Authenticate") + expected := `Bearer realm="Kubernetes MCP Server", audience="expected-audience", error="invalid_token"` + if authHeader != expected { + t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) + } + }) + t.Run("Protected resource with INVALID AUDIENCE Authorization header logs error", func(t *testing.T) { + if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || + !strings.Contains(ctx.LogBuffer.String(), "invalid audience claim (aud)") { + t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String()) + } + }) + }) // Failed OIDC validation - key, oidcProvider, httpServer := NewOidcTestServer(t) - t.Cleanup(httpServer.Close) - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}, OidcProvider: oidcProvider}, func(ctx *httpContext) { + oidcTestServer := NewOidcTestServer(t) + t.Cleanup(oidcTestServer.Close) + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) @@ -464,26 +555,26 @@ func TestAuthorizationUnauthorized(t *testing.T) { }) t.Run("Protected resource with INVALID OIDC Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") - expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="invalid_token"` + expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with INVALID OIDC Authorization header logs error", func(t *testing.T) { - if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - OIDC token validation error") && - !strings.Contains(ctx.LogBuffer.String(), "JWT token verification failed: oidc: id token issued by a different provider") { + if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || + !strings.Contains(ctx.LogBuffer.String(), "OIDC token validation error: failed to verify signature") { t.Errorf("Expected log entry for OIDC validation error, got: %s", ctx.LogBuffer.String()) } }) }) // Failed Kubernetes TokenReview rawClaims := `{ - "iss": "` + httpServer.URL + `", + "iss": "` + oidcTestServer.URL + `", "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `, - "aud": "kubernetes-mcp-server" + "aud": "mcp-server" }` - validOidcToken := oidctest.SignIDToken(key, "test-oidc-key-id", oidc.RS256, rawClaims) - testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true}, OidcProvider: oidcProvider}, func(ctx *httpContext) { + validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims) + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) { req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) @@ -501,15 +592,192 @@ func TestAuthorizationUnauthorized(t *testing.T) { }) t.Run("Protected resource with INVALID KUBERNETES Authorization header returns WWW-Authenticate header", func(t *testing.T) { authHeader := resp.Header.Get("WWW-Authenticate") - expected := `Bearer realm="Kubernetes MCP Server", audience="kubernetes-mcp-server", error="invalid_token"` + expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"` if authHeader != expected { t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader) } }) t.Run("Protected resource with INVALID KUBERNETES Authorization header logs error", func(t *testing.T) { - if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - API Server token validation error") { + if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") || + !strings.Contains(ctx.LogBuffer.String(), "kubernetes API token validation error: failed to create token review") { t.Errorf("Expected log entry for Kubernetes TokenReview error, got: %s", ctx.LogBuffer.String()) } }) }) } + +func TestAuthorizationRequireOAuthFalse(t *testing.T) { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: false}}, func(ctx *httpContext) { + resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress)) + if err != nil { + t.Fatalf("Failed to get protected endpoint: %v", err) + } + t.Cleanup(func() { _ = resp.Body.Close() }) + t.Run("Protected resource with MISSING Authorization header returns 200 - OK)", func(t *testing.T) { + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) + } + }) + }) +} + +func TestAuthorizationRawToken(t *testing.T) { + cases := []struct { + audience string + validateToken bool + }{ + {"", false}, // No audience, no validation + {"", true}, // No audience, validation enabled + {"mcp-server", false}, // Audience set, no validation + {"mcp-server", true}, // Audience set, validation enabled + } + for _, c := range cases { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: c.audience, ValidateToken: c.validateToken}}, func(ctx *httpContext) { + tokenReviewed := false + ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenReviewSuccessful)) + tokenReviewed = true + return + } + })) + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+tokenBasicNotExpired) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to get protected endpoint: %v", err) + } + t.Cleanup(func() { _ = resp.Body.Close() }) + t.Run(fmt.Sprintf("Protected resource with audience = '%s' and validate-token = '%t', with VALID Authorization header returns 200 - OK", c.audience, c.validateToken), func(t *testing.T) { + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) + } + }) + t.Run(fmt.Sprintf("Protected resource with audience = '%s' and validate-token = '%t', with VALID Authorization header performs token validation accordingly", c.audience, c.validateToken), func(t *testing.T) { + if tokenReviewed == true && !c.validateToken { + t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed") + } + if tokenReviewed == false && c.validateToken { + t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped") + } + }) + }) + } + +} + +func TestAuthorizationOidcToken(t *testing.T) { + oidcTestServer := NewOidcTestServer(t) + t.Cleanup(oidcTestServer.Close) + rawClaims := `{ + "iss": "` + oidcTestServer.URL + `", + "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `, + "aud": "mcp-server" + }` + validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims) + cases := []bool{false, true} + for _, validateToken := range cases { + testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: validateToken}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) { + tokenReviewed := false + ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenReviewSuccessful)) + tokenReviewed = true + return + } + })) + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+validOidcToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to get protected endpoint: %v", err) + } + t.Cleanup(func() { _ = resp.Body.Close() }) + t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC Authorization header returns 200 - OK", validateToken), func(t *testing.T) { + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) + } + }) + t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC Authorization header performs token validation accordingly", validateToken), func(t *testing.T) { + if tokenReviewed == true && !validateToken { + t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed") + } + if tokenReviewed == false && validateToken { + t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped") + } + }) + }) + } +} + +func TestAuthorizationOidcTokenExchange(t *testing.T) { + oidcTestServer := NewOidcTestServer(t) + t.Cleanup(oidcTestServer.Close) + rawClaims := `{ + "iss": "` + oidcTestServer.URL + `", + "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `, + "aud": "%s" + }` + validOidcClientToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, + fmt.Sprintf(rawClaims, "mcp-server")) + validOidcBackendToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, + fmt.Sprintf(rawClaims, "backend-audience")) + oidcTestServer.TokenEndpointHandler = func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":253402297199}`, validOidcBackendToken) + } + cases := []bool{false, true} + for _, validateToken := range cases { + staticConfig := &config.StaticConfig{ + RequireOAuth: true, + OAuthAudience: "mcp-server", + ValidateToken: validateToken, + StsClientId: "test-sts-client-id", + StsClientSecret: "test-sts-client-secret", + StsAudience: "backend-audience", + StsScopes: []string{"backend-scope"}, + } + testCaseWithContext(t, &httpContext{StaticConfig: staticConfig, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) { + tokenReviewed := false + ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenReviewSuccessful)) + tokenReviewed = true + return + } + })) + req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+validOidcClientToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Failed to get protected endpoint: %v", err) + } + t.Cleanup(func() { _ = resp.Body.Close() }) + t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC EXCHANGE Authorization header returns 200 - OK", validateToken), func(t *testing.T) { + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode) + } + }) + t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC EXCHANGE Authorization header performs token validation accordingly", validateToken), func(t *testing.T) { + if tokenReviewed == true && !validateToken { + t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed") + } + if tokenReviewed == false && validateToken { + t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped") + } + }) + }) + } +} diff --git a/pkg/http/sts.go b/pkg/http/sts.go new file mode 100644 index 00000000..997796ee --- /dev/null +++ b/pkg/http/sts.go @@ -0,0 +1,59 @@ +package http + +import ( + "context" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google/externalaccount" + + "github.com/containers/kubernetes-mcp-server/pkg/config" +) + +type staticSubjectTokenSupplier struct { + token string +} + +func (s *staticSubjectTokenSupplier) SubjectToken(_ context.Context, _ externalaccount.SupplierOptions) (string, error) { + return s.token, nil +} + +var _ externalaccount.SubjectTokenSupplier = &staticSubjectTokenSupplier{} + +type SecurityTokenService struct { + *oidc.Provider + ClientId string + ClientSecret string + ExternalAccountAudience string + ExternalAccountScopes []string +} + +func NewFromConfig(config *config.StaticConfig, provider *oidc.Provider) *SecurityTokenService { + return &SecurityTokenService{ + Provider: provider, + ClientId: config.StsClientId, + ClientSecret: config.StsClientSecret, + ExternalAccountAudience: config.StsAudience, + ExternalAccountScopes: config.StsScopes, + } +} + +func (sts *SecurityTokenService) IsEnabled() bool { + return sts.Provider != nil && sts.ClientId != "" && sts.ExternalAccountAudience != "" +} + +func (sts *SecurityTokenService) ExternalAccountTokenExchange(ctx context.Context, originalToken *oauth2.Token) (*oauth2.Token, error) { + ts, err := externalaccount.NewTokenSource(ctx, externalaccount.Config{ + TokenURL: sts.Endpoint().TokenURL, + ClientID: sts.ClientId, + ClientSecret: sts.ClientSecret, + Audience: sts.ExternalAccountAudience, + SubjectTokenType: "urn:ietf:params:oauth:token-type:access_token", + SubjectTokenSupplier: &staticSubjectTokenSupplier{token: originalToken.AccessToken}, + Scopes: sts.ExternalAccountScopes, + }) + if err != nil { + return nil, err + } + return ts.Token() +} diff --git a/pkg/http/sts_test.go b/pkg/http/sts_test.go new file mode 100644 index 00000000..59acf928 --- /dev/null +++ b/pkg/http/sts_test.go @@ -0,0 +1,153 @@ +package http + +import ( + "encoding/base64" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +func TestIsEnabled(t *testing.T) { + disabledCases := []SecurityTokenService{ + {}, + {Provider: nil}, + {Provider: &oidc.Provider{}}, + {Provider: &oidc.Provider{}, ClientId: "test-client-id", ClientSecret: "test-client-secret"}, + {ClientId: "test-client-id", ClientSecret: "test-client-secret", ExternalAccountAudience: "test-audience"}, + {Provider: &oidc.Provider{}, ClientSecret: "test-client-secret", ExternalAccountAudience: "test-audience"}, + } + for _, sts := range disabledCases { + t.Run(fmt.Sprintf("SecurityTokenService{%+v}.IsEnabled() = false", sts), func(t *testing.T) { + if sts.IsEnabled() { + t.Errorf("SecurityTokenService{%+v}.IsEnabled() = true; want false", sts) + } + }) + } + enabledCases := []SecurityTokenService{ + {Provider: &oidc.Provider{}, ClientId: "test-client-id", ExternalAccountAudience: "test-audience"}, + {Provider: &oidc.Provider{}, ClientId: "test-client-id", ExternalAccountAudience: "test-audience", ClientSecret: "test-client-secret"}, + {Provider: &oidc.Provider{}, ClientId: "test-client-id", ExternalAccountAudience: "test-audience", ClientSecret: "test-client-secret", ExternalAccountScopes: []string{"test-scope"}}, + } + for _, sts := range enabledCases { + t.Run(fmt.Sprintf("SecurityTokenService{%+v}.IsEnabled() = true", sts), func(t *testing.T) { + if !sts.IsEnabled() { + t.Errorf("SecurityTokenService{%+v}.IsEnabled() = false; want true", sts) + } + }) + } +} + +func TestExternalAccountTokenExchange(t *testing.T) { + mockServer := test.NewMockServer() + authServer := mockServer.Config().Host + var tokenExchangeRequest *http.Request + mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/.well-known/openid-configuration" { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "issuer": "%s", + "authorization_endpoint": "https://mock-oidc-provider/authorize", + "token_endpoint": "%s/token" + }`, authServer, authServer) + return + } + if req.URL.Path == "/token" { + tokenExchangeRequest = req + _ = tokenExchangeRequest.ParseForm() + if tokenExchangeRequest.PostForm.Get("subject_token") != "the-original-access-token" { + http.Error(w, "Invalid subject_token", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"exchanged-access-token","token_type":"Bearer","expires_in":253402297199}`)) + return + } + })) + t.Cleanup(mockServer.Close) + provider, err := oidc.NewProvider(t.Context(), authServer) + if err != nil { + t.Fatalf("oidc.NewProvider() error = %v; want nil", err) + } + // With missing Token Source information + _, err = (&SecurityTokenService{Provider: provider}).ExternalAccountTokenExchange(t.Context(), &oauth2.Token{}) + t.Run("ExternalAccountTokenExchange with missing token source returns error", func(t *testing.T) { + if err == nil { + t.Fatalf("ExternalAccountTokenExchange() error = nil; want error") + } + if !strings.Contains(err.Error(), "must be set") { + t.Errorf("ExternalAccountTokenExchange() error = %v; want missing required field", err) + } + }) + // With valid Token Source information + sts := SecurityTokenService{ + Provider: provider, + ClientId: "test-client-id", + ClientSecret: "test-client-secret", + ExternalAccountAudience: "test-audience", + ExternalAccountScopes: []string{"test-scope"}, + } + // With Invalid token + _, err = sts.ExternalAccountTokenExchange(t.Context(), &oauth2.Token{ + AccessToken: "invalid-access-token", + TokenType: "Bearer", + }) + t.Run("ExternalAccountTokenExchange with invalid token returns error", func(t *testing.T) { + if err == nil { + t.Fatalf("ExternalAccountTokenExchange() error = nil; want error") + } + if !strings.Contains(err.Error(), "status code 401: Invalid subject_token") { + t.Errorf("ExternalAccountTokenExchange() error = %v; want invalid_grant: Invalid subject_token", err) + } + }) + // With Valid token + exchangeToken, err := sts.ExternalAccountTokenExchange(t.Context(), &oauth2.Token{ + AccessToken: "the-original-access-token", + TokenType: "Bearer", + }) + t.Run("ExternalAccountTokenExchange with valid token returns new token", func(t *testing.T) { + if err != nil { + t.Errorf("ExternalAccountTokenExchange() error = %v; want nil", err) + } + if exchangeToken == nil { + t.Fatal("ExternalAccountTokenExchange() = nil; want token") + } + if exchangeToken.AccessToken != "exchanged-access-token" { + t.Errorf("exchangeToken.AccessToken = %s; want exchanged-access-token", exchangeToken.AccessToken) + } + }) + t.Run("ExternalAccountTokenExchange with valid token sends POST request", func(t *testing.T) { + if tokenExchangeRequest == nil { + t.Fatal("tokenExchangeRequest is nil; want request") + } + if tokenExchangeRequest.Method != "POST" { + t.Errorf("tokenExchangeRequest.Method = %s; want POST", tokenExchangeRequest.Method) + } + }) + t.Run("ExternalAccountTokenExchange with valid token has correct form data", func(t *testing.T) { + if tokenExchangeRequest.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("tokenExchangeRequest.Content-Type = %s; want application/x-www-form-urlencoded", tokenExchangeRequest.Header.Get("Content-Type")) + } + if tokenExchangeRequest.PostForm.Get("audience") != "test-audience" { + t.Errorf("tokenExchangeRequest.PostForm[audience] = %s; want test-audience", tokenExchangeRequest.PostForm.Get("audience")) + } + if tokenExchangeRequest.PostForm.Get("subject_token_type") != "urn:ietf:params:oauth:token-type:access_token" { + t.Errorf("tokenExchangeRequest.PostForm[subject_token_type] = %s; want urn:ietf:params:oauth:token-type:access_token", tokenExchangeRequest.PostForm.Get("subject_token_type")) + } + if tokenExchangeRequest.PostForm.Get("subject_token") != "the-original-access-token" { + t.Errorf("tokenExchangeRequest.PostForm[subject_token] = %s; want the-original-access-token", tokenExchangeRequest.PostForm.Get("subject_token")) + } + if len(tokenExchangeRequest.PostForm["scope"]) == 0 || tokenExchangeRequest.PostForm["scope"][0] != "test-scope" { + t.Errorf("tokenExchangeRequest.PostForm[scope] = %v; want [test-scope]", tokenExchangeRequest.PostForm["scope"]) + } + }) + t.Run("ExternalAccountTokenExchange with valid token sends correct client credentials header", func(t *testing.T) { + if tokenExchangeRequest.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("test-client-id:test-client-secret")) { + t.Errorf("tokenExchangeRequest.Header[Authorization] = %s; want Basic base64(test-client-id:test-client-secret)", tokenExchangeRequest.Header.Get("Authorization")) + } + }) +} diff --git a/pkg/http/wellknown.go b/pkg/http/wellknown.go new file mode 100644 index 00000000..c1e375ea --- /dev/null +++ b/pkg/http/wellknown.go @@ -0,0 +1,65 @@ +package http + +import ( + "io" + "net/http" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/config" +) + +const ( + oauthAuthorizationServerEndpoint = "/.well-known/oauth-authorization-server" + oauthProtectedResourceEndpoint = "/.well-known/oauth-protected-resource" + openIDConfigurationEndpoint = "/.well-known/openid-configuration" +) + +var WellKnownEndpoints = []string{ + oauthAuthorizationServerEndpoint, + oauthProtectedResourceEndpoint, + openIDConfigurationEndpoint, +} + +type WellKnown struct { + authorizationUrl string +} + +var _ http.Handler = &WellKnown{} + +func WellKnownHandler(staticConfig *config.StaticConfig) http.Handler { + authorizationUrl := staticConfig.AuthorizationURL + if authorizationUrl != "" && strings.HasSuffix("authorizationUrl", "/") { + authorizationUrl = strings.TrimSuffix(authorizationUrl, "/") + } + return &WellKnown{authorizationUrl} +} + +func (w WellKnown) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + if w.authorizationUrl == "" { + http.Error(writer, "Authorization URL is not configured", http.StatusNotFound) + return + } + req, err := http.NewRequest(request.Method, w.authorizationUrl+request.URL.EscapedPath(), nil) + if err != nil { + http.Error(writer, "Failed to create request: "+err.Error(), http.StatusInternalServerError) + return + } + resp, err := http.DefaultClient.Do(req.WithContext(request.Context())) + if err != nil { + http.Error(writer, "Failed to perform request: "+err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(writer, "Failed to read response body: "+err.Error(), http.StatusInternalServerError) + return + } + for key, values := range resp.Header { + for _, value := range values { + writer.Header().Add(key, value) + } + } + writer.WriteHeader(resp.StatusCode) + _, _ = writer.Write(body) +} diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index a96ba763..b335938b 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -62,8 +62,9 @@ type MCPServerOptions struct { ReadOnly bool DisableDestructive bool RequireOAuth bool + OAuthAudience string + ValidateToken bool AuthorizationURL string - JwksURL string CertificateAuthority string ServerURL string @@ -120,10 +121,12 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().BoolVar(&o.DisableDestructive, "disable-destructive", o.DisableDestructive, "If true, tools annotated with destructiveHint=true are disabled") cmd.Flags().BoolVar(&o.RequireOAuth, "require-oauth", o.RequireOAuth, "If true, requires OAuth authorization as defined in the Model Context Protocol (MCP) specification. This flag is ignored if transport type is stdio") _ = cmd.Flags().MarkHidden("require-oauth") + cmd.Flags().StringVar(&o.OAuthAudience, "oauth-audience", o.OAuthAudience, "OAuth audience for token claims validation. Optional. If not set, the audience is not validated. Only valid if require-oauth is enabled.") + _ = cmd.Flags().MarkHidden("oauth-audience") + cmd.Flags().BoolVar(&o.ValidateToken, "validate-token", o.ValidateToken, "If true, validates the token against the Kubernetes API Server using TokenReview. Optional. If not set, the token is not validated. Only valid if require-oauth is enabled.") + _ = cmd.Flags().MarkHidden("validate-token") cmd.Flags().StringVar(&o.AuthorizationURL, "authorization-url", o.AuthorizationURL, "OAuth authorization server URL for protected resource endpoint. If not provided, the Kubernetes API server host will be used. Only valid if require-oauth is enabled.") _ = cmd.Flags().MarkHidden("authorization-url") - cmd.Flags().StringVar(&o.JwksURL, "jwks-url", o.JwksURL, "OAuth JWKS server URL for protected resource endpoint. Only valid if require-oauth is enabled.") - _ = cmd.Flags().MarkHidden("jwks-url") cmd.Flags().StringVar(&o.ServerURL, "server-url", o.ServerURL, "Server URL of this application. Optional. If set, this url will be served in protected resource metadata endpoint and tokens will be validated with this audience. If not set, expected audience is kubernetes-mcp-server. Only valid if require-oauth is enabled.") _ = cmd.Flags().MarkHidden("server-url") cmd.Flags().StringVar(&o.CertificateAuthority, "certificate-authority", o.CertificateAuthority, "Certificate authority path to verify certificates. Optional. Only valid if require-oauth is enabled.") @@ -182,12 +185,15 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { if cmd.Flag("require-oauth").Changed { m.StaticConfig.RequireOAuth = m.RequireOAuth } + if cmd.Flag("oauth-audience").Changed { + m.StaticConfig.OAuthAudience = m.OAuthAudience + } + if cmd.Flag("validate-token").Changed { + m.StaticConfig.ValidateToken = m.ValidateToken + } if cmd.Flag("authorization-url").Changed { m.StaticConfig.AuthorizationURL = m.AuthorizationURL } - if cmd.Flag("jwks-url").Changed { - m.StaticConfig.JwksURL = m.JwksURL - } if cmd.Flag("server-url").Changed { m.StaticConfig.ServerURL = m.ServerURL } @@ -212,8 +218,8 @@ func (m *MCPServerOptions) Validate() error { if m.Port != "" && (m.SSEPort > 0 || m.HttpPort > 0) { return fmt.Errorf("--port is mutually exclusive with deprecated --http-port and --sse-port flags") } - if !m.StaticConfig.RequireOAuth && (m.StaticConfig.AuthorizationURL != "" || m.StaticConfig.ServerURL != "" || m.StaticConfig.JwksURL != "" || m.StaticConfig.CertificateAuthority != "") { - return fmt.Errorf("authorization-url, server-url, certificate-authority and jwks-url are only valid if require-oauth is enabled. Missing --port may implicitly set require-oauth to false") + if !m.StaticConfig.RequireOAuth && (m.StaticConfig.ValidateToken || m.StaticConfig.OAuthAudience != "" || m.StaticConfig.AuthorizationURL != "" || m.StaticConfig.ServerURL != "" || m.StaticConfig.CertificateAuthority != "") { + return fmt.Errorf("validate-token, oauth-audience, authorization-url, server-url and certificate-authority are only valid if require-oauth is enabled. Missing --port may implicitly set require-oauth to false") } if m.StaticConfig.AuthorizationURL != "" { u, err := url.Parse(m.StaticConfig.AuthorizationURL) @@ -227,18 +233,6 @@ func (m *MCPServerOptions) Validate() error { klog.Warningf("authorization-url is using http://, this is not recommended production use") } } - if m.StaticConfig.JwksURL != "" { - u, err := url.Parse(m.StaticConfig.JwksURL) - if err != nil { - return err - } - if u.Scheme != "https" && u.Scheme != "http" { - return fmt.Errorf("--jwks-url must be a valid URL") - } - if u.Scheme == "http" { - klog.Warningf("jwks-url is using http://, this is not recommended production use") - } - } return nil } diff --git a/pkg/kubernetes/token.go b/pkg/kubernetes/token.go index bac697c8..d81f4135 100644 --- a/pkg/kubernetes/token.go +++ b/pkg/kubernetes/token.go @@ -3,6 +3,7 @@ package kubernetes import ( "context" "fmt" + authenticationv1api "k8s.io/api/authentication/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index cc342361..54a9da89 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -62,7 +62,7 @@ func NewServer(configuration Configuration) (*Server, error) { server.WithLogging(), server.WithToolHandlerMiddleware(toolCallLoggingMiddleware), ) - if configuration.StaticConfig.RequireOAuth { + if configuration.StaticConfig.RequireOAuth && false { // TODO: Disabled scope auth validation for now serverOptions = append(serverOptions, server.WithToolHandlerMiddleware(toolScopedAuthorizationMiddleware)) } @@ -122,9 +122,9 @@ func (s *Server) ServeHTTP(httpServer *http.Server) *server.StreamableHTTPServer return server.NewStreamableHTTPServer(s.server, options...) } -// VerifyTokenAPIServer verifies the given token with the audience by +// KubernetesApiVerifyToken verifies the given token with the audience by // sending an TokenReview request to API Server. -func (s *Server) VerifyTokenAPIServer(ctx context.Context, token string, audience string) (*authenticationapiv1.UserInfo, []string, error) { +func (s *Server) KubernetesApiVerifyToken(ctx context.Context, token string, audience string) (*authenticationapiv1.UserInfo, []string, error) { if s.k == nil { return nil, nil, fmt.Errorf("kubernetes manager is not initialized") } diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 9b2c78ef..7be9a423 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/containers/kubernetes-mcp-server/internal/test" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) @@ -48,10 +49,10 @@ func TestWatchKubeConfig(t *testing.T) { } func TestSseHeaders(t *testing.T) { - mockServer := NewMockServer() + mockServer := test.NewMockServer() defer mockServer.Close() before := func(c *mcpContext) { - c.withKubeConfig(mockServer.config) + c.withKubeConfig(mockServer.Config()) c.clientOptions = append(c.clientOptions, client.WithHeaders(map[string]string{"kubernetes-authorization": "Bearer a-token-from-mcp-client"})) } pathHeaders := make(map[string]http.Header, 0) diff --git a/pkg/mcp/pods_exec_test.go b/pkg/mcp/pods_exec_test.go index 919e80b2..de5c00bc 100644 --- a/pkg/mcp/pods_exec_test.go +++ b/pkg/mcp/pods_exec_test.go @@ -2,27 +2,29 @@ package mcp import ( "bytes" - "github.com/containers/kubernetes-mcp-server/pkg/config" - "github.com/mark3labs/mcp-go/mcp" "io" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "net/http" "strings" "testing" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/mark3labs/mcp-go/mcp" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func TestPodsExec(t *testing.T) { testCase(t, func(c *mcpContext) { - mockServer := NewMockServer() + mockServer := test.NewMockServer() defer mockServer.Close() - c.withKubeConfig(mockServer.config) + c.withKubeConfig(mockServer.Config()) mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.URL.Path != "/api/v1/namespaces/default/pods/pod-to-exec/exec" { return } var stdin, stdout bytes.Buffer - ctx, err := createHTTPStreams(w, req, &StreamOptions{ + ctx, err := test.CreateHTTPStreams(w, req, &test.StreamOptions{ Stdin: &stdin, Stdout: &stdout, }) @@ -31,15 +33,15 @@ func TestPodsExec(t *testing.T) { _, _ = w.Write([]byte(err.Error())) return } - defer func(conn io.Closer) { _ = conn.Close() }(ctx.conn) - _, _ = io.WriteString(ctx.stdoutStream, "command:"+strings.Join(req.URL.Query()["command"], " ")+"\n") - _, _ = io.WriteString(ctx.stdoutStream, "container:"+strings.Join(req.URL.Query()["container"], " ")+"\n") + defer func(conn io.Closer) { _ = conn.Close() }(ctx.Closer) + _, _ = io.WriteString(ctx.StdoutStream, "command:"+strings.Join(req.URL.Query()["command"], " ")+"\n") + _, _ = io.WriteString(ctx.StdoutStream, "container:"+strings.Join(req.URL.Query()["container"], " ")+"\n") })) mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.URL.Path != "/api/v1/namespaces/default/pods/pod-to-exec" { return } - writeObject(w, &v1.Pod{ + test.WriteObject(w, &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "pod-to-exec", diff --git a/pkg/mcp/pods_test.go b/pkg/mcp/pods_test.go index de61dbe7..de65afa4 100644 --- a/pkg/mcp/pods_test.go +++ b/pkg/mcp/pods_test.go @@ -1,12 +1,13 @@ package mcp import ( - "github.com/containers/kubernetes-mcp-server/pkg/config" - "github.com/containers/kubernetes-mcp-server/pkg/output" "regexp" "strings" "testing" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/containers/kubernetes-mcp-server/pkg/output" + "github.com/mark3labs/mcp-go/mcp" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" diff --git a/pkg/mcp/pods_top_test.go b/pkg/mcp/pods_top_test.go index 0b63cac8..1023d96f 100644 --- a/pkg/mcp/pods_top_test.go +++ b/pkg/mcp/pods_top_test.go @@ -5,6 +5,7 @@ import ( "regexp" "testing" + "github.com/containers/kubernetes-mcp-server/internal/test" "github.com/mark3labs/mcp-go/mcp" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -12,9 +13,9 @@ import ( func TestPodsTopMetricsUnavailable(t *testing.T) { testCase(t, func(c *mcpContext) { - mockServer := NewMockServer() + mockServer := test.NewMockServer() defer mockServer.Close() - c.withKubeConfig(mockServer.config) + c.withKubeConfig(mockServer.Config()) mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-) @@ -45,9 +46,9 @@ func TestPodsTopMetricsUnavailable(t *testing.T) { func TestPodsTopMetricsAvailable(t *testing.T) { testCase(t, func(c *mcpContext) { - mockServer := NewMockServer() + mockServer := test.NewMockServer() defer mockServer.Close() - c.withKubeConfig(mockServer.config) + c.withKubeConfig(mockServer.Config()) mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { println("Request received:", req.Method, req.URL.Path) // TODO: REMOVE LINE w.Header().Set("Content-Type", "application/json") @@ -211,9 +212,9 @@ func TestPodsTopMetricsAvailable(t *testing.T) { func TestPodsTopDenied(t *testing.T) { deniedResourcesServer := &config.StaticConfig{DeniedResources: []config.GroupVersionKind{{Group: "metrics.k8s.io", Version: "v1beta1"}}} testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) { - mockServer := NewMockServer() + mockServer := test.NewMockServer() defer mockServer.Close() - c.withKubeConfig(mockServer.config) + c.withKubeConfig(mockServer.Config()) mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "application/json") // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)