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
+7 -2
View File
@@ -40,6 +40,7 @@ import {
import { RateLimitService } from '../common/rate-limit.service';
import { JwtAuthGuard } from './jwt-auth.guard';
import { CurrentUser } from './current-user.decorator';
import { decodeSessionClaims } from './jwt-verify';
import { getClientIp } from '../audit/client-ip';
import { AuditService } from '../audit/audit.service';
import type { User } from '../../types';
@@ -97,13 +98,17 @@ export class AuthController {
@Put('me/password')
changePassword(@CurrentUser() user: User, @Body() body: ChangePasswordDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
this.limit('login', req, 5);
const result = this.auth.changePassword(user.id, user.email, body);
// Carry the session's remember choice into the re-issued token/cookie so a
// "remember me" login survives a password change (#1927). Bearer callers
// have no cookie → undefined → the historical default duration.
const remember = decodeSessionClaims((req.cookies as Record<string, string> | undefined)?.trek_session)?.remember;
const result = this.auth.changePassword(user.id, user.email, body, remember);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
// Refresh this device's cookie with the new password_version so the user
// stays logged in here while all other sessions are invalidated.
if (result.token) this.auth.setAuthCookie(res, result.token, req);
if (result.token) this.auth.setAuthCookie(res, result.token, req, remember);
this.audit.writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
return { success: true };
}
+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)
+14 -1
View File
@@ -265,7 +265,20 @@ describe('AuthController (authenticated)', () => {
const setAuthCookie = vi.fn();
const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial<AuthService>), rl());
expect(c.changePassword(user, anyBody(), req, res)).toEqual({ success: true });
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req);
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req, undefined);
});
it('change-password carries the session cookie remember claim into the service and the re-issued cookie (#1927)', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jwt = require('jsonwebtoken');
const remembered = jwt.sign({ id: 1, pv: 0, remember: true }, 'any-secret');
const reqCookie = { ip: '9.9.9.9', headers: {}, cookies: { trek_session: remembered } } as unknown as Request;
const setAuthCookie = vi.fn();
const changePassword = vi.fn().mockReturnValue({ token: 'tk3' });
const c = ac(asvc({ changePassword, setAuthCookie } as Partial<AuthService>), rl());
expect(c.changePassword(user, anyBody(), reqCookie, res)).toEqual({ success: true });
expect(changePassword).toHaveBeenCalledWith(1, 'u@example.test', anyBody(), true);
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk3', reqCookie, true);
});
it('delete-account maps error, else audits and succeeds', () => {