This commit is contained in:
2026-09-25 10:14:15 +05:30
parent 3d404665ab
commit 174e7dd890
2 changed files with 115 additions and 4 deletions

78
src/auth/session.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import { strict as assert } from 'node:assert';
import { test, beforeEach } from 'node:test';
import { persist, restore, clear } from './session';
import { SESSION_STORAGE_KEY } from './token';
import type { SessionUser } from './roles';
/*
A session without a token is not a session.
On 2026-09-25 a PUT to `users/update` came back 401 and the request carried no
`authorization` header at all. The console was signed in by every visible
measure — name in the corner, nav rendered, pages mounted — and could not make
one authenticated request, because `restore()` accepted a stored blob that had a
`userid` and a `role` and no token.
Two ways in. A session stored before the token existed; or a login against a
server that could not mint one — `attachWebSession` logs that failure and
returns the user record anyway, which was right while WEB_AUTH_REQUIRED was off
and is a broken console now that it defaults on.
Either way the state is the same and it is the worst one available: authorised
enough to render, not enough to load anything, and nothing on screen saying so.
*/
// A minimal sessionStorage, since node has none.
const store = new Map<string, string>();
(globalThis as { sessionStorage?: unknown }).sessionStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
};
const signedIn: SessionUser = {
userid: 904,
role: 'store-admin',
token: 'w1.payload.signature',
} as SessionUser;
beforeEach(() => store.clear());
test('a stored session with a token comes back', () => {
persist(signedIn);
assert.equal(restore()?.userid, 904);
assert.equal(restore()?.token, 'w1.payload.signature');
});
test('a session with no token is refused', () => {
// The bug. Restoring this renders a console that cannot load anything.
store.set(SESSION_STORAGE_KEY, JSON.stringify({ userid: 904, role: 'store-admin' }));
assert.equal(restore(), null, 'a tokenless session was restored');
});
test('a session with an empty token is refused', () => {
// `token: ""` is what a server that failed to mint would produce if the field
// were assigned rather than spread. Same broken state, different shape.
store.set(SESSION_STORAGE_KEY, JSON.stringify({ userid: 904, role: 'store-admin', token: ' ' }));
assert.equal(restore(), null, 'an empty token was accepted');
});
test('the checks that were already there still hold', () => {
// A corrupted blob must not crash the shell on boot.
for (const bad of [
'{"role":"store-admin","token":"t"}',
'{"userid":904,"token":"t"}',
'{"userid":"904","role":"store-admin","token":"t"}',
'not json at all',
'',
]) {
store.set(SESSION_STORAGE_KEY, bad);
assert.equal(restore(), null, `restored from ${bad}`);
}
});
test('signing out leaves nothing behind', () => {
persist(signedIn);
clear();
assert.equal(restore(), null);
});

View File

@@ -82,10 +82,25 @@ export async function login(email: string, password: string): Promise<SessionUse
}
// The token rides on the envelope, not on `details` — it is not a fact about
// the user, it is what proves a later request is theirs. Absent against a
// Fiesta that does not issue one yet, which is why it is spread in rather
// than assigned: `exactOptionalPropertyTypes` refuses an explicit undefined.
const session = { ...toSessionUser(envelope.details), ...(envelope.token ? { token: envelope.token } : {}) };
// the user, it is what proves a later request is theirs.
//
// Refused when absent, rather than stored and hoped for. `attachWebSession`
// on the server logs a minting failure and returns the user record anyway,
// which was correct while WEB_AUTH_REQUIRED was off and is a broken console
// now that it defaults on: the sign-in succeeds, the shell renders, and every
// request after it goes out with no `authorization` header and comes back
// 401 with nothing on screen to say why.
//
// Failing here names the problem at the moment it happens, to the person best
// placed to report it, instead of scattering 401s across every page.
if (!envelope.token) {
throw new Error(
'Signed in, but the server did not issue a session. Nothing would load — ' +
'tell your administrator the API could not mint a session token.',
);
}
const session = { ...toSessionUser(envelope.details), token: envelope.token };
persist(session);
return session;
}
@@ -208,6 +223,24 @@ export function restore(): SessionUser | null {
// A stored blob is only as trustworthy as the tab it came from; a shape
// check keeps a corrupted value from crashing the shell on boot.
if (typeof parsed?.userid !== 'number' || typeof parsed?.role !== 'string') return null;
// A session without a token is not a session.
//
// This used to be restored happily, and the result was the worst state the
// console can be in: signed in by every visible measure — name in the
// corner, nav rendered, pages mounted — and unable to make a single
// authenticated request, because `authHeader()` had nothing to send. Every
// call came back 401 and nothing on screen explained why. A PUT to
// `users/update` on 2026-09-25 went out with no `authorization` header at
// all, which is what sent us looking.
//
// It happens whenever login could not mint one: `attachWebSession` logs the
// failure and returns the user record anyway, which was right while
// WEB_AUTH_REQUIRED was off and is a broken console now that it defaults on.
// A stored session predating the token also lands here.
//
// Returning null sends them to the sign-in screen, which is a state people
// know what to do with.
if (typeof parsed.token !== 'string' || parsed.token.trim() === '') return null;
return parsed;
} catch {
return null;