import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import type { AuthPayload, AccessRule } from '../shared/types'; export type { AuthPayload, AccessRule }; // Extend Express Request so TypeScript knows about req.auth in every route file. declare global { namespace Express { interface Request { auth?: AuthPayload; rawToken?: string; } } } // ── Config (all from environment) ────────────────────────────────────────────── const JWT_SECRET = process.env.JWT_SECRET ?? ''; const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com'; 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 { const header = req.headers.authorization; if ( header?.startsWith( 'Bearer ' ) ) return header.slice( 7 ); const cookie = req.cookies?.accessToken as string | undefined; if ( cookie ) return cookie; return undefined; } function cookieOpts( maxAge: number ) { return { domain: COOKIE_DOMAIN, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/', maxAge, }; } interface RefreshResult { accessToken: string; refreshToken: string; } async function tryRefresh( refreshToken: string ): Promise { const url = `${ AUTH_INTERNAL_HOST }/api/auth/refresh`; console.log( '[auth] tryRefresh →', url ); try { const r = await fetch( url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( { refreshToken } ), } ); console.log( '[auth] tryRefresh status:', r.status ); if ( !r.ok ) { console.log( '[auth] tryRefresh error body:', await r.text() ); return null; } const data = await r.json() as Partial; if ( !data.accessToken || !data.refreshToken ) { console.log( '[auth] tryRefresh: missing tokens in response' ); return null; } console.log( '[auth] tryRefresh: succeeded' ); return { accessToken: data.accessToken, refreshToken: data.refreshToken }; } catch ( err ) { console.log( '[auth] tryRefresh: fetch error:', err ); return null; } } // ── jwtMiddleware ────────────────────────────────────────────────────────────── // // Register globally before all routes and express.static. // Sets req.auth when a valid (or silently refreshed) token is present. // Never redirects — redirection is the responsibility of requireAuth / requireAccess. // // Requires in index.ts: // app.set('trust proxy', 1) // app.use(cookieParser()) // app.use(jwtMiddleware) export function jwtMiddleware( req: Request, res: Response, next: NextFunction ): void { const token = extractToken( req ); if ( !token ) { next(); return; } let verified: AuthPayload | null = null; try { verified = jwt.verify( token, JWT_SECRET, CLOCK_TOLERANCE ? { clockTolerance: CLOCK_TOLERANCE } : {} ) as AuthPayload; } catch ( err: unknown ) { if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; } } // 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( 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; } tryRefresh( refreshToken ).then( result => { if ( !result ) { console.log( '[auth] refresh failed for:', req.method, req.path ); useVerifiedAsFallback(); next(); return; } res.cookie( 'accessToken', result.accessToken, cookieOpts( 60 * 60 * 1000 ) ); res.cookie( 'refreshToken', result.refreshToken, cookieOpts( 30 * 24 * 60 * 60 * 1000 ) ); try { req.auth = jwt.verify( result.accessToken, JWT_SECRET ) as AuthPayload; req.rawToken = result.accessToken; } catch { console.log( '[auth] unexpected: new access token failed verification' ); useVerifiedAsFallback(); } next(); } ).catch( () => { useVerifiedAsFallback(); next(); } ); } // ── requireAuth ──────────────────────────────────────────────────────────────── // // Guards API routes. Always returns 401 JSON — never redirects. // Place after jwtMiddleware on the route or router. export function requireAuth( req: Request, res: Response, next: NextFunction ): void { if ( !req.auth ) { res.status( 401 ).json( { error: 'Not authenticated' } ); return; } next(); } // ── requireAuthPage ──────────────────────────────────────────────────────────── // // Guards server-rendered HTML pages. Redirects to login when not authenticated. // Never use on API routes. export function requireAuthPage( req: Request, res: Response, next: NextFunction ): void { if ( !req.auth ) { const here = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl ); res.redirect( `${ AUTH_HOST }/login.html?redirect=${ here }` ); return; } next(); } // ── requireAccess ────────────────────────────────────────────────────────────── // // Optional role/product gate. Use after requireAuth on routes that need // more than "any authenticated user". // // Rules are OR-combined: access is granted if any rule matches. // Within a rule, role and product are AND-combined. // superadmin always passes regardless of rules. // // Example: // const RULES: AccessRule[] = [ // { role: 'admin' }, // { role: 'user', product: 'pro' }, // ]; // app.get('/dashboard', jwtMiddleware, requireAccess(RULES), handler); export function requireAccess( rules: AccessRule[] ) { return ( req: Request, res: Response, next: NextFunction ): void => { const auth = req.auth; if ( !auth ) { if ( req.accepts( 'html' ) ) { const here = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl ); res.redirect( `${ AUTH_HOST }/login.html?redirect=${ here }` ); } else { res.status( 401 ).json( { error: 'Not authenticated' } ); } return; } if ( auth.roles.includes( 'superadmin' ) ) { next(); return; } const allowed = rules.some( rule => auth.roles.includes( rule.role ) && ( !rule.product || auth.products.includes( rule.product ) ) ); if ( allowed ) { next(); return; } if ( req.accepts( 'html' ) ) { res.redirect( '/' ); } else { res.status( 403 ).json( { error: 'Forbidden' } ); } }; }