mbkauthe
Version:
MBKTech's reusable authentication system for Node.js applications.
609 lines (527 loc) • 23.9 kB
JavaScript
import { dblogin } from "#pool.js";
import { mbkautheVar } from "#config.js";
import { renderError } from "#response.js";
import { clearSessionCookies, cachedCookieOptions, readAccountListFromCookie, encryptSessionId } from "#cookies.js";
import { ErrorCodes, createErrorResponse } from "../utils/errors.js";
import { hashApiToken } from "#config.js";
import { canAccessMethod } from "#config.js";
import { extractAuthorizationToken, timingSafeTokenMatch } from "../utils/timingSafeToken.js";
const IS_DEV = process.env.env === 'dev' || process.env.test === 'dev' || process.env.NODE_ENV === 'development';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUuid = (val) => typeof val === 'string' && UUID_RE.test(val);
const SQL_VALIDATE_APP_SESSION = `
SELECT s.expires_at, u."Active", u."Role"
FROM "Sessions" s
JOIN "Users" u ON s."UserName" = u."UserName"
WHERE s.id = $1
LIMIT 1
`;
/**
* Decide if the incoming request should return JSON errors instead of HTML.
* Non-browser clients (API calls / AJAX) should get JSON.
*/
function isJsonRequest(req) {
if (!req || !req.headers) return false;
const accept = (req.headers.accept || "").toLowerCase();
const xRequestedWith = (req.headers["x-requested-with"] || "").toLowerCase();
const userAgent = (req.headers["user-agent"] || "").toLowerCase();
const url = (req.originalUrl || req.url || "").toLowerCase();
const path = (req.path || "").toLowerCase();
// Explicit opt-in: allow clients to force JSON responses via a minimal user-agent.
// Useful for health checks / lightweight clients that don't send Accept headers.
if (userAgent.trim() === "json") return true;
const isApiPath = url.startsWith("/mbkauthe/api/") || url.startsWith("/api/") || path.startsWith("/mbkauthe/api/") || path.startsWith("/api/");
const isAcceptJson = accept.includes("application/json") || accept.includes("json") || accept.includes("*/*");
const nonBrowserAgent = /curl|wget|httpie|python-requests|python|go-http-client|java\/|php|node-fetch|axios|postman|insomnia|okhttp/;
const browserAgent = /mozilla|applewebkit|chrome|safari|firefox|edg|msie|trident|opera/;
if (isApiPath || xRequestedWith === "xmlhttprequest") return true;
if (isAcceptJson && !accept.includes("text/html")) return true;
if (nonBrowserAgent.test(userAgent) && !browserAgent.test(userAgent)) return true;
return false;
}
/**
* Validates a Bearer token (API Token or Session UUID)
* Returns a user object if valid, or null/error object
*/
async function validateTokenAuthentication(req) {
const authHeader = req.headers.authorization;
if (!authHeader) return null;
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
const token = parts[1];
// 1. Check for API Token (mbk_)
if (token.startsWith('mbk_')) {
const tokenHash = hashApiToken(token);
const tokenQuery = `
SELECT t.id, t."UserName", t."ExpiresAt", t."Permissions",
u.id as uid, u."Active", u."Role", u."AllowedApps" as user_allowed_apps, u."FullName"
FROM "ApiTokens" t
JOIN "Users" u ON t."UserName" = u."UserName"
WHERE t."TokenHash" = $1 LIMIT 1
`;
const tokenResult = await dblogin.query({ name: 'validate-api-token', text: tokenQuery, values: [tokenHash] });
if (tokenResult.rows.length === 0) return { error: 'INVALID_TOKEN' };
const row = tokenResult.rows[0];
if (row.ExpiresAt && new Date(row.ExpiresAt) <= new Date()) return { error: 'TOKEN_EXPIRED' };
// Parse permissions from JSONB
const permissions = row.Permissions || { scope: 'read-only', allowedApps: null };
const tokenScope = permissions.scope || 'read-only';
const tokenAllowedApps = permissions.allowedApps;
// Determine allowed apps: token-specific takes precedence over user's apps
let allowedApps = row.user_allowed_apps;
if (tokenAllowedApps !== null) {
allowedApps = tokenAllowedApps;
}
// Update usage
dblogin.query({
text: 'UPDATE "ApiTokens" SET "LastUsed" = NOW() WHERE id = $1',
values: [row.id]
}).catch(e => console.error(`[mbkauthe] Failed to update token usage:`, e));
return {
id: row.uid,
username: row.UserName,
fullname: row.FullName,
role: row.Role,
sessionId: 'api-token-session',
allowedApps: allowedApps,
userAllowedApps: row.user_allowed_apps, // Pass user apps for wildcard validation
active: row.Active,
tokenScope: tokenScope
};
}
return null;
}
async function validateSession(req, res, next, strictTokenValidation = false) {
// --- Check for API Token Header first ---
if (req.headers.authorization) {
// If strict validation is enabled, reject token-based authentication
if (strictTokenValidation) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.INVALID_AUTH_TOKEN, {
message: 'Token-based authentication not allowed for this endpoint',
hint: 'Use session-based authentication (cookies) instead'
}));
}
try {
const tokenUser = await validateTokenAuthentication(req);
if (tokenUser && !tokenUser.error) {
if (!tokenUser.active) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.ACCOUNT_INACTIVE));
}
// SuperAdmin bypasses app permission checks
if (tokenUser.role !== "SuperAdmin") {
// API tokens must respect their app restrictions for non-SuperAdmin users
const allowedApps = tokenUser.allowedApps;
const userAllowedApps = tokenUser.userAllowedApps;
// allowedApps should always be an array (never null at this point)
// If token had null allowedApps, it was already replaced with user's apps in validateTokenAuthentication
if (!Array.isArray(allowedApps) || allowedApps.length === 0) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
}
// Check if token has access to current app
const hasWildcard = allowedApps.includes('*');
const hasSpecificApp = allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
// If wildcard, check against user's allowed apps (wildcard means "all user's apps", not "all apps")
if (hasWildcard) {
const userHasApp = userAllowedApps && Array.isArray(userAllowedApps) &&
userAllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
if (!userHasApp) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
}
} else if (!hasSpecificApp) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
}
}
// Populate session for downstream
req.session.user = {
id: tokenUser.id,
username: tokenUser.username,
fullname: tokenUser.fullname,
role: tokenUser.role,
sessionId: tokenUser.sessionId,
allowedApps: tokenUser.allowedApps,
tokenScope: tokenUser.tokenScope || null, // Add scope for token-based auth
};
req.userRole = tokenUser.role;
// Validate token scope for API token requests
if (tokenUser.tokenScope) {
const requestMethod = req.method;
if (!canAccessMethod(tokenUser.tokenScope, requestMethod)) {
return res.status(403).json(createErrorResponse(403, ErrorCodes.TOKEN_SCOPE_INSUFFICIENT, {
message: `Token scope '${tokenUser.tokenScope}' does not allow ${requestMethod} requests`,
tokenScope: tokenUser.tokenScope,
requestedMethod: requestMethod,
hint: 'Use a token with write scope for write operations'
}));
}
}
return next();
}
// Token provided but invalid (or null if format incorrect)
let errorCode = ErrorCodes.INVALID_AUTH_TOKEN;
if (tokenUser && tokenUser.error === 'TOKEN_EXPIRED') {
errorCode = ErrorCodes.API_TOKEN_EXPIRED;
}
return res.status(401).json(createErrorResponse(401, errorCode));
} catch (err) {
console.error(`[mbkauthe] Token validation error:`, err);
return res.status(500).json(createErrorResponse(500, ErrorCodes.INTERNAL_SERVER_ERROR));
}
}
// --- Fallback to Cookie Session ---
if (!req.session.user) {
if (IS_DEV) {
console.log(`[mbkauthe] User not authenticated`);
console.log(`[mbkauthe] req.session.user:`, req.session.user);
}
if (isJsonRequest(req)) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND));
}
return renderError(res, req, {
code: 401,
error: "Not Logged In",
message: "You Are Not Logged In. Please Log In To Continue.",
pagename: "Login",
page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
});
}
try {
const { sessionId, role, allowedApps } = req.session.user;
// Defensive checks for sessionId and allowedApps
if (!sessionId || !isUuid(sessionId)) {
console.warn(`[mbkauthe] Missing sessionId for user "${req.session.user.username}"`);
req.session.destroy();
clearSessionCookies(res);
if (isJsonRequest(req)) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
}
return renderError(res, req, {
code: 401,
error: "Session Expired",
message: "Your Session Has Expired. Please Log In Again.",
pagename: "Login",
page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
});
}
// Validate session by DB primary key id and join to user
const result = await dblogin.query({ name: 'validate-app-session', text: SQL_VALIDATE_APP_SESSION, values: [sessionId] });
if (result.rows.length === 0) {
console.log(`[mbkauthe] Session not found for user "${req.session.user.username}"`);
req.session.destroy();
clearSessionCookies(res);
if (isJsonRequest(req)) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
}
return renderError(res, req, {
code: 401,
error: "Session Expired",
message: "Your Session Has Expired. Please Log In Again.",
pagename: "Login",
page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
});
}
const sessionRow = result.rows[0];
// Check expired
if (sessionRow.expires_at) {
const expiresMs = sessionRow.expires_at instanceof Date
? sessionRow.expires_at.getTime()
: Date.parse(sessionRow.expires_at);
if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) {
console.log(`[mbkauthe] Session invalidated (expired) for user "${req.session.user.username}"`);
// destroy and clear cookies
req.session.destroy();
clearSessionCookies(res);
if (isJsonRequest(req)) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
}
return renderError(res, req, {
code: 401,
error: "Session Expired",
message: "Your Session Has Expired. Please Log In Again.",
pagename: "Login",
page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
});
}
}
if (!sessionRow.Active) {
console.log(`[mbkauthe] Account is inactive for user "${req.session.user.username}"`);
req.session.destroy();
clearSessionCookies(res);
if (isJsonRequest(req)) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.ACCOUNT_INACTIVE));
}
return renderError(res, req, {
code: 401,
error: "Account Inactive",
message: "Your Account Is Inactive. Please Contact Support.",
pagename: "Support",
page: "https://mbktech.org/Support",
});
}
if (role !== "SuperAdmin") {
// If allowedApps is not provided or not an array, treat as no access
const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
console.warn(`[mbkauthe] User \"${req.session.user.username}\" is not authorized to use the application \"${mbkautheVar.APP_NAME}\"`);
req.session.destroy();
clearSessionCookies(res);
if (isJsonRequest(req)) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
}
return renderError(res, req, {
code: 401,
error: "Unauthorized",
message: `You Are Not Authorized To Use The Application \"${mbkautheVar.APP_NAME}\"`,
pagename: "Home",
page: `/${mbkautheVar.loginRedirectURL}`
});
}
}
// Store user role in request for checkRolePermission to use
req.userRole = sessionRow.Role;
next();
} catch (err) {
console.error(`[mbkauthe] Session validation error:`, err);
res.status(500).json({ success: false, message: "Internal Server Error" });
}
}
/**
* API-friendly session validation middleware
* Returns JSON error responses instead of rendering pages
*/
async function validateApiSession(req, res, next) {
if (!req.session.user) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND));
}
try {
const { sessionId, role, allowedApps } = req.session.user;
// Defensive checks for sessionId and allowedApps
if (!sessionId || !isUuid(sessionId)) {
console.warn(`[mbkauthe] Missing sessionId for user "${req.session.user.username}"`);
req.session.destroy();
clearSessionCookies(res);
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
}
// Validate session by DB primary key id and join to user
const result = await dblogin.query({ name: 'validate-app-session-for-api', text: SQL_VALIDATE_APP_SESSION, values: [sessionId] });
if (result.rows.length === 0) {
req.session.destroy();
clearSessionCookies(res);
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_INVALID));
}
const sessionRow = result.rows[0];
// Check expired
if (sessionRow.expires_at) {
const expiresMs = sessionRow.expires_at instanceof Date
? sessionRow.expires_at.getTime()
: Date.parse(sessionRow.expires_at);
if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) {
req.session.destroy();
clearSessionCookies(res);
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
}
}
if (!result.rows[0].Active) {
console.log(`[mbkauthe] Account is inactive for user "${req.session.user.username}"`);
req.session.destroy();
clearSessionCookies(res);
return res.status(401).json(createErrorResponse(401, ErrorCodes.ACCOUNT_INACTIVE));
}
if (role !== "SuperAdmin") {
// If allowedApps is not provided or not an array, treat as no access
const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
console.warn(`[mbkauthe] User \"${req.session.user.username}\" is not authorized to use the application \"${mbkautheVar.APP_NAME}\"`);
req.session.destroy();
clearSessionCookies(res);
return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
}
}
// Store user role in request for checkRolePermission to use
req.userRole = sessionRow.Role;
next();
} catch (err) {
console.error(`[mbkauthe] API session validation error:`, err);
return res.status(500).json(createErrorResponse(500, ErrorCodes.INTERNAL_SERVER_ERROR));
}
}
/**
* Reload session user values from the database and refresh cookies.
* - Validates sessionId and active status
* - Updates `req.session.user` fields (username, role, allowedApps, fullname)
* - Uses cached `fullName` cookie when available, otherwise queries `Users`
* - Syncs `username`, `fullName` and `sessionId` cookies
* Returns: true if session refreshed and valid, false if session invalidated
*/
async function reloadSessionUser(req, res) {
if (!req.session || !req.session.user || !req.session.user.id) return false;
try {
const { id, sessionId: currentSessionId } = req.session.user;
if (!currentSessionId) {
req.session.destroy(() => { });
clearSessionCookies(res);
return false;
}
const normalizedSessionId = String(currentSessionId);
const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps"
FROM "Sessions" s
JOIN "Users" u ON s."UserName" = u."UserName"
WHERE s.id = $1 LIMIT 1`;
const result = await dblogin.query({ name: 'reload-session-user', text: query, values: [normalizedSessionId] });
if (result.rows.length === 0) {
// Session not found — invalidate session
req.session.destroy(() => { });
clearSessionCookies(res);
return false;
}
const row = result.rows[0];
// Check expired
if (row.expires_at && new Date(row.expires_at) <= new Date()) {
req.session.destroy(() => { });
clearSessionCookies(res);
return false;
}
if (!row.Active) {
// Account is inactive
req.session.destroy(() => { });
clearSessionCookies(res);
return false;
}
// Authorization: ensure allowed for current app unless SuperAdmin
if (row.Role !== 'SuperAdmin') {
const allowedApps = row.AllowedApps;
const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
req.session.destroy(() => { });
clearSessionCookies(res);
return false;
}
}
// Update session fields
req.session.user.username = row.UserName;
req.session.user.role = row.Role;
req.session.user.allowedApps = row.AllowedApps;
// Obtain fullname from client cookie cache when present else DB
if (req.cookies && req.cookies.fullName && typeof req.cookies.fullName === 'string') {
req.session.user.fullname = req.cookies.fullName;
} else {
try {
const prof = await dblogin.query({ name: 'reload-get-fullname', text: 'SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1', values: [row.UserName] });
if (prof.rows.length > 0 && prof.rows[0].FullName) req.session.user.fullname = prof.rows[0].FullName;
} catch (profileErr) {
console.error(`[mbkauthe] Error fetching fullname during reload:`, profileErr);
}
}
// Persist session changes
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
// Sync cookies for client UI (sessionId + fullName)
try {
res.cookie('fullName', req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
const encryptedSid = encryptSessionId(req.session.user.sessionId);
if (encryptedSid) {
res.cookie('sessionId', encryptedSid, cachedCookieOptions);
}
} catch (cookieErr) {
console.error(`[mbkauthe] Error syncing cookies during reload:`, cookieErr);
}
return true;
} catch (err) {
console.error(`[mbkauthe] reloadSessionUser error:`, err);
return false;
}
}
const checkRolePermission = (requiredRoles, notAllowed) => {
return async (req, res, next) => {
try {
if (!req.session || !req.session.user || !req.session.user.id) {
console.log(`[mbkauthe] User not authenticated`);
if (isJsonRequest(req)) {
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND));
}
return renderError(res, req, {
code: 401,
error: "Not Logged In",
message: "You Are Not Logged In. Please Log In To Continue.",
pagename: "Login",
page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
});
}
// Use role from validateSession to avoid additional DB query
const userRole = req.userRole;
// SuperAdmin bypasses all role checks
if(req.session.user?.role === "SuperAdmin" || userRole === "SuperAdmin") {
return next();
}
// Check notAllowed role
if (notAllowed && userRole === notAllowed) {
if (isJsonRequest(req)) {
return res.status(403).json(createErrorResponse(403, ErrorCodes.ROLE_NOT_ALLOWED));
}
return renderError(res, req, {
code: 403,
error: "Access Denied",
message: "You are not allowed to access this resource",
pagename: "Home",
page: `/${mbkautheVar.loginRedirectURL}`
});
}
// Convert to array if single role provided
const rolesArray = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];
// Check for "Any" or "any" role
if (rolesArray.includes("Any") || rolesArray.includes("any") || rolesArray.includes("*")) {
return next();
}
// Check if user role is in allowed roles
if (!rolesArray.includes(userRole)) {
if (isJsonRequest(req)) {
return res.status(403).json(createErrorResponse(403, ErrorCodes.INSUFFICIENT_PERMISSIONS));
}
return renderError(res, req, {
code: 403,
error: "Access Denied",
message: "You do not have permission to access this resource",
pagename: "Home",
page: `/${mbkautheVar.loginRedirectURL}`
});
}
next();
} catch (err) {
console.error(`[mbkauthe] Permission check error:`, err);
res.status(500).json({ success: false, message: "Internal Server Error" });
}
};
};
const validateSessionAndRole = (requiredRole, notAllowed, strictTokenValidation = false) => {
return async (req, res, next) => {
await validateSession(req, res, async () => {
await checkRolePermission(requiredRole, notAllowed)(req, res, next);
}, strictTokenValidation);
};
};
const authenticate = (authentication) => {
return (req, res, next) => {
const token = extractAuthorizationToken(req.headers?.authorization ?? req.headers?.["authorization"]);
if (timingSafeTokenMatch(token, authentication)) {
console.log(`[mbkauthe] Authentication successful`);
next();
} else {
console.log(`[mbkauthe] Authentication failed`);
res.status(401).send("Unauthorized");
}
};
};
// Strict validation helpers (reject token-based auth)
const strictValidateSession = (req, res, next) => validateSession(req, res, next, true);
const strictValidateSessionAndRole = (requiredRole, notAllowed) => validateSessionAndRole(requiredRole, notAllowed, true);
// Short aliases for convenience
const sessVal = validateSession;
const sessRole = validateSessionAndRole;
const roleChk = checkRolePermission;
// short strict validation aliases
const strictSessVal = strictValidateSession;
const strictSessRole = strictValidateSessionAndRole;
export {
validateSession, validateApiSession, checkRolePermission,
validateSessionAndRole, authenticate, reloadSessionUser,
strictValidateSession, strictValidateSessionAndRole,
sessVal, sessRole, roleChk, strictSessVal, strictSessRole
}