Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,79 @@ DATABASE_URL=
# Leave blank to disable admin access entirely.
SUPER_ADMIN_IDS=

# --- Authentication stack (v1.8) ---
# Which login stack runs. Leave blank unless you are actively rolling out
# SuperTokens - blank means the stack RackStack has always used.
#
# passport (default) Exactly as before. SuperTokens is not initialised,
# and its SDK is not even loaded. Upgrading to v1.8 without
# setting this changes nothing about how anyone logs in.
# dual Both login paths work; a session from either is accepted.
# This is where the rollout happens. Existing login cookies
# keep working for their full 90-day life.
# supertokens NOT USABLE YET. SuperTokens only; the old OAuth routes are
# switched off - but the client still points its login buttons
# at those routes and has no SuperTokens login flow, so the
# buttons silently do nothing and nobody can sign in. Existing
# sessions keep working, which is what makes it easy to miss.
# `dual` is the intended resting state for v1.8. See
# docs/authentication-methods.md Phase 5.
#
# Rolling back is setting this back to passport (or blanking it) and
# restarting. Existing login cookies stay valid through every transition in
# both directions, so nobody is logged out by changing this.
#
# An unrecognised value stops the server on purpose rather than quietly
# falling back, so a typo can't look like a completed rollout.
# AUTH_MODE=

# Where the SuperTokens core container is reachable. Only read when AUTH_MODE
# is dual or supertokens. Two things the SuperTokens core is fussy about:
# the scheme must be postgresql:// (it rejects postgres://, unlike DATABASE_URL
# above, which accepts either), and it needs its OWN database - point it at a
# separate database on the same Postgres server, never at the rackstack one.
# SUPERTOKENS_CONNECTION_URI=http://supertokens:3567

# API key for the SuperTokens core. NOT optional for any core that is not on
# loopback: a core without one serves its whole API unauthenticated, and that
# API mints a session for ANY user id - including every value in
# SUPER_ADMIN_IDS - without a single request reaching RackStack. The server
# refuses to start in dual/supertokens mode if the core is remote and this is
# unset. Generate with `openssl rand -hex 32`, and set the same value as
# API_KEYS on the core container. Do not publish the core's port either.
# SUPERTOKENS_API_KEY=

# The public origin this server is reached at, e.g. https://rackstack.example.com
# Only needed when AUTH_MODE is dual or supertokens. Leave blank and it is
# derived from GITHUB_CALLBACK_URL / DISCORD_CALLBACK_URL below, which is
# correct for almost everyone - set it explicitly only if you sit behind a
# proxy where the public origin differs from your OAuth callback host.
# PUBLIC_ORIGIN=

# --- Discord OAuth ---
# Create an app at https://discord.com/developers/applications
# -> OAuth2 -> add a redirect matching DISCORD_CALLBACK_URL below
#
# Enabling SuperTokens later: ADD a second redirect URI,
# https://your-domain.example.com/auth/callback/discord, alongside the one
# below. Discord permits several. Keep both - passport uses the one below and
# keeps working. Leave DISCORD_CALLBACK_URL itself unchanged.
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_CALLBACK_URL=https://your-domain.example.com/auth/discord/callback

# --- GitHub OAuth ---
# Create an app at https://github.com/settings/developers -> New OAuth App
# -> Authorization callback URL must match GITHUB_CALLBACK_URL below
#
# Enabling SuperTokens later: WIDEN the app's registered Authorization callback
# URL to the parent path https://your-domain.example.com/auth . GitHub requires
# a redirect URL to be a subdirectory of what is registered, and SuperTokens'
# /auth/callback/github is NOT a subdirectory of /auth/github/callback - so
# without this, every SuperTokens GitHub login fails with a redirect_uri
# mismatch. Widening is additive and reversible: both paths then qualify and
# passport keeps working throughout. Do it BEFORE setting AUTH_MODE, and leave
# GITHUB_CALLBACK_URL below unchanged - it tells passport where to send people.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_CALLBACK_URL=https://your-domain.example.com/auth/github/callback
Expand Down
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,79 @@
# Changelog

## v1.8.0

SuperTokens as an alternative login stack, behind a switch that is off by
default.

**Upgrading changes nothing.** `AUTH_MODE` defaults to `passport`, which is
byte-for-byte the login stack that shipped in v1.7 — SuperTokens is not
initialised, its middleware is not mounted, and the SDK is not even imported.
Everything below is inert until an operator opts in.

- **`AUTH_MODE` switch**: `passport` (default), `dual` (both stacks live,
sessions from either accepted), `supertokens` (legacy OAuth routes not
registered). An unrecognised value stops the container on purpose rather
than quietly serving the legacy stack — a typo that looked like a completed
rollout would be discovered weeks later.
- **Your account and save are untouched.** RackStack identifies players by
`users.id` (`provider:providerId`, e.g. `github:37058311`). SuperTokens
issues its own internal id and that id is mapped *onto* the existing one, so
`session.getUserId()` returns exactly what the old JWT carried. No save is
rewritten, no id renumbered, no foreign key moved, and `SUPER_ADMIN_IDS`
keeps working. There is no "migrate your account" step for players.
- **Nobody is logged out, in either direction.** Existing cookies are 90-day
JWTs and stay valid through every mode change. Rollback is setting
`AUTH_MODE` back to `passport` and restarting; unlike the v1.7 Postgres
migration there is no one-way door, because changing the mode rewrites no
data.
- **Shadow-mode verification gate**: `npm run shadow:check` audits every
stored identity and reports whether the id mapping would resolve correctly,
before anything is switched on. Read-only — safe against production with
players online, and against a restored export on a laptop. Cutover is gated
on a 100% match. An empty run reports `NOT RUN` and exits non-zero rather
than passing on having read nothing.
- **Two new repository functions**, `getIdentity` and `setSupertokensUserId`,
implemented on both the SQLite and Postgres drivers. `npm run test:all`
still runs the whole suite against both backends.
- **Operator runbook**: `docs/supertokens-rollout-runbook.md` covers the OAuth
redirect widening (additive and reversible — nothing is removed, so passport
keeps working), standing up the core, the shadow gate, cutover and rollback.

**Hardening from the pre-merge review.** A whole-branch security and code
review was run before merge. Everything below was found and fixed while
`AUTH_MODE` still defaulted to `passport`, so none of it was ever live:

- An **authentication bypass** in SuperTokens' stock `signinup` endpoint, which
accepted a caller-supplied OAuth token as proof of identity. Any GitHub token
able to read `/user` — including one from an unrelated app, or a leaked PAT —
would have authenticated as its owner. Only the browser redirect flow is
accepted now.
- The **SuperTokens core shipped unauthenticated with its port published.** An
open core will mint a session for any user id, `SUPER_ADMIN_IDS` included,
without any request reaching RackStack. The port is no longer published, the
image is pinned, and the server refuses to start against a remote core with
no `SUPERTOKENS_API_KEY`.
- **`npm run shadow:check` was not read-only** despite saying so: it ran the
schema migration on load, which on SQLite renames colliding usernames and
rebuilds a table. Pointed at a pre-v1.7 export it quietly rewrote it. It now
opens its own read-only connection and issues one SELECT.
- The gate could not see **identity rows orphaned from `users`** (a player who
can never log in) and reported PASS for a run that compared nothing.
- **`POST /auth/signout`** revoked the SuperTokens session but left the legacy
cookie, so the "logged out" user stayed authenticated. Removed;
`/auth/logout` clears both.
- Two **simultaneous first logins** raced on Postgres and failed the login.

**Not yet run anywhere.** Shadow mode has not been run against production
identities, and no cutover has happened.

**`dual` is the intended resting state for this release.** The server side of
`supertokens`-only mode is complete and tested, but the client has never been
taught to talk to SuperTokens: the login buttons point at the passport routes,
which that mode does not register, so logins would silently do nothing — and
there is no token refresh. Both are frontend work that has not been started.
The runbook and `docs/authentication-methods.md` cover this in full.

## v1.7.0

Postgres support, with automatic migration from SQLite.
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT"
# only on a pushed vX.Y.Z tag, and docker/metadata-action derives the
# published image's version label from that tag - so this literal only
# affects locally-built images, not what GHCR publishes.
LABEL org.opencontainers.image.version="1.7.0"
LABEL org.opencontainers.image.version="1.8.0"

VOLUME ["/app/data"]
EXPOSE 3000
Expand Down
73 changes: 72 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ You only need to configure the provider(s) you actually want to use - leave
the other's ID/SECRET blank in `.env` and its login button will just fail if
clicked (harmless, but you may want to hide it later).

### If you plan to enable SuperTokens later

SuperTokens serves its OAuth callbacks at `/auth/callback/<provider>`, while
the paths above are `/auth/<provider>/callback`. GitHub requires a redirect
URL's path to be a **subdirectory** of the registered callback URL, and
`/auth/callback/github` is *not* a subdirectory of `/auth/github/callback` - so
left alone, every SuperTokens GitHub login would fail with a `redirect_uri`
mismatch while passport logins carried on working.

The fix is one-time, **additive and reversible**: widen the GitHub OAuth app's
registered callback to the parent path `https://<your-domain>/auth`. Both paths
are then subdirectories of it and both work simultaneously - nothing is
removed, so passport keeps working before, during and after. Discord permits
multiple redirect URIs, so simply add
`https://<your-domain>/auth/callback/discord` alongside the existing one.

Leave `GITHUB_CALLBACK_URL` / `DISCORD_CALLBACK_URL` pointing at the existing
`/auth/<provider>/callback` paths - those tell passport where to send people,
and passport's paths have not changed.

**Do this before setting `AUTH_MODE`, not at the same time.** It is safe to do
days early. Full sequence in
[`docs/supertokens-rollout-runbook.md`](docs/supertokens-rollout-runbook.md).

## 2. Configure

```bash
Expand Down Expand Up @@ -197,9 +221,56 @@ variable actually lives for your deployment:
| Deployment | Where to blank `DATABASE_URL` |
|---|---|
| Unraid / plain `docker run` | The container's Variable in the Unraid UI (or the `-e` flag) |
| Docker Compose | `.env` — `docker-compose.yml` reads it via `${DATABASE_URL:-...}` |
| Docker Compose | `.env` — `docker-compose.yml` reads it via `${DATABASE_URL-...}` (no colon, so `DATABASE_URL=` means "blank", not "unset" — that is what makes the documented rollback work) |
| Local `npm start` | `.env` |

### Authentication stack (`AUTH_MODE`)

RackStack is gaining SuperTokens as an alternative login stack, rolled out
behind a switch rather than swapped in one step. **If you do nothing, nothing
changes** — the default is the passport + JWT stack that has always shipped,
and the SuperTokens SDK is not even loaded.

| `AUTH_MODE` | Behaviour |
|---|---|
| *(blank)* or `passport` | Default. Exactly as before; SuperTokens is not initialised. |
| `dual` | Both login paths live, sessions from either accepted. Where the rollout happens. |
| `supertokens` | ⚠️ **Not usable yet** — SuperTokens only; the legacy OAuth routes are not registered, and the client has no SuperTokens login flow, so **nobody can log in**. See below. |

Two properties worth knowing before you touch it:

- **Changing this never logs anyone out.** Existing login cookies stay valid
for their full 90 days through every transition, in both directions, so
rollback is just setting it back to `passport` and restarting.
- **A typo stops the container** instead of quietly falling back to the
default. `AUTH_MODE=supertoken` would otherwise serve the legacy stack while
looking like a finished rollout — the kind of thing you'd discover weeks
later, from the wrong symptom.

- **`dual` is the intended resting state.** `supertokens`-only mode is *not*
usable yet: `client/src/Login.jsx` points its buttons at the passport routes,
which that mode does not register, so they silently do nothing and no one can
sign in. Existing sessions keep working via the JWT fallback, which is what
makes it easy to miss. There is no token refresh in the client either. Both
are frontend work that has not been started — see
[`docs/authentication-methods.md`](./docs/authentication-methods.md) Phase 5.

`SUPERTOKENS_CONNECTION_URI` points at the SuperTokens core container and is
read only in `dual`/`supertokens`. That core needs its **own** database on
your Postgres server, separate from the rackstack one.

**Set `SUPERTOKENS_API_KEY`, and do not publish the core's port.** A
SuperTokens core with no API key serves its entire API unauthenticated, and
that API can mint a session for *any* user id — including every value in
`SUPER_ADMIN_IDS`, without any request reaching RackStack. The server refuses
to start in `dual`/`supertokens` if the core is not on loopback and no key is
set. Generate one with `openssl rand -hex 32` and set it as `API_KEYS` on the
core and `SUPERTOKENS_API_KEY` here.

Full walkthrough — including the OAuth redirect-URL change that has to happen
*before* `dual`, and the verification gate before cutover — is in
[`docs/supertokens-rollout-runbook.md`](./docs/supertokens-rollout-runbook.md).

**Cutting a release:** bump `version` in `package.json` (the single release-
version authority - `client/vite.config.js` reads it for `__APP_VERSION__`,
and `client/package.json`'s own version is deliberately not kept in sync),
Expand Down
75 changes: 75 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,81 @@ services:
POSTGRES_DB: rackstack
volumes:
- ./pgdata:/var/lib/postgresql/data
# Creates the SEPARATE database the SuperTokens core needs (v1.8).
# Scripts here run only when the data directory is first initialised,
# so on an existing deployment this file does nothing and the database
# must be created by hand - see docs/supertokens-rollout-runbook.md.
# Creating it unconditionally is harmless: an unused empty database
# costs nothing, and having it ready removes a step from the rollout.
- ./docker/init-supertokens-db.sql:/docker-entrypoint-initdb.d/10-supertokens.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rackstack"]
interval: 10s
timeout: 5s
retries: 5

# The SuperTokens core (v1.8). Opt-in: it belongs to a compose profile, so
# `docker compose up` does NOT start it and the default passport auth stack
# is unaffected. Start it only when you are rolling SuperTokens out:
#
# docker compose --profile supertokens up -d
#
# It is deliberately not a `depends_on` of rackstack. In the default
# AUTH_MODE the server never contacts it, and making every deployment wait
# on a container it will not use is a good way to turn an unrelated
# SuperTokens problem into a RackStack outage.
supertokens:
# Pinned, not :latest. This container signs and validates every session in
# supertokens/dual mode; silently pulling a new major on the next `up -d`
# is not a risk worth taking for the component that IS the trust root.
image: registry.supertokens.io/supertokens/supertokens-postgresql:9.3
container_name: rackstack-supertokens
profiles: ["supertokens"]
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
# NOTE: the port is deliberately NOT published to the host.
#
# A SuperTokens core with no API key serves its entire API unauthenticated,
# and that API is the trust root of the whole stack: POST /recipe/session
# mints a session for ANY userId, and because the user-id mapping makes
# session.getUserId() return `github:37058311` verbatim, anyone who can
# reach this port can mint a valid RackStack session for any value in
# SUPER_ADMIN_IDS - which are deterministic and effectively public. That
# bypasses every guard in the Express app without touching it.
#
# rackstack reaches the core over the compose network as `supertokens:3567`
# and does not need a published port. To poke it by hand, use
# docker compose exec supertokens bash -c ':> /dev/tcp/127.0.0.1/3567'
# rather than re-adding a `ports:` mapping.
environment:
# Its OWN database, never the rackstack one - SuperTokens manages its
# own schema and must not share a database with application tables.
#
# The scheme must be postgresql://. The SuperTokens core rejects
# postgres:// at startup. (This is specific to the core: RackStack's
# own DATABASE_URL above accepts either - v1.7 verified that directly,
# against an earlier claim to the contrary.)
POSTGRESQL_CONNECTION_URI: postgresql://rackstack:rackstack@postgres:5432/supertokens
# Defence in depth behind the unpublished port: even on the compose
# network, the core should not answer to anything that merely reaches it.
# Must match SUPERTOKENS_API_KEY on the rackstack service. Generate with
# `openssl rand -hex 32` and put it in .env.
#
# Deliberately NOT the ${VAR:?error} form. Compose interpolates the whole
# file before it filters by profile, so a required-variable error here
# would break plain `docker compose up` for every deployment that never
# touches SuperTokens. The requirement is enforced in server code
# instead (see initSuperTokens), where it can fire only for operators who
# have actually opted in.
API_KEYS: ${SUPERTOKENS_API_KEY:-}
healthcheck:
test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/3567' || exit 1"]
interval: 10s
timeout: 5s
retries: 5

rackstack:
build: .
container_name: rackstack
Expand Down Expand Up @@ -42,5 +111,11 @@ services:
# tell you to do - would silently keep pointing at Postgres. Without the
# colon, only a genuinely unset variable takes the default.
DATABASE_URL: ${DATABASE_URL-postgresql://rackstack:rackstack@postgres:5432/rackstack}
# v1.8 auth stack selector. Defaults to the legacy passport + JWT stack,
# so `docker compose up` behaves exactly as it did before v1.8. Same
# non-colon ${VAR-default} form and the same reason: `AUTH_MODE=` in
# .env must mean "the default", not "unset, so substitute the default"
# - which is what the colon form would do, defeating the rollback.
AUTH_MODE: ${AUTH_MODE-passport}
env_file:
- .env
Loading
Loading