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 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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue