Skip to content

Instantly share code, notes, and snippets.

@tjhanley
Created May 29, 2026 16:38
Show Gist options
  • Select an option

  • Save tjhanley/323c672900054b7e5dc88c381621cdcc to your computer and use it in GitHub Desktop.

Select an option

Save tjhanley/323c672900054b7e5dc88c381621cdcc to your computer and use it in GitHub Desktop.
Prompt: Rebuild internal auth proxy gateway (improved)

Build an Internal Auth Proxy Gateway

Build a TypeScript authentication and authorization reverse proxy that gates access to internal services behind Google OAuth2 and group-based permissions. This is a ground-up rebuild of an existing system — keep the proven concept but improve on architecture, configurability, observability, and resilience.


Core Concept

A single Express.js gateway that:

  1. Authenticates users via Google OAuth2 (domain-whitelisted)
  2. Authorizes access per-route using Google Groups membership
  3. Reverse-proxies requests to internal services based on hostname
  4. Injects verified user identity headers into proxied requests
  5. Enforces HTTPS and handles domain aliasing/redirects

What the Original Did Well (Keep These)

  • Host-based routing: Incoming hostname determines which backend service receives the request
  • User context propagation: Injects X-User-Email, X-User-Display-Name, X-Google-Groups headers into proxied requests so backends get verified identity without their own auth
  • Google Groups authorization: Per-domain access control lists using Google Groups membership
  • WebSocket proxying: Full duplex support through the proxy
  • Graceful shutdown: SIGTERM handler with drain timeout
  • HTTPS enforcement: HTTP → HTTPS redirects in production
  • Domain aliasing: Redirect legacy/CNAME domains to canonical hostnames

What to Improve

1. Configuration-Driven Routing (was hardcoded)

The original had a static TypeScript object mapping hostnames to backends — adding a service required a code change and redeploy.

Build instead:

# config/routes.yaml
routes:
  - host: "go.team.example.com"
    target: "http://go-links-api.internal-apps.svc.cluster.local"
    aliases: ["go.example.com"]        # optional domain aliases that redirect here
    websocket: true                     # default: false
    allowedGroups:                      # omit = any authenticated user
      - "developers@example.com"
      - "devops@example.com"
    stripPrefix: ""                     # optional path prefix stripping
    healthCheck:                        # optional backend health monitoring
      path: "/healthz"
      intervalSeconds: 30

  - host: "metabase.team.example.com"
    target: "https://metabase.prod.example.com"
    allowedGroups: []                   # empty = any authenticated user

redirects:
  - from: "old.example.com"
    to: "https://new.team.example.com"
    permanent: true
  • Load from YAML/JSON file path specified by env var (e.g. PROXY_CONFIG_PATH)
  • Validate config on startup with a JSON schema — fail fast on invalid config
  • Support live reload via SIGHUP or file watcher (optional, but design for it)

2. Proper Session Store (was cookie-only)

The original used cookie-session (entire session serialized into the cookie). This limits session size, can't be revoked server-side, and the original code had TODO comments about moving to DB storage.

Build instead:

  • Use express-session with a pluggable store
  • Support Redis (connect-redis) as the default session store
  • Fall back to in-memory store in development (with a warning log)
  • Configure via env vars: SESSION_STORE=redis, REDIS_URL=redis://...
  • Session TTL: configurable (default 24h), sliding expiration on activity

3. Observability (was minimal logging only)

The original had basic Winston logging but no metrics, no tracing, no health endpoints.

Build instead:

  • Structured JSON logging (pino or winston with JSON transport)
    • Every log line includes: timestamp, level, requestId, userEmail, targetHost, method, path, statusCode, durationMs
    • Correlation ID propagation (X-Request-Id header — generate if missing, forward to backend)
  • Prometheus metrics endpoint (/metrics, excluded from auth):
    • proxy_requests_total (labels: method, target_host, status_code)
    • proxy_request_duration_seconds (histogram, labels: method, target_host)
    • proxy_active_connections (gauge)
    • proxy_auth_attempts_total (labels: result: success|failure|unauthorized)
    • proxy_backend_health (gauge per target, 0/1)
  • Health endpoints (excluded from auth):
    • GET /healthz — liveness (always 200 if process is running)
    • GET /readyz — readiness (checks session store connectivity, config loaded)

4. Resilience (had none)

The original had no circuit breaking, no timeouts, no retry logic.

Build instead:

  • Per-route proxy timeouts: Configurable in route config (default 30s)
  • Circuit breaker per backend target:
    • Track error rate over sliding window
    • Open circuit after threshold (e.g. 50% errors in 10s window)
    • Half-open after cooldown, allow single probe request
    • Return 503 with Retry-After header when circuit is open
  • Connection pooling: Use undici or Node's native http.Agent with keepAlive: true and configurable maxSockets per target
  • Graceful shutdown: Drain in-flight requests (configurable timeout, default 20s), stop accepting new connections, close session store connection

5. Rate Limiting (had none)

  • Per-user rate limiting using a sliding window
  • Configurable globally and per-route
  • Store counters in Redis (same instance as sessions) or in-memory
  • Return 429 Too Many Requests with Retry-After header
  • Default: 100 requests/minute per user (configurable)

6. Security Hardening

The original had basic security headers but no CSP, no CSRF, no request size limits.

Add:

  • Helmet.js for comprehensive security headers (CSP, HSTS, X-Frame-Options, etc.)
  • Request body size limit: Configurable per-route (default 10MB)
  • CORS: Configurable per-route (default: same-origin only)
  • IP allowlisting: Optional per-route (for extra-sensitive services)
  • Audit logging: Log all authorization decisions (who accessed what, allowed/denied) to a separate audit log stream

7. Testing (had 4 tests)

Build with comprehensive tests:

  • Unit tests for every middleware (auth, authz, redirect, rate limit, circuit breaker)
  • Integration tests for the full proxy pipeline using supertest
  • Mock Google OAuth and Directory API responses
  • Test circuit breaker state transitions
  • Test rate limiting behavior
  • Test WebSocket proxying
  • Test graceful shutdown behavior
  • Target >90% coverage

8. Developer Experience

  • Local dev mode: Skip OAuth, use a mock user identity (configurable via env)
  • CLI flags or env vars for all configuration
  • Docker Compose setup with Redis for local development
  • Hot reload in dev via tsx --watch
  • Config validation on startup with clear error messages for missing/invalid config
  • /debug/config endpoint (auth-protected, admin-only) that shows active route config (redacting secrets)

Architecture

src/
├── index.ts                    # Entry point, startup, shutdown
├── app.ts                      # Express app assembly
├── config/
│   ├── loader.ts               # Load and validate route config from YAML
│   ├── schema.ts               # JSON Schema for config validation
│   └── env.ts                  # Environment variable parsing with defaults
├── auth/
│   ├── strategy.ts             # Google OAuth2 passport strategy
│   ├── session.ts              # Session store setup (Redis / in-memory)
│   ├── groups.ts               # Google Groups API client
│   └── middleware.ts           # Authentication + authorization middleware
├── proxy/
│   ├── handler.ts              # Reverse proxy setup and request forwarding
│   ├── router.ts               # Host-based target resolution from config
│   ├── headers.ts              # User identity header injection
│   └── circuit-breaker.ts      # Per-target circuit breaker
├── middleware/
│   ├── rate-limiter.ts         # Per-user rate limiting
│   ├── request-id.ts           # X-Request-Id generation/propagation
│   ├── logging.ts              # Structured access logging
│   ├── security.ts             # Helmet, CORS, body limits
│   └── redirect.ts             # HTTPS enforcement, domain aliases
├── observability/
│   ├── metrics.ts              # Prometheus metrics registry + collectors
│   └── health.ts               # /healthz and /readyz handlers
├── types/
│   └── index.ts                # Shared types, express augmentation
└── __tests__/
    ├── auth/
    ├── proxy/
    ├── middleware/
    └── integration/

Tech Stack

Concern Package Why
HTTP framework express 5.x Stable, mature, massive middleware ecosystem
Reverse proxy http-proxy-middleware 3.x Proven, WebSocket support, flexible routing
Authentication passport + passport-google-oauth20 Standard OAuth2 flow
Sessions express-session + connect-redis Server-side sessions, revocable
Logging pino + pino-http Fast structured JSON logging
Metrics prom-client Prometheus-native, standard in K8s
Security helmet Comprehensive security headers
Rate limiting rate-limiter-flexible Redis-backed, sliding window
Config js-yaml + ajv YAML config with JSON Schema validation
Testing vitest + supertest Fast, ESM-native, HTTP integration tests
TypeScript typescript 5.x Strict mode, ESM output

Environment Variables

# Required
GOOGLE_CLIENT_ID=                    # Google OAuth2 client ID
GOOGLE_CLIENT_SECRET=                # Google OAuth2 client secret
SESSION_SECRET=                      # Session encryption key
GOOGLE_DIRECTORY_API_URL=            # Google Directory API base URL

# Required in production
REDIS_URL=redis://redis:6379        # Redis connection string

# Optional
PROXY_CONFIG_PATH=./config/routes.yaml  # Route config file path
PORT=6370                               # Listen port
NODE_ENV=production                     # Environment
SESSION_TTL_HOURS=24                    # Session lifetime
RATE_LIMIT_RPM=100                      # Global rate limit (requests/min/user)
PROXY_TIMEOUT_MS=30000                  # Default proxy timeout
SHUTDOWN_TIMEOUT_MS=20000               # Graceful shutdown drain timeout
WHITELIST_DOMAINS=example.com,dev.example.com  # Allowed email domains
LOG_LEVEL=info                          # Logging level
MOCK_USER_EMAIL=dev@example.com         # Dev mode: skip OAuth, use this identity
TRUST_PROXY=true                        # Trust X-Forwarded-* headers (for load balancers)

Request Flow

Client Request
  │
  ├─ GET /healthz or /readyz → Health handler (no auth)
  ├─ GET /metrics → Prometheus metrics (no auth)
  ├─ /auth/* → OAuth flow (login, callback, logout)
  │
  └─ All other routes:
       │
       ├─ Request ID middleware (generate/propagate X-Request-Id)
       ├─ Security headers (Helmet)
       ├─ Redirect middleware (HTTP→HTTPS, domain aliases)
       ├─ Rate limiter (per-user, 429 if exceeded)
       ├─ Session middleware
       ├─ Authentication check (→ redirect to /auth/google if not authed)
       ├─ Authorization check (→ 403 if group mismatch)
       ├─ Access logging (start timer)
       ├─ Circuit breaker check (→ 503 if open)
       ├─ Reverse proxy (forward to target, inject user headers)
       └─ Access logging (log response with duration)

Key Behaviors to Implement

Auth Flow

  1. Unauthenticated request → store original URL in session → redirect to /auth/google
  2. Google OAuth callback → validate email domain against whitelist → fetch Google Groups → create session → redirect to stored URL
  3. Session stores: { email, displayName, groups, authenticatedAt }
  4. Logout clears session and redirects to a configurable post-logout URL

Authorization

  • Route config specifies allowedGroups per host
  • No allowedGroups or empty array = any authenticated user
  • User's groups (from Google Directory API at login) checked against allowed list
  • Denied → 403 JSON response with reason

Proxy Behavior

  • Resolve target from route config by matching req.hostname
  • Inject X-User-Email, X-User-Display-Name, X-Google-Groups, X-Request-Id headers
  • changeOrigin: true (Host header matches target)
  • WebSocket upgrade support (configurable per route)
  • Strip/rewrite path prefixes (configurable per route)

Circuit Breaker

  • Per-target sliding window (10s default)
  • Opens after error rate exceeds threshold (50% default, min 5 requests)
  • Half-open after cooldown (30s default) — allows 1 probe request
  • Emits metrics on state changes

Constraints

  • Node.js 22+ (use native fetch, structuredClone, etc.)
  • Full ESM ("type": "module" in package.json)
  • TypeScript strict mode
  • No any types — use unknown and narrow
  • All config validated at startup — fail fast, don't silently default
  • Stateless horizontally — all state in Redis (sessions, rate limits)
  • Docker-ready: single Dockerfile, non-root user, multi-stage build
  • No vendor lock-in beyond Google OAuth (which is the auth provider by design)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment