2026-07-17 07:38:05 +00:00
|
|
|
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
|
|
|
|
|
{
|
2026-07-17 13:26:14 +00:00
|
|
|
interface Request { auth?: AuthPayload; rawToken?: string; }
|
2026-07-17 07:38:05 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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';
|
2026-07-18 06:37:26 +00:00
|
|
|
const CLOCK_TOLERANCE = parseInt( process.env.JWT_CLOCK_TOLERANCE ?? '0', 10 );
|
2026-07-17 07:38:05 +00:00
|
|
|
|
2026-08-02 20:38:53 +00:00
|
|
|
// 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 );
|
|
|
|
|
|
2026-07-17 07:38:05 +00:00
|
|
|
// ── Internal helpers ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function extractToken( req: Request ): string | undefined
|
|
|
|
|
{
|
|
|
|
|
const header = req.headers.authorization;
|
|
|
|
|
if ( header?.startsWith( 'Bearer ' ) ) return header.slice( 7 );
|
2026-07-18 06:37:26 +00:00
|
|
|
const cookie = req.cookies?.accessToken as string | undefined;
|
|
|
|
|
if ( cookie ) return cookie;
|
2026-07-17 07:38:05 +00:00
|
|
|
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<RefreshResult | null>
|
|
|
|
|
{
|
|
|
|
|
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<RefreshResult>;
|
|
|
|
|
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; }
|
|
|
|
|
|
2026-08-02 20:38:53 +00:00
|
|
|
let verified: AuthPayload | null = null;
|
|
|
|
|
|
2026-07-17 07:38:05 +00:00
|
|
|
try
|
|
|
|
|
{
|
2026-08-02 20:38:53 +00:00
|
|
|
verified = jwt.verify( token, JWT_SECRET, CLOCK_TOLERANCE ? { clockTolerance: CLOCK_TOLERANCE } : {} ) as AuthPayload;
|
2026-07-17 07:38:05 +00:00
|
|
|
}
|
|
|
|
|
catch ( err: unknown )
|
|
|
|
|
{
|
|
|
|
|
if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 20:38:53 +00:00
|
|
|
// 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; } };
|
|
|
|
|
|
2026-07-17 07:38:05 +00:00
|
|
|
// 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.
|
2026-08-02 20:38:53 +00:00
|
|
|
console.log( verified ? '[auth] proactively refreshing token on:' : '[auth] expired token on:', req.method, req.path );
|
2026-07-17 07:38:05 +00:00
|
|
|
|
|
|
|
|
const refreshToken = req.cookies?.refreshToken as string | undefined;
|
|
|
|
|
if ( !refreshToken )
|
|
|
|
|
{
|
|
|
|
|
console.log( '[auth] no refreshToken cookie — cannot refresh' );
|
2026-08-02 20:38:53 +00:00
|
|
|
useVerifiedAsFallback();
|
2026-07-17 07:38:05 +00:00
|
|
|
next();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tryRefresh( refreshToken ).then( result =>
|
|
|
|
|
{
|
|
|
|
|
if ( !result )
|
|
|
|
|
{
|
|
|
|
|
console.log( '[auth] refresh failed for:', req.method, req.path );
|
2026-08-02 20:38:53 +00:00
|
|
|
useVerifiedAsFallback();
|
2026-07-17 07:38:05 +00:00
|
|
|
next();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.cookie( 'accessToken', result.accessToken, cookieOpts( 60 * 60 * 1000 ) );
|
|
|
|
|
res.cookie( 'refreshToken', result.refreshToken, cookieOpts( 30 * 24 * 60 * 60 * 1000 ) );
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-07-17 13:26:14 +00:00
|
|
|
req.auth = jwt.verify( result.accessToken, JWT_SECRET ) as AuthPayload;
|
|
|
|
|
req.rawToken = result.accessToken;
|
2026-07-17 07:38:05 +00:00
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
console.log( '[auth] unexpected: new access token failed verification' );
|
2026-08-02 20:38:53 +00:00
|
|
|
useVerifiedAsFallback();
|
2026-07-17 07:38:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
next();
|
2026-08-02 20:38:53 +00:00
|
|
|
} ).catch( () =>
|
|
|
|
|
{
|
|
|
|
|
useVerifiedAsFallback();
|
|
|
|
|
next();
|
|
|
|
|
} );
|
2026-07-17 07:38:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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' } );
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|