Skip to content
Open
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
1 change: 0 additions & 1 deletion deployments/charts/osmo/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1644,7 +1644,6 @@ configuration:
- app:*
- auth:Token
- credentials:*
- mcp:Access
- pool:List
- profile:Read
- profile:Update
Expand Down
28 changes: 26 additions & 2 deletions deployments/charts/service/templates/_gateway-envoy-config.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ setting detects this rotation and triggers Envoy to reload.
{{- $mcpPath := "/mcp" }}
{{- $mcpMetadataPath := "/.well-known/oauth-protected-resource/mcp" }}
{{- $mcpResourceUrl := "" }}
{{- $mcpTokenIssuer := "" }}
{{- $mcpMetadataUrl := "" }}
{{- $skipAuthPaths := concat (default (list) $envoy.skipAuthPaths) (default (list) $envoy.extraSkipAuthPaths) }}
{{- $authnSkipPaths := $skipAuthPaths }}
Expand All @@ -49,8 +50,28 @@ setting detects this rotation and triggers Envoy to reload.
{{- fail "services.mcp.enabled requires gateway.authz.enabled=true" }}
{{- end }}
{{- $mcpResourceUrl = include "osmo.mcp-resource-url" . }}
{{- if not $envoy.jwt.providers }}
{{- fail "services.mcp.enabled requires at least one gateway.envoy.jwt.providers entry" }}
{{- /*
The relayed upstream token carries the MCP resource URL as its audience, so the
Gateway has to accept that audience from the identity provider MCP authenticates
against. That provider is already configured for this deployment's own clients,
and differs only in audience -- so the audience is appended to it rather than
asking for a second, near-identical entry.

OpenID Connect Discovery defines the configuration URL as the issuer followed by
/.well-known/openid-configuration, so the issuer is derivable. accessTokenIssuer
overrides it for providers whose access tokens are issued elsewhere, as an
application configured for v1-format tokens does.
*/ -}}
{{- $mcpTokenIssuer = $mcpOidcProxy.oidc.accessTokenIssuer | default (trimSuffix "/.well-known/openid-configuration" (required "services.mcp.oidcProxy.oidc.configUrl is required when MCP is enabled" $mcpOidcProxy.oidc.configUrl)) }}
{{- $mcpTokenIssuer = trimSuffix "/" $mcpTokenIssuer }}
{{- $mcpIssuerProviders := 0 }}
{{- range $provider := $envoy.jwt.providers }}
{{- if eq (trimSuffix "/" $provider.issuer) $mcpTokenIssuer }}
{{- $mcpIssuerProviders = add1 $mcpIssuerProviders }}
{{- end }}
{{- end }}
{{- if eq $mcpIssuerProviders 0 }}
{{- fail (printf "services.mcp.enabled requires a gateway.envoy.jwt.providers entry with issuer %s, which is where MCP's relayed tokens come from" $mcpTokenIssuer) }}
{{- end }}
{{- $mcpServiceName := required "services.mcp.serviceName is required when MCP is enabled" $mcp.serviceName }}
{{- $mcpImageName := required "services.mcp.imageName is required when MCP is enabled" $mcp.imageName }}
Expand Down Expand Up @@ -675,6 +696,9 @@ data:
issuer: {{ $provider.issuer }}
audiences:
- {{ $provider.audience }}
{{- if and $mcpEnabled (eq (trimSuffix "/" $provider.issuer) $mcpTokenIssuer) (ne $provider.audience $mcpResourceUrl) }}
- {{ $mcpResourceUrl }}
{{- end }}
forward: true
payload_in_metadata: verified_jwt
from_headers:
Expand Down
67 changes: 30 additions & 37 deletions deployments/charts/service/templates/mcp-service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
{{- if .Values.services.mcp.enabled }}
{{- $mcp := .Values.services.mcp }}
{{- $oidcProxy := $mcp.oidcProxy }}
{{- $oidcProxyEnabled := $oidcProxy.enabled | default false }}
{{- $imageTag := $mcp.imageTag | default .Values.global.osmoImageTag }}
{{- $mcpResourceUrl := include "osmo.mcp-resource-url" . }}
{{- $gatewayUrl := trimSuffix "/mcp" $mcpResourceUrl }}
Expand All @@ -28,27 +27,31 @@
{{- $oidcConfigUrl := "" }}
{{- $oidcClientId := "" }}
{{- $oidcClientSecretFile := "" }}
{{- $redisPasswordFile := "" }}
{{- $secretMountPath := "" }}
{{- $oidcAccessTokenIssuer := "" }}
{{- $oidcAccessTokenRequiredScope := "" }}
{{- $redisKeyPrefix := "" }}
{{- $redisUrl := "" }}
{{- if $oidcProxyEnabled }}
{{- $oidcConfigUrl = required "services.mcp.oidcProxy.oidc.configUrl is required when the OIDC proxy is enabled" $oidcProxy.oidc.configUrl }}
{{- $oidcClientId = required "services.mcp.oidcProxy.oidc.clientId is required when the OIDC proxy is enabled" $oidcProxy.oidc.clientId }}
{{- $oidcClientSecretFile = required "services.mcp.oidcProxy.oidc.clientSecretFile is required when the OIDC proxy is enabled" $oidcProxy.oidc.clientSecretFile }}
{{- $oidcAccessTokenIssuer = required "services.mcp.oidcProxy.oidc.accessTokenIssuer is required when the OIDC proxy is enabled" $oidcProxy.oidc.accessTokenIssuer }}
{{- $oidcAccessTokenRequiredScope = required "services.mcp.oidcProxy.oidc.accessTokenRequiredScope is required when the OIDC proxy is enabled" $oidcProxy.oidc.accessTokenRequiredScope }}
{{- $oidcConfigUrl = required "services.mcp.oidcProxy.oidc.configUrl is required when MCP is enabled" $oidcProxy.oidc.configUrl }}
{{- $oidcClientId = required "services.mcp.oidcProxy.oidc.clientId is required when MCP is enabled" $oidcProxy.oidc.clientId }}
{{- $oidcAccessTokenIssuer = $oidcProxy.oidc.accessTokenIssuer | default "" }}
{{- $oidcAccessTokenRequiredScope = required "services.mcp.oidcProxy.oidc.accessTokenRequiredScope is required when MCP is enabled" $oidcProxy.oidc.accessTokenRequiredScope }}
{{- if not (regexMatch "^[A-Za-z0-9:._~-]{1,128}$" $oidcAccessTokenRequiredScope) }}
{{- fail "services.mcp.oidcProxy.oidc.accessTokenRequiredScope must be one non-empty scope" }}
{{- end }}
{{- $oidcHttpsUrlPattern := "^https://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{1,5})?(/[A-Za-z0-9._~%!$&'()*+,;=:@/-]*)?$" }}
{{- if not (regexMatch $oidcHttpsUrlPattern $oidcConfigUrl) }}
{{- fail "services.mcp.oidcProxy.oidc.configUrl must be an absolute HTTPS URL without query or fragment" }}
{{- end }}
{{- if not (regexMatch $oidcHttpsUrlPattern $oidcAccessTokenIssuer) }}
{{- if and $oidcAccessTokenIssuer (not (regexMatch $oidcHttpsUrlPattern $oidcAccessTokenIssuer)) }}
{{- fail "services.mcp.oidcProxy.oidc.accessTokenIssuer must be an absolute HTTPS issuer without query or fragment" }}
{{- end }}
{{- range $name, $url := dict "configUrl" $oidcConfigUrl "accessTokenIssuer" $oidcAccessTokenIssuer }}
{{- $urlsToCheck := dict "configUrl" $oidcConfigUrl }}
{{- if $oidcAccessTokenIssuer }}
{{- $_ := set $urlsToCheck "accessTokenIssuer" $oidcAccessTokenIssuer }}
{{- end }}
{{- range $name, $url := $urlsToCheck }}
{{- $portMatch := regexFind ":[0-9]+(/|$)" $url }}
{{- if $portMatch }}
{{- $urlPort := trimSuffix "/" (trimPrefix ":" $portMatch) | int }}
Expand All @@ -57,9 +60,6 @@
{{- end }}
{{- end }}
{{- end }}
{{- if not (hasPrefix "/" $oidcClientSecretFile) }}
{{- fail "services.mcp.oidcProxy.oidc.clientSecretFile must be an absolute path" }}
{{- end }}
{{- $redisHost := .Values.services.redis.serviceName | required "services.redis.serviceName is required when the MCP OIDC proxy is enabled" }}
{{- $redisPort := .Values.services.redis.port }}
{{- if or (lt (int $oidcProxy.redis.dbNumber) 0) (gt (int $oidcProxy.redis.dbNumber) 15) }}
Expand Down Expand Up @@ -88,25 +88,23 @@
{{- fail "services.mcp.oidcProxy.upstreamTimeoutSeconds must be between 1 and 60" }}
{{- end }}
{{- if $oidcProxy.existingSecret.name }}
{{- $mountPath := required "services.mcp.oidcProxy.existingSecret.mountPath is required when an existing Secret is configured" $oidcProxy.existingSecret.mountPath }}
{{- if not (hasPrefix "/" $mountPath) }}
{{- $secretMountPath = $oidcProxy.existingSecret.mountPath | default "/etc/osmo/mcp-auth" | trimSuffix "/" }}
{{- if not (hasPrefix "/" $secretMountPath) }}
{{- fail "services.mcp.oidcProxy.existingSecret.mountPath must be an absolute path" }}
{{- end }}
{{- $clientSecretKey := required "services.mcp.oidcProxy.existingSecret.clientSecretKey is required" $oidcProxy.existingSecret.clientSecretKey }}
{{- $normalizedMountPath := trimSuffix "/" $mountPath }}
{{- if ne $oidcClientSecretFile (printf "%s/client-secret" $normalizedMountPath) }}
{{- fail "services.mcp.oidcProxy.oidc.clientSecretFile must be <existingSecret.mountPath>/client-secret" }}
{{- end }}
{{- if and $oidcProxy.redis.passwordFile (ne $oidcProxy.redis.passwordFile (printf "%s/redis-password" $normalizedMountPath)) }}
{{- fail "services.mcp.oidcProxy.redis.passwordFile must be <existingSecret.mountPath>/redis-password" }}
{{- $oidcClientSecretFile = printf "%s/client-secret" $secretMountPath }}
{{- if $oidcProxy.existingSecret.redisPasswordKey }}
{{- $redisPasswordFile = printf "%s/redis-password" $secretMountPath }}
{{- end }}
{{- if and $oidcProxy.redis.passwordFile (not $oidcProxy.existingSecret.redisPasswordKey) }}
{{- fail "services.mcp.oidcProxy.existingSecret.redisPasswordKey is required when redis.passwordFile is configured" }}
{{- else }}
{{- $oidcClientSecretFile = required "services.mcp.oidcProxy.oidc.clientSecretFile is required when no existingSecret is configured" $oidcProxy.oidc.clientSecretFile }}
{{- $redisPasswordFile = $oidcProxy.redis.passwordFile | default "" }}
{{- end }}
{{- if not (hasPrefix "/" $oidcClientSecretFile) }}
{{- fail "services.mcp.oidcProxy.oidc.clientSecretFile must be an absolute path" }}
{{- end }}
{{- $redisScheme := ternary "rediss" "redis" .Values.services.redis.tlsEnabled }}
{{- $redisUrl = printf "%s://%s:%v/%v" $redisScheme $redisHost $redisPort $oidcProxy.redis.dbNumber }}
{{- end }}
{{- if not (kindIs "slice" $mcp.allowedOrigins) }}
{{- fail "services.mcp.allowedOrigins must be a list" }}
{{- end }}
Expand All @@ -121,7 +119,6 @@
"OSMO_GATEWAY_URL"
"OSMO_MCP_REQUEST_TIMEOUT_SECONDS"
"OSMO_MCP_ALLOWED_ORIGINS"
"OSMO_MCP_AUTH_ENABLED"
"OSMO_MCP_AUTH_RESOURCE_URL"
"OSMO_MCP_AUTH_REDIS_URL"
"OSMO_MCP_AUTH_REDIS_PASSWORD_FILE"
Expand Down Expand Up @@ -208,16 +205,13 @@ spec:
value: {{ $requestTimeoutSeconds | quote }}
- name: OSMO_MCP_ALLOWED_ORIGINS
value: {{ join "," $mcp.allowedOrigins | quote }}
- name: OSMO_MCP_AUTH_ENABLED
value: {{ $oidcProxyEnabled | quote }}
{{- if $oidcProxyEnabled }}
- name: OSMO_MCP_AUTH_RESOURCE_URL
value: {{ $mcpResourceUrl | quote }}
- name: OSMO_MCP_AUTH_REDIS_URL
value: {{ $redisUrl | quote }}
{{- if $oidcProxy.redis.passwordFile }}
{{- if $redisPasswordFile }}
- name: OSMO_MCP_AUTH_REDIS_PASSWORD_FILE
value: {{ $oidcProxy.redis.passwordFile | quote }}
value: {{ $redisPasswordFile | quote }}
{{- end }}
- name: OSMO_MCP_AUTH_REDIS_KEY_PREFIX
value: {{ $redisKeyPrefix | quote }}
Expand All @@ -241,17 +235,16 @@ spec:
value: {{ $oidcProxy.refreshTokenTtlSeconds | int | quote }}
- name: OSMO_MCP_AUTH_UPSTREAM_TIMEOUT_SECONDS
value: {{ $oidcProxy.upstreamTimeoutSeconds | int | quote }}
{{- end }}
{{- with $mcp.extraEnv }}
{{- toYaml . | nindent 8 }}
{{- end }}
resources:
{{- toYaml $mcp.resources | nindent 10 }}
{{- if or (and $oidcProxyEnabled $oidcProxy.existingSecret.name) $mcp.extraVolumeMounts (and .Values.gateway.tls.enabled .Values.gateway.tls.upstreamCerts.mcp) }}
{{- if or $oidcProxy.existingSecret.name $mcp.extraVolumeMounts (and .Values.gateway.tls.enabled .Values.gateway.tls.upstreamCerts.mcp) }}
volumeMounts:
{{- if and $oidcProxyEnabled $oidcProxy.existingSecret.name }}
{{- if $oidcProxy.existingSecret.name }}
- name: mcp-auth-secrets
mountPath: {{ $oidcProxy.existingSecret.mountPath }}
mountPath: {{ $secretMountPath }}
readOnly: true
{{- end }}
{{- with $mcp.extraVolumeMounts }}
Expand All @@ -267,16 +260,16 @@ spec:
{{- include "osmo.upstream-probe-yaml" (dict "probe" $mcp.readinessProbe "context" .) | nindent 10 }}
startupProbe:
{{- include "osmo.upstream-probe-yaml" (dict "probe" $mcp.startupProbe "context" .) | nindent 10 }}
{{- if or (and $oidcProxyEnabled $oidcProxy.existingSecret.name) $mcp.extraVolumes (and .Values.gateway.tls.enabled .Values.gateway.tls.upstreamCerts.mcp) }}
{{- if or $oidcProxy.existingSecret.name $mcp.extraVolumes (and .Values.gateway.tls.enabled .Values.gateway.tls.upstreamCerts.mcp) }}
volumes:
{{- if and $oidcProxyEnabled $oidcProxy.existingSecret.name }}
{{- if $oidcProxy.existingSecret.name }}
- name: mcp-auth-secrets
secret:
secretName: {{ $oidcProxy.existingSecret.name }}
items:
- key: {{ $oidcProxy.existingSecret.clientSecretKey }}
path: client-secret
{{- if $oidcProxy.redis.passwordFile }}
{{- if $redisPasswordFile }}
- key: {{ $oidcProxy.existingSecret.redisPasswordKey }}
path: redis-password
{{- end }}
Expand Down
2 changes: 1 addition & 1 deletion deployments/charts/service/tests/mcp-proxy-values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ services:
allowedOrigins:
- http://localhost:6274
oidcProxy:
enabled: true
oidc:
configUrl: https://login.example.com/example-tenant/v2.0/.well-known/openid-configuration
clientId: example-mcp-proxy-client
Expand All @@ -34,6 +33,7 @@ services:
keyPrefix: test:mcp-auth
existingSecret:
name: mcp-oidc-proxy-secrets
redisPasswordKey: redis-password

gateway:
networkPolicies:
Expand Down
44 changes: 44 additions & 0 deletions deployments/charts/service/tests/render-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ grep -q "table.concat(safe_roles, ',')" <<<"$mcp_render"
# Redis connection details come from services.redis; only the database is local.
grep -q 'value: "rediss://redis:6379/14"' <<<"$mcp_workload"

# A Redis needing no password must not force a key into the Secret.
no_redis_pw=$(helm template mcp-nopw "$CHART_DIR" --values "$mcp_values" \
--set 'services.mcp.oidcProxy.existingSecret.redisPasswordKey=')
if grep -q 'redis-password' <<<"$no_redis_pw"; then
echo 'MCP demands a redis-password key when none was asked for' >&2
exit 1
fi

# Authentication is not a mode, so nothing advertises it as one. The fixture
# sets no enabled flag; the OIDC variables above must render regardless.
if grep -q 'name: OSMO_MCP_AUTH_ENABLED' <<<"$mcp_workload"; then
echo 'MCP still renders an auth-enabled switch' >&2
exit 1
fi

# Values the deployment derives must not reappear as deployer inputs.
for derived in OSMO_MCP_AUTH_ISSUER_URL OSMO_MCP_AUTH_SCOPE \
OSMO_MCP_AUTH_OIDC_ACCESS_TOKEN_AUDIENCE \
Expand All @@ -142,6 +157,35 @@ for derived in OSMO_MCP_AUTH_ISSUER_URL OSMO_MCP_AUTH_SCOPE \
fi
done

# Only a provider issuing v1-format access tokens needs the issuer stated. With
# it unset the issuer comes from the configuration URL, and the gateway provider
# for that issuer is the one the MCP audience is added to.
no_issuer=$(helm template mcp-no-issuer "$CHART_DIR" --values "$mcp_values" \
--set 'services.mcp.oidcProxy.oidc.accessTokenIssuer=' \
--set 'gateway.envoy.jwt.providers[0].issuer=https://login.example.com/example-tenant/v2.0')
grep -q 'issuer: https://login.example.com/example-tenant/v2.0' <<<"$no_issuer"

# The secret file paths follow the mount, so a deployer states neither of them.
mount_render=$(helm template mcp-mount "$CHART_DIR" --values "$mcp_values" \
--set 'services.mcp.oidcProxy.existingSecret.mountPath=/var/run/mcp')
grep -q 'value: "/var/run/mcp/client-secret"' <<<"$mount_render"
grep -q 'value: "/var/run/mcp/redis-password"' <<<"$mount_render"
grep -q 'mountPath: /var/run/mcp' <<<"$mount_render"

# The MCP audience is added to the provider for its issuer, so no deployment
# writes a second provider differing only in audience.
aud_render=$(helm template mcp-aud "$CHART_DIR" --values "$mcp_values" \
--set 'gateway.envoy.jwt.providers[0].audience=some-client-id')
grep -q -- '- some-client-id' <<<"$aud_render"
grep -q -- '- https://osmo.example.com/mcp' <<<"$aud_render"

# A provider already carrying that audience must not have it added twice.
dupes=$(grep -c -- '- https://osmo.example.com/mcp' <<<"$mcp_render" || true)
if [ "$dupes" -ne 1 ]; then
echo "MCP audience appears $dupes times on the gateway provider, want 1" >&2
exit 1
fi

# The proxy keeps its state in Redis, so scaling out must render.
helm template mcp-scale "$CHART_DIR" --values "$mcp_values" \
--set 'services.mcp.replicas=2' >/dev/null
Expand Down
14 changes: 12 additions & 2 deletions deployments/charts/service/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ services:
## Upstream OpenID Connect provider. Register the fixed redirect URL
## <resourceUrl origin>/mcp/auth/callback on this confidential application.
##
## clientSecretFile is derived from existingSecret.mountPath when an
## existing Secret supplies the credential. State it only when the secret
## arrives another way, such as Vault injection.
##
## accessTokenIssuer is optional. Leave it empty unless the provider
## issues access tokens from an issuer its discovery document does not
## advertise, as an Entra application configured for v1 tokens does.
##
oidc:
configUrl: ""
clientId: ""
Expand Down Expand Up @@ -195,7 +203,10 @@ services:
name: ""
mountPath: /etc/osmo/mcp-auth
clientSecretKey: client-secret
redisPasswordKey: redis-password
## Set only when the Redis in services.redis requires a password. The
## file path is derived from mountPath; naming the key is what opts in.
##
redisPasswordKey: ""

## Additional pod labels and annotations.
##
Expand Down Expand Up @@ -758,7 +769,6 @@ services:
- "app:*"
- "auth:Token"
- "credentials:*"
- "mcp:Access"
- "pool:List"
- "profile:Read"
- "profile:Update"
Expand Down
23 changes: 11 additions & 12 deletions docs/deployment_guide/advanced_config/mcp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ cannot elevate the user. See :ref:`mcp_identity_permissions` and
Shared Prerequisites
====================

Before enabling either mode:
Before enabling MCP:

* Keep ``gateway.envoy.enabled`` and ``gateway.authz.enabled`` set to ``true``.
* Configure a ``gateway.envoy.jwt.providers`` entry that validates the bearer
token used for downstream ``/api`` requests and resolves its identity and
roles to the intended OSMO user.
roles to the intended OSMO user. The chart adds the MCP resource URL to the
audiences of the entry whose issuer matches the one MCP authenticates
against, so no second entry is needed for MCP itself.
* Publish the release Gateway on one HTTPS hostname. Set
``services.mcp.resourceUrl`` to that origin plus the exact ``/mcp`` path.
* Ensure that the MCP pod can resolve and reach the public Gateway origin.
Expand Down Expand Up @@ -147,16 +149,13 @@ sessions and makes old encrypted state, including DCR registrations, unusable.
Users must authenticate again, and DCR clients might need to remove and re-add
the MCP entry before login.

.. warning::

OSMO currently relies on FastMCP's default derived signing key to avoid a
second operator-managed secret. FastMCP documents that default as a
development or local-testing convenience and recommends an explicit
independent signing key for production. The current OSMO chart does not
expose that independent-key option. Assess this limitation before a
production rollout and require a high-entropy upstream client secret. See
the `FastMCP OIDC proxy signing-key guidance
<https://gofastmcp.com/servers/auth/oidc-proxy#param-jwt-signing-key>`_.
Because both keys come from the client secret, the strength they provide is the
strength of that secret. FastMCP passes it through HKDF as high-entropy key
material, a path distinct from the password-based derivation it reserves for
low-entropy operator-supplied strings. The service therefore requires a client
secret of at least 32 characters and refuses to start below it. Identity
providers issue secrets well above that length; the check exists to reject a
hand-written placeholder.

Configure Helm Values
---------------------
Expand Down
4 changes: 2 additions & 2 deletions src/service/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,8 +411,8 @@ failures.
## Deployment validation

The MCP smoke target requires an MCP-enabled deployment with JWT
authentication. Its token needs `mcp:Access`, `profile:Read`, and
`workflow:Create` for `OETF_POOL`.
authentication. Its token needs `profile:Read` and `workflow:Create`
for `OETF_POOL`.

```bash
bazel run //test/oetf:run -- --env <mcp-enabled-env> --tags mcp
Expand Down
Loading