Authentication
User authentication with database sessions
The @justscale/auth package provides a complete authentication system with user registration, password hashing, session management, and route protection.
Installation
pnpm add @justscale/authQuick Start
The AuthFeature bundles all auth services together. Just provide repositories for User and Session models:
import { createClusterBuilder } from '@justscale/core';
import { AuthFeature, User, Session } from '@justscale/auth';
import { createPgModel, createPgRepository, PostgresClient } from '@justscale/postgres';
// Create PostgreSQL models
const PgUser = createPgModel(User, { table: 'users' });
const PgSession = createPgModel(Session, { table: 'sessions' });
// Build the cluster
const cluster = createClusterBuilder()
.add(PostgresClient)
.add(createPgRepository(PgUser))
.add(createPgRepository(PgSession))
.add(AuthFeature) // Provides UserService, SessionService, PasswordService
.add(AuthController)
.build();
await cluster.compile();
await cluster.start();Built-in Models
The auth package provides pre-defined User and Session models:
User Model
// Built-in User model fields:
{
email: string, // Unique email address
passwordHash: string, // Scrypt-hashed password
name?: string, // Optional display name
emailVerifiedAt?: Date, // When email was verified
lastLoginAt?: Date, // Last successful login
}Session Model
// Built-in Session model fields:
{
user: Reference<User>, // Reference to the user
token: string, // Random 64-char hex token
userAgent?: string, // Browser/client info
ipAddress?: string, // Client IP address
expiresAt: Date, // Session expiration
lastActiveAt: Date, // Last activity timestamp
}Services
UserService
Handles user registration, authentication, and profile updates:
import { createController } from '@justscale/core';
import { Post } from '@justscale/http';
import { UserService, SessionService, UserExistsError } from '@justscale/auth';
export const AuthController = createController('/auth', {
inject: {
users: UserService,
sessions: SessionService,
},
routes: ({ users, sessions }) => ({
register: Post('/register')
.handle(async ({ body, res }) => {
try {
const user = await users.register(body.email, body.password, body.name);
const session = await sessions.create(user);
res.json({ token: session.token, user: { id: user.id, email: user.email } });
} catch (e) {
if (e instanceof UserExistsError) {
res.status(409).json({ error: 'Email already registered' });
} else throw e;
}
}),
login: Post('/login')
.handle(async ({ body, res }) => {
const user = await users.authenticate(body.email, body.password);
if (!user) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
const session = await sessions.create(user);
res.json({ token: session.token });
}),
}),
});UserService Methods
// Registration
const user = await users.register(email, password, name?);
// Authentication (returns user or undefined)
const user = await users.authenticate(email, password);
// Lookup
const user = await users.findById(id);
const user = await users.findByEmail(email);
// Updates
await users.updatePassword(userId, newPassword);
await users.verifyEmail(userId); // Sets emailVerifiedAtSessionService
Manages session tokens with automatic expiration:
// Create session (default TTL: 7 days)
const session = await sessions.create(user, {
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
ttlMs: 30 * 24 * 60 * 60 * 1000, // 30 days
});
// Lookup (returns null if expired)
const session = await sessions.findByToken(token);
// Update activity timestamp
await sessions.touch(sessionId);
// Logout
await sessions.revoke(sessionId);
await sessions.revokeAllForUser(userRef); // Logout everywhere
// Cleanup
await sessions.revokeExpired(); // Remove expired sessionsPasswordService
Secure password hashing using scrypt with timing-safe comparison:
// Hash a password (scrypt with random salt)
const hash = await passwords.hash('user-password');
// Returns: "salt:derivedKey" (hex encoded)
// Verify password (timing-safe)
const valid = await passwords.verify('user-password', hash);Middleware
Protect routes with authentication middleware:
Required Authentication
import { createController } from '@justscale/core';
import { Get } from '@justscale/http';
import { auth } from '@justscale/auth';
export const ProfileController = createController('/profile', {
inject: {},
routes: () => ({
// Requires valid Authorization header
me: Get('/')
.use(auth) // Adds session + user to context
.handle(({ user, res }) => {
res.json({
id: user.id,
email: user.email,
name: user.name,
});
}),
}),
});The auth middleware extracts the Bearer token from the Authorization header, validates it, and adds session and user to the context. Throws AuthenticationError if invalid.
Optional Authentication
import { Get } from '@justscale/http';
import { optionalAuth } from '@justscale/auth';
// Works with or without authentication
feed: Get('/feed')
.use(optionalAuth) // session/user may be null
.handle(({ user, res }) => {
if (user) {
// Personalized feed for logged-in users
res.json({ feed: getPersonalizedFeed(user.id) });
} else {
// Generic feed for anonymous users
res.json({ feed: getPublicFeed() });
}
}),Guards
Add authorization checks after authentication:
import { Get, Patch } from '@justscale/http';
import { auth, requireAuth, requireVerifiedEmail, requireSelf } from '@justscale/auth';
// Require authenticated user
adminPanel: Get('/admin')
.use(auth)
.guard(requireAuth)
.handle(...),
// Require verified email
sendMessage: Post('/messages')
.use(auth)
.guard(requireVerifiedEmail)
.handle(...),
// Require user owns the resource (params.userId === user.id)
updateProfile: Patch('/users/:userId')
.use(auth)
.guard(requireSelf('userId'))
.handle(({ user, body, res }) => {
// User can only update their own profile
res.json({ updated: true });
}),Error Handling
The auth package exports specific error classes for handling auth failures:
import {
AuthenticationError,
UserExistsError,
InvalidCredentialsError,
} from '@justscale/auth';
// In your error handler
app.use(async (ctx, next) => {
try {
await next();
} catch (e) {
if (e instanceof AuthenticationError) {
ctx.res.status(401).json({ error: e.message });
} else if (e instanceof UserExistsError) {
ctx.res.status(409).json({ error: 'Email already registered' });
} else if (e instanceof InvalidCredentialsError) {
ctx.res.status(401).json({ error: 'Invalid email or password' });
} else throw e;
}
});Database Schema
Create the required tables for User and Session models:
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100),
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
-- Sessions table
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
user_agent VARCHAR(500),
ip_address VARCHAR(45),
expires_at TIMESTAMPTZ NOT NULL,
last_active_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_sessions_token ON sessions(token);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);Complete Example
import { createClusterBuilder } from '@justscale/core';
import { listen } from '@justscale/http';
import { AuthFeature, User, Session } from '@justscale/auth';
import { createPgModel, createPgRepository, PostgresClient } from '@justscale/postgres';
import { AuthController } from './controllers/auth';
import { ProfileController } from './controllers/profile';
const PgUser = createPgModel(User, { table: 'users' });
const PgSession = createPgModel(Session, { table: 'sessions' });
const cluster = createClusterBuilder()
.add(PostgresClient)
.add(createPgRepository(PgUser))
.add(createPgRepository(PgSession))
.add(AuthFeature)
.add(AuthController)
.add(ProfileController)
.build();
await cluster.compile();
await cluster.start();
listen(cluster, { port: 3000 });Best Practices
- Use HTTPS in production - Tokens are sent in headers, so always use TLS to protect them in transit
- Set appropriate session TTL - Balance security (shorter) vs UX (longer) based on your app's needs
- Clean up expired sessions - Run
sessions.revokeExpired()periodically to remove stale sessions - Store tokens securely on client - Use httpOnly cookies or secure storage, never localStorage for sensitive apps
- Implement rate limiting - Protect login/register endpoints from brute force attacks