feat(auth): sliding session renewal past half the token lifetime (#1927)

This commit is contained in:
jubnl
2026-08-15 22:48:16 +02:00
parent 61e03fdfde
commit 72221a2e45
8 changed files with 333 additions and 10 deletions
+4
View File
@@ -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
+13 -6
View File
@@ -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 };
}
+22
View File
@@ -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 };
@@ -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<unknown> {
if (context.getType() === 'http') {
const req = context.switchToHttp().getRequest<Request & { user?: { id: number } }>();
const res = context.switchToHttp().getResponse<Response>();
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<string, string> }).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);
}
}