From 7fa88118960df0fa876594cf306b3a1766634245 Mon Sep 17 00:00:00 2001 From: Rokojori Date: Sun, 2 Aug 2026 22:38:53 +0200 Subject: [PATCH] feat: jwtMiddleware proactive cookie rotation, AuthPayload iat/exp jwtMiddleware now rotates the access-token cookie proactively when within PROACTIVE_REFRESH_MARGIN_SEC (15 min) of real expiry, using the server's own clock. Removes the need for client-side exp comparison (impossible anyway for httpOnly cookies). AuthPayload gains iat/exp fields for callers that need to inspect token lifetime. Co-Authored-By: Claude Sonnet 4.6 --- source/server/auth.ts | 43 +++++++++++++++++++++++++++++++++++------- source/shared/types.ts | 2 ++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/source/server/auth.ts b/source/server/auth.ts index b578196..a4fd7c2 100644 --- a/source/server/auth.ts +++ b/source/server/auth.ts @@ -21,6 +21,11 @@ const AUTH_INTERNAL_HOST = process.env.AUTH_INTERNAL_HOST ?? AUTH_HOST; const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com'; const CLOCK_TOLERANCE = parseInt( process.env.JWT_CLOCK_TOLERANCE ?? '0', 10 ); +// How long before actual expiry a still-valid access token gets refreshed anyway. +// Must comfortably exceed the browser Token Updater's check interval (5 min) so a +// session kept alive by that updater never actually reaches expiry in normal use. +const PROACTIVE_REFRESH_MARGIN_SEC = parseInt( process.env.PROACTIVE_REFRESH_MARGIN_SEC ?? '900', 10 ); + // ── Internal helpers ─────────────────────────────────────────────────────────── function extractToken( req: Request ): string | undefined @@ -99,27 +104,45 @@ export function jwtMiddleware( req: Request, res: Response, next: NextFunction ) const token = extractToken( req ); if ( !token ) { next(); return; } + let verified: AuthPayload | null = null; + try { - req.auth = jwt.verify( token, JWT_SECRET, CLOCK_TOLERANCE ? { clockTolerance: CLOCK_TOLERANCE } : {} ) as AuthPayload; - req.rawToken = token; - next(); - return; + verified = jwt.verify( token, JWT_SECRET, CLOCK_TOLERANCE ? { clockTolerance: CLOCK_TOLERANCE } : {} ) as AuthPayload; } catch ( err: unknown ) { if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; } } - // Access token expired — attempt silent refresh via the refresh token cookie. + // Proactively refresh a still-valid token once it's within the margin of expiring, + // not just a fully expired one — this is what lets the browser Token Updater keep a + // session alive indefinitely just by pinging a cheap endpoint on its interval, instead + // of every request needing to race a reactive refresh right at the expiry boundary. + const closeToExpiry = !!verified?.exp && ( verified.exp - Date.now() / 1000 ) < PROACTIVE_REFRESH_MARGIN_SEC; + + if ( verified && !closeToExpiry ) + { + req.auth = verified; + req.rawToken = token; + next(); + return; + } + + // Fall back to the still-valid token if refreshing (for any reason) doesn't pan out — + // only relevant for the proactive case; when verified is null (actually expired) there's + // nothing to fall back to and the request proceeds unauthenticated, same as before. + const useVerifiedAsFallback = () => { if ( verified ) { req.auth = verified; req.rawToken = token; } }; + // Bearer-only clients (Electron) handle their own refresh in the Electron main process // via the 401-retry pattern; they do not send a refreshToken cookie. - console.log( '[auth] expired token on:', req.method, req.path ); + console.log( verified ? '[auth] proactively refreshing token on:' : '[auth] expired token on:', req.method, req.path ); const refreshToken = req.cookies?.refreshToken as string | undefined; if ( !refreshToken ) { console.log( '[auth] no refreshToken cookie — cannot refresh' ); + useVerifiedAsFallback(); next(); return; } @@ -129,6 +152,7 @@ export function jwtMiddleware( req: Request, res: Response, next: NextFunction ) if ( !result ) { console.log( '[auth] refresh failed for:', req.method, req.path ); + useVerifiedAsFallback(); next(); return; } @@ -144,10 +168,15 @@ export function jwtMiddleware( req: Request, res: Response, next: NextFunction ) catch { console.log( '[auth] unexpected: new access token failed verification' ); + useVerifiedAsFallback(); } next(); - } ).catch( () => next() ); + } ).catch( () => + { + useVerifiedAsFallback(); + next(); + } ); } // ── requireAuth ──────────────────────────────────────────────────────────────── diff --git a/source/shared/types.ts b/source/shared/types.ts index 36cbee2..1da4bf7 100644 --- a/source/shared/types.ts +++ b/source/shared/types.ts @@ -6,6 +6,8 @@ export interface AuthPayload roles: string[]; products: string[]; settings: Record; + iat?: number; + exp?: number; } // Stored token pair — returned by /api/auth/login and /api/auth/refresh.