Skip to content
Open
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
7 changes: 7 additions & 0 deletions docs/authentication-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ evidence that the SuperTokens path works end to end.

## Phase 5 — `supertokens` mode — blocked on client work

> **Implementation plan written 2026-08-08:**
> [`superpowers/plans/2026-08-08-v1.9-supertokens-client.md`](./superpowers/plans/2026-08-08-v1.9-supertokens-client.md).
> Not started. Task 1 of that plan drives the SuperTokens login by hand against
> production `dual` — no code — which is the cheapest way to find out whether
> the identity mapping actually works end to end. Do that before writing any
> client code.

**This is the honest state: `supertokens`-only mode cannot be used yet, and the
blocker is larger than "not recommended".**

Expand Down
223 changes: 223 additions & 0 deletions docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# v1.9 — SuperTokens client integration (Phase 5)

> **For agentic workers:** REQUIRED SUB-SKILL: use `superpowers:subagent-driven-development`
> or `superpowers:executing-plans`. Steps use `- [ ]` checkboxes.

**Status: NOT STARTED.** Written 2026-08-08 at the end of a session, so the next
one can begin immediately.

**Goal:** teach the client to authenticate through SuperTokens, so
`AUTH_MODE=supertokens` becomes usable and the `signInUp` mapping is exercised
by real logins.

## Where things stand (read this first)

`AUTH_MODE=dual` is **live in production** on Unraid, RackStack v1.8.4, core 12
at `192.168.68.50:3567`. Both gates passed against the real box:
`shadow:check` 6/6 100%, `supertokens:check` all 10 PASS.

The **entire server side is built, tested and deployed.** Nothing in this plan
requires server work unless a step below says so explicitly.

What is missing is only the client:

1. **No SuperTokens login flow.** `client/src/Login.jsx` hardcodes
`<a href="/auth/discord">` and `<a href="/auth/github">` — the *passport*
routes. `supertokens` mode does not register them, so the buttons fall
through to the SPA and nobody can sign in.
2. **No token refresh.** No frontend SDK, so nothing renews an expired access
token. Invisible in `dual` (the legacy JWT cookie still authenticates);
in `supertokens`-only mode a player is silently logged out when the access
token expires and their legacy cookie has also lapsed.

**Consequence worth internalising:** because the client logs in via passport,
`dual` has never exercised the SuperTokens `signInUp` mapping. It is thoroughly
unit-tested — including the ordering guarantee that the user-id mapping is
created before the session — but has never run against a real GitHub login.
**Step 1 below is the first time that path executes for real.**

## Constraints (all still in force from v1.8)

- **`users.id` never changes.** It is `provider:providerId`. The mapping puts
SuperTokens' internal id *onto* it; `session.getUserId()` must return
`github:37058311`.
- **Nobody gets logged out.** Legacy 90-day JWT cookies stay valid through
every transition, both directions. Rollback is `AUTH_MODE=passport` + restart.
- **The allowlist is the SuperTokens HTTP surface.** `server/app.js`'s
`gateSuperTokensPaths` permits only `/auth/authorisationurl`,
`/auth/signinup`, `/auth/session/refresh` and `/auth/callback/*`. Anything
else the client needs must be added there **deliberately** — that gate exists
because `supertokens.init()` auto-adds six recipes and 13 endpoints,
including a second half-logout (`POST /auth/oauth/logout`). Do not widen it
casually.
- **`POST /auth/signinup` accepts only the redirect flow.** Submitting
`oAuthTokens` directly is rejected (`rejectRawOAuthTokens`) — that was an
account-takeover bypass. The client must use `redirectURIInfo`.
- **`/auth/signout` is deliberately removed.** Use `/auth/logout`, which clears
both stacks. The SuperTokens frontend SDK's `signOut()` targets
`/auth/signout` — if you adopt the SDK, point it at `/auth/logout` or the
logout will half-work.
- **Both backends still tested.** `npm run test:all` green on SQLite and
Postgres.
- **Docs updated in the same task that changes behaviour.**

## Decision to make before Task 1

**Use `supertokens-web-js`, or hand-roll the three fetch calls?**

| | SDK | Hand-rolled |
|---|---|---|
| Refresh | Handled, incl. concurrent-request queueing | Must write it |
| Bundle | +~30 kB | none |
| Surface | Pulls in more than we allowlist | Exactly three calls |
| Risk | Its `signOut()` hits the removed `/auth/signout` | Fully under our control |

**Recommendation: hand-roll.** The flow is three calls, the allowlist is
already exactly those three, and the SDK's assumptions (its own signout path,
its own session storage) cut against decisions v1.8 made deliberately. Revisit
if refresh-under-concurrency proves fiddly.

---

### Task 1: Prove the SuperTokens login path works at all

**Do this before writing any client code.** It is the cheapest possible way to
find out whether the mapping works end to end, and it needs no code.

- [ ] **Step 1: Drive the flow by hand against production `dual`**

```bash
# 1. Get the provider URL (returns JSON with urlWithQueryParams)
curl -s 'https://rackstack.neverendingcode.com/auth/authorisationurl?thirdPartyId=github&redirectURIOnProviderDashboard=https://rackstack.neverendingcode.com/auth/callback/github'
```

Open `urlWithQueryParams` in a browser, authorise, and let GitHub redirect to
`/auth/callback/github?code=…&state=…`. Copy those query params, then:

```bash
curl -s -X POST 'https://rackstack.neverendingcode.com/auth/signinup' \
-H 'Content-Type: application/json' \
-d '{"thirdPartyId":"github","redirectURIInfo":{
"redirectURIOnProviderDashboard":"https://rackstack.neverendingcode.com/auth/callback/github",
"redirectURIQueryParams":{"code":"<CODE>","state":"<STATE>"}}}'
```

- [ ] **Step 2: Assert the thing the whole release rests on**

Expect `status: "OK"` and **`user.id === "github:37058311"`** — your existing
`users.id`, not a SuperTokens UUID. If it is a UUID, the mapping did not run;
stop and debug before writing any client code.

Then confirm on the box:

```sql
SELECT provider, provider_id, supertokens_user_id FROM identities;
```

`supertokens_user_id` should now be populated for that row with a real
SuperTokens id (**not** `github:37058311` — writing our own id back there was a
bug fixed in v1.8; if you see it, that regressed).

- [ ] **Step 3: Re-run both gates**

`npm run shadow:check` must still be 6/6 with **0 new identities** — a new row
would mean the login created a second account instead of matching the existing
one, which is the exact failure v1.8 exists to prevent.

> If Task 1 fails, everything below is premature. Fix the mapping first.

---

### Task 2: The login flow in the client

**Files:** `client/src/Login.jsx`, new `client/src/game/auth.js`

- [ ] **Step 1: Detect which stack to drive**

The client must not hardcode either. Add `authMode` to `GET /api/config`
(server-side, one line — it is the only server change in this plan) and have
`Login.jsx` render passport `<a href>` links when passport is enabled, and
SuperTokens buttons when it is not.

Rationale: hardcoding SuperTokens would break `passport` mode, which is still
the default and the documented rollback. Both must work from one build.

- [ ] **Step 2: Implement `startSuperTokensLogin(providerId)`**

`GET /auth/authorisationurl?thirdPartyId=<id>&redirectURIOnProviderDashboard=<origin>/auth/callback/<id>`
→ `window.location.assign(urlWithQueryParams)`. Persist nothing; the state
param round-trips through the provider.

- [ ] **Step 3: Handle the callback route**

`/auth/callback/:provider` currently falls to the SPA. Have the app detect that
path on load, `POST /auth/signinup` with `redirectURIInfo` built from
`window.location.search`, then replace history with `/` on success.

`credentials: 'include'` on both calls, or the session cookie is dropped.

- [ ] **Step 4: Failure paths**

`GENERAL_ERROR` (the raw-token refusal), `SIGN_IN_UP_NOT_ALLOWED`, and a
provider `error=access_denied` must each land the player back on the login
screen with a readable message — reuse the existing `?authError=` convention.

- [ ] **Step 5: Tests**

An e2e smoke suite (`tests/e2e/smoke-v19.mjs`, matching the existing
`smoke-v1*.mjs` glob) that stubs the provider and drives the full flow against
a real app in `dual`, asserting the session resolves to the pre-existing
`users.id` and the existing save.

---

### Task 3: Session refresh

**Files:** `client/src/game/api.js`

- [ ] **Step 1: Refresh on 401**

Wrap the existing fetch helper: on a 401, `POST /auth/session/refresh` once,
then retry the original request. On a second 401, fall through to the login
screen.

- [ ] **Step 2: Serialize concurrent refreshes**

The app fires several requests at once; a naive implementation sends N refresh
calls and races them. One in-flight refresh promise, shared — the same shape as
`server/userLock.js`. **This is the part most likely to be got wrong.**

- [ ] **Step 3: Do not break passport mode**

In `passport` mode a 401 means "not logged in" and there is nothing to refresh.
Gate the refresh attempt on the same `authMode` from Task 2 Step 1.

- [ ] **Step 4: Test both**

A 401→refresh→retry succeeds; two concurrent 401s produce exactly **one**
refresh call; passport mode never calls refresh.

---

### Task 4: Cutover to `supertokens` and release

- [ ] **Step 1: Verify in `dual` first** — with the client on SuperTokens and
`AUTH_MODE=dual`, a legacy cookie must *still* authenticate. That is the
no-forced-logout guarantee under the new client.
- [ ] **Step 2: `AUTH_MODE=supertokens`** on the box; confirm login, refresh
across an access-token expiry, and logout clearing both stacks.
- [ ] **Step 3: Un-block the docs.** `docs/authentication-methods.md` Phase 5,
the runbook's D6 and "what has NOT been verified", `README.md`,
`.env.example` and `unraid-template.xml` all currently say
`supertokens` mode is unusable. That stops being true here — update all
five, and do not leave a stale warning behind.
- [ ] **Step 4: Version, changelog, tag** — `package.json` + Dockerfile label
(`client/package.json` is deliberately NOT bumped; `client/vite.config.js`
reads the root as the single version authority). Tag `main`, never the
branch — that push is what triggers the GHCR publish.

---

## Findings and deviations

_(record as work proceeds, so the plan does not quietly diverge)_
Loading