128 lines
4.0 KiB
TypeScript
128 lines
4.0 KiB
TypeScript
import { Request, Response, NextFunction } from 'express';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
const AUTH_HOST = process.env.AUTH_HOST ?? 'https://account.rokojori.com';
|
|
const AUTH_INTERNAL_HOST = process.env.AUTH_INTERNAL_HOST ?? AUTH_HOST;
|
|
const JWT_SECRET = process.env.JWT_SECRET ?? '';
|
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
|
|
|
|
export interface JwtUser {
|
|
userId: string;
|
|
email: string;
|
|
roles: string[];
|
|
products: string[];
|
|
settings: Record<string, unknown>;
|
|
}
|
|
|
|
declare global {
|
|
namespace Express {
|
|
interface Request {
|
|
user?: JwtUser;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 isApiRequest( req: Request ): boolean {
|
|
return req.path.startsWith( '/api/' );
|
|
}
|
|
|
|
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 ) {
|
|
const body = await r.text();
|
|
console.log( '[auth] tryRefresh error body:', body );
|
|
return null;
|
|
}
|
|
const data = await r.json() as Partial<RefreshResult>;
|
|
if ( !data.accessToken || !data.refreshToken ) {
|
|
console.log( '[auth] tryRefresh missing tokens in response:', Object.keys( data ) );
|
|
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;
|
|
}
|
|
}
|
|
|
|
export function jwtMiddleware( req: Request, res: Response, next: NextFunction ): void {
|
|
const token = extractToken( req );
|
|
|
|
if ( !token ) { next(); return; }
|
|
|
|
try {
|
|
req.user = jwt.verify( token, JWT_SECRET ) as JwtUser;
|
|
next();
|
|
} catch ( err: unknown ) {
|
|
if ( !( err instanceof jwt.TokenExpiredError ) ) { next(); return; }
|
|
|
|
if ( !isApiRequest( req ) ) {
|
|
const redirect = encodeURIComponent( req.protocol + '://' + req.get( 'host' ) + req.originalUrl );
|
|
res.redirect( `${AUTH_HOST}/api/auth/refresh-session?redirect=${redirect}` );
|
|
return;
|
|
}
|
|
|
|
// API request with expired token — try transparent refresh via refreshToken cookie
|
|
console.log( '[auth] expired token on API route:', 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, returning 401 for:', 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.user = jwt.verify( result.accessToken, JWT_SECRET ) as JwtUser;
|
|
} catch { /* fall through — requireAuth will return 401 */ }
|
|
next();
|
|
} ).catch( () => next() );
|
|
}
|
|
}
|
|
|
|
export function requireAuth( req: Request, res: Response, next: NextFunction ): void {
|
|
if ( !req.user ) {
|
|
res.status( 401 ).json( { error: 'Not authenticated' } );
|
|
return;
|
|
}
|
|
next();
|
|
}
|