@proteinjs/user-server 1.10.1 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ import http from 'http';
2
+ import { AddressInfo } from 'net';
3
+ import expressSession, { Store } from 'express-session';
4
+ import passport from 'passport';
5
+ import { devLogin } from '../src/routes/devLogin';
6
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
7
+
8
+ const testEnv = new UserServerTestEnvironment();
9
+
10
+ /**
11
+ * DEV_SMOKE_OVERNIGHT (2026-08-26) finding 7: a browser holding a STALE session cookie (a sid
12
+ * absent from the store — prior server generation, swept row, or reseeded dev db) hits
13
+ * `GET /dev/login`, gets the 302, and lands on the LOGIN FORM; the second hit lands home.
14
+ *
15
+ * The mechanism is write-ordering at the session-establishment seam: a browser follows a redirect
16
+ * the moment RESPONSE HEADERS arrive — it never waits for the body. So the session row for the
17
+ * sid named by the 302's Set-Cookie must be COMMITTED to the store before those headers flush.
18
+ * passport 0.6's `request.login` guarantees exactly that (regenerate → bind → save, committed
19
+ * before the login callback — see establishSession); under a pre-0.6 passport runtime the save
20
+ * is deferred to express-session's end-of-response proxy, whose split-response write flushes the
21
+ * 302's headers BEFORE the store write commits — the redirected navigation races the commit and
22
+ * loses, reads no session, and the SPA routes to /login.
23
+ *
24
+ * This suite drives the REAL stack a browser meets — express-session 1.17.1 (the exact
25
+ * @proteinjs/server configureSession options and dev cookie-name shape) + real passport +
26
+ * the devLogin route — against a store with honest commit latency, and asserts the contract
27
+ * from the client's side: follow the 302 the instant headers arrive, carrying exactly the
28
+ * cookies the response set, and the follow-up MUST read as authenticated on the FIRST pass.
29
+ */
30
+
31
+ const SESSION_COOKIE_NAME = 'n3xa.sid.9876'; // dev cookie-name shape: n3xa.sid.${SERVER_PORT}
32
+ const SESSION_SECRET = 'dev-first-hit-test-secret';
33
+ const ENV_EMAIL = 'dev@test.local';
34
+ /** Store commit latency. Generous so a pre-0.6 runtime (write racing the redirect) loses the
35
+ * race DETERMINISTICALLY; under the passport 0.6 contract the row commits before the redirect
36
+ * is even issued, so green never depends on this number. */
37
+ const COMMIT_LATENCY_MS = 150;
38
+ const READ_LATENCY_MS = 5;
39
+
40
+ /**
41
+ * In-memory session store with DbSessionStore's contract: callbacks fire only after the
42
+ * operation commits, and commits take real time (Spanner in dev). Rows are inspectable so the
43
+ * test can assert store truth directly and clear it to model a next server generation.
44
+ */
45
+ class CommitLatencySessionStore extends Store {
46
+ readonly rows = new Map<string, string>();
47
+
48
+ get = (sessionId: string, cb: (error: unknown, session?: Express.SessionData | null) => void) => {
49
+ const timer = setTimeout(() => {
50
+ const row = this.rows.get(sessionId);
51
+ cb(null, row ? JSON.parse(row) : undefined);
52
+ }, READ_LATENCY_MS);
53
+ timer.unref();
54
+ };
55
+
56
+ set = (sessionId: string, session: Express.SessionData, cb?: (error?: unknown) => void) => {
57
+ const timer = setTimeout(() => {
58
+ this.rows.set(sessionId, JSON.stringify(session));
59
+ if (cb) {
60
+ cb();
61
+ }
62
+ }, COMMIT_LATENCY_MS);
63
+ timer.unref();
64
+ };
65
+
66
+ destroy = (sessionId: string, cb?: (error?: unknown) => void) => {
67
+ const timer = setTimeout(() => {
68
+ this.rows.delete(sessionId);
69
+ if (cb) {
70
+ cb();
71
+ }
72
+ }, READ_LATENCY_MS);
73
+ timer.unref();
74
+ };
75
+ }
76
+
77
+ type Middleware = (req: unknown, res: unknown, next: (error?: unknown) => void) => void;
78
+
79
+ type ClientResponse = { statusCode?: number; headers: http.IncomingHttpHeaders; body?: string };
80
+
81
+ describe('devLogin first hit through the real session middleware stack', () => {
82
+ const originalEnv = {
83
+ DEVELOPMENT: process.env.DEVELOPMENT,
84
+ DEV_AUTO_LOGIN_EMAIL: process.env.DEV_AUTO_LOGIN_EMAIL,
85
+ };
86
+ const store = new CommitLatencySessionStore();
87
+ let server: http.Server;
88
+ let port: number;
89
+
90
+ beforeAll(async () => {
91
+ await testEnv.beforeAll();
92
+ await testEnv.createUser({ name: 'Dev Default', email: ENV_EMAIL });
93
+ server = createServerWithRealSessionStack(store);
94
+ await new Promise<void>((resolve) => server.listen(0, resolve));
95
+ port = (server.address() as AddressInfo).port;
96
+ });
97
+
98
+ afterAll(async () => {
99
+ await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
100
+ await testEnv.afterAll();
101
+ });
102
+
103
+ beforeEach(() => {
104
+ process.env.DEVELOPMENT = 'true';
105
+ process.env.DEV_AUTO_LOGIN_EMAIL = ENV_EMAIL;
106
+ });
107
+
108
+ afterEach(() => {
109
+ for (const [key, value] of Object.entries(originalEnv)) {
110
+ if (value === undefined) {
111
+ delete process.env[key];
112
+ } else {
113
+ process.env[key] = value;
114
+ }
115
+ }
116
+ });
117
+
118
+ it('stale-cookie first hit: following the 302 on HEADERS reads as authenticated, first pass', async () => {
119
+ // A genuinely stale cookie: minted by a prior hit (valid signature, real sid), then the
120
+ // store loses the row — a prior server generation against a reseeded/swept dev db.
121
+ const priorHit = await request(port, '/dev/login', { waitForBody: true });
122
+ const staleCookie = sessionCookiePair(priorHit);
123
+ store.rows.clear();
124
+
125
+ // First hit with the stale cookie. Resolve on response HEADERS — the moment a browser
126
+ // starts the redirected navigation — never on body completion.
127
+ const firstHit = await request(port, '/dev/login', { cookie: staleCookie });
128
+ expect(firstHit.statusCode).toBe(302);
129
+ expect(firstHit.headers.location).toBe('/');
130
+
131
+ // The 302 must mint a fresh sid (the stale sid must not be resurrected)...
132
+ const followUpCookie = sessionCookiePair(firstHit);
133
+ expect(sidOf(followUpCookie)).not.toBe(sidOf(staleCookie));
134
+
135
+ // ...whose session row is ALREADY COMMITTED: the immediate follow-up navigation, carrying
136
+ // exactly the cookies the response set, reads as authenticated on the FIRST pass.
137
+ const followUp = await request(port, '/whoami', { cookie: followUpCookie, waitForBody: true });
138
+ expect(JSON.parse(followUp.body!)).toEqual({ user: ENV_EMAIL });
139
+ });
140
+
141
+ it('cookieless first hit: same first-pass guarantee for a fresh browser', async () => {
142
+ store.rows.clear();
143
+
144
+ const firstHit = await request(port, '/dev/login');
145
+ expect(firstHit.statusCode).toBe(302);
146
+
147
+ const followUp = await request(port, '/whoami', { cookie: sessionCookiePair(firstHit), waitForBody: true });
148
+ expect(JSON.parse(followUp.body!)).toEqual({ user: ENV_EMAIL });
149
+ });
150
+ });
151
+
152
+ /**
153
+ * The real middleware stack a request traverses in front of the route — express-session with
154
+ * @proteinjs/server configureSession's exact options (resave: false, saveUninitialized: false,
155
+ * rolling: true, the dev port-scoped cookie name) + passport initialize/session with the
156
+ * identity (de)serialization @proteinjs/server registers — on a raw node http server, so
157
+ * header-flush timing is the genuine article.
158
+ */
159
+ function createServerWithRealSessionStack(store: Store): http.Server {
160
+ const sixtyDays = 1000 * 60 * 60 * 24 * 60;
161
+ const sessionMiddleware = expressSession({
162
+ name: SESSION_COOKIE_NAME,
163
+ secret: SESSION_SECRET,
164
+ store,
165
+ resave: false,
166
+ saveUninitialized: false,
167
+ cookie: { maxAge: sixtyDays },
168
+ rolling: true,
169
+ });
170
+ const authenticator = new passport.Passport();
171
+ authenticator.serializeUser((user: unknown, done: (err: unknown, id?: unknown) => void) => done(null, user));
172
+ authenticator.deserializeUser((id: unknown, done: (err: unknown, user?: unknown) => void) => done(null, id));
173
+ const middleware: Middleware[] = [
174
+ sessionMiddleware as unknown as Middleware,
175
+ authenticator.initialize() as unknown as Middleware,
176
+ authenticator.session() as unknown as Middleware,
177
+ ];
178
+
179
+ return http.createServer((req, res) => {
180
+ runMiddleware(middleware, req, res, (error?: unknown) => {
181
+ if (error) {
182
+ res.statusCode = 500;
183
+ res.end(String(error));
184
+ return;
185
+ }
186
+ void dispatch(req, res);
187
+ });
188
+ });
189
+ }
190
+
191
+ function runMiddleware(
192
+ middleware: Middleware[],
193
+ req: http.IncomingMessage,
194
+ res: http.ServerResponse,
195
+ done: (error?: unknown) => void
196
+ ) {
197
+ const next = (index: number) => (error?: unknown) => {
198
+ if (error || index >= middleware.length) {
199
+ done(error);
200
+ return;
201
+ }
202
+ middleware[index](req, res, next(index + 1));
203
+ };
204
+ next(0)();
205
+ }
206
+
207
+ async function dispatch(req: http.IncomingMessage, res: http.ServerResponse) {
208
+ const url = new URL(req.url ?? '/', 'http://localhost');
209
+ if (url.pathname === '/dev/login') {
210
+ const request = req as never as Record<string, unknown>;
211
+ const query: Record<string, string> = {};
212
+ url.searchParams.forEach((value, key) => (query[key] = value));
213
+ request.query = query;
214
+ try {
215
+ await devLogin.onRequest(req as never, expressResponseShim(res) as never);
216
+ } catch (error) {
217
+ // Mirrors @proteinjs/server's wrapRoute: a route throw is caught and logged, never a
218
+ // hung response. (There it falls through to the star route; here we end with 500.)
219
+ console.error(error);
220
+ if (!res.writableEnded) {
221
+ res.statusCode = 500;
222
+ res.end();
223
+ }
224
+ }
225
+ return;
226
+ }
227
+ if (url.pathname === '/whoami') {
228
+ // The session-derived signal the SPA lands on: authenticated → home, none → login form.
229
+ const session = (req as never as { session?: { passport?: { user?: string } } }).session;
230
+ res.setHeader('Content-Type', 'application/json');
231
+ res.end(JSON.stringify({ user: session?.passport?.user ?? null }));
232
+ return;
233
+ }
234
+ res.statusCode = 404;
235
+ res.end();
236
+ }
237
+
238
+ /** The express response surface devLogin touches, over the RAW response object — so
239
+ * express-session's proxied `res.end` (the commit point under test) is the one that runs. */
240
+ function expressResponseShim(res: http.ServerResponse) {
241
+ const shim = {
242
+ status(code: number) {
243
+ res.statusCode = code;
244
+ return shim;
245
+ },
246
+ send(body?: unknown) {
247
+ res.end(body === undefined ? undefined : String(body));
248
+ },
249
+ redirect(path: string) {
250
+ const body = `Found. Redirecting to ${path}`;
251
+ res.statusCode = 302;
252
+ res.setHeader('Location', path);
253
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
254
+ res.setHeader('Content-Length', Buffer.byteLength(body));
255
+ res.end(body);
256
+ },
257
+ };
258
+ return shim;
259
+ }
260
+
261
+ /**
262
+ * GET `path`. By default resolves the moment response HEADERS arrive (browser-following-a-
263
+ * redirect semantics — the body may still be in flight and its socket is left to drain);
264
+ * `waitForBody` resolves after the full body, which with express-session's end proxy also
265
+ * means the response's session write has committed.
266
+ */
267
+ function request(
268
+ port: number,
269
+ path: string,
270
+ options: { cookie?: string; waitForBody?: boolean } = {}
271
+ ): Promise<ClientResponse> {
272
+ return new Promise((resolve, reject) => {
273
+ const clientRequest = http.get(
274
+ { port, path, headers: options.cookie ? { cookie: options.cookie } : {}, agent: false },
275
+ (res) => {
276
+ if (!options.waitForBody) {
277
+ resolve({ statusCode: res.statusCode, headers: res.headers });
278
+ res.resume();
279
+ return;
280
+ }
281
+ let body = '';
282
+ res.on('data', (chunk) => (body += chunk));
283
+ res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body }));
284
+ }
285
+ );
286
+ clientRequest.on('error', reject);
287
+ });
288
+ }
289
+
290
+ /** The `name=value` pair of the response's session Set-Cookie — what the browser sends back. */
291
+ function sessionCookiePair(response: ClientResponse): string {
292
+ const setCookies = response.headers['set-cookie'] ?? [];
293
+ const sessionCookie = setCookies.find((cookie) => cookie.startsWith(`${SESSION_COOKIE_NAME}=`));
294
+ if (!sessionCookie) {
295
+ throw new Error(`response set no ${SESSION_COOKIE_NAME} cookie (Set-Cookie: ${JSON.stringify(setCookies)})`);
296
+ }
297
+ return sessionCookie.split(';')[0];
298
+ }
299
+
300
+ /** The raw sid inside a signed session cookie pair (`name=s%3A<sid>.<signature>`). */
301
+ function sidOf(cookiePair: string): string {
302
+ const value = decodeURIComponent(cookiePair.slice(cookiePair.indexOf('=') + 1));
303
+ return value.startsWith('s:') ? value.slice(2, value.indexOf('.')) : value;
304
+ }
@@ -0,0 +1,54 @@
1
+ import { establishSession } from '../src/authentication/establishSession';
2
+ import { createPassportRequest } from './passportSessionHarness';
3
+
4
+ /**
5
+ * establishSession's post-condition: `request.login` must have REPLACED `request.session`
6
+ * (express-session's regenerate always assigns a fresh Session object — the fresh sid the
7
+ * commit-before-response save then writes under). Passport 0.6 honors that on every login;
8
+ * a pre-0.6 passport runtime (@proteinjs/server < 3.5.1) resolves the login WITHOUT
9
+ * regenerating or saving — silently reopening the session-fixation hole (CVE-2022-25896) AND
10
+ * the first-hit race (the post-login navigation beating the deferred session write — the
11
+ * /dev/login lands-on-login-form smoke finding, DEV_SMOKE_OVERNIGHT 2026-08-26 #7).
12
+ *
13
+ * The guard turns that silent degradation into a loud, seam-naming failure at the one owner of
14
+ * session establishment. DevLoginStaleCookieFirstHit.test.ts proves the honored contract
15
+ * through the real middleware stack; this suite pins the refusal when the runtime cannot honor
16
+ * it.
17
+ */
18
+ describe('establishSession runtime contract', () => {
19
+ it('rejects, naming the seam, when login resolves without replacing the session (pre-0.6 passport shape)', async () => {
20
+ const session: Record<string, unknown> = {};
21
+ const request = {
22
+ session,
23
+ login(user: string, cb: (error?: unknown) => void) {
24
+ // passport 0.4's SessionManager.logIn: bind onto the SAME session object, no
25
+ // regenerate, no save — the stale-runtime shape the guard must refuse.
26
+ session.passport = { user };
27
+ cb();
28
+ },
29
+ };
30
+
31
+ await expect(establishSession(request, 'dev@test.local')).rejects.toThrow(/@proteinjs\/server/);
32
+ });
33
+
34
+ it('resolves through the real passport 0.6 machinery, user bound on the post-regeneration session', async () => {
35
+ const { request, events } = await createPassportRequest();
36
+
37
+ await establishSession(request, 'dev@test.local');
38
+
39
+ expect(request.session.passport.user).toBe('dev@test.local');
40
+ expect(events).toEqual(['regenerate', 'login', 'save']);
41
+ });
42
+
43
+ it('still surfaces a login failure as a rejection (never as the contract error)', async () => {
44
+ const loginError = new Error('serialize blew up');
45
+ const request = {
46
+ session: {},
47
+ login(_user: string, cb: (error?: unknown) => void) {
48
+ cb(loginError);
49
+ },
50
+ };
51
+
52
+ await expect(establishSession(request, 'dev@test.local')).rejects.toThrow('serialize blew up');
53
+ });
54
+ });