Skip to content

Commit 45841e6

Browse files
Merge pull request #14 from NeverEndingCode/v1.9.1-cookie-transport
v1.9.1: fix the SuperTokens login that signed you in and left you logged out
2 parents 1f40e31 + f29006b commit 45841e6

9 files changed

Lines changed: 176 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
# Changelog
22

3+
## v1.9.1
4+
5+
- **The SuperTokens login button signed you in and then left you logged out.**
6+
`POST /auth/signinup` answered `status: "OK"`, the client reported success,
7+
the URL went back to `/` — and the very next `GET /api/me` was a 401, so the
8+
login screen came back with nothing wrong on it.
9+
10+
SuperTokens picks the session's *token transfer method* at creation time from
11+
the `st-auth-mode` request header, and when it is absent it defaults to
12+
**header**, not cookies (`session/sessionRequestFunctions.js`: *"We default
13+
to header if we can't 'parse' it or if it's undefined"*). The session came
14+
back in `st-access-token` / `st-refresh-token` response headers; no cookie
15+
was ever set. `supertokens-web-js` sends that header for you, and v1.9.0
16+
hand-rolled the calls without it — the one responsibility of the frontend SDK
17+
that hand-rolling quietly inherited.
18+
19+
Confirmed against a real core, same endpoint, both ways — note that **both
20+
return 200**, which is why nothing anywhere reported an error:
21+
22+
| request | `Set-Cookie` | response headers |
23+
|---|---|---|
24+
| without `st-auth-mode` | *(none)* | `st-access-token`, `st-refresh-token` |
25+
| with `st-auth-mode: cookie` | `sAccessToken`, `sRefreshToken` | *(none)* |
26+
27+
Both `/auth/signinup` and `/auth/session/refresh` now send it.
28+
29+
- **A login that sets no session now says so.** If sign-in succeeds but the
30+
session that follows does not exist, the login screen says the server did not
31+
set a session and suggests checking cookies, instead of silently returning to
32+
a blank login form. The absence of that message is the only reason v1.9.0's
33+
bug reached a production deploy — every layer reported success.
34+
335
## v1.9.0
436

537
- **Every SuperTokens login was impossible, and had been since v1.8.**

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT"
4444
# only on a pushed vX.Y.Z tag, and docker/metadata-action derives the
4545
# published image's version label from that tag - so this literal only
4646
# affects locally-built images, not what GHCR publishes.
47-
LABEL org.opencontainers.image.version="1.9.0"
47+
LABEL org.opencontainers.image.version="1.9.1"
4848

4949
VOLUME ["/app/data"]
5050
EXPOSE 3000

client/src/App.jsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ export default function App() {
2727
// code has to happen BEFORE /api/me, because it is what creates the
2828
// session /api/me would otherwise report as absent.
2929
let callbackFailure = null;
30+
let completedLoginFor = null;
3031
if (callbackProviderFromPath(window.location.pathname)) {
3132
const result = await completeSuperTokensLogin();
3233
if (cancelled) return;
33-
if (!result.ok) callbackFailure = result;
34+
if (result.ok) completedLoginFor = result.provider;
35+
else callbackFailure = result;
3436

3537
// Replace rather than push, and always: leaving a spent ?code= in the
3638
// URL means a reload re-POSTs an authorisation code the provider has
@@ -60,6 +62,20 @@ export default function App() {
6062
}
6163

6264
if (cancelled) return;
65+
66+
// A sign-in that the server called OK, followed by a session that does
67+
// not exist. There is nothing wrong on the login screen to look at, so
68+
// without this the player just bounces back to it and the only evidence
69+
// is in devtools. That is how v1.9.0's missing `st-auth-mode: cookie`
70+
// header survived a release: signinup answered OK, no cookie was set,
71+
// and the app looked like it had simply forgotten the click.
72+
if (completedLoginFor && !authed) {
73+
window.history.replaceState(
74+
{}, '',
75+
`/?authError=${encodeURIComponent(completedLoginFor)}&authReason=no_session`,
76+
);
77+
}
78+
6379
if (authed) { setUser(authed); setStatus('authed'); } else setStatus('anon');
6480
})();
6581

client/src/game/api.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ function refreshSession() {
7777
const res = await fetch('/auth/session/refresh', {
7878
method: 'POST',
7979
credentials: 'include',
80+
// Same reason as the signinup call in game/auth.js: this is what tells
81+
// SuperTokens to put the rotated tokens back in cookies. Refresh
82+
// tolerates its absence better than session creation does (it infers
83+
// the method from the tokens it was given), but a refresh that
84+
// silently switched the session to header transport would log the
85+
// player out on the next request, which is the same invisible failure
86+
// one step later.
87+
headers: { 'st-auth-mode': 'cookie' },
8088
});
8189
return res.ok;
8290
} catch {

client/src/game/auth.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,25 @@ export async function completeSuperTokensLogin({
181181
// wholesale).
182182
const res = await requestJSON('/auth/signinup', {
183183
method: 'POST',
184-
headers: { 'Content-Type': 'application/json' },
184+
headers: {
185+
'Content-Type': 'application/json',
186+
// NOT optional, and its absence fails silently. SuperTokens resolves the
187+
// token transfer method at session creation from this header, and when
188+
// it is missing it defaults to "header" - see
189+
// session/sessionRequestFunctions.js: "We default to header if we can't
190+
// 'parse' it or if it's undefined". The session then comes back in
191+
// st-access-token / st-refresh-token RESPONSE headers and no cookie is
192+
// ever set, so signinup answers status "OK", this function reports
193+
// success, and the very next GET /api/me is a 401. The player lands back
194+
// on the login screen with nothing wrong on it. v1.9.0 shipped exactly
195+
// that.
196+
//
197+
// supertokens-web-js sends this header for you; hand-rolling the calls
198+
// means inheriting the responsibility. Cookies are the right choice here
199+
// because the whole app already relies on them - `credentials:
200+
// 'include'` everywhere, and the legacy JWT cookie works the same way.
201+
'st-auth-mode': 'cookie',
202+
},
185203
body: JSON.stringify({
186204
thirdPartyId: provider,
187205
redirectURIInfo: {
@@ -227,6 +245,8 @@ export function loginErrorMessage(provider, reason) {
227245
return `This account is not allowed to sign in with ${name}.`;
228246
case 'no_email':
229247
return `${name} did not share an email address, which this server requires.`;
248+
case 'no_session':
249+
return `${name} signed you in, but the server did not set a session. Check that cookies are allowed for this site.`;
230250
default:
231251
return `Login with ${name} failed. Try again.`;
232252
}

docs/authentication-methods.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,16 @@ serialised so a burst of concurrent 401s produces exactly one refresh call.
233233
`GET /api/auth-info` tells the client which stack to drive, so one build serves
234234
`passport` and `supertokens` alike and the rollback stays real.
235235

236+
> **If you touch those calls, keep the `st-auth-mode: cookie` header.**
237+
> SuperTokens chooses the session's token transfer method at creation from that
238+
> header, and **defaults to `header` when it is absent** — the session comes
239+
> back in `st-access-token` response headers and no cookie is set. Every status
240+
> code stays 200: `signinup` reports OK, the client believes it, and the next
241+
> authenticated request is a 401. v1.9.0 shipped that and it survived a
242+
> production deploy, because nothing in the chain reports an error. It is the
243+
> one job `supertokens-web-js` does for you that hand-rolling silently
244+
> inherits, and it applies to any future recipe you add, not just ThirdParty.
245+
236246
**What v1.9 also fixed, and why nothing here was ever exercised before.**
237247
`server/supertokens/init.js` passed its OAuth providers to `ThirdParty.init`
238248
as `signInUpFeature`. The SDK reads `signInAndUpFeature`. The key is optional

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rackstack-server",
3-
"version": "1.9.0",
3+
"version": "1.9.1",
44
"private": true,
55
"type": "module",
66
"scripts": {

tests/clientAuth.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,29 @@ describe('completeSuperTokensLogin', () => {
151151
expect(body.oAuthTokens).toBeUndefined();
152152
});
153153

154+
it('asks for the session in COOKIES, which is what actually logs the player in', async () => {
155+
// The v1.9.0 regression, and the reason it shipped. SuperTokens picks the
156+
// token transfer method at session creation from this header, and with it
157+
// absent defaults to "header" - the session comes back in st-access-token
158+
// response headers, no cookie is set, signinup still answers status OK,
159+
// and the next /api/me is a 401. Nothing in the flow reports an error.
160+
//
161+
// supertokens-web-js sends this for you. Hand-rolling means owning it.
162+
const calls = [];
163+
globalThis.fetch = vi.fn(async (url, opts) => {
164+
calls.push({ url, opts });
165+
return jsonResponse({ status: 'OK', user: { id: 'github:37058311' } });
166+
});
167+
168+
await completeSuperTokensLogin({
169+
pathname: '/auth/callback/github',
170+
search: '?code=abc123',
171+
origin: ORIGIN,
172+
});
173+
174+
expect(calls[0].opts.headers['st-auth-mode']).toBe('cookie');
175+
});
176+
154177
it('does not POST when the player cancelled at the provider', async () => {
155178
globalThis.fetch = vi.fn(async () => jsonResponse({ status: 'OK' }));
156179

@@ -241,6 +264,30 @@ describe('refresh on 401', () => {
241264
expect(seen).toEqual(['/api/state', '/auth/session/refresh', '/api/state']);
242265
});
243266

267+
it('asks for the refreshed session in cookies too', async () => {
268+
configureAuthRefresh({ loginFlow: 'supertokens' });
269+
270+
const calls = [];
271+
let refreshed = false;
272+
globalThis.fetch = vi.fn(async (url, opts) => {
273+
calls.push({ url, opts });
274+
if (url === '/auth/session/refresh') {
275+
refreshed = true;
276+
return { ok: true, status: 200, text: async () => '' };
277+
}
278+
if (!refreshed) return jsonResponse({ error: 'unauthorized' }, { ok: false, status: 401 });
279+
return jsonResponse({ ok: true });
280+
});
281+
282+
await fetchState();
283+
284+
// A refresh that silently moved the session to header transport would log
285+
// the player out on the next request - the same invisible failure as the
286+
// signinup one, just deferred.
287+
const refresh = calls.find((c) => c.url === '/auth/session/refresh');
288+
expect(refresh.opts.headers['st-auth-mode']).toBe('cookie');
289+
});
290+
244291
it('gives up after a second 401 rather than looping', async () => {
245292
configureAuthRefresh({ loginFlow: 'supertokens' });
246293

tests/e2e/smoke-v19.mjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ if (!playwright) {
275275
try {
276276
let authUrlCalls = 0;
277277
let signinupBody = null;
278+
let signinupHeaders = null;
278279

279280
// The provider, stubbed: send the browser straight back to our own
280281
// callback with a code, so nothing external is contacted.
@@ -302,6 +303,7 @@ if (!playwright) {
302303
// the app genuinely transitions to its authenticated view.
303304
await page.route('**/auth/signinup', async (route) => {
304305
signinupBody = JSON.parse(route.request().postData() || '{}');
306+
signinupHeaders = route.request().headers();
305307
await context.addCookies([{
306308
name: COOKIE_NAME, value: cookieFor(user), url: BASE_URL, httpOnly: true,
307309
}]);
@@ -341,6 +343,14 @@ if (!playwright) {
341343
// Never oAuthTokens - the server refuses those (rejectRawOAuthTokens).
342344
assert(!signinupBody.oAuthTokens, 'the client must not submit raw oAuthTokens');
343345

346+
// Without this header SuperTokens returns the session in response
347+
// headers instead of cookies, signinup still says OK, and the next
348+
// /api/me is a 401. That is what v1.9.0 shipped.
349+
assert(
350+
signinupHeaders['st-auth-mode'] === 'cookie',
351+
`signinup must ask for cookie transport, got ${signinupHeaders['st-auth-mode']}`,
352+
);
353+
344354
// The spent code must be replaced out of the URL: a reload that re-POSTs
345355
// a burned authorisation code fails and bounces the player to login.
346356
const url = new URL(page.url());
@@ -367,6 +377,35 @@ if (!playwright) {
367377
} finally { await context.close(); }
368378
});
369379

380+
await check('a signin the server calls OK but that sets no session says so', async () => {
381+
// The v1.9.0 failure mode, reproduced: signinup answers status OK and no
382+
// session cookie is set. The player must be told, not silently returned to
383+
// a login screen with nothing wrong on it - that is what made the missing
384+
// st-auth-mode header survive a release and a production deploy.
385+
const { context, page } = await newPage();
386+
try {
387+
await page.route('**/auth/authorisationurl*', (route) => route.fulfill({
388+
status: 200,
389+
contentType: 'application/json',
390+
body: JSON.stringify({
391+
status: 'OK',
392+
urlWithQueryParams: `${BASE_URL}/auth/callback/github?code=fake-code`,
393+
}),
394+
}));
395+
396+
// OK, but deliberately no cookie - exactly what header transport does.
397+
await page.route('**/auth/signinup', (route) => route.fulfill({
398+
status: 200,
399+
contentType: 'application/json',
400+
body: JSON.stringify({ status: 'OK', user: { id: 'github:1' } }),
401+
}));
402+
403+
await page.goto(`${BASE_URL}/`);
404+
await page.locator('button', { hasText: 'Continue with GitHub' }).click();
405+
await page.locator('text=/did not set a session/i').waitFor({ timeout: 15000 });
406+
} finally { await context.close(); }
407+
});
408+
370409
await check('an unconfigured provider shows a message, not a blank screen', async () => {
371410
const { context, page } = await newPage();
372411
try {

0 commit comments

Comments
 (0)