mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
feat(auth): sliding session renewal past half the token lifetime (#1927)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user