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 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 ────────────────────────────────────────────────────────────────

View File

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