Loopar provides a robust JWT-based authentication system with cookie management, automatic token refresh, and multi-tenant support.
Authentication Architecture
The authentication system consists of two main components:
- Auth Class (
packages/loopar/core/auth.js) - Handles JWT tokens and session lifecycle
- AuthController (
packages/loopar/core/controller/auth-controller.js) - Controls access to actions
Auth Class
Initialization
The Auth class is initialized per tenant:
import jwt from 'jsonwebtoken';
import { loopar } from './loopar.js';
const getJWTSecret = () => loopar.jwtSecret;
const REFRESH_THRESHOLD = 1800; // 30 minutes in seconds
export default class Auth {
constructor(tenantId, getUser, disabledUser) {
this.tenantId = tenantId;
this.tokenName = `loopar_token_${tenantId}`;
this.loggedCookieName = `logged_${tenantId}`;
this.getUser = getUser;
this.disabledUser = disabledUser;
}
}
User Login
Create and store JWT tokens:
login(user) {
const payload = {
name: user.name,
email: user.email,
avatar: user.name.substring(0, 1).toUpperCase(),
profile_picture: user.profile_picture,
tenant: this.tenantId
};
const token = jwt.sign(payload, getJWTSecret(), { expiresIn: '1d' });
loopar.cookie.set(this.tokenName, token, { httpOnly: true, path: '/' });
loopar.cookie.set(this.loggedCookieName, '1', { httpOnly: false, path: '/' });
return payload;
}
The token cookie is httpOnly for security, while the logged cookie is accessible to JavaScript for client-side checks.
Authenticate User
Verify JWT tokens:
authUser() {
try {
const token = loopar.cookie.get(this.tokenName);
if (!token) return null;
return jwt.verify(token, getJWTSecret());
} catch (error) {
console.error(['[Auth.authUser] Error:', error.message]);
return null;
}
}
Token Refresh
Automatically refresh tokens before expiration:
async award() {
const token = loopar.cookie.get(this.tokenName);
if (!token) return this.killSession();
try {
const userData = jwt.verify(token, getJWTSecret(), { ignoreExpiration: true });
if (!userData) return this.killSession();
// Check if user is disabled
if (await this.disabledUser?.(userData?.name)) {
return this.killSession();
}
const now = Math.floor(Date.now() / 1000);
const exp = userData.exp || 0;
// Refresh token if within threshold (30 minutes)
if (exp - now < REFRESH_THRESHOLD) {
const payload = {
name: userData.name,
email: userData.email,
avatar: userData.avatar,
profile_picture: userData.profile_picture,
tenant: this.tenantId
};
const newToken = jwt.sign(payload, getJWTSecret(), { expiresIn: '1d' });
await loopar.cookie.set(this.tokenName, newToken, { httpOnly: true, path: '/' });
await loopar.cookie.set(this.loggedCookieName, '1', { httpOnly: false, path: '/' });
return payload;
}
return {
name: userData.name,
email: userData.email,
avatar: userData.avatar,
profile_picture: userData.profile_picture,
tenant: userData.tenant || this.tenantId,
exp: userData.exp,
iat: userData.iat
};
} catch (error) {
return this.killSession();
}
}
The award() method should be called on each request to ensure tokens are refreshed and users remain authenticated.
Logout and Session Cleanup
async logout() {
loopar.cookie.remove(this.loggedCookieName);
return this.killSession();
}
async killSession(res) {
const optsExpire = { httpOnly: true, path: '/', expires: new Date(0) };
try {
if (loopar.cookie && typeof loopar.cookie.remove === 'function') {
await loopar.cookie.remove(this.tokenName).catch(() => {});
await loopar.cookie.remove(this.loggedCookieName).catch(() => {});
}
} catch (e) {}
try {
if (loopar.cookie && typeof loopar.cookie.set === 'function') {
await loopar.cookie.set(this.tokenName, '', optsExpire).catch(() => {});
await loopar.cookie.set(this.loggedCookieName, '', optsExpire).catch(() => {});
if (res) {
await loopar.cookie.set(res, this.tokenName, '', optsExpire).catch(() => {});
await loopar.cookie.set(res, this.loggedCookieName, '', optsExpire).catch(() => {});
}
}
} catch (e) {}
try {
if (res && typeof res.clearCookie === 'function') {
res.clearCookie(this.tokenName, { path: '/' });
res.clearCookie(this.loggedCookieName, { path: '/' });
}
} catch (e) {}
}
AuthController
All controllers inherit from AuthController, which manages access control.
Authentication Check
export default class AuthController {
loginActions = ['login', 'register', 'recovery_user', 'recovery_password'];
async isAuthenticated() {
const action = this.action;
const workspace = this.req.__WORKSPACE_NAME__;
// Allow public actions
if (this.isPublicAction) return true;
const resolve = (message, url) => {
return loopar.throw(message, this.method != AJAX && url || "/auth/login");
}
// Check if action is enabled
if (this.actionsEnabled && !this.actionsEnabled.includes(action)) {
return resolve('Not permitted');
}
// Public workspaces
if (workspace == "web") return true;
if (workspace == "loopar") return true;
// Verify user session
const user = await loopar.auth.award();
if (user) {
if (workspace == "auth") {
if (action == "logout") return true;
return resolve('You are already logged in, refresh this page', "/desk/Desk/view");
}
if (user.name !== 'Administrator' && user.disabled) {
resolve('Not permitted');
}
return true;
} else {
if (workspace == "desk") {
return resolve('You must be logged in to access this page', "/auth/login");
}
if (workspace == "auth" && this.isLoginAction) return true;
resolve('You must be logged in to access this page');
}
}
async beforeAction() {
return (await this.isAuthenticated() && await this.isAuthorized());
}
}
Making Actions Public
Define public actions in your controller:
import { BaseController } from "loopar";
export default class MyController extends BaseController {
publicActions = ['register', 'verify-email', 'reset-password'];
async actionRegister() {
// Publicly accessible registration
}
async actionVerifyEmail() {
// Publicly accessible email verification
}
async actionResetPassword() {
// Publicly accessible password reset
}
}
Workspaces
Loopar organizes routes into workspaces:
- desk: Admin/authenticated area (requires login)
- web: Public website (no authentication)
- auth: Authentication pages (login, register)
- loopar: System routes
const workspace = this.req.__WORKSPACE_NAME__;
if (workspace === 'desk') {
// User must be authenticated
} else if (workspace === 'web') {
// Public access
} else if (workspace === 'auth') {
// Login/register pages
}
Multi-Tenant Support
Authentication is tenant-aware:
validateTenant(userData) {
if (userData.tenant && userData.tenant !== this.tenantId) {
console.warn(
`[Auth] Token tenant mismatch: expected ${this.tenantId}, got ${userData.tenant}`
);
return false;
}
return true;
}
Implementing Custom Authentication
Step 1: Create Login Action
import { BaseController, loopar } from "loopar";
export default class AuthController extends BaseController {
publicActions = ['login'];
async actionLogin() {
if (this.hasData()) {
const { email, password } = this.data;
// Validate credentials
const user = await loopar.db.getDoc('User', { email });
if (!user) {
return this.error('Invalid credentials');
}
// Verify password (implement your hashing)
const isValid = await this.verifyPassword(password, user.password);
if (!isValid) {
return this.error('Invalid credentials');
}
// Create session
loopar.auth.login(user);
return this.redirect('/desk');
}
// Render login form
return await this.render({ /* form data */ });
}
}
async actionLogout() {
await loopar.auth.logout();
return this.redirect('/auth/login');
}
export default class ProtectedController extends BaseController {
async beforeAction() {
await super.beforeAction();
// Additional checks
const user = await loopar.auth.authUser();
if (!user || !this.hasPermission(user)) {
return this.notFound('Access denied');
}
}
hasPermission(user) {
// Custom permission logic
return user.role === 'admin';
}
}
Session Management
Access session data in controllers:
import { loopar } from "loopar";
// Store session data
await loopar.session.set('cart', { items: [] });
// Retrieve session data
const cart = await loopar.session.get('cart');
// Remove session data
await loopar.session.remove('cart');
Security Best Practices
Always set httpOnly: true for authentication tokens:
loopar.cookie.set(tokenName, token, { httpOnly: true, path: '/' });
Refresh tokens before expiration to maintain sessions:
const REFRESH_THRESHOLD = 1800; // 30 minutes
if (exp - now < REFRESH_THRESHOLD) {
// Issue new token
}
Always verify the tenant matches:
if (userData.tenant !== this.tenantId) {
return this.killSession();
}
Verify users aren’t disabled:
if (await this.disabledUser?.(userData?.name)) {
return this.killSession();
}
Getting Current User
Access the current user in your code:
export default class MyController extends BaseController {
async actionProcess() {
// Get current user
const user = await loopar.auth.authUser();
if (user) {
console.log(`User: ${user.name} (${user.email})`);
console.log(`Avatar: ${user.avatar}`);
console.log(`Tenant: ${user.tenant}`);
}
}
}
Error Handling
Handle authentication errors gracefully:
try {
const user = await loopar.auth.authUser();
if (!user) {
return this.redirect('/auth/login');
}
} catch (error) {
console.error('Authentication error:', error);
await loopar.auth.killSession();
return this.redirect('/auth/login');
}
Next Steps