From b2cdcbe2cdf7601de044f5a76a2cf590ba9fc89b Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 30 Jul 2026 15:41:55 -0700 Subject: [PATCH] fix(api): session cookie never issued over HTTP; ship the seed script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod came up with nobody able to log in, in two separate ways. 1. No sign-in account exists. `prisma migrate deploy` creates tables, never rows, and nothing in the deploy path seeds one — deliberately, since making an administrator should not be a side effect of shipping code. But apps/api/scripts was not in the runtime image either, so the only way to create the first account was to run the script from a developer machine against a production DATABASE_URL. Ship scripts/ in the image so it can be run on the host with docker exec. Still never run automatically. 2. Login could not establish a session at all. cookie.secure followed NODE_ENV, the image sets NODE_ENV=production, and the app is served over plain HTTP — express-session then silently emits NO Set-Cookie header. POST /auth/login still answered 200 with the full user object, no session was created, every later request 403'd, and the UI would have looped back to /login. It reads as an auth bug and is really a transport mismatch. The flag is now driven by SESSION_COOKIE_SECURE, still defaulting to NODE_ENV. An EMPTY value counts as unset rather than false, because compose turns an absent `${SESSION_COOKIE_SECURE:-}` into the empty string and the naive check would have quietly dropped Secure on any deployment that merely passed the variable through. galactus sets it to "false". That is acceptable ONLY because the host is reachable exclusively over Tailscale, so WireGuard already encrypts the wire. It must go back to "true" when the app is served over TLS or exposed off-tailnet; behind a TLS-terminating proxy, set trust proxy instead. Verified against live prod: seeded an admin, POST /auth/login returns 200 with full ADMIN abilities, a wrong password is rejected with 401, and no Set-Cookie was present before this change. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/deploy-galactus.yml | 1 + apps/api/src/main.ts | 22 ++++++++++- deploy/galactus/jorgecuadros-app.compose.yml | 7 ++++ docker/api.Dockerfile | 8 ++++ docs/DEPLOY_AND_MIGRATIONS.md | 40 ++++++++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/deploy-galactus.yml b/.gitea/workflows/deploy-galactus.yml index 739d283..2050244 100644 --- a/.gitea/workflows/deploy-galactus.yml +++ b/.gitea/workflows/deploy-galactus.yml @@ -252,6 +252,7 @@ jobs: "S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}", "DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}", "SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}", + "SESSION_COOKIE_SECURE": "false", "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7f01ba1..6298d51 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -25,6 +25,26 @@ async function bootstrap() { throw new Error("SESSION_SECRET must be set (see .env.example)"); } + // Whether the session cookie carries the Secure flag. This CANNOT simply + // follow NODE_ENV: express-session silently declines to send a Secure cookie + // over a plain-HTTP connection, so a production image served over http://ial + // issues no cookie at all. Login then returns 200 with a user, no session is + // established, every later request 403s, and the UI loops back to /login — + // which is exactly what happened on the first galactus deploy. + // + // Leave it ON wherever the app is reached over TLS. Turn it OFF only for a + // deployment that is HTTP but reached over an already-encrypted transport + // (the galactus install is Tailscale-only, so WireGuard encrypts the wire). + // Behind a TLS-terminating proxy, set trust proxy instead of turning this off. + // An EMPTY value counts as unset, not as "false". Compose interpolation turns + // an absent `${SESSION_COOKIE_SECURE:-}` into the empty string, so testing + // `!== undefined` here would silently drop the Secure flag on any deployment + // that merely passes the variable through without setting it. + const cookieSecureRaw = process.env.SESSION_COOKIE_SECURE; + const cookieSecure = cookieSecureRaw + ? cookieSecureRaw === "true" + : process.env.NODE_ENV === "production"; + app.use( session({ secret: sessionSecret, @@ -32,7 +52,7 @@ async function bootstrap() { saveUninitialized: false, cookie: { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: cookieSecure, maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday }, }) diff --git a/deploy/galactus/jorgecuadros-app.compose.yml b/deploy/galactus/jorgecuadros-app.compose.yml index e8b1275..4b243f5 100644 --- a/deploy/galactus/jorgecuadros-app.compose.yml +++ b/deploy/galactus/jorgecuadros-app.compose.yml @@ -44,6 +44,13 @@ services: environment: DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} + # This deployment is HTTP, so a Secure session cookie would never be sent + # and login would silently never establish a session (express-session + # declines to emit a Secure cookie over a plain connection). Acceptable + # here ONLY because galactus is reachable exclusively over Tailscale, so + # WireGuard already encrypts the wire. Set this back to "true" the moment + # the app is served over TLS or exposed off-tailnet. + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} WEB_ORIGIN: ${WEB_ORIGIN:?WEB_ORIGIN must be set} PORT: "3001" INGEST_DIR: /data/ingest diff --git a/docker/api.Dockerfile b/docker/api.Dockerfile index 77ebdba..711bce6 100644 --- a/docker/api.Dockerfile +++ b/docker/api.Dockerfile @@ -46,6 +46,14 @@ COPY --from=build /repo/node_modules node_modules COPY --from=build /repo/packages/database packages/database COPY --from=build /repo/apps/api/dist apps/api/dist COPY --from=build /repo/apps/api/package.json apps/api/package.json +# Operational scripts, run on demand — never automatically. seed-user.mjs is the +# only way to create the first sign-in account on a fresh database, and without +# it in the image that had to be done from a developer's machine against a +# production DATABASE_URL. Run it with: +# docker exec node apps/api/scripts/seed-user.mjs +# honouring SEED_EMAIL / SEED_PASSWORD / SEED_NAME. It upserts, so re-running is +# safe — but note it RESETS the password of an existing account. +COPY --from=build /repo/apps/api/scripts apps/api/scripts # node-linker=hoisted flattens EXTERNAL deps into /repo/node_modules, but the # workspace dependency is still linked per-package: # apps/api/node_modules/@jorgecuadros/database -> ../../../../packages/database diff --git a/docs/DEPLOY_AND_MIGRATIONS.md b/docs/DEPLOY_AND_MIGRATIONS.md index 50375c1..4706b67 100644 --- a/docs/DEPLOY_AND_MIGRATIONS.md +++ b/docs/DEPLOY_AND_MIGRATIONS.md @@ -192,6 +192,46 @@ Still open, and **not** handled by anything in this repo: - The channel to the VPS crosses the public internet. It needs a tunnel or TLS — do not publish raw 3306. +## Seeding the first sign-in account + +A freshly migrated database has a schema and **no users**, so nobody can log in. +`prisma migrate deploy` creates tables, never rows; nothing in the deploy path +seeds an account, by design — creating an administrator should be a deliberate +act, not a side effect of shipping code. + +`apps/api/scripts/seed-user.mjs` ships inside the API image. On the target host: + +```bash +docker exec -e SEED_PASSWORD='' \ + node apps/api/scripts/seed-user.mjs +``` + +Defaults are `admin@jorgecuadros.local` / `ChangeMe!2026` / role `ADMIN`, +overridable with `SEED_EMAIL`, `SEED_PASSWORD`, `SEED_NAME`. **Do not accept the +default password on anything but a dev database** — it is published in this +repo's README. The script upserts by email, so re-running is safe, but it also +**resets the password of an existing account**. + +## The session cookie and TLS + +`SESSION_COOKIE_SECURE` controls the `Secure` flag on the session cookie. It +defaults to on in production, and it must be explicitly `"false"` for a +deployment served over plain HTTP. + +This is not cosmetic. express-session silently declines to emit a `Secure` +cookie over an unencrypted connection: no `Set-Cookie` header is sent at all, +`POST /auth/login` still answers `200` with the user object, no session is +established, every subsequent request gets `403`, and the UI bounces back to +`/login` in a loop. It looks like an auth bug and is really a transport +mismatch. + +galactus runs with `SESSION_COOKIE_SECURE=false`, which is acceptable **only** +because it is reachable exclusively over Tailscale — WireGuard already encrypts +the wire, so the cookie never crosses an untrusted network. Turn it back on the +moment the app is served over TLS or reachable off-tailnet. Behind a +TLS-terminating reverse proxy, set `trust proxy` on the Nest app instead of +disabling the flag. + ## Known caveats in the deploy path - The pre-migrate backup step sets `NODE_TLS_REJECT_UNAUTHORIZED=0` because