# Shared Remote MCP Server for Apollo GraphQL with per-user Auth0 OAuth

## 1. Context

Today `mcp-graphql` runs Apollo MCP Server in Docker with a static JWT the user copies from browser DevTools every ~20 minutes. The endgame is that **every Orchard engineer** adds one line to their Claude Code config and, on first use, a browser opens for Grass/Auth0 sign-in — exactly like Snowflake, Notion, and Sigma MCPs. The same code runs locally via `docker-compose` and in QA on a **two-container Fargate task** behind an ALB at a hostname like `apollo-mcp.qaorch.com`.

We achieve this by fronting Apollo MCP Server with a TypeScript **auth proxy** that implements the MCP Authorization spec (OAuth 2.1 Authorization Server Metadata + PKCE bridge to Auth0). Per user request the proxy validates the incoming JWT against Auth0 JWKS, extracts the `https://grass.theorchard.com/identity` claim (same data `ows-grass` uses), and injects `orchard-identity-id`, `orchard-identity-uuid`, `orchard-profile-id`, `orchard-profile-type`, `orchard-profile-uuid`, `apollographql-client-name` before forwarding to Apollo MCP on localhost.

The prior sketch at `docs/oauth-auth-proxy.md` has the right shape — this plan refines it into something buildable.

## 2. Architecture

### 2a. Two environments, one codebase

```
LOCAL (docker-compose on engineer's laptop)
────────────────────────────────────────────────────────────────────
  Claude Code ─stdio─> npx mcp-remote http://127.0.0.1:3000/mcp
                                │ HTTP
                                ▼
   ┌─────────────────── docker network "mcp-net" ─────────────────┐
   │                                                              │
   │  auth-proxy:3000  ◄─localhost─►  apollo-mcp:8000             │
   │       │                                    │                 │
   └───────┼────────────────────────────────────┼─────────────────┘
           │ HTTPS                               │ HTTPS
           ▼                                     ▼
    qalogin.theorchard.com            qa-graphql-router.theorchard.io
    (Auth0 tenant — JWKS + /oauth/token)         (Apollo Router)

QA (AWS Fargate)
────────────────────────────────────────────────────────────────────
  Claude Code ─stdio─> npx mcp-remote https://apollo-mcp.qaorch.com/mcp
                                │ HTTPS
                                ▼
              ALB :443 (ACM cert)   ──Route53──
                                │ HTTP :3000
                                ▼
   ┌──────────────── Fargate task (awsvpc) ───────────────────────┐
   │  auth-proxy:3000  ◄─localhost─►  apollo-mcp:8000             │
   └──────────────────────────────────────────────────────────────┘
           │                                     │
           ▼                                     ▼
    qalogin.theorchard.com            qa-graphql-router.theorchard.io
```

Same two-container unit in both environments. Locally it's a compose network; in QA it's a Fargate task whose two containers share `localhost` via `awsvpc`.

### 2b. Flow — first-time auth

```
Claude Code          mcp-remote       auth-proxy               Auth0
   │                    │                 │                       │
   │── MCP initialize ─►│── POST /mcp ───►│                       │
   │                    │  (no Bearer)    │                       │
   │                    │◄─ 401 + WWW-Auth: resource_metadata=... │
   │                    │                 │                       │
   │                    │── GET /.well-known/oauth-authorization- │
   │                    │   server ──────►│                       │
   │                    │◄─ metadata JSON ┤                       │
   │                    │                 │                       │
   │   (mcp-remote opens browser to /oauth/authorize)             │
   │◄──────── GET /oauth/authorize?pkce+state+redirect ──────────►│
   │                                      │── 302 Auth0/authorize─►│
   │◄───────────── user signs in on Auth0 login page ─────────────│
   │                                      │◄── 302 callback+code ─│
   │                                      │── POST Auth0/token ──►│
   │                                      │◄── access+refresh JWT ┤
   │   (proxy 302s back to mcp-remote redirect_uri with our code) │
   │                    │                 │                       │
   │                    │── POST /oauth/token (code+verifier) ──►│
   │                    │◄── JWT + refresh_token ──┤              │
   │                    │                                         │
   │── POST /mcp (Bearer <JWT>) ────────►│                        │
   │                                      │── validate w/ JWKS    │
   │                                      │── extract grass claim │
   │                                      │── POST localhost:8000 │
   │                                      │   /mcp + orchard-*    │
   │                                      │── forward GraphQL ──► │ QA Router
```

### 2c. Flow — subsequent requests and refresh

Subsequent MCP calls: Claude Code sends `Authorization: Bearer <JWT>`; proxy validates against cached JWKS, re-extracts identity, forwards. No session lookup, no database — proxy is **stateless after the initial exchange**.

Refresh: Claude Code calls proxy `POST /oauth/token` with `grant_type=refresh_token`; proxy forwards to Auth0 verbatim and returns the new `access_token`/`refresh_token`. The proxy never stores the user's tokens beyond the ~5-min auth-code-exchange window.

## 3. Project structure

```
mcp-graphql/
├── auth-proxy/                         NEW — the TypeScript service
│   ├── package.json
│   ├── tsconfig.json
│   ├── Dockerfile                      multi-stage Node 20 alpine, non-root
│   ├── .dockerignore
│   ├── src/
│   │   ├── index.ts                    express app + shutdown handler
│   │   ├── config.ts                   zod-validated env loader
│   │   ├── logger.ts                   pino with redact: [authorization, token]
│   │   ├── oauth/
│   │   │   ├── discovery.ts            GET /.well-known/oauth-authorization-server
│   │   │   ├── authorize.ts            GET /oauth/authorize  (client PKCE in, our PKCE out to Auth0)
│   │   │   ├── callback.ts             GET /oauth/callback   (Auth0 code → exchange → our code)
│   │   │   ├── token.ts                POST /oauth/token     (auth_code + refresh_token grants)
│   │   │   ├── register.ts             POST /register        (DCR stub, RFC 7591)
│   │   │   └── session-store.ts        in-memory map, 5 min TTL, LRU 10k entries
│   │   ├── mcp/
│   │   │   ├── proxy.ts                POST /mcp — validate JWT, inject headers, forward
│   │   │   └── jwks.ts                 jose.createRemoteJWKSet with 10 min cache
│   │   ├── identity/
│   │   │   └── grass-claims.ts         parse https://grass.theorchard.com/identity claim
│   │   └── health.ts                   GET /healthz, /readyz
│   └── test/
│       ├── discovery.test.ts
│       ├── authorize.test.ts
│       ├── token.test.ts
│       └── proxy.test.ts
├── apollo-mcp/                         NEW — custom image wrapping Apollo MCP + our schema/ops
│   ├── Dockerfile                      FROM ghcr.io/apollographql/apollo-mcp-server:v1.11.0
│   └── .dockerignore                   (schema.graphql + mcp.yaml + operations/ copied in)
├── docker-compose.yml                  NEW — local two-container stack
├── .env.example                        UPDATED — all AUTH0_* + ISSUER_URL + UPSTREAM_MCP_URL
├── mcp.yaml                            UPDATED — endpoint=qa-router, headers=only apollo-client-name
├── infra/                              NEW — Terraform (user's shop is Terraform-based)
│   ├── main.tf                         provider, versions
│   ├── ecr.tf                          two repos: auth-proxy, apollo-mcp
│   ├── alb.tf                          ALB, listener :443, ACM cert lookup, target group
│   ├── route53.tf                      A/alias for apollo-mcp.qaorch.com
│   ├── ecs.tf                          cluster (data), task def (2 containers), service, SGs
│   ├── iam.tf                          task role, execution role, policies
│   ├── variables.tf                    env (qa/prod), image tags, secret ARNs
│   ├── outputs.tf                      service URL, task def arn
│   └── backend.tf                      s3 + dynamodb locking (confirm user's standard)
├── .github/
│   └── workflows/
│       └── deploy.yml                  build+push to ECR, `aws ecs update-service`
├── operations/                         UNCHANGED
├── schema.graphql                      UNCHANGED
├── docs/
│   ├── research.md                     UNCHANGED
│   └── oauth-auth-proxy.md             UPDATED — remove "superseded" ideas; keep as design ref
├── scripts/
│   └── refresh-schema.sh               NEW — re-introspect router, regenerate schema.graphql
├── README.md                           UPDATED — "Quick start for engineers" + "Local dev" sections
└── CLAUDE.md                           UPDATED — new architecture, remove old refresh notes
```

## 4. Key modules — what each file does (no code, just contracts)

### `auth-proxy/src/config.ts`

`loadConfig()` returns a zod-typed object:
`ISSUER_URL`, `PORT`, `UPSTREAM_MCP_URL`, `AUTH0_DOMAIN`, `AUTH0_AUDIENCE`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET?` (optional — see §7 decision), `AUTH0_SCOPES`, `GRAPHQL_CLIENT_NAME`, `LOG_LEVEL`, `SESSION_TTL_MS`, `ALLOWED_REDIRECT_URIS` (regex array — match `http://127.0.0.1:*` plus `mcp-remote`'s scheme if it uses one).

### `auth-proxy/src/oauth/discovery.ts`

`getMetadata(req, res)` returns JSON:

```json
{
  "issuer": "...",
  "authorization_endpoint": "...",
  "token_endpoint": "...",
  "registration_endpoint": "...",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}
```

Issuer is `ISSUER_URL` verbatim (no trailing slash).

### `auth-proxy/src/oauth/authorize.ts`

`handleAuthorize(req, res)`:

1. Parse `client_id`, `response_type=code`, `redirect_uri`, `scope`, `state`, `code_challenge`, `code_challenge_method=S256`.
2. Validate `redirect_uri` against `ALLOWED_REDIRECT_URIS`.
3. Generate our own PKCE pair (`ourVerifier`, `ourChallenge`) via `crypto.randomBytes(32)` + SHA256+base64url.
4. Create `sessionId = randomBytes(32)`, store `{ clientChallenge, clientRedirect, clientState, ourVerifier, createdAt }` in session-store.
5. 302 to `https://{AUTH0_DOMAIN}/authorize?response_type=code&client_id=${AUTH0_CLIENT_ID}&redirect_uri=${ISSUER_URL}/oauth/callback&scope=${AUTH0_SCOPES}&audience=${AUTH0_AUDIENCE}&code_challenge=${ourChallenge}&code_challenge_method=S256&state=${sessionId}`.

### `auth-proxy/src/oauth/callback.ts`

`handleCallback(req, res)`:

1. Read `code` + `state` from query. Look up session; 400 if missing/expired.
2. POST to `https://{AUTH0_DOMAIN}/oauth/token` with `grant_type=authorization_code`, `code`, `redirect_uri=${ISSUER_URL}/oauth/callback`, `client_id`, `code_verifier=ourVerifier`.
3. On success: generate a fresh `ourCode = randomBytes(32)`, update session with `{ accessToken, refreshToken, idToken, expiresIn }` and `ourCode`, 302 to `clientRedirect?code=${ourCode}&state=${clientState}`.
4. On failure: 302 to `clientRedirect?error=...&state=...`.

### `auth-proxy/src/oauth/token.ts`

`handleToken(req, res)`:

- **`grant_type=authorization_code`**: Look up session by `code=ourCode`, verify `client_code_verifier` against stored `clientChallenge`, return `{ access_token, refresh_token, id_token, expires_in, token_type: "Bearer", scope }` — these are the real Auth0 tokens.
- **`grant_type=refresh_token`**: Forward to Auth0 `/oauth/token` with the refresh_token, relay the response back.

Session is deleted after auth_code use (single-use).

### `auth-proxy/src/oauth/register.ts`

`handleRegister(req, res)` (DCR stub): return static `{ client_id: AUTH0_CLIENT_ID, token_endpoint_auth_method: "none" }`. Good enough for `mcp-remote` and lets us skip real DCR until needed.

### `auth-proxy/src/oauth/session-store.ts`

`Map<string, Session>` with `cleanup()` on a `setInterval` every 60s. LRU eviction at 10k entries. `createSession`, `getSession`, `consumeSession`, `touchSession` APIs.

### `auth-proxy/src/mcp/jwks.ts`

Exports `jwks = createRemoteJWKSet(new URL('https://${AUTH0_DOMAIN}/.well-known/jwks.json'))` with `cooldownDuration: 30_000` and `cacheMaxAge: 600_000`.

### `auth-proxy/src/mcp/userinfo.ts`

`getUserInfo(accessToken, sub, exp)`:

- In-memory `Map<string, CachedIdentity>` keyed on `${sub}:${exp}`. Entry expires when `exp` passes + 30s buffer.
- Cache hit: return immediately.
- Cache miss: `GET https://{AUTH0_DOMAIN}/userinfo` with `Authorization: Bearer <accessToken>`. Parse `payload['https://grass.theorchard.com/identity']`. Call `extractGrassIdentity()`. Store + return.
- On Auth0 userinfo 401/403: propagate as 401 to the MCP client (token is valid but has no grass identity — M2M or misconfigured client).
- On Auth0 5xx: log and return 502.

**Why userinfo, not access token claims:** The grass identity object is present in the ID token but absent from the access token payload. Auth0 does include custom claims in the userinfo response regardless of whether they appear in the access token. A platform team Action to add the claim to access tokens would let us remove this module entirely.

### `auth-proxy/src/mcp/proxy.ts`

`handleMcp(req, res)`:

1. Extract `Authorization: Bearer <jwt>`; 401 with `WWW-Authenticate: Bearer resource_metadata="${ISSUER_URL}/.well-known/oauth-protected-resource"` if missing.
2. `jwtVerify(jwt, jwks, { issuer: 'https://${AUTH0_DOMAIN}/', audience: AUTH0_AUDIENCE })` — catches expired/invalid.
3. Call `getUserInfo(jwt, payload.sub, payload.exp)` (see below) → returns `{ identityId, identityUuid, profileId, profileType, profileUuid }`.
4. Build fetch to `UPSTREAM_MCP_URL`:
   - Same `Content-Type`, body streamed through.
   - `Authorization: Bearer ${jwt}` (same token — Apollo MCP forwards it to router).
   - Six `orchard-*` + `apollographql-client-name` headers.
5. Stream response bytes + headers back. Preserve `Content-Type` for SSE/chunked semantics; drop hop-by-hop.
6. On upstream 5xx, log and return 502; on upstream 4xx, pass through.

### `auth-proxy/src/identity/grass-claims.ts`

`extractGrassIdentity(payload)`:

- Reads `payload['https://grass.theorchard.com/identity']` object. Falls back to specific sub-claims if the shape is nested.
- Returns the five orchard ids. Throws a 403 with a clear message if claim missing (M2M tokens won't have it — useful signal).

### `auth-proxy/src/health.ts`

`/healthz` — always 200. `/readyz` — 503 until the first JWKS fetch succeeds; 503 if the last JWKS fetch was more than 5 minutes ago; 200 otherwise.

## 5. Dependencies (npm)

Runtime: `express@4`, `jose@5` (JWKS + `jwtVerify`), `undici@6` (`fetch`/`Response` streaming to upstream + userinfo calls), `zod@3`, `pino@9`, `pino-http@10`.

Dev: `typescript@5`, `tsx@4`, `@types/*`, `vitest@2`, `supertest@7` for integration tests, `eslint@9` + `@typescript-eslint/*`, `prettier@3`.

No dep on `@modelcontextprotocol/sdk` — we're not implementing MCP; we're a transparent HTTP proxy for it. Simpler.

## 6. Environment variables (per env)

All loaded/validated by `config.ts`. Values in `.env` for local, Secrets Manager for QA (mounted via task definition `secrets:`).

| Var | Local default | QA value |
|---|---|---|
| `PORT` | `3000` | `3000` |
| `ISSUER_URL` | `http://127.0.0.1:3000` | `https://apollo-mcp.qaorch.com` |
| `UPSTREAM_MCP_URL` | `http://apollo-mcp:8000/mcp` (compose hostname) | `http://localhost:8000/mcp` (Fargate localhost) |
| `UPSTREAM_GRAPHQL_URL` | `https://qa-graphql-router.theorchard.io/graphql` | same (QA) |
| `AUTH0_DOMAIN` | `qalogin.theorchard.com` *(QA — unverified)* | `login.distroauth.com` *(prod — verified)* |
| `AUTH0_AUDIENCE` | `https://workstation.qaorch.com/api` *(QA — unverified)* | `https://workstation.theorchard.com/api` *(prod — verified)* |
| `AUTH0_CLIENT_ID` | `x937kb4f5c3hJRGazmCm5NDrx0hVk8eg` *(QA SPA — unverified)* | `zHwVS8k6KMbZCjO9aGZud9GWQ2CGxY6t` *(prod — verified)* |
| `AUTH0_SCOPES` | `openid profile email offline_access` | same |
| `GRAPHQL_CLIENT_NAME` | `frontend-insights` | `apollo-mcp-server` (cleaner attribution) |
| `SESSION_TTL_MS` | `300000` | `300000` |
| `LOG_LEVEL` | `debug` | `info` |
| `ALLOWED_REDIRECT_URIS` (CSV of regexes) | `^http://127\.0\.0\.1:\d+/.*$,^http://localhost:\d+/.*$` | add `mcp-remote`'s URI scheme once known |

`AUTH0_CLIENT_SECRET` intentionally absent — public client using PKCE only. If the platform team requires a confidential client, add it and change token-endpoint auth method accordingly.

## 7. Auth0 tenant setup (platform team asks)

Ask the Identity/Platform team for one of:

**Option A (preferred): register a new dedicated Auth0 Application** "Apollo MCP Server (QA)":

- Type: **Regular Web Application** *or* Native (PKCE-capable, public). Picking Native gives us `token_endpoint_auth_method: none` by default.
- Callback URLs: `http://127.0.0.1:3000/oauth/callback`, `https://apollo-mcp.qaorch.com/oauth/callback`, plus whatever `mcp-remote` uses internally (verify once implemented — may loop back via localhost).
- Allowed Logout URLs: same hosts.
- Grant Types: `authorization_code`, `refresh_token`.
- Refresh Token: **Rotating**, with absolute lifetime 30 days. Reuse interval 0.
- Allowed origins/CORS: `http://127.0.0.1:3000`, `https://apollo-mcp.qaorch.com`.
- Connections: same Grass identity connection used by other OWS apps.

**Option B (fallback): reuse `ows-coda`'s SPA client (`x937kb4f5c3hJRGazmCm5NDrx0hVk8eg`)** — just add our two callback URLs. Cheaper ask but couples our rollout to theirs.

Either way, confirm the API audience the router validates (`https://qa-ows.theorchard.io` per old doc vs `https://workstation.qaorch.com/api` per recent research — **decode a working JWT's `aud` claim** before starting).

## 8. `docker-compose.yml` (local)

```yaml
name: apollo-mcp-local
services:
  apollo-mcp:
    build: ./apollo-mcp              # custom image bakes in schema+ops+mcp.yaml
    ports: []                        # not exposed to host — only auth-proxy reaches it
    networks: [mcp-net]
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3

  auth-proxy:
    build: ./auth-proxy
    ports: ["3000:3000"]
    env_file: .env
    environment:
      UPSTREAM_MCP_URL: http://apollo-mcp:8000/mcp
      ISSUER_URL: http://127.0.0.1:3000
    depends_on:
      apollo-mcp: { condition: service_healthy }
    networks: [mcp-net]

networks:
  mcp-net: {}
```

`mcp.yaml` in the `apollo-mcp` image points at `UPSTREAM_GRAPHQL_URL` (i.e. directly at QA router), **no `authorization` header** — the proxy injects that. The `apollo-mcp` container still needs VPN/network access to the QA router (same as today).

## 9. `auth-proxy/Dockerfile` (multi-stage)

```dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json tsconfig.json ./
RUN npm ci
COPY src ./src
RUN npm run build

FROM node:20-alpine AS runtime
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER app
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "dist/index.js"]
```

## 10. Fargate deployment (QA)

### Task definition (shape, not full JSON)

- `networkMode: awsvpc`
- CPU 512, memory 1024 (start small; tune)
- Two containers:
  - `auth-proxy` — image `${ecr}/apollo-mcp-auth-proxy:${sha}`, `portMappings: [{ containerPort: 3000 }]`, `essential: true`, `environment` + `secrets` from Secrets Manager, `logConfiguration: awslogs`
  - `apollo-mcp` — image `${ecr}/apollo-mcp-server:${sha}`, no port mapping needed (localhost-only), `essential: true`, depends on no one (starts first — use `dependsOn: [{ containerName: apollo-mcp, condition: HEALTHY }]` on `auth-proxy`)
- IAM: `executionRoleArn` with `AmazonECSTaskExecutionRolePolicy` + `secretsmanager:GetSecretValue`; `taskRoleArn` minimal (no AWS API calls expected — add later only if needed).

### ALB

- HTTPS listener :443 with ACM cert for `apollo-mcp.qaorch.com`.
- Target group on port 3000 with health check `/healthz`.
- HTTP → HTTPS redirect on :80.
- Security group: 443 ingress from VPN CIDR(s) — **do not** open to 0.0.0.0/0; identity flows through Auth0 but restricting ingress to VPN is good defense in depth.
- Task SG: 3000 from ALB SG only.

### Route53

Alias A record `apollo-mcp.qaorch.com` → ALB.

### ECR

Two repos: `apollo-mcp-auth-proxy`, `apollo-mcp-server`. Lifecycle policy: keep last 20 tags.

### Schema + operations (the interesting question)

**Recommend: bake into the `apollo-mcp` image.** Every PR that changes `operations/` or `schema.graphql` rebuilds + redeploys. Immutable, reproducible, no runtime S3 dependency.

Alternatives considered:

- **S3 fetch at boot** — lets you ship new operations without a redeploy. Needs init sidecar + IAM. Skip unless operations churn hourly.
- **EFS mount** — overkill, and Fargate EFS has cold-start latency.

Provide `scripts/refresh-schema.sh` for easy re-introspection locally; commit the regenerated `schema.graphql` as a normal code change.

## 11. CI/CD (GitHub Actions, skeleton)

`.github/workflows/deploy.yml`:

```yaml
on:
  push:
    branches: [master]
    paths: ['auth-proxy/**', 'apollo-mcp/**', 'operations/**', 'schema.graphql', 'mcp.yaml']
jobs:
  deploy-qa:
    runs-on: ubuntu-latest
    environment: qa
    permissions: { id-token: write, contents: read }
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4  # OIDC role
      - run: docker buildx build -t ${{ steps.ecr.outputs.registry }}/apollo-mcp-auth-proxy:${{ github.sha }} auth-proxy/ --push
      - run: docker buildx build -t ${{ steps.ecr.outputs.registry }}/apollo-mcp-server:${{ github.sha }} apollo-mcp/ --push
      - run: terraform -chdir=infra apply -auto-approve -var="image_tag=${{ github.sha }}"
      # or: aws ecs update-service --force-new-deployment ...
```

User-specific: confirm whether user's org uses OIDC-federated GitHub→AWS or long-lived access keys. Default to OIDC.

## 12. First-use UX (copy-pasteable)

**Local:**

```bash
cp .env.example .env                        # fill in AUTH0_* if defaults don't match tenant
docker compose up -d
claude mcp add apollo-local -- npx mcp-remote http://127.0.0.1:3000/mcp
claude                                      # first tool use triggers browser sign-in
```

**QA (what every engineer runs):**

```bash
claude mcp add apollo-qa -- npx mcp-remote https://apollo-mcp.qaorch.com/mcp
claude
```

First `introspect` or `search` → `mcp-remote` sees `401 + WWW-Authenticate` → opens browser to `/oauth/authorize` → Auth0 login → redirect back → token stored by `mcp-remote` (in `~/.mcp-remote/`) → subsequent requests automatic.

## 13. Verification

### Local (Phase 1)

1. `docker compose up -d` — both containers healthy (`docker compose ps`).
2. `curl -sS http://127.0.0.1:3000/.well-known/oauth-authorization-server | jq` — valid metadata with `issuer: http://127.0.0.1:3000`.
3. `curl -sS http://127.0.0.1:3000/healthz` → `ok`; `/readyz` → `ok`.
4. `claude mcp add apollo-local -- npx mcp-remote http://127.0.0.1:3000/mcp` + `claude` — browser opens, sign in, and a first `introspect` succeeds.
5. Directly exercise the proxy:

   ```bash
   TOKEN=$(cat ~/.mcp-remote/tokens.json | jq -r '.apollo-local.access_token')
   curl -sS -X POST http://127.0.0.1:3000/mcp \
     -H "Authorization: Bearer $TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
   ```

   Returns JSON with `tools` array.
6. Tail logs — confirm no token bodies logged, each request shows userId extracted from claim.
7. Wait 25 min — `mcp-remote` auto-refreshes; proxy logs show `POST /oauth/token grant_type=refresh_token 200`.

### QA (Phase 2)

1. Terraform apply; wait for ALB target healthy.
2. `curl -sS https://apollo-mcp.qaorch.com/.well-known/oauth-authorization-server | jq` — metadata with correct `issuer`.
3. Repeat steps 4–7 against the QA URL.

## 14. Security

- **PKCE enforced both legs.** Client PKCE (MCP client ↔ proxy) and proxy PKCE (proxy ↔ Auth0). No auth-code flow without `code_challenge_method=S256`.
- **`state` CSRF.** Generated server-side for our proxy↔Auth0 leg (set to `sessionId`); validated on callback. Client's `state` passed through unchanged.
- **`redirect_uri` allowlist.** Regex-matched against `ALLOWED_REDIRECT_URIS`. Never reflect a URL we didn't match.
- **JWT validation** on every `/mcp` request: signature via JWKS, `iss=https://{AUTH0_DOMAIN}/`, `aud=${AUTH0_AUDIENCE}`, `exp` in future, `nbf` if present. Reject tokens signed by any other issuer.
- **JWKS rotation**: `jose.createRemoteJWKSet` handles it automatically (30s cooldown on kid miss before re-fetch).
- **Session IDs** and auth codes: `crypto.randomBytes(32).toString('base64url')` — 256 bits of entropy.
- **No cookies.** Proxy is stateless-per-request after token issuance. No CSRF surface beyond the OAuth flow itself.
- **Logging**: pino `redact: ['req.headers.authorization', 'req.body.*.token', 'res.headers.authorization', '*.access_token', '*.refresh_token', '*.id_token']`. Never log headers verbatim.
- **Rate limits**: `express-rate-limit` on `/oauth/token` (10/min/IP) and `/oauth/authorize` (20/min/IP). Block `Authorization`-bearing requests without Bearer scheme.
- **HSTS** at the ALB (via ACM-issued cert + listener rule). HTTP → HTTPS redirect on :80.
- **Egress allow-list** (Fargate SG): 443 to `qalogin.theorchard.com`, 443 to `qa-graphql-router.theorchard.io` only. Reduces blast radius if compromised.
- **No token storage at rest.** Auth codes and in-flight tokens live in memory with 5-min TTL. Post-exchange, the proxy forwards — it does not retain the user's JWT or refresh token.

## 15. Risks & open questions

- **(a)** ~~Audience mismatch~~ **RESOLVED (2026-04-22)**: Prod audience confirmed as `https://workstation.theorchard.com/api`, issuer `https://login.distroauth.com/`, client ID `zHwVS8k6KMbZCjO9aGZud9GWQ2CGxY6t`. QA values extrapolated (swap `theorchard.com` → `qaorch.com`, `login.distroauth.com` → `qalogin.theorchard.com`) — verify against a live QA JWT before QA deploy.
- **(a2)** Grass identity claim absent from access token. The `https://grass.theorchard.com/identity` object is in the ID token only; the access token payload is minimal. **Resolved by design**: proxy calls Auth0 `/userinfo` endpoint post-validation and caches per `(sub, exp)`. Follow-up: ask platform team to add grass claim to access tokens via Auth0 Action, which would eliminate the userinfo round-trip.
- **(b)** Can we add our callback URLs to the existing SPA client, or do we need a new dedicated Application? Prefer dedicated (clean audit, own rotation policy). **Action**: request with platform team, follow up via the auth channel.
- **(c)** `mcp-remote` MCP Auth spec support: confirm version and which MCP Auth revision it implements. If older than 2025-03-26, behaviour around `WWW-Authenticate` metadata URL may differ. **Action**: grep `mcp-remote` source / check release notes.
- **(d)** Does the QA router accept user JWTs directly without the grass-proxy in front? If grass adds claims to the token or if the router demands grass-minted JWTs specifically, we may need to route through grass as an upstream instead. **Action**: test early — forward a DevTools-captured JWT with injected orchard-* headers straight to the router and see if it accepts.
- **(e)** Operations update cadence: baking into image means every new `.graphql` file is a PR+deploy. For a POC that's fine; if operations churn daily later, add S3 or git-sync sidecar.
- **(f)** ALB SG scope: VPN-only vs org-SSO-gated. If Claude Code runs from non-VPN environments (e.g., engineers at home without VPN), restrict to corporate IP ranges or fronted by a SSO-aware edge. **Action**: align with security.
- **(g)** Terraform state backend: confirm user's org standard (S3 bucket + DynamoDB table names). Defaults in `backend.tf` will need to be overridden.
- **(h)** Secrets hygiene: `AUTH0_CLIENT_SECRET` (if used) lives in AWS Secrets Manager; make sure `infra/ecs.tf` references the ARN via `secrets:` not `environment:`.
- **(i)** Refresh token absolute lifetime (Auth0 tenant policy, often 30 days): when exceeded, `mcp-remote` will re-prompt in browser. Acceptable and matches Snowflake MCP UX.
- **(j)** Observability: add `aws_cloudwatch_log_group` resources + alarm on 5xx rate. Deferred to Phase 2.5.

## 16. Phased rollout

**Phase 1 — Local end-to-end (this plan's focus)**

- Implement `auth-proxy/` + `apollo-mcp/` custom image + `docker-compose.yml`.
- Confirm audience + client ID against a live DevTools JWT.
- Exercise full browser-auth flow via `mcp-remote`.
- Exit criteria: `claude mcp add apollo-local -- npx mcp-remote http://127.0.0.1:3000/mcp` + `introspect` works.

**Phase 2 — QA Fargate deploy**

- Terraform up the ALB + task + ECR.
- CI pipeline building and pushing both images.
- Exit criteria: any engineer on VPN runs `claude mcp add apollo-qa -- npx mcp-remote https://apollo-mcp.qaorch.com/mcp` and gets working MCP after a browser sign-in.

**Phase 3 — Rollout + ops**

- Short Notion page with the one-line install command, who to ping for access.
- CloudWatch alarm on 5xx rate + a weekly log review.
- Consider prod tenant (`prod-graphql-router` + `login.theorchard.com`) — separate Fargate service, separate Auth0 Application.

## 17. Files to modify in the existing repo

- `mcp.yaml`: drop `authorization` + `orchard-*` headers (proxy injects), keep `apollographql-client-name`, keep `endpoint: ${env.GRAPHQL_ENDPOINT}`. This file now only lives inside the `apollo-mcp` image.
- `.env.example`: rewrite around the new vars (§6). Remove `GRAPHQL_TOKEN`, `ORCHARD_*`.
- `.gitignore`: keep existing rules, no new secrets files to ignore.
- `README.md`: top section becomes "Quick start for engineers" with the QA one-liner and the local compose workflow. Demote the `docker run ...` Apollo MCP single-container snippet to a "Running Apollo MCP standalone" appendix.
- `CLAUDE.md`: replace the "Auth / JWT refresh" block with the new architecture summary. Add `auth-proxy/`, `docker-compose.yml`, `infra/` to key files.
- `docs/oauth-auth-proxy.md`: keep — it's the design reference; update header to reflect that this is now the implementation target, not a sketch.
