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.
A single Express.js gateway that:
- Authenticates users via Google OAuth2 (domain-whitelisted)
- Authorizes access per-route using Google Groups membership
- Reverse-proxies requests to internal services based on hostname
- Injects verified user identity headers into proxied requests
- Enforces HTTPS and handles domain aliasing/redirects
- 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-Groupsheaders 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
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)
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-sessionwith 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
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-Idheader — generate if missing, forward to backend)
- Every log line includes:
- 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)
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-Afterheader when circuit is open
- Connection pooling: Use
undicior Node's nativehttp.AgentwithkeepAlive: trueand configurablemaxSocketsper target - Graceful shutdown: Drain in-flight requests (configurable timeout, default 20s), stop accepting new connections, close session store connection
- 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 RequestswithRetry-Afterheader - Default: 100 requests/minute per user (configurable)
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
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
- 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/configendpoint (auth-protected, admin-only) that shows active route config (redacting secrets)
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/
| 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 |
# 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)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)
- Unauthenticated request → store original URL in session → redirect to
/auth/google - Google OAuth callback → validate email domain against whitelist → fetch Google Groups → create session → redirect to stored URL
- Session stores:
{ email, displayName, groups, authenticatedAt } - Logout clears session and redirects to a configurable post-logout URL
- Route config specifies
allowedGroupsper host - No
allowedGroupsor empty array = any authenticated user - User's groups (from Google Directory API at login) checked against allowed list
- Denied → 403 JSON response with reason
- Resolve target from route config by matching
req.hostname - Inject
X-User-Email,X-User-Display-Name,X-Google-Groups,X-Request-Idheaders changeOrigin: true(Host header matches target)- WebSocket upgrade support (configurable per route)
- Strip/rewrite path prefixes (configurable per route)
- 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
- Node.js 22+ (use native fetch, structuredClone, etc.)
- Full ESM (
"type": "module"in package.json) - TypeScript strict mode
- No
anytypes — useunknownand 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)