diff --git a/charts/keycloak-configure/templates/configmap.yaml b/charts/keycloak-configure/templates/configmap.yaml index b67e0c16e..bd3174a99 100644 --- a/charts/keycloak-configure/templates/configmap.yaml +++ b/charts/keycloak-configure/templates/configmap.yaml @@ -925,6 +925,67 @@ data: fi } + # Enable CIMD (OAuth Client ID Metadata Documents): let MCP clients whose + # client_id is an https URL (e.g. VS Code, Claude Code) authenticate by + # publishing their own metadata document -- Keycloak fetches + validates it, + # no pre-registration. Requires the server 'cimd' feature (KC_FEATURES=cimd). + # + # Configures a client policy = a `client-id-uri` condition (scheme https + + # permitted domains) applying a `client-id-metadata-document` executor. + # NOTE: the executor's permitted-domains must list EVERY host a trusted + # client's document references -- the client_id host, its loopback redirect + # hosts (127.0.0.1/localhost), and any logo_uri/client_uri CDN hosts -- or + # the fetch is rejected ("not trusted domain: host = ..."). + configure_cimd_client_policy() { + local token=$1 + # CIMD_TRUSTED_DOMAINS is a JSON array (chart value cimd.trustedDomains). + local domains="${CIMD_TRUSTED_DOMAINS:-[\"vscode.dev\",\"code.visualstudio.com\",\"claude.ai\",\"127.0.0.1\",\"localhost\"]}" + echo "Configuring CIMD client policy (trusted domains: ${domains})..." + + # Upsert the 'cimd-vendors' profile (merge: preserve any other profiles). + local profiles=$(curl -s -H "Authorization: Bearer ${token}" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/client-policies/profiles") + local new_profiles=$(echo "$profiles" | jq --argjson d "$domains" ' + {profiles: (((.profiles // []) | map(select(.name != "cimd-vendors"))) + [{ + name: "cimd-vendors", + description: "CIMD for trusted MCP client vendors", + executors: [{ + executor: "client-id-metadata-document", + configuration: {"cimd-allow-permitted-domains": $d} + }] + }])}') + local r1=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/client-policies/profiles" \ + -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" \ + -d "$new_profiles") + + # Upsert the 'cimd-vendors-policy' policy (merge: preserve any others). + local policies=$(curl -s -H "Authorization: Bearer ${token}" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/client-policies/policies") + local new_policies=$(echo "$policies" | jq --argjson d "$domains" ' + {policies: (((.policies // []) | map(select(.name != "cimd-vendors-policy"))) + [{ + name: "cimd-vendors-policy", + description: "CIMD for https client-id URIs on trusted vendor domains", + enabled: true, + conditions: [{ + condition: "client-id-uri", + configuration: {"client-id-uri-scheme": ["https"], "client-id-uri-allow-permitted-domains": $d} + }], + profiles: ["cimd-vendors"] + }])}') + local r2=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/client-policies/policies" \ + -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" \ + -d "$new_policies") + + if { [ "$r1" = "204" ] || [ "$r1" = "200" ]; } && { [ "$r2" = "204" ] || [ "$r2" = "200" ]; }; then + echo -e "${GREEN}CIMD client policy configured (profile HTTP $r1, policy HTTP $r2).${NC}" + else + echo -e "${RED}Failed to configure CIMD client policy (profile HTTP $r1, policy HTTP $r2).${NC}" + return 1 + fi + } + # Function to generate random password generate_password() { # Generate a 16-character random password with alphanumeric characters @@ -1053,6 +1114,20 @@ data: echo -e "${YELLOW}DCR realm setup skipped (ENABLE_DCR_CONFIG=${ENABLE_DCR_CONFIG:-false}).${NC}" echo "Set keycloak-configure.dcr.enabled=true in chart values to enable." fi + + # MCP CIMD support: a client policy that lets clients present an + # https URL client_id (their published metadata document) instead of + # pre-registering. Requires KC_FEATURES=cimd on the Keycloak server. + # Gated by ENABLE_CIMD_CONFIG (chart value: cimd.enabled). + if [ "${ENABLE_CIMD_CONFIG:-false}" = "true" ]; then + echo "" + echo -e "${YELLOW}=== Configuring Keycloak realm for MCP Client ID Metadata Documents (CIMD) ===${NC}" + configure_cimd_client_policy "$TOKEN" + else + echo "" + echo -e "${YELLOW}CIMD realm setup skipped (ENABLE_CIMD_CONFIG=${ENABLE_CIMD_CONFIG:-false}).${NC}" + echo "Set keycloak-configure.cimd.enabled=true in chart values to enable." + fi else exit 1 fi diff --git a/charts/keycloak-configure/templates/job.yaml b/charts/keycloak-configure/templates/job.yaml index 770d35466..5f8020a8e 100644 --- a/charts/keycloak-configure/templates/job.yaml +++ b/charts/keycloak-configure/templates/job.yaml @@ -30,6 +30,10 @@ spec: name: KEYCLOAK_ADMIN_PASSWORD - name: ENABLE_DCR_CONFIG value: {{ .Values.dcr.enabled | default false | toString | quote }} + - name: ENABLE_CIMD_CONFIG + value: {{ .Values.cimd.enabled | default false | toString | quote }} + - name: CIMD_TRUSTED_DOMAINS + value: {{ .Values.cimd.trustedDomains | default (list "vscode.dev" "code.visualstudio.com" "claude.ai" "127.0.0.1" "localhost") | toJson | quote }} volumeMounts: - mountPath: /app/script.sh name: script diff --git a/charts/keycloak-configure/values.yaml b/charts/keycloak-configure/values.yaml index 24cb8c8b8..0e11dba73 100644 --- a/charts/keycloak-configure/values.yaml +++ b/charts/keycloak-configure/values.yaml @@ -28,3 +28,24 @@ keycloak: # MCP_ADVERTISED_SCOPES value. dcr: enabled: true + +# Client ID Metadata Documents (CIMD) realm setup. +# +# When true, the Job configures a client policy so MCP clients whose client_id +# is an https URL (e.g. VS Code, Claude Code) can authenticate by publishing +# their own metadata document -- Keycloak fetches + validates it, no +# pre-registration. Requires the Keycloak server 'cimd' feature +# (keycloak.features: "cimd", i.e. KC_FEATURES=cimd; >= 26.6). +# +# trustedDomains is the allowlist Keycloak will fetch CIMD documents from AND +# validate every URL host inside them against. It must include, for each trusted +# client: the client_id host, its loopback redirect hosts (127.0.0.1/localhost), +# and any logo_uri/client_uri CDN hosts the document references. +cimd: + enabled: false + trustedDomains: + - vscode.dev + - code.visualstudio.com + - claude.ai + - "127.0.0.1" + - localhost diff --git a/charts/keycloak/Chart.yaml b/charts/keycloak/Chart.yaml new file mode 100644 index 000000000..d293df3b6 --- /dev/null +++ b/charts/keycloak/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: keycloak +description: >- + Keycloak IdP for the MCP Gateway Registry, built on the official + quay.io/keycloak/keycloak image with an official postgres backend. +type: application +version: 0.1.0 +appVersion: "26.6.0" diff --git a/charts/keycloak/templates/_helpers.tpl b/charts/keycloak/templates/_helpers.tpl new file mode 100644 index 000000000..c6f123e9c --- /dev/null +++ b/charts/keycloak/templates/_helpers.tpl @@ -0,0 +1,55 @@ +{{/* +Names. These are load-bearing: other components resolve Keycloak at +"{{ .Release.Name }}-keycloak-headless:8080", so the headless service name and +port are a hard contract. Keycloak connects to postgres at a NEW service name +(avoids colliding with the Bitnami postgresql service during upgrade). +*/}} +{{- define "keycloak.fullname" -}} +{{- printf "%s-keycloak" .Release.Name -}} +{{- end -}} + +{{- define "keycloak.headlessName" -}} +{{- printf "%s-keycloak-headless" .Release.Name -}} +{{- end -}} + +{{- define "keycloak.postgresName" -}} +{{- printf "%s-keycloak-postgres" .Release.Name -}} +{{- end -}} + +{{- define "keycloak.postgresHeadlessName" -}} +{{- printf "%s-keycloak-postgres-headless" .Release.Name -}} +{{- end -}} + +{{- define "keycloak.migrationPvcName" -}} +{{- printf "%s-keycloak-pg-migration" .Release.Name -}} +{{- end -}} + +{{- define "keycloak.adminSecretName" -}} +{{- .Values.auth.existingSecret | default (printf "%s-keycloak" .Release.Name) -}} +{{- end -}} + +{{- define "keycloak.pgSecretName" -}} +{{- .Values.postgres.existingSecret | default (printf "%s-keycloak-postgresql" .Release.Name) -}} +{{- end -}} + +{{- define "keycloak.bitnamiPgService" -}} +{{- .Values.postgres.source.serviceName | default (printf "%s-postgresql" .Release.Name) -}} +{{- end -}} + +{{/* Labels */}} +{{- define "keycloak.commonLabels" -}} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "keycloak.serverSelectorLabels" -}} +app.kubernetes.io/name: keycloak +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: server +{{- end -}} + +{{- define "keycloak.postgresSelectorLabels" -}} +app.kubernetes.io/name: keycloak +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: postgres +{{- end -}} diff --git a/charts/keycloak/templates/keycloak.yaml b/charts/keycloak/templates/keycloak.yaml new file mode 100644 index 000000000..62df5cd3f --- /dev/null +++ b/charts/keycloak/templates/keycloak.yaml @@ -0,0 +1,196 @@ +{{- if .Values.create }} +{{- $adminSecret := include "keycloak.adminSecretName" . }} +{{- $pgSecret := include "keycloak.pgSecretName" . }} +{{- $rel := .Values.httpRelativePath | default "/" }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "keycloak.headlessName" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "keycloak.commonLabels" . | nindent 4 }} + {{- include "keycloak.serverSelectorLabels" . | nindent 4 }} +spec: + clusterIP: None + selector: + {{- include "keycloak.serverSelectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.containerPort }} + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "keycloak.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "keycloak.commonLabels" . | nindent 4 }} + {{- include "keycloak.serverSelectorLabels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "keycloak.serverSelectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.httpPort }} + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "keycloak.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "keycloak.commonLabels" . | nindent 4 }} + {{- include "keycloak.serverSelectorLabels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + {{- include "keycloak.serverSelectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "keycloak.commonLabels" . | nindent 8 }} + {{- include "keycloak.serverSelectorLabels" . | nindent 8 }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + initContainers: + - name: wait-for-postgres + image: "{{ .Values.postgres.image.repository }}:{{ .Values.postgres.image.tag }}" + imagePullPolicy: {{ .Values.postgres.image.pullPolicy }} + command: + - /bin/sh + - -c + - | + until pg_isready -h {{ include "keycloak.postgresName" . }} -p 5432 -U {{ .Values.postgres.username }}; do + echo "waiting for postgres..."; sleep 3 + done + containers: + - name: keycloak + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: ["start", "--optimized=false"] + env: + - name: KC_BOOTSTRAP_ADMIN_USERNAME + value: {{ .Values.auth.adminUser | quote }} + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $adminSecret }} + key: {{ .Values.auth.existingSecretPasswordKey }} + - name: KC_DB + value: postgres + - name: KC_DB_URL_HOST + value: {{ include "keycloak.postgresName" . }} + - name: KC_DB_URL_PORT + value: "5432" + - name: KC_DB_URL_DATABASE + value: {{ .Values.postgres.database | quote }} + - name: KC_DB_USERNAME + value: {{ .Values.postgres.username | quote }} + - name: KC_DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $pgSecret }} + key: {{ .Values.postgres.existingSecretPasswordKey }} + - name: KC_DB_SCHEMA + value: public + - name: KC_HTTP_ENABLED + value: "true" + - name: KC_PROXY_HEADERS + value: {{ .Values.proxyHeaders | quote }} + - name: KC_HOSTNAME_STRICT + value: {{ .Values.hostnameStrict | toString | quote }} + - name: KC_HEALTH_ENABLED + value: "true" + - name: KC_CACHE + value: {{ .Values.cache | quote }} + {{- if ne $rel "/" }} + - name: KC_HTTP_RELATIVE_PATH + value: {{ $rel | quote }} + {{- end }} + {{- if .Values.features }} + - name: KC_FEATURES + value: {{ .Values.features | quote }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.service.containerPort }} + - name: management + containerPort: {{ .Values.service.managementPort }} + readinessProbe: + httpGet: + path: /health/ready + port: management + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /health/live + port: management + initialDelaySeconds: 60 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 10 + {{- if .Values.sslInit.enabled }} + # Preserve the realm SSL-disable init: relax sslRequired on the admin + # realm so the admin console works over HTTP behind the edge proxy. + lifecycle: + postStart: + exec: + command: + - /bin/bash + - -c + - | + ( + SERVER="http://localhost:{{ .Values.service.containerPort }}${KC_HTTP_RELATIVE_PATH:-}" + echo "PostStart: setting sslRequired=NONE on realm {{ .Values.adminRealm }} via ${SERVER}" + # No curl in the Keycloak image: poll with kcadm itself, which + # only succeeds once the server is up and the admin is bootstrapped. + for i in $(seq 1 120); do + if /opt/keycloak/bin/kcadm.sh config credentials \ + --config /tmp/kcadm.config --server "${SERVER}" \ + --realm {{ .Values.adminRealm }} \ + --user "$KC_BOOTSTRAP_ADMIN_USERNAME" \ + --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" > /dev/null 2>&1; then + /opt/keycloak/bin/kcadm.sh update realms/{{ .Values.adminRealm }} \ + --config /tmp/kcadm.config -s sslRequired=NONE \ + && echo "sslRequired=NONE set on realm {{ .Values.adminRealm }} after $i attempts" \ + && break + fi + sleep 5 + done + ) > /tmp/poststart-config.log 2>&1 & + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/keycloak/templates/migration.yaml b/charts/keycloak/templates/migration.yaml new file mode 100644 index 000000000..74c889561 --- /dev/null +++ b/charts/keycloak/templates/migration.yaml @@ -0,0 +1,97 @@ +{{- /* Migration is upgrade-only: a fresh install has no legacy Bitnami DB, and + the dump Job's pg-secret is only created in the main apply (after any + pre-install hooks) -- so gating on .Release.IsUpgrade avoids aborting a + clean `helm install` on a missing secret. */ -}} +{{- if and .Values.create .Values.postgres.migrateFromBitnami .Release.IsUpgrade }} +{{- $pg := .Values.postgres }} +{{- $pgSecret := include "keycloak.pgSecretName" . }} +--- +# Durable scratch volume shared: pre-upgrade dump Job writes restore.sql here; +# the new postgres mounts it read-only at /docker-entrypoint-initdb.d and runs +# it on first-init. Created as a pre-upgrade hook so it exists before the dump +# Job and persists into the main apply. Not deleted post-hook. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "keycloak.migrationPvcName" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "keycloak.commonLabels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-upgrade + "helm.sh/hook-weight": "-20" + "helm.sh/hook-delete-policy": before-hook-creation +spec: + accessModes: ["ReadWriteOnce"] + {{- if $pg.persistence.storageClass }} + storageClassName: {{ $pg.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ $pg.migrationPvcSize | quote }} +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-keycloak-pg-migrate-dump + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "keycloak.commonLabels" . | nindent 4 }} + annotations: + "helm.sh/hook": pre-upgrade + "helm.sh/hook-weight": "-10" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + backoffLimit: 2 + template: + metadata: + labels: + {{- include "keycloak.commonLabels" . | nindent 8 }} + spec: + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 999 + fsGroup: 999 + containers: + - name: dump + image: "{{ $pg.image.repository }}:{{ $pg.image.tag }}" + imagePullPolicy: {{ $pg.image.pullPolicy }} + env: + - name: PGPASSWORD + valueFrom: + secretKeyRef: + name: {{ $pgSecret }} + key: {{ $pg.existingSecretPasswordKey }} + command: + - /bin/sh + - -c + - | + set -e + SRC="{{ include "keycloak.bitnamiPgService" . }}" + PORT="{{ $pg.source.port }}" + SRC_USER="{{ $pg.source.username }}" + SRC_DB="{{ $pg.source.database }}" + # Graceful fallback: only migrate when the legacy DB is + # actually reachable with the migration creds. Covers fresh installs + # and repeat upgrades (source already gone) -> skip, never abort helm. + # (A real pg_dump failure below still aborts -- fail-safe, old release + # untouched -- so we never proceed into data loss.) + if ! psql -h "$SRC" -p "$PORT" -U "$SRC_USER" -d "$SRC_DB" -tAc "select 1" >/dev/null 2>&1; then + echo "No legacy bitnami DB at $SRC_DB@$SRC:$PORT (nothing to migrate); skipping" + rm -f /migration/restore.sql || true + exit 0 + fi + echo "Dumping $SRC_DB from $SRC (normalized to {{ $pg.database }}/{{ $pg.username }} via --no-owner --no-acl)..." + pg_dump -h "$SRC" -p "$PORT" -U "$SRC_USER" -d "$SRC_DB" \ + --no-owner --no-acl --no-comments \ + -f /migration/restore.sql + echo "Dump complete: $(wc -c < /migration/restore.sql) bytes" + volumeMounts: + - name: migration + mountPath: /migration + volumes: + - name: migration + persistentVolumeClaim: + claimName: {{ include "keycloak.migrationPvcName" . }} +{{- end }} diff --git a/charts/keycloak/templates/postgres.yaml b/charts/keycloak/templates/postgres.yaml new file mode 100644 index 000000000..be84c8720 --- /dev/null +++ b/charts/keycloak/templates/postgres.yaml @@ -0,0 +1,143 @@ +{{- if .Values.create }} +{{- $pg := .Values.postgres }} +{{- $pgSecret := include "keycloak.pgSecretName" . }} +--- +# Headless service governing the postgres StatefulSet. +apiVersion: v1 +kind: Service +metadata: + name: {{ include "keycloak.postgresHeadlessName" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "keycloak.commonLabels" . | nindent 4 }} + {{- include "keycloak.postgresSelectorLabels" . | nindent 4 }} +spec: + clusterIP: None + selector: + {{- include "keycloak.postgresSelectorLabels" . | nindent 4 }} + ports: + - name: postgresql + port: 5432 + targetPort: postgresql +--- +# ClusterIP service Keycloak connects to (KC_DB_URL_HOST). +apiVersion: v1 +kind: Service +metadata: + name: {{ include "keycloak.postgresName" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "keycloak.commonLabels" . | nindent 4 }} + {{- include "keycloak.postgresSelectorLabels" . | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "keycloak.postgresSelectorLabels" . | nindent 4 }} + ports: + - name: postgresql + port: 5432 + targetPort: postgresql +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "keycloak.postgresName" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "keycloak.commonLabels" . | nindent 4 }} + {{- include "keycloak.postgresSelectorLabels" . | nindent 4 }} +spec: + serviceName: {{ include "keycloak.postgresHeadlessName" . }} + replicas: 1 + selector: + matchLabels: + {{- include "keycloak.postgresSelectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "keycloak.commonLabels" . | nindent 8 }} + {{- include "keycloak.postgresSelectorLabels" . | nindent 8 }} + spec: + securityContext: + fsGroup: 999 + runAsUser: 999 + runAsGroup: 999 + runAsNonRoot: true + containers: + - name: postgresql + image: "{{ $pg.image.repository }}:{{ $pg.image.tag }}" + imagePullPolicy: {{ $pg.image.pullPolicy }} + ports: + - name: postgresql + containerPort: 5432 + env: + - name: POSTGRES_USER + value: {{ $pg.username | quote }} + - name: POSTGRES_DB + value: {{ $pg.database | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $pgSecret }} + key: {{ $pg.existingSecretPasswordKey }} + # Subdir keeps the cluster off the volume root (avoids lost+found). + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + {{- if and $pg.migrateFromBitnami .Release.IsUpgrade }} + # One-time restore: the pre-upgrade dump Job wrote restore.sql here; + # postgres runs it during first-init, before it accepts connections. + - name: migration + mountPath: /docker-entrypoint-initdb.d + readOnly: true + {{- end }} + readinessProbe: + exec: + command: ["/bin/sh", "-c", "pg_isready -U {{ $pg.username }} -d {{ $pg.database }} -h 127.0.0.1"] + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + exec: + command: ["/bin/sh", "-c", "pg_isready -U {{ $pg.username }} -d {{ $pg.database }} -h 127.0.0.1"] + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 6 + {{- with $pg.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with $pg.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $pg.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with $pg.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if and $pg.migrateFromBitnami .Release.IsUpgrade }} + volumes: + - name: migration + persistentVolumeClaim: + claimName: {{ include "keycloak.migrationPvcName" . }} + {{- end }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + {{- if $pg.persistence.storageClass }} + storageClassName: {{ $pg.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ $pg.persistence.size | quote }} +{{- end }} diff --git a/charts/keycloak/values.yaml b/charts/keycloak/values.yaml new file mode 100644 index 000000000..f677ee906 --- /dev/null +++ b/charts/keycloak/values.yaml @@ -0,0 +1,78 @@ +# Keycloak subchart — official quay.io/keycloak/keycloak image + official postgres. + +# Gate: the stack sets this to false for Entra / external Keycloak. +create: true + +image: + repository: quay.io/keycloak/keycloak + tag: "26.6.0" # CIMD (Client ID Metadata Documents) requires >= 26.6 + pullPolicy: IfNotPresent + +replicas: 1 + +# Admin bootstrap (Keycloak 26 KC_BOOTSTRAP_ADMIN_*). Password comes from a +# pre-created secret (the stack's keycloak-admin-secret template). +auth: + adminUser: user + existingSecret: "" # defaults to {{ .Release.Name }}-keycloak + existingSecretPasswordKey: admin-password # pragma: allowlist secret + +# The admin realm whose sslRequired is relaxed by the SSL-init hook. +adminRealm: master + +# Comma-separated KC_FEATURES to enable, e.g. "cimd" for Client ID Metadata +# Documents (experimental, >= 26.6). Empty disables the flag. +features: "" + +# Reverse-proxy / HTTP settings (Keycloak runs HTTP behind the registry nginx). +proxyHeaders: xforwarded +hostnameStrict: false +httpRelativePath: "/" +cache: local # 'local' for single replica; use HA cache config for replicas > 1 + +# Relax sslRequired on the admin realm so the admin console works over HTTP +# behind the edge proxy. Replaces the former Bitnami postStart hook. +sslInit: + enabled: true + +service: + httpPort: 80 + containerPort: 8080 + managementPort: 9000 + +resources: {} +nodeSelector: {} +tolerations: [] +affinity: {} +podAnnotations: {} +extraEnv: [] + +postgres: + image: + repository: postgres + tag: "17" # matches the Bitnami source major (17.x) for a clean dump/restore + pullPolicy: IfNotPresent + database: keycloak + username: keycloak + existingSecret: "" # defaults to {{ .Release.Name }}-keycloak-postgresql + existingSecretPasswordKey: password # pragma: allowlist secret + persistence: + size: 8Gi + storageClass: "" + resources: {} + nodeSelector: {} + tolerations: [] + affinity: {} + # Bitnami->official data migration (default TRUE, auto-detecting). A pre-upgrade + # Job dumps the bitnami DB into a migration PVC and the new postgres + # restores it (normalized to the DB/user above) on first init. If the + # source isn't reachable (fresh install, or repeat upgrade after it's gone) the + # Job skips and it's a no-op — safe by default so a missed flag can't drop a DB. + migrateFromBitnami: false + # Source to dump from (pre-upgrade only). + source: + serviceName: "" # defaults to {{ .Release.Name }}-postgresql + port: 5432 + database: bitnami_keycloak + username: bn_keycloak + migrationPvcSize: 2Gi diff --git a/charts/mcp-gateway-registry-stack/Chart.yaml b/charts/mcp-gateway-registry-stack/Chart.yaml index e5c7bba68..c7771b1e3 100644 --- a/charts/mcp-gateway-registry-stack/Chart.yaml +++ b/charts/mcp-gateway-registry-stack/Chart.yaml @@ -6,8 +6,8 @@ version: 0.1.0 appVersion: "1.0.0" dependencies: - name: keycloak - version: 25.2.0 - repository: oci://registry-1.docker.io/bitnamicharts + version: 0.1.0 + repository: "file://../keycloak" condition: keycloak.create - name: mongodb-kubernetes version: 1.6.1 diff --git a/charts/mcp-gateway-registry-stack/values.yaml b/charts/mcp-gateway-registry-stack/values.yaml index 04fcd11c4..bf73c9b08 100644 --- a/charts/mcp-gateway-registry-stack/values.yaml +++ b/charts/mcp-gateway-registry-stack/values.yaml @@ -192,78 +192,36 @@ mongodb: # NOTE: When using Entra (global.authProvider.type: entra), set create: false keycloak: create: true # Deploy Keycloak in this stack (set to false for external Keycloak or Entra) + # Official Keycloak image. + # CIMD (Client ID Metadata Documents) requires >= 26.6. image: - registry: docker.io - repository: bitnamilegacy/keycloak - tag: 26.3.3-debian-12-r0 - global: - security: - allowInsecureImages: true + repository: quay.io/keycloak/keycloak + tag: "26.6.0" auth: adminUser: *keycloakAdmin - existingSecret: '{{ .Release.Name }}-keycloak' - postgresql: - auth: - existingSecret: '{{ .Release.Name }}-keycloak-postgresql' - image: - registry: docker.io - repository: bitnamilegacy/postgresql - tag: 17.6.0-debian-12-r0 - # Keycloak always serves from its own root (httpRelativePath defaults - # to "/" in the Bitnami chart). The registry pod's nginx rewrites - # browser traffic under /keycloak before forwarding, so Keycloak - # itself doesn't need to know about any URL prefix. DO NOT set - # keycloak.httpRelativePath to /keycloak/ — it breaks the /realms - # and /resources proxy locations in the registry's nginx config. - - # NOTE: Keycloak uses Bitnami's `extraEnvVars` convention. Our own - # subcharts (auth-server, registry, mcpgw) use `extraEnv` instead — - # see the `.extraEnv` sections later in this file. Both - # accept the Kubernetes EnvVar schema (name + value/valueFrom); only - # the key name differs. - extraEnvVars: - - name: KC_PROXY - value: edge - - name: KC_PROXY_HEADERS - value: xforwarded - ingress: - enabled: false - nodeSelector: {} - # Lifecycle hook to configure realm SSL settings - lifecycleHooks: - postStart: - exec: - command: - - "/bin/bash" - - "-c" - - | - ( - echo "PostStart: Waiting for Keycloak to be ready..." - # Determine the base path - check if KC_HTTP_RELATIVE_PATH is set - BASE_PATH="${KC_HTTP_RELATIVE_PATH:-}" - BASE_URL="http://localhost:8080${BASE_PATH}" - echo "Using base URL: $BASE_URL" - for i in {1..120}; do - if curl -sf ${BASE_URL}/realms/$KC_SPI_ADMIN_REALM > /dev/null 2>&1; then - echo "Keycloak ready after $i attempts" - break - fi - sleep 5 - done - sleep 10 - echo "Configuring $KC_SPI_ADMIN_REALM realm..." - /opt/bitnami/keycloak/bin/kcadm.sh config credentials \ - --config /tmp/kcadm.config \ - --server ${BASE_URL} \ - --realm $KC_SPI_ADMIN_REALM \ - --user $KC_BOOTSTRAP_ADMIN_USERNAME \ - --password $(cat $KC_BOOTSTRAP_ADMIN_PASSWORD_FILE) - /opt/bitnami/keycloak/bin/kcadm.sh update \ - --config /tmp/kcadm.config \ - realms/$KC_SPI_ADMIN_REALM \ - -s sslRequired=NONE - echo "✓ $KC_SPI_ADMIN_REALM realm configured!" - ) > /tmp/poststart-config.log 2>&1 & + # Admin password comes from the stack-managed secret + # {{ .Release.Name }}-keycloak (auth.existingSecret defaults to it). + # Comma-separated KC_FEATURES to enable. "cimd" turns on Keycloak's experimental + # Client ID Metadata Documents support (>= 26.6). Empty = none. + features: "" + # Keycloak serves from its own root; the registry pod's nginx rewrites browser + # traffic under /keycloak before forwarding. DO NOT set httpRelativePath to + # /keycloak — it breaks the /realms and /resources proxy locations. Proxy + # handling uses KC_PROXY_HEADERS=xforwarded; the old KC_PROXY=edge is + # deprecated in Keycloak 26 and intentionally dropped. The realm SSL-disable + # init is preserved via the subchart's postStart hook (sslInit.enabled). + postgres: + # Normalized DB identity (no bitnami/bn conventions). The one-time migration + # loads data into these via `pg_dump --no-owner --no-acl`. + database: keycloak + username: keycloak + # Bitnami migration. Defaults TRUE and auto-detects: a + # pre-upgrade Job dumps the bitnami DB (if reachable) and the new + # postgres restores it on first init. If there's nothing to migrate (fresh + # install, or a repeat upgrade after the source is gone) it degrades + # gracefully to a no-op — so nobody loses a database by forgetting a flag. + # A genuine dump failure aborts the upgrade (fail-safe; old release intact). + migrateFromBitnami: true # Keycloak configuration job # Automatically enabled when global.authProvider.type = "keycloak" # Set to false to skip configuration (e.g., when using pre-configured Keycloak) @@ -285,6 +243,18 @@ keycloak-configure: # using a pre-configured external Keycloak with its own DCR setup. dcr: enabled: true + # Client ID Metadata Documents (CIMD) realm setup. Requires the Keycloak + # server 'cimd' feature (keycloak.features: "cimd" above). trustedDomains is + # the allowlist Keycloak fetches CIMD docs from and validates every URL host + # inside them against (client_id host + loopback redirect hosts + CDN hosts). + cimd: + enabled: true + trustedDomains: + - vscode.dev + - code.visualstudio.com + - claude.ai + - "127.0.0.1" + - localhost # Mongodb configuration job mongodb-configure: enabled: true # Whether to run the MongoDB configuration job diff --git a/keycloak/setup/init-keycloak.sh b/keycloak/setup/init-keycloak.sh index e2416cd32..dbdf178b5 100755 --- a/keycloak/setup/init-keycloak.sh +++ b/keycloak/setup/init-keycloak.sh @@ -797,6 +797,70 @@ configure_dcr_trusted_hosts() { fi } +# ============================================================================= +# Client ID Metadata Documents (CIMD) support +# ============================================================================= +# Lets MCP clients whose client_id is an https URL (e.g. VS Code, Claude Code) +# authenticate by publishing their own metadata document -- Keycloak fetches + +# validates it, no pre-registration. Requires the server 'cimd' feature +# (KC_FEATURES=cimd; Keycloak >= 26.6). Configures a client policy: a +# `client-id-uri` condition (scheme https + permitted domains) applying a +# `client-id-metadata-document` executor. +# +# NOTE: the executor's permitted-domains must list EVERY host a trusted client's +# document references -- the client_id host, its loopback redirect hosts +# (127.0.0.1/localhost), and any logo_uri/client_uri CDN hosts -- or the fetch +# is rejected ("not trusted domain: host = ..."). +configure_cimd_client_policy() { + local token=$1 + # CIMD_TRUSTED_DOMAINS is a JSON array; default covers VS Code + Claude Code. + local domains="${CIMD_TRUSTED_DOMAINS:-[\"vscode.dev\",\"code.visualstudio.com\",\"claude.ai\",\"127.0.0.1\",\"localhost\"]}" + echo "Configuring CIMD client policy (trusted domains: ${domains})..." + + # Upsert the 'cimd-vendors' profile (merge: preserve any other profiles). + local profiles=$(curl -s -H "Authorization: Bearer ${token}" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/client-policies/profiles") + local new_profiles=$(echo "$profiles" | jq --argjson d "$domains" ' + {profiles: (((.profiles // []) | map(select(.name != "cimd-vendors"))) + [{ + name: "cimd-vendors", + description: "CIMD for trusted MCP client vendors", + executors: [{ + executor: "client-id-metadata-document", + configuration: {"cimd-allow-permitted-domains": $d} + }] + }])}') + local r1=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/client-policies/profiles" \ + -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" \ + -d "$new_profiles") + + # Upsert the 'cimd-vendors-policy' policy (merge: preserve any others). + local policies=$(curl -s -H "Authorization: Bearer ${token}" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/client-policies/policies") + local new_policies=$(echo "$policies" | jq --argjson d "$domains" ' + {policies: (((.policies // []) | map(select(.name != "cimd-vendors-policy"))) + [{ + name: "cimd-vendors-policy", + description: "CIMD for https client-id URIs on trusted vendor domains", + enabled: true, + conditions: [{ + condition: "client-id-uri", + configuration: {"client-id-uri-scheme": ["https"], "client-id-uri-allow-permitted-domains": $d} + }], + profiles: ["cimd-vendors"] + }])}') + local r2=$(curl -s -o /dev/null -w "%{http_code}" \ + -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/client-policies/policies" \ + -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" \ + -d "$new_policies") + + if { [ "$r1" = "204" ] || [ "$r1" = "200" ]; } && { [ "$r2" = "204" ] || [ "$r2" = "200" ]; }; then + echo -e "${GREEN}CIMD client policy configured (profile HTTP $r1, policy HTTP $r2).${NC}" + else + echo -e "${RED}Failed to configure CIMD client policy (profile HTTP $r1, policy HTTP $r2).${NC}" + return 1 + fi +} + # Main execution main() { # Get script directory and find .env file @@ -863,6 +927,15 @@ main() { setup_dcr_audience_mapper "$TOKEN" configure_dcr_allowed_scopes "$TOKEN" configure_dcr_trusted_hosts "$TOKEN" + + # MCP CIMD support (client-id-metadata-document client policy). Requires + # the Keycloak server 'cimd' feature (KC_FEATURES=cimd; >= 26.6). Opt-in + # via ENABLE_CIMD_CONFIG so it's skipped on servers without the feature. + if [ "${ENABLE_CIMD_CONFIG:-false}" = "true" ]; then + configure_cimd_client_policy "$TOKEN" + else + echo -e "${YELLOW}CIMD client policy skipped (set ENABLE_CIMD_CONFIG=true; requires KC_FEATURES=cimd).${NC}" + fi else exit 1 fi