diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 542fa9848..4d4a88bc7 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -64,6 +64,7 @@ import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../mcp/nest-mcp-poli import { TrekExceptionFilter } from './common/trek-exception.filter'; import { SpaFallbackFilter } from './platform/spa-fallback.filter'; import { IdempotencyInterceptor } from './common/idempotency.interceptor'; +import { SessionRenewalInterceptor } from './auth/session-renewal.interceptor'; import { IdempotencyCleanupJob } from './common/idempotency-cleanup.job'; import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; @@ -97,6 +98,9 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; // Replays the X-Idempotency-Key the client sends on every write, so retried // mutations don't double-apply. { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, + // Sliding session renewal: re-issues the trek_session cookie once a + // cookie-authenticated token is past half its lifetime (#1927). + { provide: APP_INTERCEPTOR, useClass: SessionRenewalInterceptor }, // Its nightly TTL purge — a provider here because common/ has no module. IdempotencyCleanupJob, // Global Zod validation: any parameter typed with a createZodDto class diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index b6eab60b6..3cb0c91c6 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -197,15 +197,19 @@ export class AuthService { return !this.resolveAuthToggles().password_login; } - generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) { + generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean) { const pv = typeof user.password_version === 'number' ? user.password_version : (this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', user.id)?.password_version ?? 0); // "Remember me" extends the JWT lifetime to match the persistent cookie maxAge; // the cookie service decides session-vs-persistent off the same flag. - const expiresIn = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS; + const expiresIn = remember === true ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS; + // The flag is embedded as a claim so sliding renewal can re-issue with the + // same duration AND cookie semantics (false → browser-session cookie is not + // recoverable from exp − iat). Omitted when the caller didn't choose, so + // register/demo/passkey tokens keep their historical payload. return jwt.sign( - { id: user.id, pv }, + { id: user.id, pv, ...(typeof remember === 'boolean' ? { remember } : {}) }, JWT_SECRET, { expiresIn, algorithm: 'HS256' } ); @@ -514,7 +518,8 @@ export class AuthService { changePassword( userId: number, userEmail: string, - rawBody: unknown + rawBody: unknown, + remember?: boolean, ): { error?: string; status?: number; success?: boolean; token?: string } { const body = rawBody as { current_password?: string; new_password?: string }; if (this.isOidcOnlyMode()) { @@ -554,8 +559,10 @@ export class AuthService { try { revokeUserSessions?.(userId); } catch { /* best-effort */ } // Re-issue a session bound to the new password_version so the current device - // stays logged in while other existing sessions are rotated out by the pv gate. - const token = this.generateToken({ id: userId, password_version: newPv }); + // stays logged in while other existing sessions are rotated out by the pv + // gate — preserving the login's remember choice instead of downgrading a + // remembered session to the default duration (#1927). + const token = this.generateToken({ id: userId, password_version: newPv }, remember); return { success: true, token }; } diff --git a/server/src/nest/auth/jwt-verify.ts b/server/src/nest/auth/jwt-verify.ts index 24280e326..c22d881b7 100644 --- a/server/src/nest/auth/jwt-verify.ts +++ b/server/src/nest/auth/jwt-verify.ts @@ -32,6 +32,28 @@ export function extractToken(req: Request): string | null { * compares the claim. Several paths used to call `jwt.verify` directly and skip * the DB lookup, so a stolen token kept working after the victim reset. */ +export interface SessionClaims { + id?: number; + pv?: number; + remember?: boolean; + purpose?: string; + iat?: number; + exp?: number; +} + +/** + * Decode (NOT verify) a session token's claims. Only for callers that have + * already authenticated the request through a guard and need the token's + * metadata — sliding renewal and the password-change cookie re-issue read the + * `remember` claim this way. Never use this as an auth check. + */ +export function decodeSessionClaims(token: string | undefined): SessionClaims | null { + if (!token) return null; + const decoded = jwt.decode(token); + if (!decoded || typeof decoded !== 'object') return null; + return decoded as SessionClaims; +} + export function verifyJwtAndLoadUser(token: string): User | null { try { const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string }; diff --git a/server/src/nest/auth/session-renewal.interceptor.ts b/server/src/nest/auth/session-renewal.interceptor.ts new file mode 100644 index 000000000..a0f688e17 --- /dev/null +++ b/server/src/nest/auth/session-renewal.interceptor.ts @@ -0,0 +1,62 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { Observable } from 'rxjs'; +import { AuthService } from './auth.service'; +import { decodeSessionClaims } from './jwt-verify'; +import { setAuthCookie } from '../common/cookie'; + +/** + * Sliding session renewal (#1927): once a cookie-authenticated session token is + * past half of its lifetime, re-issue the trek_session cookie with a fresh + * token so an active user never hits the expiry cliff mid-use. Renewal + * preserves the login's remember semantics via the token's `remember` claim + * (true → long persistent cookie, false → browser-session cookie, absent → the + * historical default duration). + * + * Cheap by construction — no DB access: + * - `req.user` is only set after a guard ran verifyJwtAndLoadUser on this exact + * request, which already proved the token's `pv` matches the DB, so both the + * user id and pv can be copied from the decoded (not re-verified) token. + * - The cookie is only renewed when it is the verified credential: extractToken + * prefers the cookie over the Authorization header, so cookie-present + + * req.user set means the guard verified the cookie. Bearer-only callers + * (MCP, API clients) are never renewed. + * + * The cookie is written eagerly, before the handler runs: renewal doesn't + * depend on the handler's outcome, and this sidesteps @Res()/streaming routes + * whose headers are gone by the time the observable settles. When a handler + * later sets or clears trek_session itself (password change, logout), Express + * appends its Set-Cookie after this one and the browser keeps the last — the + * handler's cookie wins, which is the correct outcome. + */ +@Injectable() +export class SessionRenewalInterceptor implements NestInterceptor { + constructor(private readonly auth: AuthService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() === 'http') { + const req = context.switchToHttp().getRequest(); + const res = context.switchToHttp().getResponse(); + this.maybeRenew(req, res); + } + return next.handle(); + } + + private maybeRenew(req: Request & { user?: { id: number } }, res: Response): void { + const cookieToken: string | undefined = (req as { cookies?: Record }).cookies?.trek_session; + if (!req.user || !cookieToken || res.headersSent) return; + + const claims = decodeSessionClaims(cookieToken); + if (!claims || claims.purpose || claims.id !== req.user.id) return; + if (typeof claims.iat !== 'number' || typeof claims.exp !== 'number' || claims.exp <= claims.iat) return; + + const halfLife = claims.iat + (claims.exp - claims.iat) / 2; + if (Date.now() / 1000 < halfLife) return; + + const token = this.auth.generateToken( + { id: req.user.id, password_version: claims.pv ?? 0 }, + claims.remember, + ); + setAuthCookie(res, token, req, claims.remember); + } +} diff --git a/server/tests/e2e/harness.ts b/server/tests/e2e/harness.ts index e829bce08..d50f59f3f 100644 --- a/server/tests/e2e/harness.ts +++ b/server/tests/e2e/harness.ts @@ -54,12 +54,28 @@ export function seedUser(db: Database.Database, overrides: Partial = return user; } +export interface SessionSignOptions { + /** Embed the remember claim (#1927). Omitted when undefined, like the real generateToken. */ + remember?: boolean; + /** Backdate the token: seconds of its lifetime already consumed. Requires lifetime. */ + consumed?: number; + /** Token lifetime in seconds. Default: no exp claim (the historical harness token). */ + lifetime?: number; +} + /** Sign a `trek_session` token the real guard will accept (matching JWT_SECRET + pv). */ -export function signSession(userId: number, passwordVersion = 0): string { - return jwt.sign({ id: userId, pv: passwordVersion }, JWT_SECRET, { algorithm: 'HS256' }); +export function signSession(userId: number, passwordVersion = 0, opts: SessionSignOptions = {}): string { + const payload: Record = { id: userId, pv: passwordVersion }; + if (typeof opts.remember === 'boolean') payload.remember = opts.remember; + // jsonwebtoken computes exp = iat + expiresIn when iat is supplied in the payload. + if (typeof opts.consumed === 'number') payload.iat = Math.floor(Date.now() / 1000) - opts.consumed; + return jwt.sign(payload, JWT_SECRET, { + algorithm: 'HS256', + ...(typeof opts.lifetime === 'number' ? { expiresIn: opts.lifetime } : {}), + }); } /** Convenience: the Cookie header value for a signed session. */ -export function sessionCookie(userId: number, passwordVersion = 0): string { - return `trek_session=${signSession(userId, passwordVersion)}`; +export function sessionCookie(userId: number, passwordVersion = 0, opts: SessionSignOptions = {}): string { + return `trek_session=${signSession(userId, passwordVersion, opts)}`; } diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index 171a2b779..418aa8a23 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -301,4 +301,7 @@ export const TEST_CONFIG = { SESSION_DURATION: '24h', SESSION_DURATION_MS: 86400000, SESSION_DURATION_SECONDS: 86400, + SESSION_DURATION_REMEMBER: '30d', + SESSION_DURATION_REMEMBER_MS: 2592000000, + SESSION_DURATION_REMEMBER_SECONDS: 2592000, }; diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 8f092acbd..8588ea1a8 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -480,6 +480,18 @@ describe('changePassword — session invalidation', () => { expect(verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump }); + + it('AUTH-DB-036d: preserves the remember choice in the re-issued session (#1927)', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const jwt = require('jsonwebtoken'); + const { user, password } = createUser(testDb); + + const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, true); + expect(result.success).toBe(true); + const decoded = jwt.decode(result.token!) as { remember?: boolean; iat: number; exp: number }; + expect(decoded.remember).toBe(true); + expect(decoded.exp - decoded.iat).toBe(2592000); // remember window survives the change + }); }); // --------------------------------------------------------------------------- @@ -965,3 +977,25 @@ describe('MCP token verification round-trips', () => { expect(svc.verifyJwtToken('not-a-jwt')).toBeNull(); }); }); + +describe('generateToken remember claim (#1927)', () => { + const jwt = require('jsonwebtoken'); + + it('AUTH-TOKEN-001: embeds remember and picks the matching lifetime when the caller chose', () => { + const { user } = createUser(testDb); + const long = jwt.decode(svc.generateToken({ id: user.id }, true)) as { remember?: boolean; iat: number; exp: number }; + expect(long.remember).toBe(true); + expect(long.exp - long.iat).toBe(2592000); + + const short = jwt.decode(svc.generateToken({ id: user.id }, false)) as { remember?: boolean; iat: number; exp: number }; + expect(short.remember).toBe(false); + expect(short.exp - short.iat).toBe(86400); + }); + + it('AUTH-TOKEN-002: omits the claim entirely when the caller did not choose (legacy payload)', () => { + const { user } = createUser(testDb); + const decoded = jwt.decode(svc.generateToken({ id: user.id })) as { remember?: boolean; iat: number; exp: number }; + expect('remember' in decoded).toBe(false); + expect(decoded.exp - decoded.iat).toBe(86400); + }); +}); diff --git a/server/tests/unit/nest/session-renewal.interceptor.test.ts b/server/tests/unit/nest/session-renewal.interceptor.test.ts new file mode 100644 index 000000000..4c4468828 --- /dev/null +++ b/server/tests/unit/nest/session-renewal.interceptor.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for SessionRenewalInterceptor (#1927) — sliding renewal of the + * trek_session cookie once a cookie-authenticated token is past half of its + * lifetime. Covers the renew path (fresh token + cookie semantics per the + * `remember` claim) and every skip condition: young token, bearer-only request, + * unauthenticated request, purpose token, id mismatch, malformed claims, + * headers already sent, non-http context. + */ +import { describe, it, expect, vi } from 'vitest'; +import type { CallHandler, ExecutionContext } from '@nestjs/common'; +import { of, lastValueFrom } from 'rxjs'; +import jwt from 'jsonwebtoken'; + +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, + SESSION_DURATION_REMEMBER: '30d', + SESSION_DURATION_REMEMBER_MS: 2592000000, + SESSION_DURATION_REMEMBER_SECONDS: 2592000, + updateJwtSecret: () => {}, +})); + +import { SessionRenewalInterceptor } from '../../../src/nest/auth/session-renewal.interceptor'; +import type { AuthService } from '../../../src/nest/auth/auth.service'; + +const SECRET = 'test-jwt-secret-for-trek-testing-only'; + +/** Sign a session token whose lifetime is `lifetime` seconds, `consumed` of which have already passed. */ +function token(payload: Record, lifetime: number, consumed: number): string { + const iat = Math.floor(Date.now() / 1000) - consumed; + // jsonwebtoken computes exp = iat + expiresIn when iat is supplied in the payload. + return jwt.sign({ ...payload, iat }, SECRET, { expiresIn: lifetime, algorithm: 'HS256' }); +} + +function makeRes(headersSent = false) { + return { headersSent, cookie: vi.fn() }; +} + +type ReqShape = { cookies?: Record; user?: { id: number }; headers?: Record; secure?: boolean }; + +function ctx(req: ReqShape, res: ReturnType, type = 'http'): ExecutionContext { + return { + getType: () => type, + switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }), + } as unknown as ExecutionContext; +} + +function handler(): CallHandler & { handle: ReturnType } { + return { handle: vi.fn(() => of('ok')) }; +} + +function makeInterceptor() { + const generateToken = vi.fn().mockReturnValue('renewed.jwt'); + const interceptor = new SessionRenewalInterceptor({ generateToken } as unknown as AuthService); + return { interceptor, generateToken }; +} + +async function run(interceptor: SessionRenewalInterceptor, req: ReqShape, res: ReturnType, type = 'http') { + const h = handler(); + const out = await lastValueFrom(interceptor.intercept(ctx(req, res, type), h)); + expect(out).toBe('ok'); + expect(h.handle).toHaveBeenCalled(); +} + +describe('SessionRenewalInterceptor', () => { + it('SES-RENEW-001: does not renew a token under half of its lifetime', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + const t = token({ id: 1, pv: 0 }, 86400, 1000); // ~1% consumed + await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res); + expect(generateToken).not.toHaveBeenCalled(); + expect(res.cookie).not.toHaveBeenCalled(); + }); + + it('SES-RENEW-002: renews a token past half of its lifetime, copying pv from the old token', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + const t = token({ id: 1, pv: 3 }, 86400, 60000); // ~70% consumed + await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res); + expect(generateToken).toHaveBeenCalledWith({ id: 1, password_version: 3 }, undefined); + expect(res.cookie).toHaveBeenCalledWith('trek_session', 'renewed.jwt', expect.objectContaining({ maxAge: 86400000, httpOnly: true })); + }); + + it('SES-RENEW-003: a remember:true claim renews with the long persistent cookie', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + const t = token({ id: 1, pv: 0, remember: true }, 2592000, 1600000); // ~62% consumed + await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res); + expect(generateToken).toHaveBeenCalledWith({ id: 1, password_version: 0 }, true); + expect(res.cookie).toHaveBeenCalledWith('trek_session', 'renewed.jwt', expect.objectContaining({ maxAge: 2592000000 })); + }); + + it('SES-RENEW-004: a remember:false claim renews as a browser-session cookie (no maxAge)', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + const t = token({ id: 1, pv: 0, remember: false }, 86400, 60000); + await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res); + expect(generateToken).toHaveBeenCalledWith({ id: 1, password_version: 0 }, false); + const options = res.cookie.mock.calls[0][2]; + expect(options).not.toHaveProperty('maxAge'); + }); + + it('SES-RENEW-005: a claimless legacy token renews with the historical default cookie', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + const t = token({ id: 1, pv: 0 }, 86400, 60000); + await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res); + expect(generateToken).toHaveBeenCalledWith({ id: 1, password_version: 0 }, undefined); + expect(res.cookie).toHaveBeenCalledWith('trek_session', 'renewed.jwt', expect.objectContaining({ maxAge: 86400000 })); + }); + + it('SES-RENEW-006: never renews a Bearer-only request (no session cookie)', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + await run(interceptor, { headers: { authorization: 'Bearer x' }, user: { id: 1 } }, res); + expect(generateToken).not.toHaveBeenCalled(); + expect(res.cookie).not.toHaveBeenCalled(); + }); + + it('SES-RENEW-007: never renews an unauthenticated (public-route) request', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + const t = token({ id: 1, pv: 0 }, 86400, 60000); + await run(interceptor, { cookies: { trek_session: t } }, res); + expect(generateToken).not.toHaveBeenCalled(); + }); + + it('SES-RENEW-008: never renews a purpose-scoped token or a cookie whose id differs from req.user', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const purposeRes = makeRes(); + const purposeToken = token({ id: 1, pv: 0, purpose: 'mfa_login' }, 86400, 60000); + await run(interceptor, { cookies: { trek_session: purposeToken }, user: { id: 1 } }, purposeRes); + + const mismatchRes = makeRes(); + const otherUsers = token({ id: 2, pv: 0 }, 86400, 60000); + await run(interceptor, { cookies: { trek_session: otherUsers }, user: { id: 1 } }, mismatchRes); + + expect(generateToken).not.toHaveBeenCalled(); + expect(purposeRes.cookie).not.toHaveBeenCalled(); + expect(mismatchRes.cookie).not.toHaveBeenCalled(); + }); + + it('SES-RENEW-009: skips when headers were already sent or the cookie is not a JWT', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const sentRes = makeRes(true); + const t = token({ id: 1, pv: 0 }, 86400, 60000); + await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, sentRes); + + const garbageRes = makeRes(); + await run(interceptor, { cookies: { trek_session: 'not-a-jwt' }, user: { id: 1 } }, garbageRes); + + expect(generateToken).not.toHaveBeenCalled(); + expect(sentRes.cookie).not.toHaveBeenCalled(); + expect(garbageRes.cookie).not.toHaveBeenCalled(); + }); + + it('SES-RENEW-010: skips tokens with missing or inverted iat/exp claims', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + // Hand-built token without exp (noTimestamp keeps iat but expiresIn omitted). + const noExp = jwt.sign({ id: 1, pv: 0 }, SECRET, { algorithm: 'HS256' }); + await run(interceptor, { cookies: { trek_session: noExp }, user: { id: 1 } }, res); + expect(generateToken).not.toHaveBeenCalled(); + }); + + it('SES-RENEW-011: ignores non-http execution contexts', async () => { + const { interceptor, generateToken } = makeInterceptor(); + const res = makeRes(); + const t = token({ id: 1, pv: 0 }, 86400, 60000); + await run(interceptor, { cookies: { trek_session: t }, user: { id: 1 } }, res, 'ws'); + expect(generateToken).not.toHaveBeenCalled(); + }); +});