diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba44dd..06a05fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## v1.8.2 + +- **`npm run supertokens:check`** — a deployment preflight for the SuperTokens + rollout, to be run *before* `AUTH_MODE` is set. Verifies the core is + reachable, **requires authentication**, accepts your API key, and has its own + database rather than sharing RackStack's; also checks the public origin and + providers, and prints the exact redirect URLs to register. + + The authentication check is the one that earns its keep: a core running + without `API_KEYS` will mint a login session for any user id — including + every value in `SUPER_ADMIN_IDS` — without a request ever reaching RackStack, + so nothing about it fails visibly. `shadow:check` gates the data half of the + cutover; this gates the deployment half. + +- **The SuperTokens core version is now verified at boot.** `supertokens-node` + speaks one core-driver-interface version, and a core outside that window + starts cleanly, passes its health check, and then fails every login — the SDK + only notices from inside a request. RackStack now checks at startup and + refuses to boot with a message naming both versions. + + The bundled compose file pins the core's **major** (`:12`) rather than + `:latest` or a frozen patch: `:latest` would cross a major boundary + unannounced, which is the only place protocol support realistically changes, + while a frozen patch means a stale core signing every session. + ## v1.8.1 - **`npm run shadow:check` now names the database it audited**, both as a log diff --git a/Dockerfile b/Dockerfile index 3656bcb..9143946 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.8.1" +LABEL org.opencontainers.image.version="1.8.2" VOLUME ["/app/data"] EXPOSE 3000 diff --git a/docker-compose.yml b/docker-compose.yml index 1346f10..420ea23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,33 @@ services: # 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 + # + # The MAJOR is pinned; the minor and patch float. That is the deliberate + # middle ground between `:latest` and a fully-frozen tag: + # + # - `:latest` would pick up a new MAJOR unannounced, and the major + # boundary is the only place core-driver-interface support realistically + # changes. supertokens-node@24 speaks CDI 5.4 *only* - a single version, + # not a range - so the compatible window is narrow, and leaving it + # breaks every login rather than degrading. + # - Freezing at a patch would mean sitting on a stale core forever, + # missing security fixes on the component that signs every session. + # + # Within a major it is very safe: core 12 still serves CDI 2.7 through 5.5, + # so minor upgrades do not drop protocol support. + # + # Core 12 is also the FLOOR: cores 9.x/10.x top out at CDI 5.2 and 11.x at + # 5.3 - each of which runs, answers health checks, accepts its API key, and + # then fails every request. This file pinned 9.3 until that was caught. + # initSuperTokens now verifies the negotiated version at BOOT (the SDK only + # notices from inside a request, i.e. on the first login), and + # `npm run supertokens:check` verifies it before cutover. + # + # Docker Hub rather than registry.supertokens.io: the latter's certificate + # chains to ISRG Root YR, a new Let's Encrypt root that older CA bundles + # (Unraid's included) do not carry yet, giving + # `x509: certificate signed by unknown authority` on pull. Same image. + image: supertokens/supertokens-postgresql:12 container_name: rackstack-supertokens profiles: ["supertokens"] restart: unless-stopped diff --git a/docs/authentication-methods.md b/docs/authentication-methods.md index ec30502..1e97ec7 100644 --- a/docs/authentication-methods.md +++ b/docs/authentication-methods.md @@ -88,19 +88,19 @@ that is what bounds how far the rollout can go — see Phase 5. ## Phase 0 — Prerequisites -1. **v1.7 running in production on Postgres.** Status unconfirmed — the Unraid - box may still be on SQLite. From v1.8.1 the shadow report names the database - it read, so `npm run shadow:check` now tells you which. Note the SuperTokens - core needs its own **Postgres** database in Phase 2 regardless of what - RackStack itself uses, so a Postgres instance is required either way. See - [`postgres-migration-runbook.md`](./postgres-migration-runbook.md). +1. ~~v1.7 running in production on Postgres.~~ **DONE.** Confirmed 2026-08-08 + by the v1.8.1 shadow report, which names the database it read: + `postgres postgresql://rackstack_user@…:5432/rackstack`. The SuperTokens + core in Phase 2 needs its **own** database on that same instance — never the + `rackstack` one. 2. ~~A current production export supplied, for the shadow gate.~~ **Moot — satisfied a better way.** The gate was run directly on the Unraid container on 2026-08-08 (`GATE: PASS`, 6/6), which audits the live database rather than a copy of it. No export is needed. 3. **A backup**, taken the same way as for the Postgres migration. -**Gate:** 1 and 3. Phase 3 has already passed. +**Gate:** 3 (a backup). 1 is done and Phase 3 has already passed, so the only +work left before `dual` is Phases 1 and 2. ## Phase 1 — Widen the OAuth redirect URLs diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 9d824f1..ffe5692 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -54,8 +54,9 @@ worse than one that admits it has not: `GATE: PASS`. **Cutover to `AUTH_MODE=dual` is cleared.** - **No cutover has happened.** `AUTH_MODE` has never been anything but `passport` on any real deployment. -- **v1.7 has not been cut over on the Unraid box either.** The design gates - v1.8's rollout on v1.7 running in production, and that is still outstanding. +- ~~v1.7 has not been cut over on the Unraid box either.~~ **DONE** — + confirmed 2026-08-08 by the v1.8.1 shadow report naming the database it + read (`postgres postgresql://rackstack_user@…:5432/rackstack`). - **No SuperTokens core has been run against this code outside tests.** Part B is written from the documented configuration, not from a stood-up instance. - **`supertokens`-only mode cannot be used yet** — and the reason is bigger @@ -268,15 +269,38 @@ docker compose --profile supertokens up -d ``` **Unraid** — add a container from -`registry.supertokens.io/supertokens/supertokens-postgresql:9.3` (pin the tag; -this container signs every session, and a silent major upgrade is not a risk -worth taking). Set **two** variables, and **do not publish port 3567**: +`supertokens/supertokens-postgresql:12`. Set **two** variables, and **do not +publish port 3567**: ``` POSTGRESQL_CONNECTION_URI=postgresql://rackstack_user:PASSWORD@192.168.x.x:5432/supertokens API_KEYS= ``` +> **Two things about that image reference.** +> +> **Pin the major, let the minor float — `:12`, not `:latest` and not +> `:12.0.10`.** `:latest` would cross a major boundary unannounced, and that +> is the only place protocol support realistically changes; freezing a patch +> means sitting on a stale core forever. Within a major it is safe — core 12 +> still serves CDI 2.7 through 5.5. RackStack now verifies the negotiated +> version at boot, so if a core ever does drift out of range the container +> refuses to start and says so, rather than failing logins quietly. +> +> **Core 12 is a floor, not a preference.** `supertokens-node@24` speaks +> core-driver-interface 5.4 only. Cores 9.x and 10.x top out at CDI 5.2 and +> 11.x at 5.3 — each of which starts fine, answers its health check, accepts +> its API key, and then fails *every* request on a version mismatch. This +> runbook said `9.3` until that was caught; `npm run supertokens:check` (B3a) +> now verifies the negotiated version. +> +> **Docker Hub, not `registry.supertokens.io`.** Same image, but the +> SuperTokens registry's certificate chains to `ISRG Root YR`, a new Let's +> Encrypt root that older CA bundles — Unraid's included — do not carry yet. +> Pulling from it fails with `x509: certificate signed by unknown authority`. +> That is a trust-store gap on the puller, not an outage. See the quick +> reference if you want to fix the CA bundle instead. + > **The API key is not optional, and neither is keeping the port private.** > A SuperTokens core with no `API_KEYS` serves its entire API unauthenticated, > and that API is the trust root of the whole stack: it will mint a session for @@ -312,7 +336,30 @@ docker compose exec supertokens bash -c 'curl -s http://127.0.0.1:3567/hello' ``` Expect `Hello`. (Run from inside the container, since the port is deliberately -not published to the host.) If it does not respond, check the core's log for a connection +not published to the host.) + +### B3a. Run the preflight — this is the gate for Part B + +```bash +npm run supertokens:check +``` + +Read-only, and it does not read `AUTH_MODE` — the point is to verify the +deployment *before* you flip anything. It checks the five things that otherwise +only surface after cutover, one of which never surfaces at all: + +| Check | Why it is here | +|---|---| +| Core reachable | The `localhost`-from-inside-a-container mistake | +| **Core requires authentication** | **A core with no `API_KEYS` mints a session for any user id, `SUPER_ADMIN_IDS` included, without a request ever reaching RackStack. Nothing about this fails visibly.** | +| `SUPERTOKENS_API_KEY` accepted | A mismatch fails every login the moment `AUTH_MODE` is set | +| Core has its own database | Detects SuperTokens tables sitting inside the `rackstack` database | +| Providers + public origin | The two boot failures in D3 | + +It also prints the exact redirect URLs to register with each provider. + +**Gate: `PREFLIGHT: PASS` (exit 0).** A `FAIL` names what to change. This gates +the *deployment*; `shadow:check` (Part C) gates the *data*. Both must pass. If it does not respond, check the core's log for a connection error against the database from B1 — that is the overwhelmingly common cause. ### B4. Point RackStack at it — but do not switch yet @@ -545,3 +592,5 @@ gone wrong and will send you chasing the wrong problem. | Container won't start, wants `SUPERTOKENS_API_KEY` | Correct and deliberate. An unauthenticated core can mint a session for any user id, `SUPER_ADMIN_IDS` included. Set `API_KEYS` on the core and the same value here. | | `shadow:check` says the database predates the v1.7 split | You restored a pre-v1.7 export. Migrate it to v1.7 first, or point at the right database. | | `shadow:check` reports `ORPHAN` rows | An identity points at a user that does not exist; that player cannot log in. Investigate before cutting over — do not ignore it. | +| Pulling the core fails with `x509: certificate signed by unknown authority` | Not an outage. The SuperTokens registry chains to `ISRG Root YR`, a new Let's Encrypt root your CA bundle lacks. Pull `supertokens/supertokens-postgresql:12` from Docker Hub instead, or update the host's `ca-certificates`. Confirm which by running `openssl s_client -connect registry.supertokens.io:443 -servername registry.supertokens.io offered.includes(v)); + if (shared.length > 0) return shared; + + throw new Error( + `The SuperTokens core at ${connectionURI} speaks core-driver-interface ` + + `${offered[offered.length - 1]} at newest, but this SDK requires ` + + `${cdiSupported.join(' or ')}. The core would start and answer health checks while ` + + 'failing every login. Use a core image new enough for that interface - ' + + 'supertokens/supertokens-postgresql:12 or later at the time of writing.', + ); +} + export function disableStockSignOut(originalImplementation) { return { ...originalImplementation, signOutPOST: undefined }; } @@ -320,6 +375,12 @@ export async function initSuperTokens({ env = process.env, mode } = {}) { // hiccup into an outage. await assertCoreRejectsAnonymous({ connectionURI, hasKey: Boolean(env.SUPERTOKENS_API_KEY) }); + // Fail fast on a core too old (or too new) for this SDK. Without this the + // mismatch only surfaces on the first login attempt, because the SDK checks + // the version from inside a request - so the container would look healthy + // right up until a player reported they could not sign in. + await assertCoreSpeaksOurProtocol({ connectionURI, apiKey: env.SUPERTOKENS_API_KEY }); + initialised = true; // An operator who has just flipped AUTH_MODE needs to see that it took // effect, and needs to see it in the log rather than by inferring it from diff --git a/server/supertokens/preflight.js b/server/supertokens/preflight.js new file mode 100644 index 0000000..d3477fc --- /dev/null +++ b/server/supertokens/preflight.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node +// Phase 2 preflight: `npm run supertokens:check`. +// +// Verifies that a SuperTokens core is configured correctly BEFORE anyone sets +// AUTH_MODE. That ordering is the whole point - every check below is one an +// operator would otherwise only discover by flipping the switch and watching +// what breaks, and one of them (an unauthenticated core) is not something that +// breaks visibly at all. +// +// Deliberately does NOT read AUTH_MODE, and deliberately does not import +// server/db/index.js. It is a read-only diagnostic that must be safe to run at +// any point, including on a box still happily serving the legacy stack. +// +// Companion to `npm run shadow:check`, which gates the DATA half of the +// cutover. This gates the DEPLOYMENT half. + +import { buildProviders, resolvePublicOrigin, PROVIDER_IDS } from './providers.js'; +import { isLoopback } from './init.js'; + +const PASS = 'PASS'; +const FAIL = 'FAIL'; +const WARN = 'WARN'; + +/** A single check result. `fatal` marks a FAIL that must block the cutover. */ +function result(status, name, detail) { + return { status, name, detail }; +} + +/** + * An endpoint that requires an API key when one is configured. + * + * NOT `/hello` - that answers unauthenticated by design as a health check, so + * a 200 there proves nothing about whether the core is locked down. + */ +const AUTHED_ENDPOINT = '/recipe/users/count'; + +async function probe(url, { apiKey, fetchImpl, timeoutMs = 5000 }) { + const headers = { 'api-version': '3.0' }; + if (apiKey) headers['api-key'] = apiKey; + return fetchImpl(url, { method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs) }); +} + +/** + * Runs every deployment check and returns the results. + * + * Injected `fetchImpl` and `pgConnect` so the whole thing is testable without a + * core or a database; both default to the real implementations. + */ +export async function runPreflight({ + env = process.env, + fetchImpl = fetch, + pgConnect = defaultPgConnect, +} = {}) { + const checks = []; + const connectionURI = (env.SUPERTOKENS_CONNECTION_URI || '').replace(/\/$/, ''); + const apiKey = env.SUPERTOKENS_API_KEY; + + // ---- 1. Is there anything to check? ------------------------------------- + if (!connectionURI) { + checks.push(result( + FAIL, 'SUPERTOKENS_CONNECTION_URI', + 'Not set. Point it at the core, e.g. http://192.168.1.10:3567 - and not at ' + + 'localhost from inside a container, where that means the container itself.', + )); + return checks; // Nothing else is meaningful without it. + } + checks.push(result(PASS, 'SUPERTOKENS_CONNECTION_URI', connectionURI)); + + // ---- 2. Public origin ---------------------------------------------------- + const origin = resolvePublicOrigin(env); + checks.push(origin + ? result(PASS, 'public origin', origin) + : result( + FAIL, 'public origin', + 'Cannot be determined. Set PUBLIC_ORIGIN, or configure GITHUB_CALLBACK_URL / ' + + 'DISCORD_CALLBACK_URL as you would for passport. The server refuses to start ' + + 'in dual/supertokens without it.', + )); + + // ---- 3. Providers -------------------------------------------------------- + const providers = buildProviders(env).map((p) => p.config.thirdPartyId); + if (providers.length === 0) { + checks.push(result( + FAIL, 'OAuth providers', + `None configured. Set GITHUB_CLIENT_ID/SECRET and/or DISCORD_CLIENT_ID/SECRET, ` + + `or SuperTokens would start with no way for anyone to log in. Known: ${PROVIDER_IDS.join(', ')}.`, + )); + } else { + checks.push(result(PASS, 'OAuth providers', providers.join(', '))); + if (origin) { + // The redirect URLs the operator must have registered. Printed rather + // than probed - we cannot ask GitHub what is registered - but getting + // them wrong is the single most common way the first login fails, so + // spelling them out beats leaving it to a doc lookup. + for (const id of providers) { + checks.push(result( + PASS, `${id} redirect to register`, + `${origin}/auth/callback/${id}` + + (id === 'github' ? ` (register the PARENT path ${origin}/auth - GitHub allows one URL and matches subdirectories)` : ''), + )); + } + } + } + + // ---- 4. Is the core reachable? ------------------------------------------ + let reachable = false; + try { + const hello = await probe(`${connectionURI}/hello`, { fetchImpl }); + reachable = hello.status === 200; + checks.push(reachable + ? result(PASS, 'core reachable', `${connectionURI}/hello answered 200`) + : result(FAIL, 'core reachable', `${connectionURI}/hello answered ${hello.status}, expected 200`)); + } catch (e) { + checks.push(result( + FAIL, 'core reachable', + `${connectionURI} did not answer (${e.message}). Check the container is running, and that ` + + 'the host is not `localhost` if RackStack runs in a different container.', + )); + } + + if (!reachable) return checks; + + // ---- 5. THE ONE THAT MATTERS: is the core locked down? ------------------- + // + // A core with no API_KEYS serves its whole API unauthenticated, and that API + // mints a session for ANY user id. Because the id mapping makes + // session.getUserId() return `github:37058311` verbatim, anyone who can reach + // it can mint a RackStack session for any SUPER_ADMIN_IDS value without a + // single request touching RackStack. Nothing about this fails visibly, which + // is exactly why it is checked here rather than left to be noticed. + try { + const anon = await probe(`${connectionURI}${AUTHED_ENDPOINT}`, { fetchImpl }); + if (anon.status === 401) { + checks.push(result(PASS, 'core requires authentication', 'anonymous request rejected (401)')); + } else { + checks.push(result( + FAIL, 'core requires authentication', + `An anonymous request got HTTP ${anon.status}. The core is running without API_KEYS: ` + + 'anyone who can reach it can mint a login session for any user id, including every ' + + 'value in SUPER_ADMIN_IDS. Set API_KEYS on the core (openssl rand -hex 32), set the ' + + "same value as SUPERTOKENS_API_KEY here, and do not publish the core's port.", + )); + } + } catch (e) { + checks.push(result(WARN, 'core requires authentication', `could not verify (${e.message})`)); + } + + // ---- 5a. Does the core speak a protocol version the SDK understands? ---- + // + // supertokens-node pins an exact set of core-driver-interface versions, and + // the core must offer one of them. Get this wrong and the core is reachable, + // authenticated, healthy - and every single request fails on a version + // mismatch. Nothing about "the container is running" tells you. + // + // This check exists because the runbook originally pinned core 9.3, which + // tops out at CDI 5.2 while supertokens-node@24 requires 5.4. That would + // have been a working-looking deployment that could not log anyone in. + try { + const { cdiSupported } = await import('supertokens-node/lib/build/version.js'); + const res = await probe(`${connectionURI}/apiversion`, { apiKey, fetchImpl }); + const body = typeof res.json === 'function' ? await res.json() : {}; + const offered = body?.versions ?? []; + const shared = cdiSupported.filter((v) => offered.includes(v)); + + if (shared.length > 0) { + checks.push(result(PASS, 'core protocol version', `core and SDK share CDI ${shared.join(', ')}`)); + } else if (offered.length === 0) { + checks.push(result(WARN, 'core protocol version', 'the core did not report its CDI versions')); + } else { + checks.push(result( + FAIL, 'core protocol version', + `The SDK speaks CDI ${cdiSupported.join(', ')} but this core offers up to ` + + `${offered[offered.length - 1]}. The container will run and answer health checks, ` + + 'but every request fails on a version mismatch. Use a newer core image ' + + `(supertokens/supertokens-postgresql:12 or later supports ${cdiSupported.join(', ')}).`, + )); + } + } catch (e) { + checks.push(result(WARN, 'core protocol version', `could not verify (${e.message})`)); + } + + // ---- 6. Does OUR key actually work? ------------------------------------- + if (!apiKey) { + checks.push(result( + isLoopback(connectionURI) ? WARN : FAIL, 'SUPERTOKENS_API_KEY', + isLoopback(connectionURI) + ? 'Not set. Tolerated because the core is on loopback and only this host can reach it.' + : 'Not set, and the core is not on loopback. The server will refuse to start in ' + + 'dual/supertokens mode until this matches API_KEYS on the core.', + )); + } else { + try { + const authed = await probe(`${connectionURI}${AUTHED_ENDPOINT}`, { apiKey, fetchImpl }); + checks.push(authed.status === 200 + ? result(PASS, 'SUPERTOKENS_API_KEY', 'accepted by the core') + : result( + FAIL, 'SUPERTOKENS_API_KEY', + `The core rejected it (HTTP ${authed.status}). It must be byte-identical to a value in ` + + "the core's API_KEYS. A mismatch here fails every login once AUTH_MODE is set.", + )); + } catch (e) { + checks.push(result(WARN, 'SUPERTOKENS_API_KEY', `could not verify (${e.message})`)); + } + } + + // ---- 7. Does the core have its OWN database? ---------------------------- + // + // SuperTokens manages its own schema. Pointed at the rackstack database it + // would create its tables alongside the game's - which is not immediately + // fatal, but entangles two schemas that have to be backed up, migrated and + // rolled back independently. Detected by looking for SuperTokens' tables + // inside RackStack's own database. + if (env.DATABASE_URL) { + try { + const stray = await pgConnect(env.DATABASE_URL); + checks.push(stray.length === 0 + ? result(PASS, 'core has its own database', "no SuperTokens tables in RackStack's database") + : result( + FAIL, 'core has its own database', + `Found SuperTokens tables inside RackStack's own database (${stray.join(', ')}). The core's ` + + 'POSTGRESQL_CONNECTION_URI must point at a SEPARATE database on the same server - ' + + 'e.g. CREATE DATABASE supertokens OWNER - never at the rackstack one.', + )); + } catch (e) { + checks.push(result(WARN, 'core has its own database', `could not check (${e.message})`)); + } + } + + return checks; +} + +/** Looks for SuperTokens-owned tables inside RackStack's own database. */ +async function defaultPgConnect(url) { + const pg = (await import('pg')).default; + const client = new pg.Client({ connectionString: url }); + await client.connect(); + try { + await client.query('BEGIN TRANSACTION READ ONLY'); + const { rows } = await client.query(` + SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() + AND (table_name LIKE 'supertokens%' OR table_name IN + ('all_auth_recipe_users', 'session_info', 'thirdparty_users', 'key_value')) + ORDER BY table_name + `); + await client.query('COMMIT'); + return rows.map((r) => r.table_name); + } finally { + await client.end(); + } +} + +export function formatPreflight(checks) { + const width = Math.max(...checks.map((c) => c.name.length), 0); + const lines = ['=== SuperTokens deployment preflight ===']; + for (const c of checks) { + lines.push(`[${c.status.padEnd(4)}] ${c.name.padEnd(width)} ${c.detail}`); + } + + const failed = checks.filter((c) => c.status === FAIL); + const warned = checks.filter((c) => c.status === WARN); + lines.push(''); + if (failed.length > 0) { + lines.push( + `PREFLIGHT: FAIL - ${failed.length} problem(s). Do NOT set AUTH_MODE yet; ` + + 'each line above says what to change.', + ); + } else if (warned.length > 0) { + lines.push( + `PREFLIGHT: PASS with ${warned.length} warning(s). Nothing blocks the cutover, but read them.`, + ); + } else { + lines.push('PREFLIGHT: PASS - the deployment side is ready for AUTH_MODE=dual.'); + } + lines.push('Run `npm run shadow:check` too: this gates the deployment, that gates the data.'); + return lines.join('\n'); +} + +export function preflightPassed(checks) { + return checks.length > 0 && !checks.some((c) => c.status === FAIL); +} + +async function main() { + const checks = await runPreflight(); + console.log(formatPreflight(checks)); + process.exitCode = preflightPassed(checks) ? 0 : 1; +} + +if (process.argv[1] && process.argv[1].endsWith('preflight.js')) { + main().catch((e) => { + console.error('[preflight] failed to run:', e); + process.exitCode = 2; + }); +} diff --git a/tests/supertokens.preflight.test.js b/tests/supertokens.preflight.test.js new file mode 100644 index 0000000..0f16bd8 --- /dev/null +++ b/tests/supertokens.preflight.test.js @@ -0,0 +1,240 @@ +// The Phase 2 preflight gates the DEPLOYMENT half of the cutover, the way +// shadow:check gates the data half. +// +// The check that earns its keep is "core requires authentication". An +// unauthenticated SuperTokens core mints a session for ANY user id, and the id +// mapping turns that into a real RackStack session for any SUPER_ADMIN_IDS +// value without a request ever reaching Express - so it is invisible from +// inside the app, and nothing else in the system would ever surface it. + +import { describe, it, expect } from 'vitest'; + +const { + runPreflight, formatPreflight, preflightPassed, +} = await import('../server/supertokens/preflight.js'); + +const BASE_ENV = { + SUPERTOKENS_CONNECTION_URI: 'http://core.example.com:3567', + SUPERTOKENS_API_KEY: 'a-real-key', + PUBLIC_ORIGIN: 'https://rackstack.example.com', + GITHUB_CLIENT_ID: 'gh', GITHUB_CLIENT_SECRET: 'ghs', + DISCORD_CLIENT_ID: 'dc', DISCORD_CLIENT_SECRET: 'dcs', +}; + +/** A fake core. `open: true` models one running with no API_KEYS. */ +function fakeCore({ + open = false, keyAccepted = true, reachable = true, cdi = ['5.3', '5.4', '5.5'], +} = {}) { + return async (url, { headers } = {}) => { + if (!reachable) throw new Error('ECONNREFUSED'); + if (url.endsWith('/hello')) return { status: 200 }; + if (url.endsWith('/apiversion')) { + return { status: 200, json: async () => ({ versions: cdi }) }; + } + const hasKey = Boolean(headers && headers['api-key']); + if (!hasKey) return { status: open ? 200 : 401 }; + return { status: keyAccepted ? 200 : 401 }; + }; +} + +const noStrayTables = async () => []; +const byName = (checks, name) => checks.find((c) => c.name === name); + +describe('the preflight catches an open core', () => { + it('FAILS when an anonymous request is answered', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore({ open: true }), pgConnect: noStrayTables, + }); + const auth = byName(checks, 'core requires authentication'); + expect(auth.status).toBe('FAIL'); + expect(auth.detail).toMatch(/without API_KEYS/); + expect(auth.detail).toMatch(/SUPER_ADMIN_IDS/); + expect(preflightPassed(checks)).toBe(false); + expect(formatPreflight(checks)).toContain('PREFLIGHT: FAIL'); + }); + + it('PASSES when the core rejects anonymous callers', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + expect(byName(checks, 'core requires authentication').status).toBe('PASS'); + expect(preflightPassed(checks)).toBe(true); + expect(formatPreflight(checks)).toContain('PREFLIGHT: PASS'); + }); + + it('does not probe /hello for the auth check', async () => { + // /hello answers unauthenticated by design, so a 200 there proves nothing. + // If the auth check ever moved to it, an open core would read as healthy. + const probed = []; + await runPreflight({ + env: BASE_ENV, + fetchImpl: async (url, opts) => { + probed.push({ url, keyed: Boolean(opts?.headers?.['api-key']) }); + return url.endsWith('/hello') ? { status: 200 } : { status: opts?.headers?.['api-key'] ? 200 : 401 }; + }, + pgConnect: noStrayTables, + }); + const anonProbes = probed.filter((p) => !p.keyed && !p.url.endsWith('/hello')); + expect(anonProbes.length).toBeGreaterThan(0); + expect(anonProbes.every((p) => p.url.includes('/recipe/'))).toBe(true); + }); +}); + +describe('the preflight catches a core too old for the SDK', () => { + it('FAILS on a core that tops out below the SDK\'s CDI', async () => { + // The real case: the runbook originally pinned core 9.3, which offers up + // to CDI 5.2 while supertokens-node@24 requires 5.4. That core runs, + // answers /hello, accepts its API key - and fails every single request. + // Nothing else in this preflight would have caught it. + const checks = await runPreflight({ + env: BASE_ENV, + fetchImpl: fakeCore({ cdi: ['5.0', '5.1', '5.2'] }), + pgConnect: noStrayTables, + }); + const cdi = byName(checks, 'core protocol version'); + expect(cdi.status).toBe('FAIL'); + expect(cdi.detail).toMatch(/5\.2/); + expect(cdi.detail).toMatch(/every request fails/); + expect(preflightPassed(checks)).toBe(false); + }); + + it('PASSES when the core offers a CDI version the SDK speaks', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + const cdi = byName(checks, 'core protocol version'); + expect(cdi.status).toBe('PASS'); + expect(cdi.detail).toMatch(/5\.4/); + }); + + it('checks against the SDK\'s real declared versions, not a hardcoded list', async () => { + // If supertokens-node is upgraded and its CDI requirement moves, this + // check must move with it rather than silently keep asserting 5.4. + const { cdiSupported } = await import('supertokens-node/lib/build/version.js'); + expect(Array.isArray(cdiSupported)).toBe(true); + expect(cdiSupported.length).toBeGreaterThan(0); + + const checks = await runPreflight({ + env: BASE_ENV, + fetchImpl: fakeCore({ cdi: cdiSupported }), + pgConnect: noStrayTables, + }); + expect(byName(checks, 'core protocol version').status).toBe('PASS'); + }); +}); + +describe('the preflight catches a key mismatch', () => { + it('FAILS when the core rejects our key', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore({ keyAccepted: false }), pgConnect: noStrayTables, + }); + const key = byName(checks, 'SUPERTOKENS_API_KEY'); + expect(key.status).toBe('FAIL'); + expect(key.detail).toMatch(/byte-identical/); + expect(preflightPassed(checks)).toBe(false); + }); + + it('FAILS on a missing key for a remote core, but only WARNs on loopback', async () => { + const { SUPERTOKENS_API_KEY: _drop, ...noKey } = BASE_ENV; + + const remote = await runPreflight({ + env: noKey, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + expect(byName(remote, 'SUPERTOKENS_API_KEY').status).toBe('FAIL'); + + const local = await runPreflight({ + env: { ...noKey, SUPERTOKENS_CONNECTION_URI: 'http://127.0.0.1:3567' }, + fetchImpl: fakeCore(), + pgConnect: noStrayTables, + }); + expect(byName(local, 'SUPERTOKENS_API_KEY').status).toBe('WARN'); + expect(preflightPassed(local)).toBe(true); + }); +}); + +describe('the preflight catches a shared database', () => { + it('FAILS when SuperTokens tables sit in RackStack\'s own database', async () => { + const checks = await runPreflight({ + env: { ...BASE_ENV, DATABASE_URL: 'postgresql://u@h:5432/rackstack' }, + fetchImpl: fakeCore(), + pgConnect: async () => ['all_auth_recipe_users', 'session_info'], + }); + const db = byName(checks, 'core has its own database'); + expect(db.status).toBe('FAIL'); + expect(db.detail).toMatch(/SEPARATE database/); + expect(preflightPassed(checks)).toBe(false); + }); + + it('PASSES when they are separate', async () => { + const checks = await runPreflight({ + env: { ...BASE_ENV, DATABASE_URL: 'postgresql://u@h:5432/rackstack' }, + fetchImpl: fakeCore(), + pgConnect: noStrayTables, + }); + expect(byName(checks, 'core has its own database').status).toBe('PASS'); + }); +}); + +describe('the preflight on an incomplete configuration', () => { + it('stops early, and usefully, with no connection URI', async () => { + const checks = await runPreflight({ env: {}, fetchImpl: fakeCore(), pgConnect: noStrayTables }); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('FAIL'); + expect(checks[0].detail).toMatch(/localhost/); + expect(preflightPassed(checks)).toBe(false); + }); + + it('FAILS when the core cannot be reached, and does not pretend to know more', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore({ reachable: false }), pgConnect: noStrayTables, + }); + expect(byName(checks, 'core reachable').status).toBe('FAIL'); + // No auth verdict should be invented for a core we never spoke to. + expect(byName(checks, 'core requires authentication')).toBeUndefined(); + }); + + it('FAILS with no OAuth provider configured', async () => { + const checks = await runPreflight({ + env: { SUPERTOKENS_CONNECTION_URI: BASE_ENV.SUPERTOKENS_CONNECTION_URI, PUBLIC_ORIGIN: BASE_ENV.PUBLIC_ORIGIN }, + fetchImpl: fakeCore(), + pgConnect: noStrayTables, + }); + expect(byName(checks, 'OAuth providers').status).toBe('FAIL'); + }); + + it('FAILS when the public origin cannot be determined', async () => { + const { PUBLIC_ORIGIN: _drop, ...noOrigin } = BASE_ENV; + const checks = await runPreflight({ + env: noOrigin, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + expect(byName(checks, 'public origin').status).toBe('FAIL'); + }); +}); + +describe('the preflight tells the operator what to register', () => { + it('prints the exact redirect URLs, and the GitHub parent-path caveat', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + const gh = byName(checks, 'github redirect to register'); + const dc = byName(checks, 'discord redirect to register'); + + expect(gh.detail).toContain('https://rackstack.example.com/auth/callback/github'); + // GitHub allows one URL and matches subdirectories, so the PARENT path is + // what gets registered - getting this wrong breaks every existing login. + expect(gh.detail).toContain('https://rackstack.example.com/auth'); + expect(gh.detail).toMatch(/subdirector/i); + expect(dc.detail).toContain('https://rackstack.example.com/auth/callback/discord'); + }); + + it('does not claim PASS on an empty run', async () => { + expect(preflightPassed([])).toBe(false); + }); + + it('points at the other half of the gate', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + expect(formatPreflight(checks)).toContain('shadow:check'); + }); +}); diff --git a/tests/supertokens.security.test.js b/tests/supertokens.security.test.js index 99abf2a..3a239b2 100644 --- a/tests/supertokens.security.test.js +++ b/tests/supertokens.security.test.js @@ -111,6 +111,45 @@ describe('rejectRawOAuthTokens (authentication bypass guard)', () => { expect(probed).toContain('/recipe/'); }); + it('refuses to boot against a core too old for this SDK', async () => { + // The SDK does catch this, but only from inside a request - so without a + // boot check the container starts healthy, passes its health check, and + // fails every login. This is the difference between "the container will + // not start, and here is why" and a player reporting they cannot sign in. + const { assertCoreSpeaksOurProtocol } = await import('../server/supertokens/init.js'); + const oldCore = async () => ({ status: 200, json: async () => ({ versions: ['5.0', '5.1', '5.2'] }) }); + + await expect(assertCoreSpeaksOurProtocol({ + connectionURI: 'http://core:3567', fetchImpl: oldCore, + })).rejects.toThrow(/core-driver-interface/); + await expect(assertCoreSpeaksOurProtocol({ + connectionURI: 'http://core:3567', fetchImpl: oldCore, + })).rejects.toThrow(/failing every login/); + }); + + it('accepts a core that offers a version the SDK speaks', async () => { + const { assertCoreSpeaksOurProtocol } = await import('../server/supertokens/init.js'); + const { cdiSupported } = await import('supertokens-node/lib/build/version.js'); + // Built from the SDK's own declared support, so upgrading the SDK moves + // this test with it instead of leaving a stale literal behind. + const goodCore = async () => ({ status: 200, json: async () => ({ versions: ['2.7', ...cdiSupported] }) }); + + await expect(assertCoreSpeaksOurProtocol({ + connectionURI: 'http://core:3567', fetchImpl: goodCore, + })).resolves.toEqual(cdiSupported); + }); + + it('warns rather than refuses when the core cannot be reached', async () => { + // Same reasoning as the API-key probe: a core that is still starting must + // not turn a boot-ordering hiccup into an outage. + const { assertCoreSpeaksOurProtocol } = await import('../server/supertokens/init.js'); + const unreachable = async () => { throw new Error('ECONNREFUSED'); }; + + await expect(assertCoreSpeaksOurProtocol({ + connectionURI: 'http://core:3567', fetchImpl: unreachable, + })).resolves.toBe('unverified'); + }); + it('lets the legitimate redirect-URI flow through untouched', async () => { // The guard must not break real logins. In this flow the token is obtained // by exchanging an authorization code with our own client secret, so it is