fix(auth): preserve remember-me when password change re-issues the session (#1927)

This commit is contained in:
jubnl
2026-08-15 22:48:22 +02:00
parent 72221a2e45
commit 2bffee8fd4
3 changed files with 96 additions and 3 deletions
+75
View File
@@ -45,7 +45,10 @@ import { MailerService } from '../../src/nest/notifications/mailer/mailer.servic
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { createUser } from '../helpers/factories';
import { resetRateLimits } from '../helpers/test-db';
import { AuthModule } from '../../src/nest/auth/auth.module';
import { AuthService } from '../../src/nest/auth/auth.service';
import { SessionRenewalInterceptor } from '../../src/nest/auth/session-renewal.interceptor';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
@@ -70,6 +73,9 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
// Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
// by metatype, exactly as they do under buildApp().
nest.useGlobalPipes(new ZodValidationPipe());
// Mirror the production APP_INTERCEPTOR: sliding session renewal (#1927).
// Inert for the harness's exp-less tokens and freshly issued logins.
nest.useGlobalInterceptors(new SessionRenewalInterceptor(moduleRef.get(AuthService)));
await nest.init();
return nest;
}
@@ -154,6 +160,75 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
expect(cookie).not.toMatch(/Expires/i);
}, 10000);
it('renews the session cookie when the token is past half its lifetime (#1927)', async () => {
// 70% of a 24h token consumed → the interceptor re-issues the cookie.
const res = await request(server)
.get('/api/auth/me')
.set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 60000 }));
expect(res.status).toBe(200);
const setCookie = (res.headers['set-cookie'] ?? []) as unknown as string[];
const renewed = setCookie.find((c) => c.startsWith('trek_session='))!;
expect(renewed).toBeTruthy();
expect(renewed).toMatch(/Max-Age=86400/i);
// The renewed token is a fresh full-lifetime session for the same user.
const jwt = require('jsonwebtoken');
const decoded = jwt.decode(/trek_session=([^;]+)/.exec(renewed)![1]) as { id: number; exp: number; iat: number };
expect(decoded.id).toBe(userId);
expect(decoded.exp - decoded.iat).toBe(86400);
}, 10000);
it('renews a remembered session with the long window and a remember:false one as a session cookie (#1927)', async () => {
const long = await request(server)
.get('/api/auth/me')
.set('Cookie', sessionCookie(userId, 0, { lifetime: 2592000, consumed: 1600000, remember: true }));
expect(long.status).toBe(200);
const longCookie = ((long.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
expect(longCookie).toMatch(/Max-Age=2592000/i);
const sess = await request(server)
.get('/api/auth/me')
.set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 60000, remember: false }));
expect(sess.status).toBe(200);
const sessCookie = ((sess.headers['set-cookie'] ?? []) as unknown as string[]).find((c) => c.startsWith('trek_session='))!;
expect(sessCookie).not.toMatch(/Max-Age/i);
expect(sessCookie).not.toMatch(/Expires/i);
}, 10000);
it('does not renew a young session cookie (#1927)', async () => {
const res = await request(server)
.get('/api/auth/me')
.set('Cookie', sessionCookie(userId, 0, { lifetime: 86400, consumed: 1000 }));
expect(res.status).toBe(200);
const setCookie = (res.headers['set-cookie'] ?? []) as unknown as string[];
expect(setCookie.some((c) => c.startsWith('trek_session='))).toBe(false);
}, 10000);
it('PUT /me/password preserves the remember choice on the re-issued cookie (#1927)', async () => {
resetRateLimits(app); // earlier login cases share the same per-ip 'login' bucket
const seeded = createUser(db as never, { username: 'pw-remember', email: 'pw-remember@example.test' });
const login = await request(server)
.post('/api/auth/login')
.send({ email: seeded.user.email, password: seeded.password, remember_me: true });
expect(login.status).toBe(200);
const loginCookie = ((login.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session=')))!;
const sessionValue = /trek_session=([^;]+)/.exec(loginCookie)![1];
const change = await request(server)
.put('/api/auth/me/password')
.set('Cookie', `trek_session=${sessionValue}`)
.send({ current_password: seeded.password, new_password: 'New1234!x' });
expect(change.status).toBe(200);
// The handler's re-issue is appended after any interceptor renewal — the
// browser keeps the last trek_session cookie, so assert on that one.
const setCookie = (change.headers['set-cookie'] ?? []) as unknown as string[];
const finalCookie = setCookie.filter((c) => c.startsWith('trek_session=')).pop()!;
expect(finalCookie).toMatch(/Max-Age=2592000/i);
const jwt = require('jsonwebtoken');
const decoded = jwt.decode(/trek_session=([^;]+)/.exec(finalCookie)![1]) as { remember?: boolean; exp: number; iat: number };
expect(decoded.remember).toBe(true);
expect(decoded.exp - decoded.iat).toBe(2592000);
}, 10000);
it('POST /register creates the user, sets the cookie and audits user.register', async () => {
const before = auditRows('user.register');
const res = await request(server)