Skip to content

feat: secure microservices gateway + EdDSA internal auth + refresh rotation#10

Merged
dherrero merged 6 commits into
mainfrom
feat/secure-microservices-gateway
May 16, 2026
Merged

feat: secure microservices gateway + EdDSA internal auth + refresh rotation#10
dherrero merged 6 commits into
mainfrom
feat/secure-microservices-gateway

Conversation

@dherrero
Copy link
Copy Markdown
Owner

Summary

Microservices split (apps/gateway + apps/api)

  • New apps/gateway: Express + http-proxy-middleware. Public service. Owns CORS, cookies, JWT issue/verify, login (delegates credential check to api), refresh flow, and proxies /api/v1/* to the api after injecting X-Internal-Auth.
  • apps/api (renamed from apps/back): Express + Sequelize. Private — only reachable through internal-network. Drops public auth routes, exposes /internal/auth/validate plus CRUD routes gated by requireInternalAuth.
  • New libs/internal-auth: JWT helpers (EdDSA after security commits) and Express middleware factory with explicit scopes (USER_REQUEST, AUTH_VALIDATE, REFRESH_LIFECYCLE).
  • compose.yaml: postgresdb + api on internal-network (internal: true), gateway straddles both networks; only gateway and front publish ports. Hardened Dockerfiles (node:22-slim, HUSKY=0, npm prune --omit=dev, no NODE_TLS_REJECT_UNAUTHORIZED bypass).

Security hardening

  • Access/refresh separation with typ + jti — two independent HMAC secrets (JWT_ACCESS_SECRET, JWT_REFRESH_SECRET); each token carries typ (verifier rejects cross-type) and a random jti.
  • Refresh-token family with reuse detection — new refresh_token_family table tracks every issued refresh JWT; rotation marks the prior jti used and emits a new sibling in the same family; presenting an already-rotated jti revokes the entire family and clears the cookie. Logout best-effort revokes the active family.
  • EdDSA (Ed25519) for internal-auth — gateway holds INTERNAL_JWT_PRIVATE_KEY and signs; api only holds INTERNAL_JWT_PUBLIC_KEY and verifies. Privilege separation: a compromised api cannot mint internal tokens. Migrated from jsonwebtoken to jose@5 because jsonwebtoken's algorithm allowlist does not include EdDSA.
  • TLS bypass removed from Dockerfiles. Operators must now provision valid CA bundles or proper internal certificates rather than disabling verification with NODE_TLS_REJECT_UNAUTHORIZED=0.

Breaking changes

Environment variables

Removed Replaced by
JWT_SECRET JWT_ACCESS_SECRET + JWT_REFRESH_SECRET (independent strong randoms)
JWT_REFRESH_SECRET (old single var) now distinct from access secret, no longer shared
INTERNAL_JWT_SECRET INTERNAL_JWT_PRIVATE_KEY (gateway only) + INTERNAL_JWT_PUBLIC_KEY (api only), Ed25519 PEM
NODE_JWT_* aliases normalised to JWT_*

Database

  • New table public.refresh_token_family declared by db/20.refresh_token_family.sql. Bundled with the postgres init scripts on a fresh volume; for existing environments apply it manually.
  • db/11.permission_enum_migration.sql rewritten to be idempotent so it no longer aborts the init chain on fresh databases.

Stack

  • apps/back/** renamed to apps/api/** (history preserved as renames).
  • package.json scripts: dev:back/test:back/build:backdev:api + dev:gateway, test:api + test:gateway + test:internal-auth, build:api + build:gateway.
  • Front proxy now points at the gateway (http://localhost:3100).
  • compose.yaml services renamed (backapi + new gateway), postgres pinned to postgres:16 (avoids the postgres:18 data-dir layout change).

Test plan

  • npm install succeeds (adds jose@5).
  • npm run test from root: 90+ passing across api, gateway and libs/internal-auth.
  • npm run test:front still passes.
  • npm run build:api and npm run build:gateway succeed.
  • Generate keys per docs/SECURITY.md, populate .env, then docker compose --env-file .env up -d postgresdb api gateway.
  • curl http://localhost:<gateway>/api/v1/health returns 200.
  • curl -c c.txt -X POST /api/v1/auth/login with seed creds (test@local.com / 123456) sets Authorization header + refreshToken cookie.
  • curl -b c.txt /api/v1/user returns the user list (verifies full gateway → EdDSA → api proxy).
  • Re-presenting the same refresh cookie after rotation returns 401 with Set-Cookie: refreshToken= (reuse detection).
  • Tampering with the access token (or trying to verify it on the api) fails — only the gateway can sign access tokens.
  • The api cannot mint internal tokens (no private key) — confirmed by the privilege-separation unit test in libs/internal-auth.
  • docker logs api shows no NODE_TLS_REJECT_UNAUTHORIZED warnings.

Manual steps required

Before the first deploy, follow docs/SECURITY.md:

  1. Generate two independent client secrets (e.g. openssl rand -base64 64) and set JWT_ACCESS_SECRET and JWT_REFRESH_SECRET.
  2. Generate the Ed25519 keypair:
    openssl genpkey -algorithm ed25519 -out internal_private.pem
    openssl pkey -in internal_private.pem -pubout -out internal_public.pem
  3. Convert each PEM to a single-line \n-escaped value and set INTERNAL_JWT_PRIVATE_KEY (gateway env only) and INTERNAL_JWT_PUBLIC_KEY (api env only).
  4. Verify the compose file does not leak the private key to the api service (it does not in this PR).
  5. For existing databases (not fresh volumes) run db/20.refresh_token_family.sql manually.
  6. Update apps/front/src/environments/environment.prod.ts (gitignored) to point at the gateway path (/api/v1/).

🤖 Generated with Claude Code

dherrero and others added 6 commits May 16, 2026 18:30
Move public token handling out of the business backend so new services
can join without sharing the public JWT secret.

- apps/gateway (new): public Express + http-proxy-middleware. Owns CORS,
  cookies, login (delegates credential check to api via a system-scoped
  internal JWT), refresh flow, and proxies /api/v1/* to the api after
  attaching X-Internal-Auth.
- apps/api (renamed from apps/back): private, Express + Sequelize, owns
  the DB and AbstractCrudService. Drops public auth routes, exposes
  /internal/auth/validate and CRUD routes gated by requireInternalAuth.
- libs/internal-auth (new): HS256 JWT helpers and Express middleware
  factory with scope (USER_REQUEST | AUTH_VALIDATE), issuer and
  audience claims.
- compose.yaml: postgresdb + api on internal-network (internal: true),
  gateway straddles both networks, only gateway and front publish
  ports. Postgres healthcheck wired to api depends_on.
- Hardened Dockerfiles for api and gateway following the node:22-slim
  pattern adopted on main (HUSKY=0, npm prune --omit=dev, no
  NODE_TLS_REJECT_UNAUTHORIZED bypass).
- nx scripts, tsconfig paths, root vitest aliases, front proxy.conf,
  CI workflow and .env.example rewired for the new layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t secrets

Hardens the gateway token surface against confusion and replay:

- Two independent HMAC secrets, JWT_ACCESS_SECRET and JWT_REFRESH_SECRET,
  so leaking one does not let the attacker mint the other.
- Each token carries an explicit `typ` claim ('access' | 'refresh') and
  the verifier rejects cross-type use (you can no longer present a
  refresh token as an access token or vice versa).
- Each token includes a random `jti` (UUID v4), which the upcoming
  refresh-family service (fix #2) uses to detect refresh-token reuse.
- TokenService exposes verifyAccessToken / verifyRefreshToken instead of
  the previous generic verifyToken; the gateway middleware uses the
  correct one in the access-path vs refresh-path.

Breaking changes:
- env: JWT_SECRET is removed; set JWT_ACCESS_SECRET and JWT_REFRESH_SECRET
  to distinct strong random values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce a server-side ledger of refresh tokens so a stolen refresh
token can no longer outlive a single use:

- New table public.refresh_token_family stores one row per refresh JWT
  ever issued (user_id, family_id, jti, parent_jti, used, revoked_at)
  with indexes for active jti lookup and family-wide revocation. Created
  by db/20.refresh_token_family.sql, modeled by RefreshTokenFamily.
- New RefreshTokenFamilyService in apps/api implements record, rotate,
  revokeByJti and revokeFamily. rotate() returns 'rotated' for first use,
  'family-revoked' if the chain was already revoked, and 'reused-revoked'
  if the same jti is presented twice — in which case the whole family is
  revoked atomically.
- New REFRESH_LIFECYCLE scope and gateway-only endpoints under
  /internal/refresh (record / rotate / revoke), all gated by
  requireInternalAuth on that scope.
- Gateway: ApiClient gains recordRefresh, rotateRefresh, revokeRefresh.
  Login mints a new familyId + jti and records it; the refresh path
  rotates via the api before issuing the new cookie; logout best-effort
  revokes the entire family. Reuse → 401 + cookie cleared.
- Tests cover service rotation outcomes, the middleware rotation path
  with fetch mocked, and the reuse-revoked clear-cookie flow.

Breaking changes:
- New table refresh_token_family must exist before the api boots; the
  bundled db/20.refresh_token_family.sql runs automatically via the
  postgres docker init scripts on a fresh volume.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…paration

Replace the symmetric HS256 INTERNAL_JWT_SECRET shared between gateway
and api with an asymmetric EdDSA (Ed25519) keypair:

- Gateway holds INTERNAL_JWT_PRIVATE_KEY (signs gateway → api JWTs).
- API holds INTERNAL_JWT_PUBLIC_KEY (only verifies). Even if the api is
  fully compromised it cannot mint new tokens for other services.
- libs/internal-auth migrates from jsonwebtoken (which hardcodes its
  alg list and does not support EdDSA) to `jose@5`, which natively
  supports EdDSA on Node 18+. The lib's sign / verify functions become
  async; downstream callers in the gateway proxy router and ApiClient
  are awaited accordingly.
- PEM keys passed through env vars accept the `\n`-escaped form used by
  .env files; the lib normalises them to real newlines before importing.
- Tests cover round-trip sign/verify, mismatched key rejection, expiry,
  audience enforcement, `\n` normalisation, and the explicit assertion
  that holding only the public key prevents signing.

Breaking changes:
- env: INTERNAL_JWT_SECRET is removed. Generate a new keypair and set
  INTERNAL_JWT_PRIVATE_KEY (gateway only) and INTERNAL_JWT_PUBLIC_KEY
  (api only). See docs/SECURITY.md for the openssl one-liner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the trust model (gateway holds private key, api only public),
the access/refresh token split with typ + jti, refresh-token family
rotation and reuse detection, plus the openssl commands operators must
run before the first deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…res:16

Two operational fixes needed to bring the stack up cleanly:

- db/11.permission_enum_migration.sql used to recreate the
  permission_type enum unconditionally, which fails on a fresh schema
  (10.user.sql already declares the enum) and aborts the
  docker-entrypoint-initdb.d chain — preventing 20.refresh_token_family
  from ever being created. Rewritten as a no-op DO block that creates
  the type only when missing.
- compose.yaml pinned to postgres:16. postgres:18 changed its data
  directory layout (requires the parent /var/lib/postgresql mount) and
  refuses to start with the legacy /var/lib/postgresql/data mount used
  by this starter. Sticking to 16 keeps the existing operational story.

Smoke test passes: login through gateway returns access + refresh, the
gateway proxies /v1/user through to the api with X-Internal-Auth (EdDSA
verified), refresh rotation works, and presenting an already-rotated
refresh cookie triggers reuse-revoked (401 + cookie cleared).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dherrero dherrero merged commit fdb0528 into main May 16, 2026
@dherrero dherrero deleted the feat/secure-microservices-gateway branch May 19, 2026 08:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant