From 5b98a9036242ff4908185e563befdc33c57ae611 Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:01:48 -0400 Subject: [PATCH 1/4] Restructure vMCP auth docs around scenarios Split the 1009-line authentication.mdx into a scenario-first entry point plus a dedicated embedded-auth-server-vmcp.mdx deep-dive, mirroring the Kubernetes auth restructure. Both nest under a new "Authentication and authorization" sidebar category. The entry point leads with a "Choose a backend authentication pattern" decision table and covers incoming auth, authorization, and the standalone backend strategies. The embedded auth server page holds the strategies that depend on it (upstream injection, token exchange with subjectProviderName, XAA) plus the complete example. Re-validate against the CRD schemas and document previously missing surface: passthroughHeaders, outgoingAuth.default, oidcConfigRef resourceUrl/scopes, AWS STS backend auth, and vMCP Cedar authorization via authzConfig/authzConfigRef. Cross-link the full AWS STS tutorial. Fix inbound links in concept, K8s, and integration docs that pointed at moved or renamed anchors. All YAML dry-run validated against a live cluster; site builds with no broken links. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concepts/embedded-auth-server.mdx | 6 +- docs/toolhive/concepts/vmcp.mdx | 2 +- .../guides-k8s/embedded-auth-server-k8s.mdx | 2 +- docs/toolhive/guides-k8s/remote-mcp-proxy.mdx | 2 +- docs/toolhive/guides-vmcp/authentication.mdx | 979 +++++------------- docs/toolhive/guides-vmcp/configuration.mdx | 2 +- .../guides-vmcp/embedded-auth-server-vmcp.mdx | 738 +++++++++++++ docs/toolhive/integrations/vmcp-entra-id.mdx | 2 +- docs/toolhive/integrations/vmcp-okta.mdx | 2 +- sidebars.ts | 14 +- 10 files changed, 1014 insertions(+), 735 deletions(-) create mode 100644 docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx diff --git a/docs/toolhive/concepts/embedded-auth-server.mdx b/docs/toolhive/concepts/embedded-auth-server.mdx index 7d414ec9..d02edbc9 100644 --- a/docs/toolhive/concepts/embedded-auth-server.mdx +++ b/docs/toolhive/concepts/embedded-auth-server.mdx @@ -269,15 +269,15 @@ resources, with some differences: For single-backend deployments on MCPServer, the embedded auth server automatically swaps the token for each request. For vMCP with multiple backends, you configure which upstream provider's token goes to which backend using -[upstream token injection](../guides-vmcp/authentication.mdx#upstream-token-injection) +[upstream token injection](../guides-vmcp/embedded-auth-server-vmcp.mdx#forward-a-stored-upstream-token-upstream-injection) or -[token exchange with upstream tokens](../guides-vmcp/authentication.mdx#token-exchange-with-upstream-tokens). +[token exchange with upstream tokens](../guides-vmcp/embedded-auth-server-vmcp.mdx#exchange-a-stored-upstream-token-token-exchange). ## Next steps - [Set up the embedded authorization server in Kubernetes](../guides-k8s/embedded-auth-server-k8s.mdx) for step-by-step setup of MCPServer resources in Kubernetes -- [Configure the vMCP embedded authorization server](../guides-vmcp/authentication.mdx#embedded-authorization-server) +- [Configure the vMCP embedded authorization server](../guides-vmcp/embedded-auth-server-vmcp.mdx) for multiple upstream providers on a VirtualMCPServer - [Deploy Redis Sentinel](../guides-k8s/redis-session-storage.mdx) for production session storage diff --git a/docs/toolhive/concepts/vmcp.mdx b/docs/toolhive/concepts/vmcp.mdx index e70af9f1..e04d3f4c 100644 --- a/docs/toolhive/concepts/vmcp.mdx +++ b/docs/toolhive/concepts/vmcp.mdx @@ -143,7 +143,7 @@ auth server acquires tokens from each upstream provider and injects them into requests to the appropriate backends. MCP clients register or identify themselves automatically through Client ID Metadata Documents (CIMD) or Dynamic Client Registration (DCR), so no manual client configuration is needed. See -[Authentication](../guides-vmcp/authentication.mdx#embedded-authorization-server) +[Embedded authorization server](../guides-vmcp/embedded-auth-server-vmcp.mdx) for setup details. ## When to use vMCP diff --git a/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx b/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx index 57abbe23..071293fe 100644 --- a/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx +++ b/docs/toolhive/guides-k8s/embedded-auth-server-k8s.mdx @@ -746,7 +746,7 @@ attempt. - For the other Kubernetes authentication approaches, see [Authentication and authorization](./auth-k8s.mdx) - For multi-upstream provider support with vMCP, see - [vMCP embedded authorization server](../guides-vmcp/authentication.mdx#embedded-authorization-server) + [vMCP embedded authorization server](../guides-vmcp/embedded-auth-server-vmcp.mdx) - For a similar configuration pattern using token exchange, see [Configure token exchange](./token-exchange-k8s.mdx) - For combining the embedded auth server with AWS STS, see diff --git a/docs/toolhive/guides-k8s/remote-mcp-proxy.mdx b/docs/toolhive/guides-k8s/remote-mcp-proxy.mdx index 9ce3f33a..1f6e6eb1 100644 --- a/docs/toolhive/guides-k8s/remote-mcp-proxy.mdx +++ b/docs/toolhive/guides-k8s/remote-mcp-proxy.mdx @@ -831,7 +831,7 @@ any other backend. (In inline mode, you list the backends in the VirtualMCPServer spec instead.) If the proxy is configured to validate incoming requests, whether through `oidcConfigRef` or an embedded authorization server, configure an -[outgoing authentication strategy](../guides-vmcp/authentication.mdx#outgoing-authentication) +[backend authentication strategy](../guides-vmcp/authentication.mdx#backend-authentication) on the vMCP so it presents a token the proxy accepts, such as `upstreamInject` to forward a user's upstream token or `tokenExchange` to obtain a token with the proxy's expected audience. A proxy left without an authentication source accepts diff --git a/docs/toolhive/guides-vmcp/authentication.mdx b/docs/toolhive/guides-vmcp/authentication.mdx index d78da45e..8130c666 100644 --- a/docs/toolhive/guides-vmcp/authentication.mdx +++ b/docs/toolhive/guides-vmcp/authentication.mdx @@ -1,11 +1,14 @@ --- -title: Authentication -description: Configure client and backend authentication for vMCP. +title: Authentication and authorization +description: + Configure client authentication, authorization, and backend auth for vMCP. --- Virtual MCP Server (vMCP) implements a two-boundary authentication model that separates client and backend authentication, giving you centralized control over -access while supporting diverse backend requirements. +access while supporting diverse backend requirements. This page covers incoming +authentication (how clients reach vMCP), authorization (which requests are +allowed), and backend authentication (how vMCP reaches each backend). ## Two-boundary authentication model @@ -37,26 +40,27 @@ flowchart LR Proxy -->|"Backend-scoped
token"| Jira ``` -**Boundary 1 (Incoming):** Clients authenticate to vMCP using OAuth 2.1 +**Boundary 1 (incoming):** Clients authenticate to vMCP using OAuth 2.1 authorization as defined in the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). The vMCP validates the token by checking issuer, audience, expiry, and signature for JWTs, or by using token introspection for opaque tokens. It then evaluates Cedar policies before forwarding the request. This all happens inside the single `vmcp` process, unlike a plain MCPServer deployment where a separate ToolHive -proxy handles this step. When using shared OIDC configuration via -`oidcConfigRef`, the audience value must be explicitly set. For inline OIDC -configuration, it is optional but recommended. See -[OIDC authentication](#oidc-authentication) below. +proxy handles this step. See [Incoming authentication](#incoming-authentication) +and [Authorize requests](#authorize-requests). -**Boundary 2 (Outgoing):** vMCP obtains credentials for each backend API using -the configured outgoing auth strategy. See -[Outgoing authentication](#outgoing-authentication) for the available -strategies. +**Boundary 2 (outgoing):** vMCP obtains credentials for each backend API using +the configured backend authentication strategy. See +[Backend authentication](#backend-authentication) for how to choose one. ## Incoming authentication -Configure how clients authenticate to vMCP. +Configure how clients authenticate to vMCP with the `incomingAuth` block. The +`type` is either `anonymous` or `oidc`. To have vMCP itself broker interactive +user login (redirecting to GitHub, Google, Okta, and similar), use the +[embedded authorization server](./embedded-auth-server-vmcp.mdx) instead of an +external IdP. ### Anonymous (development only) @@ -77,13 +81,14 @@ already isolated behind a trusted network boundary (such as a NetworkPolicy or service mesh); do not use it in production. To enforce client authentication, configure an incoming authentication source: [OIDC authentication](#oidc-authentication) with an `MCPOIDCConfig`, or the -[embedded authorization server](#embedded-authorization-server). +[embedded authorization server](./embedded-auth-server-vmcp.mdx). ::: ### OIDC authentication -Validate tokens from an external identity provider: +Validate tokens from an external identity provider. Create an `MCPOIDCConfig` +resource, then reference it from `incomingAuth`: ```yaml title="MCPOIDCConfig resource" apiVersion: toolhive.stacklok.dev/v1beta1 @@ -107,8 +112,30 @@ spec: audience: vmcp ``` -When using an identity provider that issues opaque OAuth tokens, add a -`clientSecretRef` to the MCPOIDCConfig resource to enable token introspection: +The `oidcConfigRef` carries per-server overrides on top of the shared +`MCPOIDCConfig`: + +- **`audience`** is the expected token audience. It must be unique per server to + prevent token replay across vMCP instances. When using shared OIDC + configuration via `oidcConfigRef`, set it explicitly. +- **`resourceUrl`** is the public URL advertised in OAuth protected resource + metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)). Set it + to the vMCP's externally reachable `/mcp` URL so spec-compliant clients can + discover the authorization server. +- **`scopes`** lists the OAuth scopes advertised in the well-known endpoint. + +```yaml title="VirtualMCPServer resource" +spec: + incomingAuth: + type: oidc + oidcConfigRef: + name: vmcp-oidc + audience: https://mcp.example.com/mcp + resourceUrl: https://mcp.example.com/mcp +``` + +When an identity provider issues opaque OAuth tokens, add a `clientSecretRef` to +the `MCPOIDCConfig` to enable token introspection: ```yaml title="MCPOIDCConfig resource" apiVersion: toolhive.stacklok.dev/v1beta1 @@ -126,15 +153,6 @@ spec: key: clientSecret ``` -```yaml title="VirtualMCPServer resource" -spec: - incomingAuth: - type: oidc - oidcConfigRef: - name: vmcp-oidc - audience: vmcp -``` - Create the Secret: ```yaml @@ -186,9 +204,98 @@ validates service account tokens. The defaults work for most clusters: - **issuer**: `https://kubernetes.default.svc` (auto-detected) - **audience**: `toolhive` (configurable) -## Outgoing authentication +## Authorize requests -Configure how vMCP authenticates to backend MCP servers. +After validating the client token, vMCP evaluates +[Cedar policies](../concepts/cedar-policies.mdx) to decide whether to forward +the request. Authorization is configured under `incomingAuth`. Provide policies +inline with `authzConfig`, or reference a shared `MCPAuthzConfig` resource with +`authzConfigRef`. The two are mutually exclusive. + +```yaml title="VirtualMCPServer resource (inline policies)" +spec: + incomingAuth: + type: oidc + oidcConfigRef: + name: vmcp-oidc + audience: https://mcp.example.com/mcp + authzConfig: + type: inline + inline: + policies: + # Allow any authenticated client to call the search tool + - | + permit( + principal, + action == Action::"call_tool", + resource == Tool::"search" + ); +``` + +To share one policy set across multiple vMCP servers, reference an +`MCPAuthzConfig` instead. Only `cedarv1` `MCPAuthzConfig` resources are +supported for VirtualMCPServer: + +```yaml title="VirtualMCPServer resource (shared policies)" +spec: + incomingAuth: + type: oidc + oidcConfigRef: + name: vmcp-oidc + audience: https://mcp.example.com/mcp + authzConfigRef: + name: shared-vmcp-policies +``` + +vMCP reads group and role claims from the validated token. With the +[embedded authorization server](./embedded-auth-server-vmcp.mdx), claims come +from the +[primary upstream provider](./embedded-auth-server-vmcp.mdx#select-the-primary-upstream-provider). +For Cedar policy syntax, entity types, and worked examples, see +[Cedar policies](../concepts/cedar-policies.mdx). The Kubernetes how-to at +[Authorization in Kubernetes](../guides-k8s/authorization-k8s.mdx) covers the +`MCPAuthzConfig` resource in more depth. + +## Backend authentication + +vMCP obtains credentials for each backend independently, on the hop from vMCP to +the backend MCP server. The right strategy depends on what credential that +backend expects, and specifically whether it's the same credential the client +already presented or a different one vMCP must mint. + +Choosing to mint a fresh credential is also an opportunity to apply least +privilege on purpose. A user might hold broad permissions at a service, but that +doesn't mean an agent acting on their behalf should wield all of it. Minting a +narrower, backend-scoped token with +[token exchange](#federate-identity-with-token-exchange), instead of forwarding +the user's original token verbatim, lets you scope the agent's reach to the +workflow at hand rather than the user's full entitlements. + +### Choose a backend authentication pattern + +Most strategies are configured per backend, keyed by name under +`outgoingAuth.backends`: + +| If the backend... | Use | Embedded auth server | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------- | +| Needs no authentication | [Discovery mode](#discovery-mode) (no per-backend config) | No | +| Uses a static API key or shared secret | [Static header injection](#inject-a-static-credential-header-injection) | No | +| Trusts your IdP but wants a user-scoped token | [Token exchange](#federate-identity-with-token-exchange) | No | +| Is an AWS API and you want per-user IAM identity | [AWS STS](#federate-to-aws-apis-aws-sts) | No | +| Requires a user-delegated OAuth login (GitHub, Google, Slack) | [Upstream token injection](./embedded-auth-server-vmcp.mdx#forward-a-stored-upstream-token-upstream-injection) | Yes | +| Trusts a different authorization server, with no federation | [Cross-application access](./embedded-auth-server-vmcp.mdx#bridge-separate-trust-domains-cross-application-access) | Yes | + +The last two strategies read tokens acquired during an interactive user login, +so they require the +[embedded authorization server](./embedded-auth-server-vmcp.mdx). The rest are +covered below. + +To forward a header the client already holds (its own token in the same trust +domain, or an API key the backend resolves to a user itself) to _all_ backends, +use the top-level [`passthroughHeaders`](#pass-through-client-headers) allowlist +instead. It is a global, lower-precedence fallback: on any overlapping header +such as `Authorization`, a per-backend strategy from the table above overrides +it. ### Discovery mode @@ -206,16 +313,63 @@ This is the recommended approach for most deployments. Backends that don't require authentication work automatically, while backends with `externalAuthConfigRef` configured use their specified authentication method. -See -[Configure token exchange for backend authentication](../guides-k8s/token-exchange-k8s.mdx) -for details on using service account token exchange for backend authentication. +To apply the same fallback strategy to every backend that lacks its own config, +set `outgoingAuth.default`: + +```yaml title="VirtualMCPServer resource" +spec: + outgoingAuth: + source: discovered + default: + type: externalAuthConfigRef + externalAuthConfigRef: + name: shared-backend-auth +``` + +To connect to a specific backend without authentication (for example, to opt it +out of a default), reference an `MCPExternalAuthConfig` with +`type: unauthenticated`, which takes no further configuration. + +### Pass through client headers + +To forward specific incoming client request headers verbatim, set the top-level +`passthroughHeaders` allowlist. Use this when a backend accepts the client's +token unchanged (same trust domain) or resolves a forwarded header, such as an +API key, to a user itself. + +```yaml title="VirtualMCPServer resource" +spec: + passthroughHeaders: + - Authorization + - X-Api-Key +``` + +Unlike the per-backend strategies above, `passthroughHeaders` applies to every +backend and sits at the lowest precedence: for a given backend, an +`externalAuthConfigRef` strategy that sets the same header (for example, +`Authorization`) overrides the forwarded value. Use it as a fallback for +backends without their own config, not as a per-backend override. -### Static header injection +:::warning[Forwarding a token hands over its full privileges] -The `headerInjection` outgoing auth strategy injects a fixed HTTP header into -every request to a backend. Use it when the backend authenticates with a -pre-issued API key or static bearer token rather than per-user OAuth — for -example, an MCP server that wraps a SaaS API behind a single shared credential. +Passing the client's token through unchanged gives the backend, and anything it +calls downstream, the caller's full entitlements at that service. When you want +the agent to act with less than the user's full reach, mint a scoped credential +with [token exchange](#federate-identity-with-token-exchange) instead of +forwarding the original token. + +Restricted headers (`Host`, hop-by-hop headers, `X-Forwarded-*`) are rejected. +Forwarded values are also attacker-influenceable unless a trusted upstream sets +them, so only allowlist headers your backends are prepared to trust. + +::: + +### Inject a static credential (header injection) + +The `headerInjection` strategy injects a fixed HTTP header into every request to +a backend. Use it when the backend authenticates with a pre-issued API key or +static bearer token rather than per-user OAuth, for example an MCP server that +wraps a SaaS API behind a single shared credential. Store the header value in a Secret, then create an `MCPExternalAuthConfig` of type `headerInjection` that references it: @@ -246,8 +400,7 @@ spec: key: value ``` -Reference the config from the VirtualMCPServer's outgoing auth, the same way as -other strategies: +Reference the config from the VirtualMCPServer's outgoing auth: ```yaml title="VirtualMCPServer resource" spec: @@ -270,725 +423,101 @@ the Secret value. :::note -The header value must come from a Kubernetes Secret — plaintext inline values -are not accepted at the CRD layer. Rotating the credential is a matter of -updating the Secret; restart the vMCP deployment/pods, or whichever ToolHive -workload is making the outbound requests using this `MCPExternalAuthConfig`, to -pick up the new value. +The header value must come from a Kubernetes Secret; plaintext inline values are +not accepted at the CRD layer. Rotating the credential is a matter of updating +the Secret, then restarting the vMCP deployment/pods to pick up the new value. ::: -### Upstream token injection - -The `upstreamInject` outgoing auth strategy injects a user's upstream access -token into outgoing requests to a backend. Unlike other strategies that use -static credentials or token exchange, upstream token injection reads tokens that -the [embedded authorization server](#embedded-authorization-server) acquired -during the user's interactive login. +### Federate identity with token exchange -Create an `MCPExternalAuthConfig` resource with the `upstreamInject` type. The -`providerName` must match an upstream provider configured on the embedded -authorization server: +The `tokenExchange` strategy performs an +[RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange, +minting a backend-scoped token from the vMCP-issued JWT. Use it when the backend +trusts your identity provider but wants a token scoped to itself, carrying the +real user's identity. -```yaml title="MCPExternalAuthConfig resource" -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: MCPExternalAuthConfig -metadata: - name: inject-github - namespace: toolhive-system -spec: - type: upstreamInject - upstreamInject: - providerName: github -``` - -Then reference it in the VirtualMCPServer's outgoing auth configuration: +Configuration mirrors the MCPServer setup. See +[Configure token exchange for backend authentication](../guides-k8s/token-exchange-k8s.mdx) +for the full walkthrough. On vMCP, reference the resulting +`MCPExternalAuthConfig` the same way as other strategies: ```yaml title="VirtualMCPServer resource" spec: outgoingAuth: source: inline backends: - backend-github: + backend-jira: type: externalAuthConfigRef externalAuthConfigRef: - name: inject-github + name: exchange-jira ``` -When a request reaches the `backend-github` MCPServer, vMCP replaces the -`Authorization` header with the upstream access token stored for the `github` -provider during the user's login flow. Backends not listed in the `backends` map -receive unauthenticated requests. +When vMCP runs the embedded auth server, you can exchange a stored upstream +token instead of the vMCP JWT by adding `subjectProviderName`. See +[Exchange a stored upstream token](./embedded-auth-server-vmcp.mdx#exchange-a-stored-upstream-token-token-exchange). -:::note - -Upstream token injection requires an -[embedded authorization server](#embedded-authorization-server) configured on -the VirtualMCPServer. The `providerName` must match a provider `name` in the -auth server's `upstreamProviders` list. - -::: - -### Token exchange with upstream tokens - -You can combine the embedded authorization server with -[token exchange](../guides-k8s/token-exchange-k8s.mdx) by adding the -`subjectProviderName` field to a `tokenExchange` config. This tells the token -exchange middleware to use the stored upstream token from the named provider as -the subject token for the -[RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) exchange, instead of -the vMCP-issued JWT. - -This is useful when a backend needs a token exchanged at the same identity -provider that issued the upstream token. For example, if the embedded auth -server acquires an Okta access token during login, you can exchange that token -at a different Okta authorization server for a backend-scoped token: - -```yaml title="MCPExternalAuthConfig resource" -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: MCPExternalAuthConfig -metadata: - name: exchange-okta - namespace: toolhive-system -spec: - type: tokenExchange - tokenExchange: - tokenUrl: https:///oauth2//v1/token - clientId: - clientSecretRef: - name: okta-exchange-client-secret - key: client-secret - audience: backend - scopes: - - backend-api:read - # highlight-next-line - subjectProviderName: okta -``` - -Without `subjectProviderName`, token exchange uses the vMCP-issued JWT as the -subject token. With it, the exchange uses the raw upstream provider's access -token, which the exchange endpoint can validate directly. - -You can mix both strategies in the same vMCP deployment. For example, some -backends can use `upstreamInject` for direct token forwarding while others use -`tokenExchange` with `subjectProviderName` for exchanged tokens: - -```yaml title="VirtualMCPServer resource" -spec: - outgoingAuth: - source: inline - backends: - backend-github: - type: externalAuthConfigRef - externalAuthConfigRef: - name: inject-github - backend-okta-app: - type: externalAuthConfigRef - externalAuthConfigRef: - name: exchange-okta -``` - -### Cross-application access (XAA) +:::enterprise -:::warning[Experimental] +Microsoft Entra ID doesn't implement RFC 8693 token exchange. It has its own +On-Behalf-Of (OBO) flow, based on the RFC 7523 JWT bearer grant, that solves the +same backend-federation problem in a non-standard way. Stacklok Enterprise adds +a dedicated `obo` backend authentication type for this flow. -XAA implements the IETF draft -[`draft-ietf-oauth-identity-assertion-authz-grant`](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant) -(ID-JAG), which is not yet a ratified standard. The CRD surface and runtime -behavior may change in backward-incompatible ways as the draft evolves. +[Learn more about Stacklok Enterprise](../../platform/index.mdx). ::: -The `xaa` outgoing auth strategy handles the cross-domain case, where a backend -trusts a different authorization server than the one that issued the client's -token, and no federation is configured between them. XAA performs a two-step -exchange on each backend call: +### Federate to AWS APIs (AWS STS) -- **Step A ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693))**: vMCP - exchanges the user's upstream ID token at their IdP for an ID-JAG assertion - addressed to the target authorization server. -- **Step B ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523))**: vMCP - presents the ID-JAG at the target authorization server, which returns an - access token for the backend. - -Use `xaa` when [`tokenExchange`](#token-exchange-with-upstream-tokens) doesn't -apply (the backend doesn't trust the client's IdP) and -[`upstreamInject`](#upstream-token-injection) doesn't apply (the user has not -previously authenticated to the backend's provider). XAA requires the -[embedded authorization server](#embedded-authorization-server) so the user's -upstream ID token is available at request time. - -Create an `MCPExternalAuthConfig` with `type: xaa`. Store the IdP and target -authorization server client secrets in Kubernetes Secrets: +The `awsSts` strategy exchanges the user's identity for temporary AWS +credentials via STS with SigV4 request signing, so an AWS-backed MCP server acts +with per-user IAM identity. For the full walkthrough, including creating the IAM +OIDC identity provider, roles, and trust policies, see +[AWS STS authentication for the AWS MCP Server](../integrations/aws-sts.mdx). +Map JWT claims to IAM roles with `roleMappings`, and provide a `fallbackRoleArn` +for requests that match no rule: ```yaml title="MCPExternalAuthConfig resource" apiVersion: toolhive.stacklok.dev/v1beta1 kind: MCPExternalAuthConfig metadata: - name: xaa-backend + name: aws-mcp-sts namespace: toolhive-system spec: - type: xaa - xaa: - # Step A: RFC 8693 token exchange at the user's IdP - idpTokenUrl: https://idp.example.com/oauth2/v1/token - # Optional in the schema, but strongly recommended: conformant IdPs - # reject unauthenticated RFC 8693 token exchange requests - idpClientId: - idpClientSecretRef: - name: xaa-idp-client-secret - key: client-secret - - # Step B: RFC 7523 JWT Bearer grant at the target AS - targetTokenUrl: https://target-as.example.com/oauth2/v1/token - targetClientId: - targetClientSecretRef: - name: xaa-target-client-secret - key: client-secret - - # Required: URL of the target authorization server, used as the - # ID-JAG audience claim - targetAudience: https://target-as.example.com - - # Optional: RFC 8707 resource indicator sent in Step A. Some IdPs - # (e.g. Okta's early ID-JAG implementation) require this even though - # the draft marks it optional. Set it to the backend MCP server URL. - targetResource: https://backend.example.com/mcp - - # Optional: scopes requested at both steps - scopes: - - backend-api:read - - # Required when the VirtualMCPServer has more than one upstream provider: - # with multiple upstreams and this field unset, admission rejects the - # backend with an AmbiguousSubjectProvider condition (the rest of the - # server keeps reconciling). With a single upstream provider, the - # controller auto-populates this field. Must match the name of an entry - # under authServerConfig.upstreamProviders. - subjectProviderName: my-idp -``` - -Attach the `MCPExternalAuthConfig` to the backend `MCPServer`, then use -discovery mode on the VirtualMCPServer to pick it up automatically: - -```yaml title="MCPServer resource" -spec: - externalAuthConfigRef: - name: xaa-backend -``` - -```yaml title="VirtualMCPServer resource" -spec: - outgoingAuth: - source: discovered -``` - -Inlining the reference directly in the VirtualMCPServer's -`outgoingAuth.backends` block (the `source: inline` pattern used by other -strategies) is structurally valid in the CRD, but XAA's secret mounting and -`subjectProviderName` auto-population haven't been verified to behave the same -way under `inline`. Use `discovered` until that's confirmed. - -See the -[MCPExternalAuthConfig reference](../reference/crds/mcpexternalauthconfig.mdx) -for the full `xaa` field list, including which fields are required. - -:::note[Target client credentials are strongly recommended] - -The ID-JAG draft (§9.1) recommends confidential clients for Step B, and most -target authorization servers reject unauthenticated JWT-bearer grants. Omitting -`targetClientId` and `targetClientSecretRef` causes vMCP to log a warning at -startup, and Step B typically fails at runtime. - -::: + type: awsSts + awsSts: + region: us-east-1 + roleClaim: groups + roleMappings: + - claim: platform-admins + roleArn: arn:aws:iam::123456789012:role/mcp-admin + priority: 1 + fallbackRoleArn: arn:aws:iam::123456789012:role/mcp-readonly +``` + +Reference it from a backend the same way as other strategies. When vMCP runs the +embedded auth server, set `subjectProviderName` to exchange a stored upstream +token instead of the vMCP JWT. For the field reference, see the +[MCPExternalAuthConfig reference](../reference/crds/mcpexternalauthconfig.mdx). ## Embedded authorization server -The embedded authorization server runs an OAuth authorization server within the -vMCP process. It redirects users to one or more upstream identity providers -(such as GitHub, Google, or Okta) for interactive authentication, stores the -upstream tokens, and issues its own JWTs that the vMCP validates on subsequent -requests. Combined with [upstream token injection](#upstream-token-injection) or -[token exchange with upstream tokens](#token-exchange-with-upstream-tokens), -this bridges both authentication boundaries: the auth server handles incoming -auth by issuing JWTs, while the outgoing strategies forward or exchange the -stored upstream tokens for backends. - -Use the embedded authorization server when your backend MCP servers call -external APIs on behalf of individual users and no federation relationship -exists between your identity provider and those services. It also supports OAuth -2.0 Client ID Metadata Documents (CIMD) and Dynamic Client Registration -([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)), so MCP clients can -identify themselves or register automatically without manual client -configuration in ToolHive. - -:::info - -For conceptual background on the embedded authorization server (including the -OAuth flow, token storage and forwarding, and when to use it), see -[Embedded authorization server](../concepts/embedded-auth-server.mdx). For -configuring the embedded auth server on individual MCPServer resources (single -upstream provider), see -[Set up the embedded authorization server in Kubernetes](../guides-k8s/embedded-auth-server-k8s.mdx). - -::: - -### How it works - -```mermaid -sequenceDiagram - participant Client as MCP Client - participant AS as Embedded Auth Server - participant GitHub as GitHub (upstream) - participant Google as Google (upstream) - participant vMCP as vMCP - participant Backend as Backend MCP Server - - Client->>AS: Connect (OAuth authorize) - AS-->>Client: Redirect to GitHub - Client->>GitHub: Authenticate + consent - GitHub-->>AS: Authorization code - AS->>GitHub: Exchange code for token - AS-->>Client: Redirect to Google - Client->>Google: Authenticate + consent - Google-->>AS: Authorization code - AS->>Google: Exchange code for token - AS-->>Client: Issue JWT (upstream tokens stored) - - Client->>vMCP: MCP request with JWT - vMCP->>vMCP: Validate JWT, load upstream tokens - vMCP->>Backend: Forward with GitHub access token - Backend-->>vMCP: Response - vMCP-->>Client: Response -``` - -When multiple upstream providers are configured, the auth server chains -authorization flows sequentially. The user is redirected to each provider in -order, and the auth server stores each provider's tokens before moving to the -next. After the final provider completes, the auth server issues a single JWT to -the client. - -### Differences from MCPServer embedded auth server - -The embedded auth server uses the same configuration structure whether on a -VirtualMCPServer or an MCPServer. The key differences for vMCP are: - -- **Inline configuration:** The auth server config lives directly on the - VirtualMCPServer resource under `authServerConfig`, rather than in a separate - `MCPExternalAuthConfig` resource. -- **Multiple upstream providers:** vMCP supports multiple upstream providers - with sequential authorization chaining. MCPServer is limited to a single - upstream provider. -- **Flexible outgoing strategies:** vMCP uses `upstreamInject` or - `tokenExchange` with `subjectProviderName` to route stored tokens to the - correct backends. MCPServer swaps the token automatically because it has a - single upstream provider. - -### Configure the embedded auth server - -Add an `authServerConfig` block to your VirtualMCPServer. The configuration -fields are the same as for the -[MCPServer embedded auth server](../guides-k8s/embedded-auth-server-k8s.mdx) -- -see that guide for generating keys and creating Secrets. - -```yaml title="VirtualMCPServer resource" -spec: - authServerConfig: - issuer: https://auth.example.com - signingKeySecretRefs: - - name: auth-signing-key - key: private-key - hmacSecretRefs: - - name: auth-hmac-key - key: hmac-key - tokenLifespans: - accessTokenLifespan: 1h - refreshTokenLifespan: 168h - authCodeLifespan: 10m - upstreamProviders: - - name: github - type: oauth2 - oauth2Config: { ... } - - name: google - type: oidc - oidcConfig: { ... } -``` - -:::warning[Signing keys and HMAC secrets] - -`signingKeySecretRefs` and `hmacSecretRefs` are technically optional. When -omitted, the auth server auto-generates ephemeral keys on startup. This is -convenient for development, but **tokens become invalid after pod restart**. -JWTs can no longer be verified (signing keys) and authorization codes and -refresh tokens can no longer be decoded (HMAC secrets), forcing all users to -re-authenticate. Always configure persistent keys for production. See -[Set up the embedded authorization server in Kubernetes](../guides-k8s/embedded-auth-server-k8s.mdx) -for key generation steps. - -::: - -The issuer URL must use the `https://` scheme. The single exception is -`localhost`, which can use `http://` for local development. For in-cluster -deployments where traffic between the embedded auth server and other pods stays -on a trusted network (for example, an in-cluster service mesh), you can opt in -to an `http://` issuer on a non-localhost host by setting -`insecureAllowHTTP: true`. The VirtualMCPServer controller rejects this -combination at reconcile time with `AuthServerConfigValidated=False` if the flag -is unset, so misconfiguration surfaces on the resource rather than crashing the -pod at startup. Never set this for issuers reachable outside the cluster. - -If the browser-facing authorization endpoint needs to be on a different host -than the issuer (for example, behind an ingress that rewrites paths), set -`authorizationEndpointBaseUrl` to override the `authorization_endpoint` in the -OAuth discovery document. All other endpoints remain derived from `issuer`: - -```yaml -spec: - authServerConfig: - issuer: https://auth.internal.example.com - authorizationEndpointBaseUrl: https://auth.example.com -``` - -If your MCP clients register via DCR with a narrowed `scope` value and then -request additional scopes at `/oauth/authorize` (a pattern used by Claude Code, -among others), set `baselineClientScopes` so the embedded auth server merges -those scopes into every registered client's scope set. If `scopesSupported` is -set explicitly, all baseline values must appear in it; otherwise the server -validates against its default scope set (`openid`, `profile`, `email`, -`offline_access`). See -[Baseline scopes for DCR clients](../concepts/embedded-auth-server.mdx#baseline-scopes-for-dcr-clients) -for guidance on which scopes to include. - -```yaml -spec: - authServerConfig: - issuer: https://auth.example.com - baselineClientScopes: - - openid - - offline_access -``` - -Each upstream provider `name` must be a valid DNS label (lowercase alphanumeric -and hyphens, max 63 characters). This name is what -[upstream token injection](#upstream-token-injection) and -[token exchange](#token-exchange-with-upstream-tokens) configs reference to map -backends to providers. For details on configuring OIDC vs OAuth 2.0 upstream -providers, see -[Using an OAuth 2.0 upstream provider](../guides-k8s/embedded-auth-server-k8s.mdx#using-an-oauth-20-upstream-provider). -The [complete example](#complete-example) below shows full provider -configurations. - -When multiple upstream providers are configured, Cedar reads JWT claims from the -first upstream's access token by default. Pin a specific provider with -`spec.authServerConfig.primaryUpstreamProvider`. The value must match one of the -names in `upstreamProviders`; unresolvable values are rejected at admission with -`AuthServerConfigValidated=False`: - -```yaml -spec: - authServerConfig: - issuer: https://auth.example.com - primaryUpstreamProvider: okta - upstreamProviders: - - name: okta - type: oidc - # ... - - name: github - type: oauth2 - # ... -``` - -:::note[Deprecated location] - -`primaryUpstreamProvider` previously lived under -`spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider`. The old location -is still read for backward compatibility, but the VirtualMCPServer controller -emits a Warning event with reason `AuthzPrimaryUpstreamProviderDeprecated` -whenever it reads the value. Move the field to -`spec.authServerConfig.primaryUpstreamProvider` to clear the warning. Removal of -the deprecated location is planned for the release after the deprecation cycle. - -::: - -:::tip[Non-standard token responses] - -Some OAuth 2.0 providers nest tokens under non-standard paths instead of -returning them at the top level (for example, GovSlack returns the access token -at `authed_user.access_token`). Add a `tokenResponseMapping` block to the -`oauth2Config` with dot-notation paths for `accessTokenPath`, `scopePath`, -`refreshTokenPath`, and `expiresInPath`. See the -[CRD reference](../reference/crds/index.mdx) for field details. - -::: - -:::tip[Identity in the token response] - -When an upstream returns user identity in the token response itself (Slack -returns it at `authed_user.id`; Snowflake embeds it in the access-token JWT), -set `identityFromToken` on the `oauth2Config` with gjson dot-notation paths for -`subjectPath` (required), `namePath`, and `emailPath`. See -[Extract identity from the token response](../guides-k8s/embedded-auth-server-k8s.mdx#extract-identity-from-the-token-response) -for the full pattern and trust-model caveats. - -::: - -:::tip[Stable subject for OIDC upstreams] - -By default the embedded auth server keys users on the upstream ID token's `sub` -claim. Some identity providers rotate `sub` per application, so the same user -appears as a different principal across apps; the stable identifier lives in -another claim (Entra/Azure AD uses `oid`, some Okta custom auth servers expose a -custom `uid` claim). Set `subjectClaim` on an upstream's `oidcConfig` to pin -ToolHive to the stable claim: - -```yaml -upstreamProviders: - - name: entra - type: oidc - oidcConfig: - issuerUrl: 'https://login.microsoftonline.com//v2.0' - clientId: '' - # ... - subjectClaim: oid -``` - -The value must be a single top-level claim name (letters, digits, underscores; -must start with a letter or underscore). Changing it on a live deployment -re-keys existing users, so treat it as immutable once users exist. The Entra -walkthrough at -[Connect ToolHive to Microsoft Entra ID](../integrations/vmcp-entra-id.mdx) -includes this setting. - -::: - -### Incoming auth with the embedded auth server - -When using the embedded auth server, configure `incomingAuth` to validate the -JWTs it issues. Create an `MCPOIDCConfig` resource whose `issuer` matches -`authServerConfig.issuer`, then reference it with `oidcConfigRef`. Note that -`jwksAllowPrivateIP: true` is no longer needed when using the embedded auth -server because JWKS retrieval is done in-process. - -```yaml title="VirtualMCPServer resource" -spec: - incomingAuth: - type: oidc - # highlight-start - oidcConfigRef: - name: my-oidc-config - audience: https://mcp.example.com/mcp - # highlight-end -``` - -### Session storage - -By default, upstream tokens are stored in memory and lost on pod restart. For -production, configure Redis Sentinel by adding a `storage` block to -`authServerConfig`. The configuration is the same as for the MCPServer embedded -auth server. See -[Redis Sentinel session storage](../guides-k8s/redis-session-storage.mdx) for a -complete walkthrough. - -### Skip upstream token injection for public backends - -By default, the embedded auth server swaps the client's ToolHive JWT for the -matching upstream token before forwarding the request to a backend, using -[upstream token injection](#upstream-token-injection) or -[token exchange with upstream tokens](#token-exchange-with-upstream-tokens). -That assumes the backend itself requires authentication. - -If the backend MCP server is public (for example, a documentation server with no -auth of its own) but you still want clients to authenticate to vMCP, set -`disableUpstreamTokenInjection: true` on `authServerConfig`. The embedded auth -server still runs the OAuth flow for clients, but the proxy then **strips** the -client's credential headers (`Authorization`, `Cookie`, and -`Proxy-Authorization`) after the JWT is validated, so the backend receives an -unauthenticated request. - -```yaml title="VirtualMCPServer resource" -spec: - authServerConfig: - issuer: https://auth.example.com - # highlight-next-line - disableUpstreamTokenInjection: true - upstreamProviders: - - name: github - # ... -``` - -Use `outgoingAuth` with `headerInjection` (see -[Static header injection](#static-header-injection)) if the backend still needs -a static credential such as an API key. - -:::warning[Incompatible with token exchange and AWS STS] - -The strip happens after JWT validation but before token-exchange and AWS STS -middlewares would normally attach a new credential. Combining -`disableUpstreamTokenInjection: true` with `tokenExchange` or `awsSts` on the -same vMCP causes the embedded auth server to fail validation at startup. Use -this flag only when the backend should remain unauthenticated. - -::: - -### Complete example - -This example deploys a vMCP with an embedded auth server that authenticates -users through GitHub and Google, then injects the GitHub access token into -requests to a GitHub MCP server backend. - -**Prerequisites:** Create Secrets for signing keys, HMAC keys, and upstream -provider credentials following the steps in -[Set up the embedded authorization server in Kubernetes](../guides-k8s/embedded-auth-server-k8s.mdx). -You need: `auth-signing-key`, `auth-hmac-key`, `github-client-secret`, and -`google-client-secret`. - -**Step 1:** Create an MCPGroup, OIDC config, and deploy the backend MCP server: - -```yaml title="backends.yaml" -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: MCPGroup -metadata: - name: my-backends - namespace: toolhive-system ---- -# highlight-start -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: MCPOIDCConfig -metadata: - name: my-oidc-config - namespace: toolhive-system -spec: - type: inline - inline: - issuer: https://auth.example.com -# highlight-end ---- -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: MCPServer -metadata: - name: backend-github - namespace: toolhive-system -spec: - image: ghcr.io/github/github-mcp-server - transport: streamable-http - # highlight-start - groupRef: - name: my-backends - # highlight-end -``` - -**Step 2:** Create the upstream token injection config: - -```yaml title="auth-configs.yaml" -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: MCPExternalAuthConfig -metadata: - name: inject-github - namespace: toolhive-system -spec: - type: upstreamInject - upstreamInject: - providerName: github -``` - -**Step 3:** Deploy the VirtualMCPServer: - -```yaml title="virtualmcpserver.yaml" -apiVersion: toolhive.stacklok.dev/v1beta1 -kind: VirtualMCPServer -metadata: - name: my-vmcp - namespace: toolhive-system -spec: - groupRef: - name: my-backends - # highlight-start - authServerConfig: - issuer: https://auth.example.com - signingKeySecretRefs: - - name: auth-signing-key - key: private-key - hmacSecretRefs: - - name: auth-hmac-key - key: hmac-key - tokenLifespans: - accessTokenLifespan: 1h - refreshTokenLifespan: 168h - authCodeLifespan: 10m - upstreamProviders: - - name: github - type: oauth2 - oauth2Config: - authorizationEndpoint: https://github.com/login/oauth/authorize - tokenEndpoint: https://github.com/login/oauth/access_token - clientId: - clientSecretRef: - name: github-client-secret - key: client-secret - scopes: - - repo - - read:user - userInfo: - endpointUrl: https://api.github.com/user - httpMethod: GET - additionalHeaders: - Accept: application/vnd.github+json - fieldMapping: - subjectFields: - - id - - login - nameFields: - - name - - login - emailFields: - - email - - name: google - type: oidc - oidcConfig: - issuerUrl: https://accounts.google.com - clientId: - clientSecretRef: - name: google-client-secret - key: client-secret - scopes: - - openid - - email - # highlight-end - incomingAuth: - type: oidc - # highlight-start - oidcConfigRef: - name: my-oidc-config - audience: https://mcp.example.com/mcp - # highlight-end - outgoingAuth: - source: inline - backends: - backend-github: - type: externalAuthConfigRef - externalAuthConfigRef: - name: inject-github -``` - -**Step 4:** Verify the deployment: - -```bash -# Check the VirtualMCPServer status -kubectl get virtualmcpserver -n toolhive-system my-vmcp - -# Verify OAuth discovery is available -curl https://auth.example.com/.well-known/oauth-authorization-server -``` - -Connect with an MCP client that supports the -[MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). -The client discovers the authorization server through protected resource -metadata, then redirects you through each upstream provider for authentication. -After completing the login flow, MCP tool calls to the GitHub backend -automatically include your GitHub access token. +When your backends call external APIs on behalf of individual users and no +federation relationship exists between your identity provider and those +services, run the +[embedded authorization server](./embedded-auth-server-vmcp.mdx). It brokers +interactive login to one or more upstream providers, stores each user's tokens, +and issues the JWTs that vMCP validates on incoming requests. Paired with +[upstream token injection](./embedded-auth-server-vmcp.mdx#forward-a-stored-upstream-token-upstream-injection) +or +[token exchange with a stored token](./embedded-auth-server-vmcp.mdx#exchange-a-stored-upstream-token-token-exchange), +it bridges both authentication boundaries in one deployment. ## Next steps +- [Set up the embedded authorization server](./embedded-auth-server-vmcp.mdx) to + broker user login and forward per-user tokens to backends - [Configure tool aggregation](./tool-aggregation.mdx) to manage how tools from multiple backends are presented to clients - [Set up audit logging](./audit-logging.mdx) to track authentication decisions @@ -1001,9 +530,9 @@ automatically include your GitHub access token. - [Authentication and authorization](../concepts/auth-framework.mdx) - [Backend authentication](../concepts/backend-auth.mdx) - [Embedded authorization server](../concepts/embedded-auth-server.mdx) for - conceptual background on the embedded auth server + conceptual background - [Cedar policies](../concepts/cedar-policies.mdx) for detailed policy syntax - [VirtualMCPServer configuration](./configuration.mdx) +- [Backend discovery modes](./backend-discovery.mdx) - [Token exchange in Kubernetes](../guides-k8s/token-exchange-k8s.mdx) -- [Embedded auth server for MCPServer](../guides-k8s/embedded-auth-server-k8s.mdx) -- [Redis Sentinel session storage](../guides-k8s/redis-session-storage.mdx) +- [AWS STS authentication for the AWS MCP Server](../integrations/aws-sts.mdx) diff --git a/docs/toolhive/guides-vmcp/configuration.mdx b/docs/toolhive/guides-vmcp/configuration.mdx index 7536a04f..b61f2794 100644 --- a/docs/toolhive/guides-vmcp/configuration.mdx +++ b/docs/toolhive/guides-vmcp/configuration.mdx @@ -81,7 +81,7 @@ spec: When vMCP connects to an MCPRemoteProxy that validates incoming requests, whether through `oidcConfigRef` or an embedded authorization server, configure an -[outgoing authentication strategy](./authentication.mdx#outgoing-authentication) +[backend authentication strategy](./authentication.mdx#backend-authentication) so vMCP can authenticate to the proxy. See [Proxy remote MCP servers](../guides-k8s/remote-mcp-proxy.mdx#use-with-virtual-mcp-server) for details. diff --git a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx new file mode 100644 index 00000000..2ea0cc4e --- /dev/null +++ b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx @@ -0,0 +1,738 @@ +--- +title: Embedded authorization server +description: + Run an OAuth authorization server inside vMCP to broker user login and forward + per-user tokens to backends. +--- + +The embedded authorization server runs an OAuth authorization server within the +vMCP process. It redirects users to one or more upstream identity providers +(such as GitHub, Google, or Okta) for interactive authentication, stores the +upstream tokens, and issues its own JWTs that the vMCP validates on subsequent +requests. Combined with an outgoing strategy that forwards or exchanges those +stored tokens, this bridges both authentication boundaries: the auth server +handles incoming auth by issuing JWTs, while the outgoing strategy sends the +stored upstream tokens to backends. + +Use the embedded authorization server when your backend MCP servers call +external APIs on behalf of individual users and no federation relationship +exists between your identity provider and those services. It also supports OAuth +2.0 Client ID Metadata Documents (CIMD) and Dynamic Client Registration +([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)), so MCP clients can +identify themselves or register automatically without manual client +configuration in ToolHive. + +:::info + +This page covers the embedded auth server on a VirtualMCPServer. For conceptual +background (the OAuth flow, token storage and forwarding, and when to use it), +see [Embedded authorization server](../concepts/embedded-auth-server.mdx). For +the equivalent on individual MCPServer resources (single upstream provider), see +[Set up the embedded authorization server in Kubernetes](../guides-k8s/embedded-auth-server-k8s.mdx). +For incoming and standalone backend authentication on vMCP, see +[Authentication](./authentication.mdx). + +::: + +## How it works + +```mermaid +sequenceDiagram + participant Client as MCP Client + participant AS as Embedded Auth Server + participant GitHub as GitHub (upstream) + participant Google as Google (upstream) + participant vMCP as vMCP + participant Backend as Backend MCP Server + + Client->>AS: Connect (OAuth authorize) + AS-->>Client: Redirect to GitHub + Client->>GitHub: Authenticate + consent + GitHub-->>AS: Authorization code + AS->>GitHub: Exchange code for token + AS-->>Client: Redirect to Google + Client->>Google: Authenticate + consent + Google-->>AS: Authorization code + AS->>Google: Exchange code for token + AS-->>Client: Issue JWT (upstream tokens stored) + + Client->>vMCP: MCP request with JWT + vMCP->>vMCP: Validate JWT, load upstream tokens + vMCP->>Backend: Forward with GitHub access token + Backend-->>vMCP: Response + vMCP-->>Client: Response +``` + +When multiple upstream providers are configured, the auth server chains +authorization flows sequentially. The user is redirected to each provider in +order, and the auth server stores each provider's tokens before moving to the +next. After the final provider completes, the auth server issues a single JWT to +the client. + +## Differences from the MCPServer embedded auth server + +The embedded auth server uses the same configuration structure whether on a +VirtualMCPServer or an MCPServer. The key differences for vMCP are: + +- **Inline configuration:** The auth server config lives directly on the + VirtualMCPServer resource under `authServerConfig`, rather than in a separate + `MCPExternalAuthConfig` resource. +- **Multiple upstream providers:** vMCP supports multiple upstream providers + with sequential authorization chaining. MCPServer is limited to a single + upstream provider. +- **Flexible outgoing strategies:** vMCP uses `upstreamInject` or + `tokenExchange` with `subjectProviderName` to route stored tokens to the + correct backends. MCPServer swaps the token automatically because it has a + single upstream provider. + +## Configure the embedded auth server + +Add an `authServerConfig` block to your VirtualMCPServer. The configuration +fields are the same as for the +[MCPServer embedded auth server](../guides-k8s/embedded-auth-server-k8s.mdx) -- +see that guide for generating keys and creating Secrets. + +```yaml title="VirtualMCPServer resource" +spec: + authServerConfig: + issuer: https://auth.example.com + signingKeySecretRefs: + - name: auth-signing-key + key: private-key + hmacSecretRefs: + - name: auth-hmac-key + key: hmac-key + tokenLifespans: + accessTokenLifespan: 1h + refreshTokenLifespan: 168h + authCodeLifespan: 10m + upstreamProviders: + - name: github + type: oauth2 + oauth2Config: { ... } + - name: google + type: oidc + oidcConfig: { ... } +``` + +:::warning[Signing keys and HMAC secrets] + +`signingKeySecretRefs` and `hmacSecretRefs` are technically optional. When +omitted, the auth server auto-generates ephemeral keys on startup. This is +convenient for development, but **tokens become invalid after pod restart**. +JWTs can no longer be verified (signing keys) and authorization codes and +refresh tokens can no longer be decoded (HMAC secrets), forcing all users to +re-authenticate. Always configure persistent keys for production. See +[Set up the embedded authorization server in Kubernetes](../guides-k8s/embedded-auth-server-k8s.mdx) +for key generation steps. + +::: + +The issuer URL must use the `https://` scheme. The single exception is +`localhost`, which can use `http://` for local development. For in-cluster +deployments where traffic between the embedded auth server and other pods stays +on a trusted network (for example, an in-cluster service mesh), you can opt in +to an `http://` issuer on a non-localhost host by setting +`insecureAllowHTTP: true`. The VirtualMCPServer controller rejects this +combination at reconcile time with `AuthServerConfigValidated=False` if the flag +is unset, so misconfiguration surfaces on the resource rather than crashing the +pod at startup. Never set this for issuers reachable outside the cluster. + +If the browser-facing authorization endpoint needs to be on a different host +than the issuer (for example, behind an ingress that rewrites paths), set +`authorizationEndpointBaseUrl` to override the `authorization_endpoint` in the +OAuth discovery document. All other endpoints remain derived from `issuer`: + +```yaml +spec: + authServerConfig: + issuer: https://auth.internal.example.com + authorizationEndpointBaseUrl: https://auth.example.com +``` + +If your MCP clients register via DCR with a narrowed `scope` value and then +request additional scopes at `/oauth/authorize` (a pattern used by Claude Code, +among others), set `baselineClientScopes` so the embedded auth server merges +those scopes into every registered client's scope set. If `scopesSupported` is +set explicitly, all baseline values must appear in it; otherwise the server +validates against its default scope set (`openid`, `profile`, `email`, +`offline_access`). See +[Baseline scopes for DCR clients](../concepts/embedded-auth-server.mdx#baseline-scopes-for-dcr-clients) +for guidance on which scopes to include. + +```yaml +spec: + authServerConfig: + issuer: https://auth.example.com + baselineClientScopes: + - openid + - offline_access +``` + +Each upstream provider `name` must be a valid DNS label (lowercase alphanumeric +and hyphens, max 63 characters). This name is what the +[upstream token injection](#forward-a-stored-upstream-token-upstream-injection) +and [token exchange](#exchange-a-stored-upstream-token-token-exchange) outgoing +strategies reference to map backends to providers. For details on configuring +OIDC vs OAuth 2.0 upstream providers, see +[Using an OAuth 2.0 upstream provider](../guides-k8s/embedded-auth-server-k8s.mdx#using-an-oauth-20-upstream-provider). +The [complete example](#complete-example) below shows full provider +configurations. + +### Select the primary upstream provider + +When multiple upstream providers are configured, Cedar reads JWT claims from the +first upstream's access token by default. Pin a specific provider with +`spec.authServerConfig.primaryUpstreamProvider`. The value must match one of the +names in `upstreamProviders`; unresolvable values are rejected at admission with +`AuthServerConfigValidated=False`: + +```yaml +spec: + authServerConfig: + issuer: https://auth.example.com + primaryUpstreamProvider: okta + upstreamProviders: + - name: okta + type: oidc + # ... + - name: github + type: oauth2 + # ... +``` + +:::note[Deprecated location] + +`primaryUpstreamProvider` previously lived under +`spec.incomingAuth.authzConfig.inline.primaryUpstreamProvider`. The old location +is still read for backward compatibility, but the VirtualMCPServer controller +emits a Warning event with reason `AuthzPrimaryUpstreamProviderDeprecated` +whenever it reads the value. Move the field to +`spec.authServerConfig.primaryUpstreamProvider` to clear the warning. Removal of +the deprecated location is planned for the release after the deprecation cycle. + +::: + +### Handle non-standard provider responses + +Not every provider returns tokens and identity in the standard shape. Three +optional fields adapt the embedded auth server to providers that don't. + +#### Tokens under non-standard paths + +Some OAuth 2.0 providers nest tokens instead of returning them at the top level +(for example, GovSlack returns the access token at `authed_user.access_token`). +Add a `tokenResponseMapping` block to the `oauth2Config` with dot-notation paths +for `accessTokenPath`, `scopePath`, `refreshTokenPath`, and `expiresInPath`. See +the [CRD reference](../reference/crds/index.mdx) for field details. + +#### Identity in the token response + +When an upstream returns user identity in the token response itself (Slack +returns it at `authed_user.id`; Snowflake embeds it in the access-token JWT), +set `identityFromToken` on the `oauth2Config` with gjson dot-notation paths for +`subjectPath` (required), `namePath`, and `emailPath`. See +[Extract identity from the token response](../guides-k8s/embedded-auth-server-k8s.mdx#extract-identity-from-the-token-response) +for the full pattern and trust-model caveats. + +#### Stable subject for OIDC upstreams + +By default the embedded auth server keys users on the upstream ID token's `sub` +claim. Some identity providers rotate `sub` per application, so the same user +appears as a different principal across apps; the stable identifier lives in +another claim (Entra/Azure AD uses `oid`, some Okta custom auth servers expose a +custom `uid` claim). Set `subjectClaim` on an upstream's `oidcConfig` to pin +ToolHive to the stable claim: + +```yaml +upstreamProviders: + - name: entra + type: oidc + oidcConfig: + issuerUrl: 'https://login.microsoftonline.com//v2.0' + clientId: '' + # ... + subjectClaim: oid +``` + +The value must be a single top-level claim name (letters, digits, underscores; +must start with a letter or underscore). Changing it on a live deployment +re-keys existing users, so treat it as immutable once users exist. The Entra +walkthrough at +[Connect ToolHive to Microsoft Entra ID](../integrations/vmcp-entra-id.mdx) +includes this setting. + +## Validate the issued JWTs + +When using the embedded auth server, configure `incomingAuth` to validate the +JWTs it issues. Create an `MCPOIDCConfig` resource whose `issuer` matches +`authServerConfig.issuer`, then reference it with `oidcConfigRef`. Note that +`jwksAllowPrivateIP: true` is no longer needed when using the embedded auth +server because JWKS retrieval is done in-process. + +```yaml title="VirtualMCPServer resource" +spec: + incomingAuth: + type: oidc + # highlight-start + oidcConfigRef: + name: my-oidc-config + audience: https://mcp.example.com/mcp + # highlight-end +``` + +## Configure session storage + +By default, upstream tokens are stored in memory and lost on pod restart. For +production, configure Redis Sentinel by adding a `storage` block to +`authServerConfig`. The configuration is the same as for the MCPServer embedded +auth server. See +[Redis Sentinel session storage](../guides-k8s/redis-session-storage.mdx) for a +complete walkthrough. + +## Forward user tokens to backends + +The embedded auth server stores each user's upstream tokens after login. Three +outgoing strategies send those stored tokens to backends. All three require the +embedded auth server, because they read tokens it acquired during the user's +interactive login. For backend strategies that do not depend on the embedded +auth server (static credentials, plain token exchange, AWS STS, pass-through), +see [Authentication](./authentication.mdx#backend-authentication). + +### Forward a stored upstream token (upstream injection) + +The `upstreamInject` strategy injects a user's stored upstream access token into +outgoing requests to a backend. Use it when the backend accepts the same token +the upstream provider issued (for example, a GitHub MCP server that accepts a +GitHub access token). + +Create an `MCPExternalAuthConfig` resource with the `upstreamInject` type. The +`providerName` must match an upstream provider configured on the embedded +authorization server: + +```yaml title="MCPExternalAuthConfig resource" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPExternalAuthConfig +metadata: + name: inject-github + namespace: toolhive-system +spec: + type: upstreamInject + upstreamInject: + providerName: github +``` + +Then reference it in the VirtualMCPServer's outgoing auth configuration: + +```yaml title="VirtualMCPServer resource" +spec: + outgoingAuth: + source: inline + backends: + backend-github: + type: externalAuthConfigRef + externalAuthConfigRef: + name: inject-github +``` + +When a request reaches the `backend-github` MCPServer, vMCP replaces the +`Authorization` header with the upstream access token stored for the `github` +provider during the user's login flow. Backends not listed in the `backends` map +receive unauthenticated requests. + +:::note + +The `providerName` must match a provider `name` in the auth server's +`upstreamProviders` list. + +::: + +### Exchange a stored upstream token (token exchange) + +You can combine the embedded authorization server with +[token exchange](../guides-k8s/token-exchange-k8s.mdx) by adding the +`subjectProviderName` field to a `tokenExchange` config. This tells the token +exchange middleware to use the stored upstream token from the named provider as +the subject token for the +[RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) exchange, instead of +the vMCP-issued JWT. + +This is useful when a backend needs a token exchanged at the same identity +provider that issued the upstream token. For example, if the embedded auth +server acquires an Okta access token during login, you can exchange that token +at a different Okta authorization server for a backend-scoped token: + +```yaml title="MCPExternalAuthConfig resource" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPExternalAuthConfig +metadata: + name: exchange-okta + namespace: toolhive-system +spec: + type: tokenExchange + tokenExchange: + tokenUrl: https:///oauth2//v1/token + clientId: + clientSecretRef: + name: okta-exchange-client-secret + key: client-secret + audience: backend + scopes: + - backend-api:read + # highlight-next-line + subjectProviderName: okta +``` + +Without `subjectProviderName`, token exchange uses the vMCP-issued JWT as the +subject token (the standalone +[token exchange](./authentication.mdx#federate-identity-with-token-exchange) +pattern). With it, the exchange uses the raw upstream provider's access token, +which the exchange endpoint can validate directly. + +You can mix both strategies in the same vMCP deployment. For example, some +backends can use `upstreamInject` for direct token forwarding while others use +`tokenExchange` with `subjectProviderName` for exchanged tokens: + +```yaml title="VirtualMCPServer resource" +spec: + outgoingAuth: + source: inline + backends: + backend-github: + type: externalAuthConfigRef + externalAuthConfigRef: + name: inject-github + backend-okta-app: + type: externalAuthConfigRef + externalAuthConfigRef: + name: exchange-okta +``` + +### Bridge separate trust domains (cross-application access) + +:::warning[Experimental] + +Cross-application access (XAA) implements the IETF draft +[`draft-ietf-oauth-identity-assertion-authz-grant`](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-identity-assertion-authz-grant) +(ID-JAG), which is not yet a ratified standard. The CRD surface and runtime +behavior may change in backward-incompatible ways as the draft evolves. + +::: + +The `xaa` strategy handles the cross-domain case, where a backend trusts a +different authorization server than the one that issued the client's token, and +no federation is configured between them. XAA performs a two-step exchange on +each backend call: + +- **Step A ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693))**: vMCP + exchanges the user's upstream ID token at their IdP for an ID-JAG assertion + addressed to the target authorization server. +- **Step B ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523))**: vMCP + presents the ID-JAG at the target authorization server, which returns an + access token for the backend. + +Use `xaa` when `tokenExchange` doesn't apply (the backend doesn't trust the +client's IdP) and `upstreamInject` doesn't apply (the user has not previously +authenticated to the backend's provider). + +Create an `MCPExternalAuthConfig` with `type: xaa`. Store the IdP and target +authorization server client secrets in Kubernetes Secrets: + +```yaml title="MCPExternalAuthConfig resource" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPExternalAuthConfig +metadata: + name: xaa-backend + namespace: toolhive-system +spec: + type: xaa + xaa: + # Step A: RFC 8693 token exchange at the user's IdP + idpTokenUrl: https://idp.example.com/oauth2/v1/token + # Optional in the schema, but strongly recommended: conformant IdPs + # reject unauthenticated RFC 8693 token exchange requests + idpClientId: + idpClientSecretRef: + name: xaa-idp-client-secret + key: client-secret + + # Step B: RFC 7523 JWT Bearer grant at the target AS + targetTokenUrl: https://target-as.example.com/oauth2/v1/token + targetClientId: + targetClientSecretRef: + name: xaa-target-client-secret + key: client-secret + + # Required: URL of the target authorization server, used as the + # ID-JAG audience claim + targetAudience: https://target-as.example.com + + # Optional: RFC 8707 resource indicator sent in Step A. Some IdPs + # (e.g. Okta's early ID-JAG implementation) require this even though + # the draft marks it optional. Set it to the backend MCP server URL. + targetResource: https://backend.example.com/mcp + + # Optional: scopes requested at both steps + scopes: + - backend-api:read + + # Required when the VirtualMCPServer has more than one upstream provider: + # with multiple upstreams and this field unset, admission rejects the + # backend with an AmbiguousSubjectProvider condition (the rest of the + # server keeps reconciling). With a single upstream provider, the + # controller auto-populates this field. Must match the name of an entry + # under authServerConfig.upstreamProviders. + subjectProviderName: my-idp +``` + +Attach the `MCPExternalAuthConfig` to the backend `MCPServer`, then use +discovery mode on the VirtualMCPServer to pick it up automatically: + +```yaml title="MCPServer resource" +spec: + externalAuthConfigRef: + name: xaa-backend +``` + +```yaml title="VirtualMCPServer resource" +spec: + outgoingAuth: + source: discovered +``` + +Inlining the reference directly in the VirtualMCPServer's +`outgoingAuth.backends` block (the `source: inline` pattern used by other +strategies) is structurally valid in the CRD, but XAA's secret mounting and +`subjectProviderName` auto-population haven't been verified to behave the same +way under `inline`. Use `discovered` until that's confirmed. + +See the +[MCPExternalAuthConfig reference](../reference/crds/mcpexternalauthconfig.mdx) +for the full `xaa` field list, including which fields are required. + +:::note[Target client credentials are strongly recommended] + +The ID-JAG draft (§9.1) recommends confidential clients for Step B, and most +target authorization servers reject unauthenticated JWT-bearer grants. Omitting +`targetClientId` and `targetClientSecretRef` causes vMCP to log a warning at +startup, and Step B typically fails at runtime. + +::: + +## Skip token injection for public backends + +By default, the embedded auth server swaps the client's ToolHive JWT for the +matching upstream token before forwarding the request to a backend, using +[upstream token injection](#forward-a-stored-upstream-token-upstream-injection) +or [token exchange](#exchange-a-stored-upstream-token-token-exchange). That +assumes the backend itself requires authentication. + +If the backend MCP server is public (for example, a documentation server with no +auth of its own) but you still want clients to authenticate to vMCP, set +`disableUpstreamTokenInjection: true` on `authServerConfig`. The embedded auth +server still runs the OAuth flow for clients, but the proxy then **strips** the +client's credential headers (`Authorization`, `Cookie`, and +`Proxy-Authorization`) after the JWT is validated, so the backend receives an +unauthenticated request. + +```yaml title="VirtualMCPServer resource" +spec: + authServerConfig: + issuer: https://auth.example.com + # highlight-next-line + disableUpstreamTokenInjection: true + upstreamProviders: + - name: github + # ... +``` + +Use `outgoingAuth` with `headerInjection` (see +[Static header injection](./authentication.mdx#inject-a-static-credential-header-injection)) +if the backend still needs a static credential such as an API key. + +:::warning[Incompatible with token exchange and AWS STS] + +The strip happens after JWT validation but before token-exchange and AWS STS +middlewares would normally attach a new credential. Combining +`disableUpstreamTokenInjection: true` with `tokenExchange` or `awsSts` on the +same vMCP causes the embedded auth server to fail validation at startup. Use +this flag only when the backend should remain unauthenticated. + +::: + +## Complete example + +This example deploys a vMCP with an embedded auth server that authenticates +users through GitHub and Google, then injects the GitHub access token into +requests to a GitHub MCP server backend. + +**Prerequisites:** Create Secrets for signing keys, HMAC keys, and upstream +provider credentials following the steps in +[Set up the embedded authorization server in Kubernetes](../guides-k8s/embedded-auth-server-k8s.mdx). +You need: `auth-signing-key`, `auth-hmac-key`, `github-client-secret`, and +`google-client-secret`. + +**Step 1:** Create an MCPGroup, OIDC config, and deploy the backend MCP server: + +```yaml title="backends.yaml" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPGroup +metadata: + name: my-backends + namespace: toolhive-system +--- +# highlight-start +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPOIDCConfig +metadata: + name: my-oidc-config + namespace: toolhive-system +spec: + type: inline + inline: + issuer: https://auth.example.com +# highlight-end +--- +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPServer +metadata: + name: backend-github + namespace: toolhive-system +spec: + image: ghcr.io/github/github-mcp-server + transport: streamable-http + # highlight-start + groupRef: + name: my-backends + # highlight-end +``` + +**Step 2:** Create the upstream token injection config: + +```yaml title="auth-configs.yaml" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPExternalAuthConfig +metadata: + name: inject-github + namespace: toolhive-system +spec: + type: upstreamInject + upstreamInject: + providerName: github +``` + +**Step 3:** Deploy the VirtualMCPServer: + +```yaml title="virtualmcpserver.yaml" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: VirtualMCPServer +metadata: + name: my-vmcp + namespace: toolhive-system +spec: + groupRef: + name: my-backends + # highlight-start + authServerConfig: + issuer: https://auth.example.com + signingKeySecretRefs: + - name: auth-signing-key + key: private-key + hmacSecretRefs: + - name: auth-hmac-key + key: hmac-key + tokenLifespans: + accessTokenLifespan: 1h + refreshTokenLifespan: 168h + authCodeLifespan: 10m + upstreamProviders: + - name: github + type: oauth2 + oauth2Config: + authorizationEndpoint: https://github.com/login/oauth/authorize + tokenEndpoint: https://github.com/login/oauth/access_token + clientId: + clientSecretRef: + name: github-client-secret + key: client-secret + scopes: + - repo + - read:user + userInfo: + endpointUrl: https://api.github.com/user + httpMethod: GET + additionalHeaders: + Accept: application/vnd.github+json + fieldMapping: + subjectFields: + - id + - login + nameFields: + - name + - login + emailFields: + - email + - name: google + type: oidc + oidcConfig: + issuerUrl: https://accounts.google.com + clientId: + clientSecretRef: + name: google-client-secret + key: client-secret + scopes: + - openid + - email + # highlight-end + incomingAuth: + type: oidc + # highlight-start + oidcConfigRef: + name: my-oidc-config + audience: https://mcp.example.com/mcp + # highlight-end + outgoingAuth: + source: inline + backends: + backend-github: + type: externalAuthConfigRef + externalAuthConfigRef: + name: inject-github +``` + +**Step 4:** Verify the deployment: + +```bash +# Check the VirtualMCPServer status +kubectl get virtualmcpserver -n toolhive-system my-vmcp + +# Verify OAuth discovery is available +curl https://auth.example.com/.well-known/oauth-authorization-server +``` + +Connect with an MCP client that supports the +[MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). +The client discovers the authorization server through protected resource +metadata, then redirects you through each upstream provider for authentication. +After completing the login flow, MCP tool calls to the GitHub backend +automatically include your GitHub access token. + +## Next steps + +- [Connect ToolHive to an enterprise identity provider](../integrations/vmcp-idp-overview.mdx) + for IdP-specific walkthroughs (Entra ID, Okta) that use the embedded auth + server +- [Set up audit logging](./audit-logging.mdx) to track authentication decisions + and request activity +- [Configure Redis Sentinel session storage](../guides-k8s/redis-session-storage.mdx) + so upstream tokens survive pod restarts + +## Related information + +- [Authentication](./authentication.mdx) for incoming and standalone backend + authentication on vMCP +- [Embedded authorization server](../concepts/embedded-auth-server.mdx) for + conceptual background +- [Embedded auth server for MCPServer](../guides-k8s/embedded-auth-server-k8s.mdx) +- [Token exchange in Kubernetes](../guides-k8s/token-exchange-k8s.mdx) +- [MCPExternalAuthConfig reference](../reference/crds/mcpexternalauthconfig.mdx) diff --git a/docs/toolhive/integrations/vmcp-entra-id.mdx b/docs/toolhive/integrations/vmcp-entra-id.mdx index 57f6bec3..f0b42f6e 100644 --- a/docs/toolhive/integrations/vmcp-entra-id.mdx +++ b/docs/toolhive/integrations/vmcp-entra-id.mdx @@ -368,7 +368,7 @@ kubectl create secret generic idp-client-secret \ For production, configure persistent signing keys so tokens survive pod restarts. See -[Configure the embedded auth server](../guides-vmcp/authentication.mdx#configure-the-embedded-auth-server). +[Configure the embedded auth server](../guides-vmcp/embedded-auth-server-vmcp.mdx#configure-the-embedded-auth-server). ::: diff --git a/docs/toolhive/integrations/vmcp-okta.mdx b/docs/toolhive/integrations/vmcp-okta.mdx index ea3b9f26..27eb22b2 100644 --- a/docs/toolhive/integrations/vmcp-okta.mdx +++ b/docs/toolhive/integrations/vmcp-okta.mdx @@ -213,7 +213,7 @@ kubectl create secret generic idp-client-secret \ For production, configure persistent signing keys so tokens survive pod restarts. See -[Configure the embedded auth server](../guides-vmcp/authentication.mdx#configure-the-embedded-auth-server). +[Configure the embedded auth server](../guides-vmcp/embedded-auth-server-vmcp.mdx#configure-the-embedded-auth-server). ::: diff --git a/sidebars.ts b/sidebars.ts index 559cafb0..bbd4c19a 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -222,7 +222,19 @@ const mcpSidebar: SidebarsConfig[string] = [ 'toolhive/guides-vmcp/quickstart', 'toolhive/guides-vmcp/configuration', 'toolhive/guides-vmcp/backend-discovery', - 'toolhive/guides-vmcp/authentication', + { + type: 'category', + label: 'Authentication and authorization', + description: + 'How to secure vMCP with client authentication, authorization, and backend auth', + collapsed: false, + collapsible: false, + link: { + type: 'doc', + id: 'toolhive/guides-vmcp/authentication', + }, + items: ['toolhive/guides-vmcp/embedded-auth-server-vmcp'], + }, 'toolhive/guides-vmcp/tool-aggregation', 'toolhive/guides-vmcp/composite-tools', 'toolhive/guides-vmcp/optimizer', From 1ad126ef3cab0ea858d0dd7cd3e00c2a76a0f6cb Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:25:03 -0400 Subject: [PATCH 2/4] Add CRD relationship diagrams to vMCP auth pages Add two mermaid diagrams and tighten the existing one: - "How the resources connect" on the entry page, showing the backend's own oidcConfigRef and the shared MCPExternalAuthConfig referenced inline or via discovery. - "How the references link by name" on the embedded auth server page, showing the issuer and providerName/subjectProviderName string matches that trip people up. - Simplify the two-boundary diagram (boundary labels become subgraph titles, collapse the backend API boxes into one). - Convert the stacked "non-standard provider responses" tips into prose subsections, and note the backend-linkage rules apply to the embedded strategies too. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/toolhive/guides-vmcp/authentication.mdx | 57 ++++++++++++++++--- .../guides-vmcp/embedded-auth-server-vmcp.mdx | 35 ++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/docs/toolhive/guides-vmcp/authentication.mdx b/docs/toolhive/guides-vmcp/authentication.mdx index 8130c666..9a1c0f44 100644 --- a/docs/toolhive/guides-vmcp/authentication.mdx +++ b/docs/toolhive/guides-vmcp/authentication.mdx @@ -14,13 +14,12 @@ allowed), and backend authentication (how vMCP reaches each backend). ```mermaid flowchart LR - subgraph Boundary1[" "] + subgraph Boundary1["Boundary 1: Client → vMCP"] direction TB Client[MCP Client] - B1Label["**Boundary 1**
Client → vMCP"] end - subgraph vMCP["Virtual MCP Server (vMCP)"] + subgraph vMCP["Virtual MCP Server (vMCP)"] direction TB Auth["Token validation
(issuer, audience, expiry,
signature/introspection)"] Authz["Authorization
(Cedar policies)"] @@ -28,16 +27,13 @@ flowchart LR Auth --> Authz --> Proxy end - subgraph Boundary2[" "] + subgraph Boundary2["Boundary 2: vMCP → Backend APIs"] direction TB - B2Label["**Boundary 2**
vMCP → Backend APIs"] - GitHub[GitHub API] - Jira[Jira API] + Backends[Backend APIs] end Client -->|"vMCP-scoped
token"| Auth - Proxy -->|"Backend-scoped
token"| GitHub - Proxy -->|"Backend-scoped
token"| Jira + Proxy -->|"Backend-scoped
token"| Backends ``` **Boundary 1 (incoming):** Clients authenticate to vMCP using OAuth 2.1 @@ -54,6 +50,49 @@ and [Authorize requests](#authorize-requests). the configured backend authentication strategy. See [Backend authentication](#backend-authentication) for how to choose one. +## How the resources connect + +The VirtualMCPServer references other resources for both boundaries: an +`MCPOIDCConfig` (and optionally an `MCPAuthzConfig`) for incoming requests, and +an `MCPExternalAuthConfig` for outgoing credentials. + +The piece that's easy to miss is that each backend is itself a ToolHive resource +with its own authentication surface. A backend MCPServer or MCPRemoteProxy +validates its inbound requests against its own `oidcConfigRef`, exactly as a +standalone server would. So vMCP's outgoing boundary meets the backend's +incoming boundary at that hop: the credential vMCP presents must satisfy the +backend's own `oidcConfigRef`. + +```mermaid +flowchart LR + Client[MCP client] -->|token| VMCP[VirtualMCPServer] + + VMCP -->|oidcConfigRef| OIDC[("MCPOIDCConfig")] + VMCP -.->|"authzConfig /
authzConfigRef"| Authz[("MCPAuthzConfig")] + + VMCP -->|outgoing token| Backend["Backend
(MCPServer /
MCPRemoteProxy)"] + Backend -->|"oidcConfigRef
(validates vMCP's token)"| BOIDC[("MCPOIDCConfig")] + + VMCP -.->|"outgoingAuth.backends[]
(inline mode)"| Ext[("MCPExternalAuthConfig")] + Backend -.->|"externalAuthConfigRef
(discovery mode)"| Ext +``` + +The `MCPExternalAuthConfig` is the shared resource, and which side references it +depends on the [backend discovery mode](./backend-discovery.mdx): + +- **Inline** (`outgoingAuth.source: inline`): vMCP owns the reference under + `outgoingAuth.backends..externalAuthConfigRef`, and each key must match + a backend name in the `MCPGroup`. +- **Discovery** (`outgoingAuth.source: discovered`): vMCP reads the backend's + own `externalAuthConfigRef`, the same field the backend would use standalone. + One `MCPExternalAuthConfig` can be shared across several backends. + +An `MCPServerEntry` backend has only an `externalAuthConfigRef`, not its own +`oidcConfigRef`. When vMCP runs the +[embedded authorization server](./embedded-auth-server-vmcp.mdx), some +references also link by name (issuer and upstream provider); that wiring is +shown on the embedded auth server page. + ## Incoming authentication Configure how clients authenticate to vMCP with the `incomingAuth` block. The diff --git a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx index 2ea0cc4e..725809ac 100644 --- a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx +++ b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx @@ -69,6 +69,32 @@ order, and the auth server stores each provider's tokens before moving to the next. After the final provider completes, the auth server issues a single JWT to the client. +## How the references link by name + +Two of the references around the embedded auth server link by matching string +values, not by resource name, which is a common source of misconfiguration. The +`MCPOIDCConfig` that validates incoming requests must carry the same `issuer` as +`authServerConfig`, and each outgoing strategy's `providerName` (upstream +injection) or `subjectProviderName` (token exchange, XAA) must match a `name` +under `authServerConfig.upstreamProviders`. + +```mermaid +flowchart TB + subgraph VMCP[VirtualMCPServer] + IN["incomingAuth.oidcConfigRef"] + AS["authServerConfig
issuer + upstreamProviders[].name"] + OUT["outgoingAuth.backends.<name>"] + end + + IN -->|oidcConfigRef| OIDC[("MCPOIDCConfig
issuer")] + OIDC -.->|"issuer must match"| AS + + OUT -->|externalAuthConfigRef| EAC[("MCPExternalAuthConfig
upstreamInject / tokenExchange / xaa")] + EAC -.->|"providerName /
subjectProviderName
must match a provider name"| AS + + AS -.->|OAuth login| IdP[Upstream IdPs
GitHub, Google, Okta] +``` + ## Differences from the MCPServer embedded auth server The embedded auth server uses the same configuration structure whether on a @@ -299,6 +325,15 @@ interactive login. For backend strategies that do not depend on the embedded auth server (static credentials, plain token exchange, AWS STS, pass-through), see [Authentication](./authentication.mdx#backend-authentication). +The same backend-linkage rules from +[How the resources connect](./authentication.mdx#how-the-resources-connect) +still apply here: the token a strategy produces must satisfy the backend's own +`oidcConfigRef`, and the `MCPExternalAuthConfig` is referenced either inline +(`outgoingAuth.backends`) or through discovery (the backend's own +`externalAuthConfigRef`). `upstreamInject`, for instance, only works when the +backend accepts the upstream provider's token directly; when it doesn't, +exchange the token instead. + ### Forward a stored upstream token (upstream injection) The `upstreamInject` strategy injects a user's stored upstream access token into From 7cb17c77b75f98e03f80d3a1a988ddcb0856390d Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:44:46 -0400 Subject: [PATCH 3/4] Apply editorial review fixes to vMCP auth docs From an independent editorial pass: - Elevate MCPServerEntry as a first-class backend shape. "How the resources connect" now contrasts ToolHive-managed backends (own oidcConfigRef) with remote MCPServerEntry pointers (no oidcConfigRef; the remote validates), the diagram shows both, and the upstream injection section adds a remote-SaaS MCPServerEntry example since that is its typical use. - Match cross-link text to the "Authentication and authorization" page title. - Replace a spaced double-hyphen with a semicolon. - Trim the duplicated passthroughHeaders precedence explanation to a pointer, keeping the full version in its own section. - Reword the front-matter description ("backend auth" -> "backend authentication"). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/toolhive/guides-vmcp/authentication.mdx | 39 ++++++++++++------- .../guides-vmcp/embedded-auth-server-vmcp.mdx | 30 ++++++++++++-- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/docs/toolhive/guides-vmcp/authentication.mdx b/docs/toolhive/guides-vmcp/authentication.mdx index 9a1c0f44..fb06e263 100644 --- a/docs/toolhive/guides-vmcp/authentication.mdx +++ b/docs/toolhive/guides-vmcp/authentication.mdx @@ -1,7 +1,8 @@ --- title: Authentication and authorization description: - Configure client authentication, authorization, and backend auth for vMCP. + Configure client authentication, authorization, and backend authentication for + vMCP. --- Virtual MCP Server (vMCP) implements a two-boundary authentication model that @@ -56,12 +57,20 @@ The VirtualMCPServer references other resources for both boundaries: an `MCPOIDCConfig` (and optionally an `MCPAuthzConfig`) for incoming requests, and an `MCPExternalAuthConfig` for outgoing credentials. -The piece that's easy to miss is that each backend is itself a ToolHive resource -with its own authentication surface. A backend MCPServer or MCPRemoteProxy -validates its inbound requests against its own `oidcConfigRef`, exactly as a -standalone server would. So vMCP's outgoing boundary meets the backend's -incoming boundary at that hop: the credential vMCP presents must satisfy the -backend's own `oidcConfigRef`. +The piece that's easy to miss is that each backend has its own authentication +surface, and it differs by +[backend type](./configuration.mdx#add-backends-to-a-group): + +- A **ToolHive-managed backend** (MCPServer or MCPRemoteProxy) validates its + inbound requests against its own `oidcConfigRef`, exactly as a standalone + server would. vMCP's outgoing boundary meets that backend's incoming boundary + at the hop: the credential vMCP presents must satisfy the backend's own + `oidcConfigRef`. +- An **MCPServerEntry** is a zero-infrastructure pointer to a remote MCP server + that ToolHive doesn't run, so it has no `oidcConfigRef` of its own. The remote + service validates the request, and vMCP simply presents whatever credential + the outgoing strategy produces (commonly the user's upstream OAuth token). + This is the usual shape for remote SaaS backends. ```mermaid flowchart LR @@ -70,11 +79,14 @@ flowchart LR VMCP -->|oidcConfigRef| OIDC[("MCPOIDCConfig")] VMCP -.->|"authzConfig /
authzConfigRef"| Authz[("MCPAuthzConfig")] - VMCP -->|outgoing token| Backend["Backend
(MCPServer /
MCPRemoteProxy)"] - Backend -->|"oidcConfigRef
(validates vMCP's token)"| BOIDC[("MCPOIDCConfig")] + VMCP -->|token| Backend["MCPServer /
MCPRemoteProxy"] + Backend -->|"own oidcConfigRef
(validates vMCP's token)"| BOIDC[("MCPOIDCConfig")] + + VMCP -->|token| Entry["MCPServerEntry
→ remote server
(no oidcConfigRef)"] VMCP -.->|"outgoingAuth.backends[]
(inline mode)"| Ext[("MCPExternalAuthConfig")] Backend -.->|"externalAuthConfigRef
(discovery mode)"| Ext + Entry -.-> Ext ``` The `MCPExternalAuthConfig` is the shared resource, and which side references it @@ -87,8 +99,7 @@ depends on the [backend discovery mode](./backend-discovery.mdx): own `externalAuthConfigRef`, the same field the backend would use standalone. One `MCPExternalAuthConfig` can be shared across several backends. -An `MCPServerEntry` backend has only an `externalAuthConfigRef`, not its own -`oidcConfigRef`. When vMCP runs the +When vMCP runs the [embedded authorization server](./embedded-auth-server-vmcp.mdx), some references also link by name (issuer and upstream provider); that wiring is shown on the embedded auth server page. @@ -330,11 +341,9 @@ so they require the covered below. To forward a header the client already holds (its own token in the same trust -domain, or an API key the backend resolves to a user itself) to _all_ backends, +domain, or an API key the backend resolves to a user itself) to all backends, use the top-level [`passthroughHeaders`](#pass-through-client-headers) allowlist -instead. It is a global, lower-precedence fallback: on any overlapping header -such as `Authorization`, a per-backend strategy from the table above overrides -it. +instead. ### Discovery mode diff --git a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx index 725809ac..dd0dab03 100644 --- a/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx +++ b/docs/toolhive/guides-vmcp/embedded-auth-server-vmcp.mdx @@ -30,7 +30,7 @@ see [Embedded authorization server](../concepts/embedded-auth-server.mdx). For the equivalent on individual MCPServer resources (single upstream provider), see [Set up the embedded authorization server in Kubernetes](../guides-k8s/embedded-auth-server-k8s.mdx). For incoming and standalone backend authentication on vMCP, see -[Authentication](./authentication.mdx). +[Authentication and authorization](./authentication.mdx). ::: @@ -115,7 +115,7 @@ VirtualMCPServer or an MCPServer. The key differences for vMCP are: Add an `authServerConfig` block to your VirtualMCPServer. The configuration fields are the same as for the -[MCPServer embedded auth server](../guides-k8s/embedded-auth-server-k8s.mdx) -- +[MCPServer embedded auth server](../guides-k8s/embedded-auth-server-k8s.mdx); see that guide for generating keys and creating Secrets. ```yaml title="VirtualMCPServer resource" @@ -382,6 +382,28 @@ The `providerName` must match a provider `name` in the auth server's ::: +Upstream injection is the typical pattern for remote SaaS backends, which are +usually [`MCPServerEntry`](./configuration.mdx#add-backends-to-a-group) +resources that point vMCP at a hosted MCP server instead of running a pod. The +entry has no auth surface of its own, so vMCP forwards the user's stored +upstream token straight to the remote endpoint: + +```yaml title="MCPServerEntry backend" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPServerEntry +metadata: + name: backend-google + namespace: toolhive-system +spec: + groupRef: + name: my-backends + remoteUrl: https://mcp.example.com/mcp + transport: streamable-http +``` + +Map it under `outgoingAuth.backends` by its name (`backend-google`), exactly as +with the container backend above. + ### Exchange a stored upstream token (token exchange) You can combine the embedded authorization server with @@ -764,8 +786,8 @@ automatically include your GitHub access token. ## Related information -- [Authentication](./authentication.mdx) for incoming and standalone backend - authentication on vMCP +- [Authentication and authorization](./authentication.mdx) for incoming and + standalone backend authentication on vMCP - [Embedded authorization server](../concepts/embedded-auth-server.mdx) for conceptual background - [Embedded auth server for MCPServer](../guides-k8s/embedded-auth-server-k8s.mdx) From 4fcc094e562bab5be347b045274a751466206a17 Mon Sep 17 00:00:00 2001 From: Dan Barr <6922515+danbarr@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:30:29 -0400 Subject: [PATCH 4/4] Address Copilot review comments - Use a unique URL-like audience in the first OIDC example instead of the generic "vmcp", matching the uniqueness guidance below it. - Standardize the passthrough header example on X-API-Key casing. - Trim the Next steps list to three links (the IdP overview is already linked earlier on the page). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/toolhive/guides-vmcp/authentication.mdx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/toolhive/guides-vmcp/authentication.mdx b/docs/toolhive/guides-vmcp/authentication.mdx index fb06e263..359ab92e 100644 --- a/docs/toolhive/guides-vmcp/authentication.mdx +++ b/docs/toolhive/guides-vmcp/authentication.mdx @@ -159,7 +159,7 @@ spec: type: oidc oidcConfigRef: name: vmcp-oidc - audience: vmcp + audience: https://mcp.example.com/mcp ``` The `oidcConfigRef` carries per-server overrides on top of the shared @@ -389,7 +389,7 @@ API key, to a user itself. spec: passthroughHeaders: - Authorization - - X-Api-Key + - X-API-Key ``` Unlike the per-backend strategies above, `passthroughHeaders` applies to every @@ -570,8 +570,6 @@ it bridges both authentication boundaries in one deployment. multiple backends are presented to clients - [Set up audit logging](./audit-logging.mdx) to track authentication decisions and request activity -- [Connect ToolHive to an enterprise identity provider](../integrations/vmcp-idp-overview.mdx) - for IdP-specific setup guides covering Entra ID and Okta ## Related information