Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions charts/keycloak-configure/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions charts/keycloak-configure/templates/job.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions charts/keycloak-configure/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions charts/keycloak/Chart.yaml
Original file line number Diff line number Diff line change
@@ -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"
55 changes: 55 additions & 0 deletions charts/keycloak/templates/_helpers.tpl
Original file line number Diff line number Diff line change
@@ -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 -}}
196 changes: 196 additions & 0 deletions charts/keycloak/templates/keycloak.yaml
Original file line number Diff line number Diff line change
@@ -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 }}
Loading
Loading