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:
parent
cb2fba6e8b
commit
7fa8811896
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue