236 lines
7.3 KiB
TypeScript
236 lines
7.3 KiB
TypeScript
|
|
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; }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── 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';
|
||
|
|
|
||
|
|
// ── Internal helpers ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function extractToken( req: Request ): string | undefined
|
||
|
|
{
|
||
|
|
const cookie = req.cookies?.accessToken as string | undefined;
|
||
|
|
if ( cookie ) return cookie;
|
||
|
|
const header = req.headers.authorization;
|
||
|
|
if ( header?.startsWith( 'Bearer ' ) ) return header.slice( 7 );
|
||
|
|
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; }
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
req.auth = jwt.verify( token, JWT_SECRET ) as AuthPayload;
|
||
|
|
next();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
catch ( err: unknown )
|
||
|
|
{
|
||
|
|
if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; }
|
||
|
|
}
|
||
|
|
|
||
|
|
// Access token expired — attempt silent refresh via the refresh token cookie.
|
||
|
|
// 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 );
|
||
|
|
|
||
|
|
const refreshToken = req.cookies?.refreshToken as string | undefined;
|
||
|
|
if ( !refreshToken )
|
||
|
|
{
|
||
|
|
console.log( '[auth] no refreshToken cookie — cannot refresh' );
|
||
|
|
next();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
tryRefresh( refreshToken ).then( result =>
|
||
|
|
{
|
||
|
|
if ( !result )
|
||
|
|
{
|
||
|
|
console.log( '[auth] refresh failed for:', req.method, req.path );
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
console.log( '[auth] unexpected: new access token failed verification' );
|
||
|
|
}
|
||
|
|
|
||
|
|
next();
|
||
|
|
} ).catch( () => 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' } );
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|