diff --git a/app/_data/entity_examples/gateway/routes/mcp-token-exchange-isolated.yaml b/app/_data/entity_examples/gateway/routes/mcp-token-exchange-isolated.yaml new file mode 100644 index 0000000000..04d74fcf75 --- /dev/null +++ b/app/_data/entity_examples/gateway/routes/mcp-token-exchange-isolated.yaml @@ -0,0 +1,9 @@ +name: mcp-token-exchange-isolated +paths: + - /mcp-exchange + - /.well-known/oauth-protected-resource/mcp-exchange +service: + name: mcp-token-exchange-isolated-service +protocols: + - http + - https diff --git a/app/_data/entity_examples/gateway/services/mcp-token-exchange-isolated-service.yaml b/app/_data/entity_examples/gateway/services/mcp-token-exchange-isolated-service.yaml new file mode 100644 index 0000000000..1af59576cc --- /dev/null +++ b/app/_data/entity_examples/gateway/services/mcp-token-exchange-isolated-service.yaml @@ -0,0 +1,2 @@ +name: mcp-token-exchange-isolated-service +url: http://host.docker.internal:3002/mcp diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md new file mode 100644 index 0000000000..c6ec090031 --- /dev/null +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -0,0 +1,433 @@ +--- +title: Configure token exchange with the AI MCP OAuth2 plugin +permalink: /mcp/configure-mcp-oauth2-token-exchange/ +content_type: how_to +description: Configure token exchange with the AI MCP OAuth2 plugin using Keycloak and an upstream MCP server +breadcrumbs: + - /mcp/ + +related_resources: + - text: "{{site.ai_gateway}}" + url: /ai-gateway/ + - text: AI MCP OAuth2 plugin + url: /plugins/ai-mcp-oauth2/ + - text: Token exchange in the AI MCP OAuth2 plugin + url: /plugins/ai-mcp-oauth2/#token-exchange + - text: AI MCP Proxy plugin + url: /plugins/ai-mcp-proxy/ + - text: OAuth 2.0 specification for MCP + url: https://modelcontextprotocol.io/specification/draft/basic/authorization + +plugins: + - ai-mcp-oauth2 + - ai-mcp-proxy + +entities: + - service + - route + - plugin + +products: + - gateway + - ai-gateway + +works_on: + - on-prem + - konnect + +min_version: + gateway: '3.14' + +tools: + - deck + +prereqs: + inline: + - title: WeatherAPI + include_content: prereqs/weatherapi + icon_url: /assets/icons/gateway.svg + - title: Set up isolated Keycloak token exchange + include_content: prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather + icon_url: /assets/icons/keycloak.svg + - title: Upstream MCP server + content: | + This guide uses a small MCP debug server that rejects the original client token and only accepts the exchanged token forwarded by {{site.ai_gateway}}. This makes the token exchange observable during validation. + + Create the server: + + ```sh + cat > token-exchange-mcp-server.py <<'EOF' + #!/usr/bin/env python3 + import base64 + import json + import os + from http.server import BaseHTTPRequestHandler, HTTPServer + + + HOST = os.environ.get("TOKEN_EXCHANGE_MCP_HOST", "0.0.0.0") + PORT = int(os.environ.get("TOKEN_EXCHANGE_MCP_PORT", "3002")) + EXPECTED_AZP = os.environ.get("TOKEN_EXCHANGE_EXPECTED_AZP", "token-exchange-gateway") + + USERS = [ + {"id": "a1b2c3d4", "fullName": "Alice Johnson"}, + {"id": "e5f6g7h8", "fullName": "Bob Smith"}, + ] + + + def _json_response(handler, status, payload): + body = json.dumps(payload).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + + def _decode_jwt_payload(token): + parts = token.split(".") + if len(parts) != 3: + raise ValueError("invalid JWT format") + + payload = parts[1] + payload += "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload.encode("ascii")) + return json.loads(decoded.decode("utf-8")) + + + def _extract_claims(handler): + authorization = handler.headers.get("Authorization", "") + if not authorization.startswith("Bearer "): + raise PermissionError("missing bearer token") + + token = authorization.split(" ", 1)[1].strip() + claims = _decode_jwt_payload(token) + if claims.get("azp") != EXPECTED_AZP: + raise PermissionError( + f"unexpected azp '{claims.get('azp')}', expected '{EXPECTED_AZP}'" + ) + return claims + + + def _mcp_result(request_id, structured_content): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(structured_content, indent=2)}], + "structuredContent": structured_content, + }, + } + + + class Handler(BaseHTTPRequestHandler): + server_version = "token-exchange-mcp/1.0" + + def do_POST(self): + if self.path != "/mcp": + _json_response(self, 404, {"error": "not found"}) + return + + length = int(self.headers.get("Content-Length", "0")) + raw_body = self.rfile.read(length) + + try: + claims = _extract_claims(self) + request = json.loads(raw_body.decode("utf-8")) + except PermissionError as exc: + _json_response(self, 403, {"error": str(exc)}) + return + except Exception as exc: + _json_response(self, 400, {"error": str(exc)}) + return + + method = request.get("method") + request_id = request.get("id") + params = request.get("params") or {} + + if method == "tools/list": + _json_response( + self, + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "list_users", + "description": "List sample users.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + { + "name": "show_auth_context", + "description": "Return selected claims from the upstream bearer token.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + ] + }, + }, + ) + return + + if method == "tools/call": + tool_name = params.get("name") + if tool_name == "list_users": + _json_response(self, 200, _mcp_result(request_id, {"users": USERS})) + return + if tool_name == "show_auth_context": + aud = claims.get("aud") + if isinstance(aud, str): + aud = [aud] + _json_response( + self, + 200, + _mcp_result( + request_id, + { + "iss": claims.get("iss"), + "azp": claims.get("azp"), + "aud": aud or [], + "sub": claims.get("sub"), + }, + ), + ) + return + + _json_response( + self, + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"unknown tool '{tool_name}'"}, + }, + ) + return + + _json_response( + self, + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"unsupported method '{method}'"}, + }, + ) + + def log_message(self, format, *args): + return + + + if __name__ == "__main__": + httpd = HTTPServer((HOST, PORT), Handler) + print(f"Token-exchange MCP server listening at http://{HOST}:{PORT}/mcp") + httpd.serve_forever() + EOF + ``` + + Start the server in a separate terminal: + + ```sh + python3 token-exchange-mcp-server.py + ``` + + Verify the server is running at `http://localhost:3002/mcp`. + entities: + services: + - mcp-token-exchange-isolated-service + routes: + - mcp-token-exchange-isolated + +tags: + - ai + - mcp + - oauth2 + - authentication + +tldr: + q: How do I configure token exchange with the AI MCP OAuth2 plugin? + a: | + Configure the AI MCP Proxy plugin in passthrough-listener mode on a dedicated MCP route. + Add the AI MCP OAuth2 plugin with token exchange enabled. This setup uses separate + routes, resource metadata, and Keycloak clients so it can coexist with a JWK-based + MCP configuration while still validating a real token-exchange flow. + +cleanup: + inline: + - title: Clean up Konnect environment + include_content: cleanup/platform/konnect + icon_url: /assets/icons/gateway.svg + - title: Destroy the {{site.ai_gateway}} container + include_content: cleanup/products/gateway + icon_url: /assets/icons/gateway.svg + +automated_tests: false +--- + +## Configure the AI MCP Proxy plugin in passthrough-listener mode + +Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `passthrough-listener` mode on the `mcp-token-exchange-isolated` Route. This mode proxies incoming MCP requests directly to the upstream MCP server while preserving the exchanged bearer token on the upstream request. + +{% entity_examples %} +entities: + plugins: + - name: ai-mcp-proxy + route: mcp-token-exchange-isolated + tags: + - token-exchange + config: + mode: passthrough-listener + max_request_body_size: 1048576 +{% endentity_examples %} + +## Configure the AI MCP OAuth2 plugin with token exchange + +Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the same `mcp-token-exchange-isolated` Route. The plugin validates the incoming bearer token via introspection, then exchanges it for a new token at the Keycloak token endpoint before forwarding the request to the upstream MCP server. + +Token exchange requires `passthrough_credentials` set to `true` so that the exchanged token is forwarded to the upstream. + +{:.info} +> This example sets `insecure_relaxed_audience_validation` to `true` because the exchanged-token flow in this guide relies on a dedicated Keycloak audience mapper for the gateway client, not on the MCP resource URL being present in the incoming token's `aud` claim. + +{% entity_examples %} +entities: + plugins: + - name: ai-mcp-oauth2 + route: mcp-token-exchange-isolated + tags: + - token-exchange + config: + resource: http://localhost:8000/mcp-exchange + metadata_endpoint: /.well-known/oauth-protected-resource/mcp-exchange + authorization_servers: + - ${keycloak_issuer} + introspection_endpoint: ${keycloak_introspection_url} + client_id: ${token_exchange_gateway_client_id} + client_secret: ${token_exchange_gateway_client_secret} + client_auth: client_secret_post + insecure_relaxed_audience_validation: true + passthrough_credentials: true + claim_to_header: + - claim: sub + header: X-User-Id + token_exchange: + enabled: true + token_endpoint: ${keycloak_token_url} + client_auth: inherit +variables: + keycloak_issuer: + value: $TOKEN_EXCHANGE_KEYCLOAK_ISSUER + keycloak_introspection_url: + value: $TOKEN_EXCHANGE_KEYCLOAK_INTROSPECTION_URL + keycloak_token_url: + value: $TOKEN_EXCHANGE_KEYCLOAK_TOKEN_URL + token_exchange_gateway_client_id: + value: $TOKEN_EXCHANGE_GATEWAY_CLIENT_ID + token_exchange_gateway_client_secret: + value: $TOKEN_EXCHANGE_GATEWAY_CLIENT_SECRET +{% endentity_examples %} + +With this configuration, {{site.ai_gateway}} first validates the incoming bearer token against the dedicated Keycloak realm using introspection, then exchanges that token at the Keycloak token endpoint before proxying the MCP request upstream. `passthrough_credentials: true` ensures the upstream server receives the exchanged token instead of the original client token. The dedicated `resource` and `metadata_endpoint` keep this flow isolated from the JWK-based setup while still exposing OAuth Protected Resource Metadata for MCP clients. + +## Validate the flow + +### Verify unauthenticated requests are rejected + +Send a request without a token: + + +{% validation request-check %} +url: /mcp-exchange +status_code: 401 +method: POST +headers: + - 'Content-Type: application/json' +body: + jsonrpc: "2.0" + method: tools/list + id: 1 + params: {} +{% endvalidation %} + + +The response returns `401`, confirming the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) is enforcing authentication. + +### Obtain a token from Keycloak + +Obtain a token from Keycloak as `token-exchange-client`, including the `add-token-exchange-gateway-audience` optional scope so that `token-exchange-gateway` is added to the audience: + +```sh +TOKEN_EXCHANGE_TOKEN=$(curl -s -X POST \ + http://$KEYCLOAK_HOST:8080/realms/token-exchange/protocol/openid-connect/token \ + -d "grant_type=password" \ + -d "client_id=$DECK_TOKEN_EXCHANGE_CLIENT_ID" \ + -d "client_secret=$DECK_TOKEN_EXCHANGE_CLIENT_SECRET" \ + -d "username=alex" \ + -d "password=doe" \ + -d "scope=openid profile add-token-exchange-gateway-audience" | jq -r .access_token) && echo $TOKEN_EXCHANGE_TOKEN +``` + +If you decode the token, the resulting access token will have an `aud` claim containing `token-exchange-gateway`, and an `azp` claim with `token-exchange-client`. + +### Verify authenticated MCP requests succeed + +Send the token to {{site.ai_gateway}} and list the available MCP tools: + + +{% validation request-check %} +url: /mcp-exchange +status_code: 200 +method: POST +headers: + - 'Accept: application/json, text/event-stream' + - 'Content-Type: application/json' + - 'Authorization: Bearer $TOKEN_EXCHANGE_TOKEN' +body: + jsonrpc: "2.0" + method: tools/list + id: 1 + params: {} +{% endvalidation %} + + +A successful response returns the tools exposed by the upstream MCP server. + +Call the upstream directly with the original token. The request fails because the upstream only accepts tokens whose `azp` claim is `token-exchange-gateway`: + +```sh +curl -i --no-progress-meter http://localhost:3002/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN_EXCHANGE_TOKEN" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +The response returns `403`, proving the original token is not accepted by the upstream MCP server. + +Call a tool through {{site.ai_gateway}} to verify the full request chain, including token exchange: + + +{% validation request-check %} +url: /mcp-exchange +status_code: 200 +method: POST +headers: + - 'Accept: application/json, text/event-stream' + - 'Content-Type: application/json' + - 'Authorization: Bearer $TOKEN_EXCHANGE_TOKEN' +body: + jsonrpc: "2.0" + method: tools/call + id: 2 + params: + name: show_auth_context + arguments: {} +{% endvalidation %} + + +A successful response confirms that {{site.ai_gateway}} validated the original token, exchanged it at the Keycloak token endpoint, and forwarded the exchanged token to the upstream MCP server. The upstream accepts the request only because it receives a token whose `azp` claim is `token-exchange-gateway`: + +```json +{"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "{\n \"iss\": \"http://localhost:8080/realms/token-exchange\",\n \"azp\": \"token-exchange-gateway\",\n \"aud\": [\n \"account\"\n ],\n \"sub\": \"3f61670f-5e6b-4344-a5a0-a41fd48f3e39\"\n}"}], "structuredContent": {"iss": "http://localhost:8080/realms/token-exchange", "azp": "token-exchange-gateway", "aud": ["account"], "sub": "3f61670f-5e6b-4344-a5a0-a41fd48f3e39"}}} +``` +{:.no-copy-code} diff --git a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md new file mode 100644 index 0000000000..21ff8869a0 --- /dev/null +++ b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md @@ -0,0 +1,140 @@ +This tutorial requires [Keycloak](http://www.keycloak.org/) (version 26 or later) as the authorization server for MCP OAuth2 token exchange. + +This setup is intentionally separate from the JWK validation guide. It uses a dedicated realm and separate clients so it doesn't interfere with any existing MCP OAuth2 configuration. + +#### Install and run Keycloak + +Run Keycloak using Docker on the same network as {{site.ai_gateway}}: + + ```sh + docker run -p 127.0.0.1:8080:8080 \ + --name keycloak \ + --network kong-quickstart-net \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + -e KC_HOSTNAME=http://localhost:8080 \ + quay.io/keycloak/keycloak start-dev --features=token-exchange + ``` + +1. Open the admin console at `http://localhost:8080/admin/master/console/`. + +#### Create the isolated realm + +1. In the top-left realm menu, click **Create realm**. +1. Set the realm name to `token-exchange`. +1. Click **Create**. + +#### Create the MCP client + +This client represents the application or agent that requests access to the MCP server. + +1. In the `token-exchange` realm sidebar, open **Clients**, then click **Create client**. +1. Configure the client: + + +{% table %} +columns: + - title: Section + key: section + - title: Settings + key: settings +rows: + - section: "**General settings**" + settings: | + * Client type: **OpenID Connect** + * Client ID: `token-exchange-client` + - section: "**Capability config**" + settings: | + * Toggle **Client authentication** to **on** + * Make sure that **Direct access grants** is checked. +{% endtable %} + + +#### Create the gateway client + +This client represents {{site.base_gateway}}. It performs token introspection and token exchange. + +1. In the `token-exchange` realm sidebar, open **Clients**, then click **Create client**. +1. Configure the client: + + +{% table %} +columns: + - title: Section + key: section + - title: Settings + key: settings +rows: + - section: "**General settings**" + settings: | + * Client type: **OpenID Connect** + * Client ID: `token-exchange-gateway` + - section: "**Capability config**" + settings: | + * Toggle **Client authentication** to **on** + * Make sure that **Standard Token Exchange** is checked. +{% endtable %} + + +#### Create an optional audience scope for token exchange + +Create an optional client scope that adds `token-exchange-gateway` to the `aud` claim. Keycloak requires the exchanging client to be present in the subject token's audience. Without this mapper, the token exchange request fails with "Client is not within the token audience". + +1. In the sidebar, open **Client scopes**, then click **Create client scope**. +1. Set the name to `add-token-exchange-gateway-audience`. +1. Click **Save**. +1. Open the **Mappers** tab. +1. Click **Configure a new mapper** and select **Audience**. +1. Configure the mapper: + + +{% table %} +columns: + - title: Field + key: field + - title: Value + key: value +rows: + - field: "**Name**" + value: "`add-token-exchange-gateway-audience`" + - field: "**Included Client Audience**" + value: "`token-exchange-gateway`" + - field: "**Add to access token**" + value: "**on**" +{% endtable %} + + +1. In the sidebar, open **Clients** and select `token-exchange-client`. +1. Open the **Client scopes** tab. +1. Click **Add client scope**. +1. Check `add-token-exchange-gateway-audience`, click **Add**, and set it as **Optional**. + +#### Create a test user + +1. In the sidebar, open **Users**, then click **Add user**. +1. Set the username to `alex`. +1. Click **Create**. +1. Open the **Credentials** tab and click **Set password**. +1. Set the password to `doe` and disable **Temporary Password**. + +#### Export environment variables + +1. In the sidebar, open **Clients** and select `token-exchange-client`. Open the **Credentials** tab and copy the client secret. +1. Export the following environment variables: + + ```sh + export DECK_TOKEN_EXCHANGE_CLIENT_ID='token-exchange-client' + export DECK_TOKEN_EXCHANGE_CLIENT_SECRET='' + ``` + +1. In the sidebar, open **Clients** and select `token-exchange-gateway`. Open the **Credentials** tab and copy the client secret. +1. Export the following environment variables: + + ```sh + export DECK_TOKEN_EXCHANGE_GATEWAY_CLIENT_ID='token-exchange-gateway' + export DECK_TOKEN_EXCHANGE_GATEWAY_CLIENT_SECRET='' + export DECK_TOKEN_EXCHANGE_KEYCLOAK_ISSUER='http://localhost:8080/realms/token-exchange' + export DECK_TOKEN_EXCHANGE_KEYCLOAK_INTROSPECTION_URL='http://keycloak:8080/realms/token-exchange/protocol/openid-connect/token/introspect' + export DECK_TOKEN_EXCHANGE_KEYCLOAK_TOKEN_URL='http://keycloak:8080/realms/token-exchange/protocol/openid-connect/token' + export KEYCLOAK_HOST='localhost' + ``` diff --git a/app/_includes/prereqs/weatherapi.md b/app/_includes/prereqs/weatherapi.md new file mode 100644 index 0000000000..b6301da326 --- /dev/null +++ b/app/_includes/prereqs/weatherapi.md @@ -0,0 +1,8 @@ +1. Go to [WeatherAPI](https://www.weatherapi.com/). +1. Sign up for a free account. +1. Navigate to [your dashboard](https://www.weatherapi.com/my/) and copy your API key. +1. Export your API key: + + ```sh + export DECK_WEATHERAPI_API_KEY='your-weatherapi-api-key' + ```