# Claude Desktop Gateway (Entra JWT → Anthropic)

Dedicated AgentGateway for **Claude Desktop** users: Interactive sign-in (PKCE)
against Microsoft Entra ID, JWT validation on the gateway, Anthropic upstream with
the Vault-synced API key. No MDM / Jamf / Intune / `.mobileconfig` required for
lab use — configure once in Claude Desktop's Developer UI (or a local
`claude_desktop_config.json`).

**Related:** [Claude Desktop lab page](https://goose.maniak.ai/claude-desktop.html)
([`#config`](https://goose.maniak.ai/claude-desktop.html#config) ·
[`#links`](https://goose.maniak.ai/claude-desktop.html#links)) ·
sibling open Anthropic path `:30398/anthropic` (no Entra) on the
[LLM gateway](https://goose.maniak.ai/llm-gateway.html#endpoints).

**Lab walkthrough slides:** [`assets/claude-desktop/`](https://github.com/sebbycorp/k8s-goose/tree/main/assets/claude-desktop)
(`01` welcome → `02` Entra consent → `03` signed-in home → `05` Connection form /
`06` model discovery at `http://127.0.0.1:18789/v1/models` → `04` chat works).

## Architecture

```
Claude Desktop  (Gateway base URL = http://127.0.0.1:18789/)
  │  Interactive sign-in (PKCE) → Entra
  │  Authorization: Bearer <ID token>
  ▼
scripts/claude-desktop-lab-proxy.py   (TCP loopback → lab HTTP NodePort)
  ▼
claude-desktop-gateway  (HTTP :31938 / optional HTTPS :31211 on 172.16.10.155)
  │  AgentgatewayPolicy claude-desktop-jwt-auth
  │    jwtAuthentication Strict → entra-jwks (login.microsoftonline.com)
  │  HTTPRoute claude-desktop  PathPrefix /
  ▼
AgentgatewayBackend anthropic-claude-desktop
  │  injects anthropic-secret (Vault llm-keys/anthropic → ESO)
  ▼
api.anthropic.com  (/v1/messages, /v1/messages/count_tokens)
```

Claude Desktop posts to `{Gateway base URL}/v1/messages`. This gateway matches
`/` (not `/anthropic`) so that path works at the NodePort root.

Claude Desktop rejects non-loopback HTTP base URLs
(`baseUrl: must use https (or http on loopback)`). **Lab recommendation:** run
the loopback proxy and use `http://127.0.0.1:18789/` — avoids ngrok and avoids
fighting the lab self-signed cert inside Electron. The Gateway still exposes
**HTTPS:443** (optional/advanced) and **HTTP:80** (proxy upstream + debug).

## Recommended lab path (laptop)

1. Be on the lab LAN or VPN so `172.16.10.155` is reachable.
2. Start the proxy and keep it running:

   ```bash
   python3 scripts/claude-desktop-lab-proxy.py
   # or, if you installed a copy: ~/bin/claude-desktop-lab-proxy.py
   ```

   Listens on `127.0.0.1:18789`, forwards to `172.16.10.155:31938`
   (HTTP NodePort of `claude-desktop-gateway`).

3. Set Claude Desktop **Gateway base URL** to:

   ```text
   http://127.0.0.1:18789/
   ```

Script: [`scripts/claude-desktop-lab-proxy.py`](https://github.com/sebbycorp/k8s-goose/blob/main/scripts/claude-desktop-lab-proxy.py).

### HTTPS self-signed vs loopback

Optional advanced base URL: `https://172.16.10.155:31211/` (TLS terminate with
`solo-ui-tls`). Electron / Claude Desktop often fails with
`ERR_CERT_AUTHORITY_INVALID` on that lab cert. Prefer the loopback proxy unless
you have a good reason to fight OS trust stores.

## Entra app (already created)

| Field | Value |
|-------|--------|
| App name | `agw-claude-desktop` |
| Tenant ID | `8635e970-2205-4189-bc77-77519ff5064f` |
| Client ID (Application ID) | `adf4a4f8-45a4-4bda-a7e2-35f39b1db59d` |
| Issuer | `https://login.microsoftonline.com/8635e970-2205-4189-bc77-77519ff5064f/v2.0` |
| Platform | **Mobile and desktop applications** |
| Redirect URI | `http://127.0.0.1/callback` |
| Allow public client flows | **Yes** |
| Client secret | **none** (public client + PKCE) |

Do not register the redirect under Web — Entra will reject the loopback PKCE
callback. The path must include `/callback` (`http://127.0.0.1` alone fails with
`AADSTS50011`); the port is wildcarded. **Leave this redirect as HTTP loopback**
— do not change it when using either the lab proxy or the HTTPS NodePort.

## Cluster resources

| Kind | Name | File |
|------|------|------|
| Gateway | `claude-desktop-gateway` | [`config/gateway/claude-desktop-gateway.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/gateway/claude-desktop-gateway.yaml) |
| HTTPRoute | `claude-desktop` | [`config/routes/claude-desktop-route.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/routes/claude-desktop-route.yaml) |
| AgentgatewayBackend | `anthropic-claude-desktop` | [`config/backends/anthropic-claude-desktop.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/backends/anthropic-claude-desktop.yaml) |
| AgentgatewayPolicy | `claude-desktop-jwt-auth` | [`config/policies/claude-desktop-jwt-auth.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/policies/claude-desktop-jwt-auth.yaml) |
| tracing targetRef | Gateway `claude-desktop-gateway` | [`config/policies/tracing.yaml`](https://github.com/sebbycorp/k8s-goose/blob/main/config/policies/tracing.yaml) |
| Lab proxy script | — | [`scripts/claude-desktop-lab-proxy.py`](https://github.com/sebbycorp/k8s-goose/blob/main/scripts/claude-desktop-lab-proxy.py) |

**Reused (do not recreate):**

- `AgentgatewayBackend/entra-jwks` — Entra JWKS host
- `Secret/anthropic-secret` + ExternalSecret → Vault `llm-keys/anthropic`
- `Secret/solo-ui-tls` — lab self-signed TLS (same Secret as Solo UI `:30443`;
  must live in `agentgateway-system`, same ns as the Gateway)
- `EnterpriseAgentgatewayParameters/agentgateway-proxy-params` — cost catalog

Argo app `agentgateway-config` syncs `config/` recursively — push + sync is enough.
The laptop proxy path does **not** require Gateway/JWT/route YAML changes.

## Find the NodePort / base URL

Live lab values (Service `claude-desktop-gateway`):

| Port | Use |
|------|-----|
| **Claude Desktop (lab)** | `http://127.0.0.1:18789/` via `scripts/claude-desktop-lab-proxy.py` |
| HTTP **`80:31938/TCP`** | Proxy upstream + debug/curl |
| HTTPS **`443:31211/TCP`** | Optional/advanced direct TLS (`solo-ui-tls`) |

Messages (lab): `http://127.0.0.1:18789/v1/messages`.

If the NodePort ever changes, re-confirm with:

```bash
kubectl --context maniak-goose -n agentgateway-system get svc claude-desktop-gateway -o wide
kubectl --context maniak-goose -n agentgateway-system get gateway claude-desktop-gateway
kubectl --context maniak-goose -n agentgateway-system get httproute claude-desktop
```

Optional discovery one-liner (HTTPS NodePort; examples below use **31211**):

```bash
HTTPS_NP=$(kubectl --context maniak-goose -n agentgateway-system get svc claude-desktop-gateway \
  -o jsonpath='{range .spec.ports[*]}{.port}:{.nodePort}{"\n"}{end}' | awk -F: '$1==443{print $2}')
echo "https://172.16.10.155:${HTTPS_NP}/"
```

## Claude Desktop developer config

**Help → Troubleshooting → Enable Developer Mode**, then
**Developer → Configure Third-Party Inference…**.

| Field | Value |
|-------|--------|
| Connection / Inference provider | **Gateway** |
| Gateway base URL | `http://127.0.0.1:18789/` (lab proxy) |
| Credential kind | **Interactive sign-in** |
| Client ID | `adf4a4f8-45a4-4bda-a7e2-35f39b1db59d` |
| Issuer URL | `https://login.microsoftonline.com/8635e970-2205-4189-bc77-77519ff5064f/v2.0` |
| Bearer token | **ID token** (critical — not Access token) |
| Scopes | `openid email profile offline_access` |

`offline_access` enables refresh so users are not re-prompted every ~1h when
using ID tokens.

### `deploymentMode: 3p` (`claude_desktop_config.json`)

Third-party inference runs in **3P** mode. When configuring via local JSON
(instead of MDM), the file typically includes:

```json
{
  "deploymentMode": "3p",
  "enterpriseConfig": {
    "inferenceProvider": "gateway",
    "inferenceGatewayBaseUrl": "http://127.0.0.1:18789/",
    "inferenceCredentialKind": "interactive",
    "inferenceGatewayOidc": {
      "clientId": "adf4a4f8-45a4-4bda-a7e2-35f39b1db59d",
      "issuer": "https://login.microsoftonline.com/8635e970-2205-4189-bc77-77519ff5064f/v2.0",
      "scopes": ["openid", "email", "profile", "offline_access"],
      "bearerTokenType": "id_token"
    }
  }
}
```

Prefer the in-app Developer UI for lab machines; it validates fields and can
export. Fully quit and relaunch Claude Desktop after edits (config is read at
launch). Paths vary by OS (`Claude-3p` app-support / config directories once in
3P mode).

## Smoke test (curl + Entra ID token)

Anonymous must fail; a real Entra **ID token** for this app must reach Anthropic.
Hit the HTTP NodePort directly (or the loopback proxy if it is running).

```bash
BASE="http://172.16.10.155:31938"
# or: BASE="http://127.0.0.1:18789"   # with the lab proxy running

# Expect 401/403
curl -sS -o /tmp/cd-anon -w '%{http_code}\n' -m 30 \
  -H 'Content-Type: application/json' \
  -H 'anthropic-version: 2023-06-01' \
  -d '{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"ping"}]}' \
  "${BASE}/v1/messages"

# Mint an ID token for this public client (interactive / device flow as needed),
# then:
# curl -sS ... -H "Authorization: Bearer ${ID_TOKEN}" ... "${BASE}/v1/messages"
```

For optional HTTPS smoke without trusting the cert: `BASE="https://172.16.10.155:31211"` and `curl -skS …`.

Gateway JWT `audiences` is the app client ID only — that matches ID-token `aud`.

## Lessons learned

1. **Redirect must be `http://127.0.0.1/callback`** under **Mobile and desktop
   applications** — Web platform + missing `/callback` both break PKCE.
2. **Public client, no secret** — Claude Desktop uses PKCE; do not add a client
   secret to this app.
3. **Bearer = ID token** — default Claude Desktop mode; gateway `aud` = client
   ID. Access tokens have a different audience story and will fail this policy.
4. **Use `jwtAuthentication` (AgentgatewayPolicy), not `entExtAuth`** — same
   pattern as `/secure-openai` and `/mcp-secure`.
5. **Path is `/` on a dedicated Gateway** — keep `/anthropic` on
   `anthropic-claude-gateway` for open/kagent traffic; Claude Desktop expects
   `/v1/messages` at the gateway base URL.
6. **Reuse `entra-jwks` + `anthropic-secret`** — one JWKS backend and one Vault
   key for all Entra JWT + Anthropic demos.
7. **Lab laptop: loopback HTTP proxy** — Claude Desktop allows plain HTTP only
   on loopback; prefer `http://127.0.0.1:18789/` +
   `scripts/claude-desktop-lab-proxy.py` over HTTPS/`solo-ui-tls` in Electron
   (`ERR_CERT_AUTHORITY_INVALID`) or ngrok.

## Teardown

Removes only the Claude Desktop resources (keeps `entra-jwks`, `anthropic-secret`,
`solo-ui-tls`, and the open `/anthropic` gateway):

```bash
kubectl --context maniak-goose -n agentgateway-system delete \
  agentgatewaypolicy claude-desktop-jwt-auth \
  httproute claude-desktop \
  gateway claude-desktop-gateway \
  agentgatewaybackend anthropic-claude-desktop
```

Also drop the `claude-desktop-gateway` entry from `config/policies/tracing.yaml`
and delete the four YAML files under `config/` if reverting GitOps.
