diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index 04e0480eb..b692eb235 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -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 | 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 }; } diff --git a/server/tests/e2e/auth.e2e.test.ts b/server/tests/e2e/auth.e2e.test.ts index 05b99b53f..c1bd09cdf 100644 --- a/server/tests/e2e/auth.e2e.test.ts +++ b/server/tests/e2e/auth.e2e.test.ts @@ -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) diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index 77390c5b9..23a26b742 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -265,7 +265,20 @@ describe('AuthController (authenticated)', () => { const setAuthCookie = vi.fn(); const c = ac(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial), 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), 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', () => {