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:
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."
GameServer WebSocket trusts unsigned/forged JWTs (reads sub with no signature/issuer/audience/expiry check) → full account impersonation.
MCP server is unauthenticated and stamps a caller-supplied userId → read/write any user's data.
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
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.
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.
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.
### 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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:
tcp://…:2375— Docker API = root on the host. This violatesapi-conventions.md: "The API has NO Docker socket and must never get one — cluster topology reads come from the Redis snapshot viaIClusterTopologyService."subwith no signature/issuer/audience/expiry check) → full account impersonation.userId→ read/write any user's data.Evidence:
SpikerSoft.Api/Extensions/ServiceCollectionExtensions.cs:1021-1082(Dockertcp://…:2375)SpikerSoft.GameServer/Network/WebSocketHandler.cs:49-58SpikerSoft.AI.MCPServer/Program.cs:79;CalendarTools.cs→AuthenticatedSpikerSoftApiClient.cs:44-48Fix: Remove the API's
IDockerClient/DockerSwarmServiceand read topology from the Redis snapshot; validate GameServer JWTs against Keycloak JWKS (signature + issuer + audience + lifetime); require auth on/mcpand derive the acting user from the authenticated principal.Acceptance criteria: API has no Docker client; GameServer rejects forged tokens;
/mcprequires auth and ignores client-supplied user IDs.Effort: M · Related: #401 (SignalR hubs — separate anonymous-hub gap).
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 provenAuthentication:*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:
DockerSwarmControlleris read-only — 4 GETs (services,services/{id},daemon,summary), all viaGetDockerSwarmServicesQuery. No management/writes, so removing the Docker client doesn't drop any control-plane feature. Good.IClusterTopologyService.GetDockerSwarmServicesQueryHandlersources fromIDockerSwarmService(thetcp://…:2375client) and returns the richDockerSwarmServicesResponseDto/DockerSwarmServiceDtoshape.IClusterTopologyService.GetTopologyAsync()returns a different abstraction (ClusterTopologyDto:Nodes/Workloads/Instances), and there's no topology equivalent for thedaemonendpoint.DockerSwarmServiceDtofields 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 mapClusterWorkloadDto[] → DockerSwarmServiceDto[]+ decide whatdaemon/summaryreturn from a snapshot; (d) then delete theIDockerClient/DockerSwarmServiceregistration (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
daemonview). Item 3 (MCP/mcpauth) still open.Item 2 (GameServer JWT forgery) — merged to
mastervia PR #158 (commitf68ba51).The game WebSocket now derives
userIdonly 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 impersonatedsub. 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:
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./mcpauth + stop trusting caller-supplieduserId) — still open; needs to establish who calls/mcptoday (adding auth could break the MCP client integration if it doesn't present a Keycloak token).Keeping open for 1 & 3.
Item 3 (MCP auth) — scoped; blocked on one external-integration decision
Verified the shape so the decision is teed up:
SpikerSoft.AI.MCPServeris an HTTP-hosted MCP server —AddMcpServer().WithHttpTransport()+app.MapMcp("/mcp")(ModelContextProtocol.AspNetCore1.3.0), health at/api/healthz. NoUseAuthentication/UseAuthorization, no[Authorize]on/mcp.docker-compose.yml(deployment); nothing in the codebase calls/mcp.SpikerSoftAuthenticationService→AuthenticatedSpikerSoftApiClient), and act on a caller-supplieduserId— that's the impersonation lever: whoever reaches/mcpcan 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.AspNetCoresupports OAuth/bearer for HTTP transport, so the real question is a deployment one: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 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 onClusterHub+/api/Cluster, fed by the docker-monitor Redis snapshot), and no MCP tool calls it (server has onlyCalendarTools; the generated client's methods are dead).DockerSwarmController, its query/handler,IDockerSwarmService/DockerSwarmService(the client that connected to unauthenticatedtcp://…:2375with2375/2376/host.docker.internalfallbacks + a blocking startup probe), theIDockerClientDI wiring, and the now-deadDocker.DotNetref inSpikerSoft.Business.SpikerSoft.Data(inert; still referenced by the generated MCP client). FullTests.Unitbuild clean; 83 Cluster tests pass.The API tier no longer has any Docker-daemon dependency — matching the
.claude/rules/api-conventions.mdinvariant ("the API has NO Docker socket and must never get one").Epic status: item 1 ✅ (#162), item 2 ✅ (#158, GameServer JWT). Item 3 (MCP
/mcpauth + caller-supplieduserId) 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.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.