feat: secure microservices gateway + EdDSA internal auth + refresh rotation#10
Merged
Conversation
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>
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Microservices split (apps/gateway + apps/api)
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 injectingX-Internal-Auth.apps/api(renamed fromapps/back): Express + Sequelize. Private — only reachable throughinternal-network. Drops public auth routes, exposes/internal/auth/validateplus CRUD routes gated byrequireInternalAuth.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 oninternal-network(internal: true), gateway straddles both networks; only gateway and front publish ports. Hardened Dockerfiles (node:22-slim, HUSKY=0,npm prune --omit=dev, noNODE_TLS_REJECT_UNAUTHORIZEDbypass).Security hardening
typ+jti— two independent HMAC secrets (JWT_ACCESS_SECRET,JWT_REFRESH_SECRET); each token carriestyp(verifier rejects cross-type) and a randomjti.refresh_token_familytable tracks every issued refresh JWT; rotation marks the priorjtiused and emits a new sibling in the same family; presenting an already-rotatedjtirevokes the entire family and clears the cookie. Logout best-effort revokes the active family.INTERNAL_JWT_PRIVATE_KEYand signs; api only holdsINTERNAL_JWT_PUBLIC_KEYand verifies. Privilege separation: a compromised api cannot mint internal tokens. Migrated fromjsonwebtokentojose@5because jsonwebtoken's algorithm allowlist does not include EdDSA.NODE_TLS_REJECT_UNAUTHORIZED=0.Breaking changes
Environment variables
JWT_SECRETJWT_ACCESS_SECRET+JWT_REFRESH_SECRET(independent strong randoms)JWT_REFRESH_SECRET(old single var)INTERNAL_JWT_SECRETINTERNAL_JWT_PRIVATE_KEY(gateway only) +INTERNAL_JWT_PUBLIC_KEY(api only), Ed25519 PEMNODE_JWT_*aliasesJWT_*Database
public.refresh_token_familydeclared bydb/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.sqlrewritten to be idempotent so it no longer aborts the init chain on fresh databases.Stack
apps/back/**renamed toapps/api/**(history preserved as renames).package.jsonscripts:dev:back/test:back/build:back→dev:api+dev:gateway,test:api+test:gateway+test:internal-auth,build:api+build:gateway.http://localhost:3100).compose.yamlservices renamed (back→api+ newgateway), postgres pinned topostgres:16(avoids the postgres:18 data-dir layout change).Test plan
npm installsucceeds (addsjose@5).npm run testfrom root: 90+ passing across api, gateway and libs/internal-auth.npm run test:frontstill passes.npm run build:apiandnpm run build:gatewaysucceed.docs/SECURITY.md, populate.env, thendocker compose --env-file .env up -d postgresdb api gateway.curl http://localhost:<gateway>/api/v1/healthreturns 200.curl -c c.txt -X POST /api/v1/auth/loginwith seed creds (test@local.com / 123456) setsAuthorizationheader +refreshTokencookie.curl -b c.txt /api/v1/userreturns the user list (verifies full gateway → EdDSA → api proxy).Set-Cookie: refreshToken=(reuse detection).libs/internal-auth.docker logs apishows noNODE_TLS_REJECT_UNAUTHORIZEDwarnings.Manual steps required
Before the first deploy, follow
docs/SECURITY.md:openssl rand -base64 64) and setJWT_ACCESS_SECRETandJWT_REFRESH_SECRET.\n-escaped value and setINTERNAL_JWT_PRIVATE_KEY(gateway env only) andINTERNAL_JWT_PUBLIC_KEY(api env only).db/20.refresh_token_family.sqlmanually.apps/front/src/environments/environment.prod.ts(gitignored) to point at the gateway path (/api/v1/).🤖 Generated with Claude Code