[Security][Go-live] Remove unauthenticated infra control planes (Docker 2375, GameServer JWT, MCP) #407

Open
opened 2026-07-05 20:24:36 +00:00 by spikerj · 5 comments
Owner

Context: The JWT and MCP parts are real code fixes regardless of environment. The Docker-daemon connection is a doctrine violation (below), not merely a dev artifact.

Problem — three unauthenticated control planes:

  1. The API connects to an unauthenticated Docker daemon on tcp://…:2375 — Docker API = root on the host. This violates api-conventions.md: "The API has NO Docker socket and must never get one — cluster topology reads come from the Redis snapshot via IClusterTopologyService."
  2. GameServer WebSocket trusts unsigned/forged JWTs (reads sub with no signature/issuer/audience/expiry check) → full account impersonation.
  3. MCP server is unauthenticated and stamps a caller-supplied userId → read/write any user's data.

Evidence:

  • SpikerSoft.Api/Extensions/ServiceCollectionExtensions.cs:1021-1082 (Docker tcp://…:2375)
  • SpikerSoft.GameServer/Network/WebSocketHandler.cs:49-58
  • SpikerSoft.AI.MCPServer/Program.cs:79; CalendarTools.csAuthenticatedSpikerSoftApiClient.cs:44-48

Fix: Remove the API's IDockerClient/DockerSwarmService and read topology from the Redis snapshot; validate GameServer JWTs against Keycloak JWKS (signature + issuer + audience + lifetime); require auth on /mcp and derive the acting user from the authenticated principal.

Acceptance criteria: API has no Docker client; GameServer rejects forged tokens; /mcp requires auth and ignores client-supplied user IDs.

Effort: M · Related: #401 (SignalR hubs — separate anonymous-hub gap).

**Context:** The JWT and MCP parts are real code fixes regardless of environment. The Docker-daemon connection is a doctrine violation (below), not merely a dev artifact. **Problem — three unauthenticated control planes:** 1. The API connects to an unauthenticated Docker daemon on `tcp://…:2375` — Docker API = root on the host. This violates `api-conventions.md`: *"The API has NO Docker socket and must never get one — cluster topology reads come from the Redis snapshot via `IClusterTopologyService`."* 2. GameServer WebSocket trusts unsigned/forged JWTs (reads `sub` with no signature/issuer/audience/expiry check) → full account impersonation. 3. MCP server is unauthenticated and stamps a caller-supplied `userId` → read/write any user's data. **Evidence:** - `SpikerSoft.Api/Extensions/ServiceCollectionExtensions.cs:1021-1082` (Docker `tcp://…:2375`) - `SpikerSoft.GameServer/Network/WebSocketHandler.cs:49-58` - `SpikerSoft.AI.MCPServer/Program.cs:79`; `CalendarTools.cs` → `AuthenticatedSpikerSoftApiClient.cs:44-48` **Fix:** Remove the API's `IDockerClient`/`DockerSwarmService` and read topology from the Redis snapshot; validate GameServer JWTs against Keycloak JWKS (signature + issuer + audience + lifetime); require auth on `/mcp` and derive the acting user from the authenticated principal. **Acceptance criteria:** API has no Docker client; GameServer rejects forged tokens; `/mcp` requires auth and ignores client-supplied user IDs. **Effort:** M · Related: #401 (SignalR hubs — separate anonymous-hub gap).
spikerj added the agentic label 2026-07-05 20:24:36 +00:00
Author
Owner

Item 2 (GameServer JWT) shipped — backend PR #158 (in review)

Server-side JWKS validation on the game WebSocket; forged/expired/wrong-issuer/wrong-audience/unsigned tokens now resolve to unauthenticated instead of an impersonated sub. 10 unit tests cover every rejection path. Flagged for a live-Keycloak smoke test before merge (positive path isn't headless-verifiable; config mirrors the API's proven Authentication:* block, so low risk).

Item 1 (API → Docker 2375) — scoped; needs frontend-contract verification, not a blind read-swap

Dug into it so the next person starts with eyes open:

  • DockerSwarmController is read-only — 4 GETs (services, services/{id}, daemon, summary), all via GetDockerSwarmServicesQuery. No management/writes, so removing the Docker client doesn't drop any control-plane feature. Good.
  • But it's not a clean swap to IClusterTopologyService. GetDockerSwarmServicesQueryHandler sources from IDockerSwarmService (the tcp://…:2375 client) and returns the rich DockerSwarmServicesResponseDto/DockerSwarmServiceDto shape. IClusterTopologyService.GetTopologyAsync() returns a different abstraction (ClusterTopologyDto: Nodes/Workloads/Instances), and there's no topology equivalent for the daemon endpoint.
  • Therefore the real work is: (a) confirm which DockerSwarmServiceDto fields the Angular cluster views actually consume; (b) confirm the docker-monitor Redis snapshot carries them (may need extending the worker's snapshot); (c) rewrite the handler to map ClusterWorkloadDto[] → DockerSwarmServiceDto[] + decide what daemon/summary return from a snapshot; (d) then delete the IDockerClient/DockerSwarmService registration (ServiceCollectionExtensions.cs:1021-1082).

I'm not blind-shipping (c)/(d): a wrong mapping silently returns partial/empty data to the cluster dashboard, and I can't verify the frontend contract or snapshot coverage headless. Happy to implement it once we confirm the consumed fields + snapshot coverage (or if we're fine dropping/synthesizing the daemon view). Item 3 (MCP /mcp auth) still open.

### Item 2 (GameServer JWT) shipped — backend PR #158 (in review) Server-side JWKS validation on the game WebSocket; forged/expired/wrong-issuer/wrong-audience/unsigned tokens now resolve to unauthenticated instead of an impersonated `sub`. 10 unit tests cover every rejection path. Flagged for a live-Keycloak smoke test before merge (positive path isn't headless-verifiable; config mirrors the API's proven `Authentication:*` block, so low risk). ### Item 1 (API → Docker 2375) — scoped; needs frontend-contract verification, not a blind read-swap Dug into it so the next person starts with eyes open: - **`DockerSwarmController` is read-only** — 4 GETs (`services`, `services/{id}`, `daemon`, `summary`), all via `GetDockerSwarmServicesQuery`. No management/writes, so removing the Docker client doesn't drop any control-plane feature. Good. - **But it's not a clean swap to `IClusterTopologyService`.** `GetDockerSwarmServicesQueryHandler` sources from `IDockerSwarmService` (the `tcp://…:2375` client) and returns the rich `DockerSwarmServicesResponseDto`/`DockerSwarmServiceDto` shape. `IClusterTopologyService.GetTopologyAsync()` returns a *different* abstraction (`ClusterTopologyDto`: `Nodes`/`Workloads`/`Instances`), and there's **no topology equivalent for the `daemon` endpoint**. - **Therefore the real work is:** (a) confirm which `DockerSwarmServiceDto` fields the Angular cluster views actually consume; (b) confirm the docker-monitor Redis snapshot carries them (may need extending the worker's snapshot); (c) rewrite the handler to map `ClusterWorkloadDto[] → DockerSwarmServiceDto[]` + decide what `daemon`/`summary` return from a snapshot; (d) then delete the `IDockerClient`/`DockerSwarmService` registration (`ServiceCollectionExtensions.cs:1021-1082`). I'm not blind-shipping (c)/(d): a wrong mapping silently returns partial/empty data to the cluster dashboard, and I can't verify the frontend contract or snapshot coverage headless. Happy to implement it once we confirm the consumed fields + snapshot coverage (or if we're fine dropping/synthesizing the `daemon` view). **Item 3** (MCP `/mcp` auth) still open.
Author
Owner

Item 2 (GameServer JWT forgery) — merged to master via PR #158 (commit f68ba51).

The game WebSocket now derives userId only from a token that cryptographically validates against the Keycloak JWKS (signature + issuer + audience + lifetime); forged/expired/wrong-party/unsigned tokens resolve to unauthenticated (spectator) instead of an impersonated sub. Validation runs once at connect (never on the game loop) and is local crypto against cached keys; a startup pre-warm (JwksPrewarmService) ensures even the first connect never waits on a key fetch. 12 unit tests.

Remaining on this epic:

  • Item 1 (API → Docker 2375) — scoped in the comment above; needs the frontend-contract check + possible docker-monitor snapshot extension before the read-path can be safely moved off the Docker client. Not a blind swap.
  • Item 3 (MCP /mcp auth + stop trusting caller-supplied userId) — still open; needs to establish who calls /mcp today (adding auth could break the MCP client integration if it doesn't present a Keycloak token).

Keeping open for 1 & 3.

**Item 2 (GameServer JWT forgery) — merged to `master`** via PR #158 (commit `f68ba51`). The game WebSocket now derives `userId` only from a token that cryptographically validates against the Keycloak JWKS (signature + issuer + audience + lifetime); forged/expired/wrong-party/unsigned tokens resolve to unauthenticated (spectator) instead of an impersonated `sub`. Validation runs once at connect (never on the game loop) and is local crypto against cached keys; a startup pre-warm (`JwksPrewarmService`) ensures even the first connect never waits on a key fetch. 12 unit tests. **Remaining on this epic:** - **Item 1** (API → Docker `2375`) — scoped in the comment above; needs the frontend-contract check + possible docker-monitor snapshot extension before the read-path can be safely moved off the Docker client. Not a blind swap. - **Item 3** (MCP `/mcp` auth + stop trusting caller-supplied `userId`) — still open; needs to establish who calls `/mcp` today (adding auth could break the MCP client integration if it doesn't present a Keycloak token). Keeping open for 1 & 3.
Author
Owner

Item 3 (MCP auth) — scoped; blocked on one external-integration decision

Verified the shape so the decision is teed up:

  • SpikerSoft.AI.MCPServer is an HTTP-hosted MCP server — AddMcpServer().WithHttpTransport() + app.MapMcp("/mcp") (ModelContextProtocol.AspNetCore 1.3.0), health at /api/healthz. No UseAuthentication/UseAuthorization, no [Authorize] on /mcp.
  • It's reached by external AI-assistant clients, not internal services — the only in-repo reference is docker-compose.yml (deployment); nothing in the codebase calls /mcp.
  • Tools authenticate to the SpikerSoft API via a service account (SpikerSoftAuthenticationServiceAuthenticatedSpikerSoftApiClient), and act on a caller-supplied userId — that's the impersonation lever: whoever reaches /mcp can pass any user id.

Why it can't be blind-shipped: the two halves are coupled — deriving the acting user from an authenticated principal (fixing the caller-supplied userId) requires first authenticating /mcp, and turning on auth breaks every MCP client that doesn't present a token. ModelContextProtocol.AspNetCore supports OAuth/bearer for HTTP transport, so the real question is a deployment one:

How should MCP clients authenticate? (a) per-user OAuth (each client acts as its own Keycloak user → derive userId from the token, drop the caller-supplied one — the correct end state); (b) a shared bearer/service credential on /mcp (closes anonymous access but keeps caller-supplied userId — partial); (c) network-restrict /mcp (no public exposure) as an interim.

I can implement (a) or (b) on a call, but it needs the MCP-client auth model decided first (and a smoke test that the configured clients still connect). Flagging rather than guessing.

### Item 3 (MCP auth) — scoped; blocked on one external-integration decision Verified the shape so the decision is teed up: - `SpikerSoft.AI.MCPServer` is an **HTTP-hosted** MCP server — `AddMcpServer().WithHttpTransport()` + `app.MapMcp("/mcp")` (`ModelContextProtocol.AspNetCore` 1.3.0), health at `/api/healthz`. **No `UseAuthentication`/`UseAuthorization`, no `[Authorize]` on `/mcp`.** - It's reached by **external AI-assistant clients**, not internal services — the only in-repo reference is `docker-compose.yml` (deployment); nothing in the codebase calls `/mcp`. - Tools authenticate to the SpikerSoft API via a **service account** (`SpikerSoftAuthenticationService` → `AuthenticatedSpikerSoftApiClient`), and act on a **caller-supplied `userId`** — that's the impersonation lever: whoever reaches `/mcp` can pass any user id. **Why it can't be blind-shipped:** the two halves are coupled — deriving the acting user from an authenticated principal (fixing the caller-supplied `userId`) requires first authenticating `/mcp`, and turning on auth **breaks every MCP client that doesn't present a token**. `ModelContextProtocol.AspNetCore` supports OAuth/bearer for HTTP transport, so the real question is a deployment one: > **How should MCP clients authenticate?** (a) per-user OAuth (each client acts as its own Keycloak user → derive `userId` from the token, drop the caller-supplied one — the correct end state); (b) a shared bearer/service credential on `/mcp` (closes anonymous access but keeps caller-supplied `userId` — partial); (c) network-restrict `/mcp` (no public exposure) as an interim. I can implement (a) or (b) on a call, but it needs the MCP-client auth model decided first (and a smoke test that the configured clients still connect). Flagging rather than guessing.
Author
Owner

Item 1 (API → Docker 2375) — done, merged via PR #162 (0811a2f).

Resolved the way this ticket asks (remove, not re-point) after confirming the earlier "needs frontend-contract verification" blocker was empty:

  • /api/DockerSwarm/* is read-only and consumed by nobody — Angular calls it 0 times (the live cluster dashboard runs on ClusterHub + /api/Cluster, fed by the docker-monitor Redis snapshot), and no MCP tool calls it (server has only CalendarTools; the generated client's methods are dead).
  • Removed DockerSwarmController, its query/handler, IDockerSwarmService/DockerSwarmService (the client that connected to unauthenticated tcp://…:2375 with 2375/2376/host.docker.internal fallbacks + a blocking startup probe), the IDockerClient DI wiring, and the now-dead Docker.DotNet ref in SpikerSoft.Business.
  • The DockerMonitor worker keeps its own Docker client (the one service meant to talk to Docker, publishing the snapshot). DTOs kept in SpikerSoft.Data (inert; still referenced by the generated MCP client). Full Tests.Unit build clean; 83 Cluster tests pass.

The API tier no longer has any Docker-daemon dependency — matching the .claude/rules/api-conventions.md invariant ("the API has NO Docker socket and must never get one").

Epic status: item 1 (#162), item 2 (#158, GameServer JWT). Item 3 (MCP /mcp auth + caller-supplied userId) remains open — blocked on the MCP-client auth-model decision (per-user OAuth vs shared bearer vs network-restrict). Keeping this open for item 3.

**Item 1 (API → Docker 2375) — done, merged via PR #162 (`0811a2f`).** Resolved the way this ticket asks (remove, not re-point) after confirming the earlier "needs frontend-contract verification" blocker was empty: - `/api/DockerSwarm/*` is read-only and consumed by **nobody** — Angular calls it 0 times (the live cluster dashboard runs on `ClusterHub` + `/api/Cluster`, fed by the docker-monitor Redis snapshot), and no MCP tool calls it (server has only `CalendarTools`; the generated client's methods are dead). - Removed `DockerSwarmController`, its query/handler, `IDockerSwarmService`/`DockerSwarmService` (the client that connected to unauthenticated `tcp://…:2375` with `2375/2376/host.docker.internal` fallbacks + a blocking startup probe), the `IDockerClient` DI wiring, and the now-dead `Docker.DotNet` ref in `SpikerSoft.Business`. - The **DockerMonitor worker keeps its own Docker client** (the one service meant to talk to Docker, publishing the snapshot). DTOs kept in `SpikerSoft.Data` (inert; still referenced by the generated MCP client). Full `Tests.Unit` build clean; 83 Cluster tests pass. The API tier no longer has any Docker-daemon dependency — matching the `.claude/rules/api-conventions.md` invariant ("the API has NO Docker socket and must never get one"). **Epic status:** item 1 ✅ (#162), item 2 ✅ (#158, GameServer JWT). **Item 3** (MCP `/mcp` auth + caller-supplied `userId`) remains open — blocked on the MCP-client auth-model decision (per-user OAuth vs shared bearer vs network-restrict). Keeping this open for item 3.
Author
Owner

Board-sweep status (2026-07-22): items 1+2 merged+verified (Docker 2375 removed #162; GameServer JWKS #158). REMAINING: item 3 — SpikerSoft.AI.MCPServer/Program.cs still has NO inbound authN middleware.

Board-sweep status (2026-07-22): items 1+2 merged+verified (Docker 2375 removed #162; GameServer JWKS #158). REMAINING: item 3 — SpikerSoft.AI.MCPServer/Program.cs still has NO inbound authN middleware.
Sign in to join this conversation.