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 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-08-02 22:38:53 +02:00
parent cb2fba6e8b
commit 7fa8811896
2 changed files with 38 additions and 7 deletions

View File

@ -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 COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
const CLOCK_TOLERANCE = parseInt( process.env.JWT_CLOCK_TOLERANCE ?? '0', 10 ); 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 ─────────────────────────────────────────────────────────── // ── Internal helpers ───────────────────────────────────────────────────────────
function extractToken( req: Request ): string | undefined function extractToken( req: Request ): string | undefined
@ -99,27 +104,45 @@ export function jwtMiddleware( req: Request, res: Response, next: NextFunction )
const token = extractToken( req ); const token = extractToken( req );
if ( !token ) { next(); return; } if ( !token ) { next(); return; }
let verified: AuthPayload | null = null;
try try
{ {
req.auth = jwt.verify( token, JWT_SECRET, CLOCK_TOLERANCE ? { clockTolerance: CLOCK_TOLERANCE } : {} ) as AuthPayload; verified = jwt.verify( token, JWT_SECRET, CLOCK_TOLERANCE ? { clockTolerance: CLOCK_TOLERANCE } : {} ) as AuthPayload;
req.rawToken = token;
next();
return;
} }
catch ( err: unknown ) catch ( err: unknown )
{ {
if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; } 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 // 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. // 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; const refreshToken = req.cookies?.refreshToken as string | undefined;
if ( !refreshToken ) if ( !refreshToken )
{ {
console.log( '[auth] no refreshToken cookie — cannot refresh' ); console.log( '[auth] no refreshToken cookie — cannot refresh' );
useVerifiedAsFallback();
next(); next();
return; return;
} }
@ -129,6 +152,7 @@ export function jwtMiddleware( req: Request, res: Response, next: NextFunction )
if ( !result ) if ( !result )
{ {
console.log( '[auth] refresh failed for:', req.method, req.path ); console.log( '[auth] refresh failed for:', req.method, req.path );
useVerifiedAsFallback();
next(); next();
return; return;
} }
@ -144,10 +168,15 @@ export function jwtMiddleware( req: Request, res: Response, next: NextFunction )
catch catch
{ {
console.log( '[auth] unexpected: new access token failed verification' ); console.log( '[auth] unexpected: new access token failed verification' );
useVerifiedAsFallback();
} }
next(); next();
} ).catch( () => next() ); } ).catch( () =>
{
useVerifiedAsFallback();
next();
} );
} }
// ── requireAuth ──────────────────────────────────────────────────────────────── // ── requireAuth ────────────────────────────────────────────────────────────────

View File

@ -6,6 +6,8 @@ export interface AuthPayload
roles: string[]; roles: string[];
products: string[]; products: string[];
settings: Record<string, unknown>; settings: Record<string, unknown>;
iat?: number;
exp?: number;
} }
// Stored token pair — returned by /api/auth/login and /api/auth/refresh. // Stored token pair — returned by /api/auth/login and /api/auth/refresh.