rokojori-auth-connector/source/server/auth.ts

268 lines
8.9 KiB
TypeScript
Raw Normal View History

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';
const CLOCK_TOLERANCE = parseInt( process.env.JWT_CLOCK_TOLERANCE ?? '0', 10 );
2026-07-17 07:38:05 +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 );
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; }
let verified: AuthPayload | null = null;
2026-07-17 07:38:05 +00:00
try
{
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; }
}
// 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.
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' );
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 );
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' );
useVerifiedAsFallback();
2026-07-17 07:38:05 +00:00
}
next();
} ).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' } );
}
};
}