Skip to content

Commit 40debf5

Browse files
authored
Merge pull request #1 from babs/feat/security-hardening
feat: security hardening with replay store, metrics, and rate limits
2 parents 1f432dd + 88cdacd commit 40debf5

26 files changed

Lines changed: 1635 additions & 116 deletions

.github/workflows/release.yml

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ jobs:
1414
build-image:
1515
name: "Build OCI image"
1616
runs-on: ${{ matrix.os }}
17+
# Build job only reads source and pushes images — no release creation
18+
# happens here, so contents:read is sufficient.
1719
permissions:
18-
contents: write
20+
contents: read
1921
packages: write
20-
attestations: write
2122
id-token: write
2223

2324
strategy:
@@ -115,13 +116,14 @@ jobs:
115116
name: "Merge platform images into one"
116117
needs: build-image
117118
runs-on: ubuntu-latest
119+
# contents:write is required to create the GitHub Release below.
120+
# packages:write lets docker manifest push into GHCR.
118121
permissions:
119122
contents: write
120123
packages: write
121-
attestations: write
122-
id-token: write
123124

124125
steps:
126+
- uses: actions/checkout@v4
125127
- name: Login to GitHub Container Registry
126128
uses: docker/login-action@v3.4.0
127129
with:
@@ -148,7 +150,17 @@ jobs:
148150
done
149151
done
150152
151-
- uses: "marvinpinto/action-automatic-releases@latest"
152-
with:
153-
repo_token: "${{ secrets.GITHUB_TOKEN }}"
154-
prerelease: false
153+
# Use the preinstalled gh CLI instead of a third-party release action.
154+
# Only runs on tag pushes; skipped on workflow_dispatch against main.
155+
- name: Create GitHub Release
156+
if: github.ref_type == 'tag'
157+
env:
158+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
159+
run: |
160+
if ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
161+
gh release create "${GITHUB_REF_NAME}" \
162+
--title "${GITHUB_REF_NAME}" \
163+
--generate-notes
164+
else
165+
echo "Release ${GITHUB_REF_NAME} already exists; skipping."
166+
fi

Dockerfile

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
1919
-X 'main.ProjectURL=${PROJECT_URL}'" \
2020
-o mcp-auth-proxy ./main.go
2121

22-
FROM debian:bookworm-slim
22+
# distroless/static-debian13:nonroot ships ca-certificates and runs as UID
23+
# 65532 by default — no shell, no apt, minimal attack surface. The static
24+
# Go binary (CGO_ENABLED=0) needs nothing else.
25+
FROM gcr.io/distroless/static-debian13:nonroot
2326

2427
ARG BUILD_TIMESTAMP="1970-01-01T00:00:00+00:00"
2528
ARG COMMIT_HASH="00000000-dirty"
@@ -31,13 +34,8 @@ LABEL org.opencontainers.image.created=${BUILD_TIMESTAMP}
3134
LABEL org.opencontainers.image.version=${VERSION}
3235
LABEL org.opencontainers.image.revision=${COMMIT_HASH}
3336

34-
# Security: install CA certs for TLS, then run as non-root
35-
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
36-
&& rm -rf /var/lib/apt/lists/* \
37-
&& groupadd -r app && useradd -r -g app -s /usr/sbin/nologin app
38-
3937
COPY --from=builder /app/mcp-auth-proxy /usr/local/bin/mcp-auth-proxy
4038

41-
USER app:app
42-
EXPOSE 8080
43-
ENTRYPOINT ["mcp-auth-proxy"]
39+
USER nonroot:nonroot
40+
EXPOSE 8080 9090
41+
ENTRYPOINT ["/usr/local/bin/mcp-auth-proxy"]

README.md

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,13 @@
3333
- Federates authentication to **any OIDC-compliant IdP** via auto-discovery
3434
(no vendor lock-in, zero IdP-specific code)
3535
- Reverse-proxies to your **unmodified** upstream MCP server
36-
- **Stateless** — no database, no Redis, no sticky sessions; scale
36+
- **Stateless by default** — no database, no sticky sessions; scale
3737
horizontally by sharing one secret
38+
- **Optional Redis** unlocks strict single-use authorization codes and
39+
refresh-rotation reuse detection (OAuth 2.1 §6.1) across replicas
40+
- Per-IP **rate limiting** on every pre-auth endpoint, `email_verified`
41+
enforcement on the IdP id_token, and **Prometheus metrics** for every
42+
security-relevant event
3843

3944
---
4045

@@ -116,6 +121,9 @@ All configuration via **environment variables**. Bold = required.
116121
| `REVOKE_BEFORE` | (empty) | RFC3339 cutoff for bulk revocation (applies to access *and* refresh tokens) |
117122
| `PKCE_REQUIRED` | `true` | Set `false` for clients that omit PKCE (Cursor, MCP Inspector, ChatGPT) |
118123
| `SHUTDOWN_TIMEOUT` | `120s` | Graceful shutdown; must be ≥ longest expected SSE stream |
124+
| `REDIS_URL` | (empty) | Optional. Enables single-use authz codes + refresh-rotation reuse detection. `rediss://` for TLS |
125+
| `REDIS_KEY_PREFIX` | `mcp-auth-proxy:` | Key prefix for shared Redis; set to empty to opt out of namespacing |
126+
| `RATE_LIMIT_ENABLED` | `true` | Per-IP rate limiting on pre-auth endpoints. Disable only behind a WAF that already enforces it |
119127

120128
---
121129

@@ -132,7 +140,7 @@ required to operate this service.
132140
|---|---|---|
133141
| Client registration | `client_id` | 24h |
134142
| Authorize session | IdP `state` parameter | 10min |
135-
| Authorization code | `code` parameter | 5min |
143+
| Authorization code | `code` parameter | 60s |
136144
| Access token | Opaque bearer | 1h |
137145
| Refresh token | Opaque bearer | 7d |
138146

@@ -156,7 +164,8 @@ rollout notes, and K8s deployment shape.
156164
| `GET /authorize` | PKCE authorization endpoint |
157165
| `GET /callback` | OIDC callback from the IdP |
158166
| `POST /token` | `authorization_code` + `refresh_token` grants |
159-
| `GET /healthz` | Liveness / readiness probe |
167+
| `GET /healthz` | Liveness probe (always 200 while the process is up) |
168+
| `GET /readyz` | Readiness probe (503 when Redis is configured but unreachable) |
160169
| `*` (any other path) | Reverse-proxied to `UPSTREAM_MCP_URL` after Bearer check |
161170
| `GET /metrics` (port 9090) | Prometheus metrics |
162171

@@ -166,10 +175,20 @@ rollout notes, and K8s deployment shape.
166175

167176
- **Structured logs** — zap, JSON in production, console when run on a TTY.
168177
Every request carries a `request_id` in the log and in the
169-
`X-Request-Id` response header.
170-
- **Metrics** — Prometheus on a dedicated port so it's never exposed
171-
through the public listener.
172-
- **Health**`GET /healthz` returns `200 OK`.
178+
`X-Request-Id` response header. Inbound `X-Request-Id` is stripped to
179+
prevent log-forgery.
180+
- **Metrics** — Prometheus on a dedicated port (`:9090`, separate listener,
181+
not exposed through the public router). Alongside the default Go
182+
runtime counters, the proxy emits:
183+
- `mcp_auth_tokens_issued_total{grant_type}` — access tokens minted
184+
- `mcp_auth_access_denied_total{reason}` — group / `email_unverified`
185+
/ `refresh_family_revoked` rejections
186+
- `mcp_auth_replay_detected_total{kind}``code` or `refresh` replays
187+
caught by the Redis-backed store
188+
- `mcp_auth_rate_limited_total{endpoint}` — httprate 429s by endpoint
189+
- `mcp_auth_clients_registered_total` — RFC 7591 registrations
190+
- **Health**`GET /healthz` (liveness) and `GET /readyz` (reflects
191+
Redis reachability when `REDIS_URL` is set).
173192

174193
---
175194

build.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/bin/bash
1+
#!/usr/bin/env bash
22
set -euo pipefail
33

44
MODULE=$(grep module go.mod | cut -d\ -f2)

config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ type Config struct {
2222
RevokeBefore time.Time // tokens issued before this time are rejected (zero = disabled)
2323
PKCERequired bool // require PKCE on /authorize (default true, set false for Cursor/MCP Inspector)
2424
ShutdownTimeout time.Duration // graceful shutdown deadline; raise to drain long-lived SSE streams
25+
RedisURL string // optional; when set, enables single-use authorization codes (replay protection)
26+
RedisKeyPrefix string // prefix applied to every Redis key (for shared-Redis deployments); default "mcp-auth-proxy:"
27+
RateLimitEnabled bool // enable per-IP rate limiting on pre-auth endpoints (default true)
2528
}
2629

2730
func Load() (*Config, error) {
@@ -102,6 +105,16 @@ func Load() (*Config, error) {
102105
}
103106
}
104107

108+
c.RedisURL = os.Getenv("REDIS_URL")
109+
// LookupEnv so operators can opt into an empty prefix explicitly
110+
// (REDIS_KEY_PREFIX="") without tripping the default.
111+
if v, ok := os.LookupEnv("REDIS_KEY_PREFIX"); ok {
112+
c.RedisKeyPrefix = v
113+
} else {
114+
c.RedisKeyPrefix = "mcp-auth-proxy:"
115+
}
116+
c.RateLimitEnabled = strings.ToLower(os.Getenv("RATE_LIMIT_ENABLED")) != "false"
117+
105118
return c, nil
106119
}
107120

config/config_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,41 @@ func TestLoad_ShutdownTimeout_Negative(t *testing.T) {
303303
t.Errorf("error %q should mention positive requirement", err)
304304
}
305305
}
306+
307+
func TestLoad_RedisKeyPrefix_Default(t *testing.T) {
308+
setAllRequired(t)
309+
cfg, err := Load()
310+
if err != nil {
311+
t.Fatalf("unexpected error: %v", err)
312+
}
313+
if cfg.RedisKeyPrefix != "mcp-auth-proxy:" {
314+
t.Errorf("RedisKeyPrefix default = %q, want %q", cfg.RedisKeyPrefix, "mcp-auth-proxy:")
315+
}
316+
}
317+
318+
func TestLoad_RedisKeyPrefix_Custom(t *testing.T) {
319+
setAllRequired(t)
320+
t.Setenv("REDIS_KEY_PREFIX", "tenant-42:")
321+
cfg, err := Load()
322+
if err != nil {
323+
t.Fatalf("unexpected error: %v", err)
324+
}
325+
if cfg.RedisKeyPrefix != "tenant-42:" {
326+
t.Errorf("RedisKeyPrefix = %q, want %q", cfg.RedisKeyPrefix, "tenant-42:")
327+
}
328+
}
329+
330+
// TestLoad_RedisKeyPrefix_ExplicitEmpty verifies that setting REDIS_KEY_PREFIX
331+
// to the empty string is respected — operators can opt out of namespacing
332+
// without being overridden back to the default.
333+
func TestLoad_RedisKeyPrefix_ExplicitEmpty(t *testing.T) {
334+
setAllRequired(t)
335+
t.Setenv("REDIS_KEY_PREFIX", "")
336+
cfg, err := Load()
337+
if err != nil {
338+
t.Fatalf("unexpected error: %v", err)
339+
}
340+
if cfg.RedisKeyPrefix != "" {
341+
t.Errorf("RedisKeyPrefix = %q, want empty (explicit opt-out)", cfg.RedisKeyPrefix)
342+
}
343+
}

0 commit comments

Comments
 (0)