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

Bash
pnpm add @justscale/auth

Quick Start

The AuthFeature bundles all auth services together. Just provide repositories for User and Session models:

main.tsTypeScript
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

models/user.tsTypeScript
// 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

models/session.tsTypeScript
// 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:

controllers/auth.controller.tsTypeScript
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

user-service-api.tsTypeScript
// 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 emailVerifiedAt

SessionService

Manages session tokens with automatic expiration:

session-service-api.tsTypeScript
// 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 sessions

PasswordService

Secure password hashing using scrypt with timing-safe comparison:

password-service-api.tsTypeScript
// 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

protected-routes.tsTypeScript
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

optional-auth.tsTypeScript
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:

guards-example.tsTypeScript
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:

error-handling.tsTypeScript
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:

migrations/001_auth.sqlTypeScript
-- 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

src/main.tsTypeScript
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