Documentation/Learn/3. Auth
In this section

Step 3 — Auth

By the end: users can sign in, get a JWT, and the UsersService.list method is admin-only.

Add the auth module + Redis

cd apps/api
pnpm add @omnitron-dev/titan-auth
docker run -d --name redis -p 6379:6379 redis:7-alpine

Schema additions

apps/api/migrations/002_auth.sql:

ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN roles TEXT[] NOT NULL DEFAULT ARRAY['user'];

CREATE TABLE sessions (
  id           TEXT PRIMARY KEY,
  user_id      TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at   TIMESTAMPTZ NOT NULL,
  is_revoked   BOOLEAN NOT NULL DEFAULT FALSE,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX sessions_user_id_idx ON sessions(user_id);
psql -U postgres -h localhost -d platform < apps/api/migrations/002_auth.sql

Update apps/api/src/db/schema.ts:

import type { Generated } from 'kysely';

export interface UsersTable {
  id:            Generated<string>;
  email:         string;
  name:          string;
  passwordHash:  string;
  roles:         string[];
  createdAt:     Generated<Date>;
}

export interface SessionsTable {
  id:         string;
  userId:     string;
  expiresAt:  Date;
  isRevoked:  Generated<boolean>;
  createdAt:  Generated<Date>;
}

export interface Database {
  users:    UsersTable;
  sessions: SessionsTable;
}

Auth service

apps/api/src/auth/auth.service.ts:

import { Injectable, Inject, Service } from '@omnitron-dev/titan';
import { Public, Validate } from '@omnitron-dev/titan/decorators';
import { Errors } from '@omnitron-dev/titan/errors';
import { JWT_SERVICE_TOKEN, type IJWTService } from '@omnitron-dev/titan-auth';
import { DATABASE_CONNECTION } from '@omnitron-dev/titan-database';
import type { Kysely } from 'kysely';
import { cuid } from '@omnitron-dev/cuid';
import * as argon2 from 'argon2';
import { z } from 'zod';
import type { Database } from '../db/schema.js';

const SignInSchema = z.object({
  email:    z.string().email(),
  password: z.string().min(8),
});
type SignIn = z.infer<typeof SignInSchema>;

@Service('auth@1.0.0')
export class AuthService {
  constructor(
    @Inject(JWT_SERVICE_TOKEN)    private jwt: IJWTService,
    @Inject(DATABASE_CONNECTION)  private db:  Kysely<Database>,
  ) {}

  @Public({ auth: { allowAnonymous: true } })
  @Validate(SignInSchema)
  async signIn({ email, password }: SignIn) {
    const user = await this.db.selectFrom('users')
      .selectAll()
      .where('email', '=', email)
      .executeTakeFirst();

    if (!user || !(await argon2.verify(user.passwordHash, password))) {
      throw Errors.unauthorized('invalid credentials');
    }

    const sessionId = cuid();
    const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days

    await this.db.insertInto('sessions')
      .values({ id: sessionId, userId: user.id, expiresAt })
      .execute();

    const token = await this.jwt.sign({
      sub:   user.id,
      sid:   sessionId,
      roles: user.roles,
    });

    return { token, user: { id: user.id, email: user.email, roles: user.roles } };
  }

  @Public()
  async signOut({ sessionId }: { sessionId: string }) {
    await this.db.updateTable('sessions')
      .set({ isRevoked: true })
      .where('id', '=', sessionId)
      .execute();
    return { success: true };
  }
}

Wire the module

Update apps/api/src/app.module.ts:

import { Module }                from '@omnitron-dev/titan';
import { ConfigModule }          from '@omnitron-dev/titan/module/config';
import { TitanDatabaseModule }   from '@omnitron-dev/titan-database';
import { TitanRedisModule }      from '@omnitron-dev/titan-redis';
import { TitanAuthModule }       from '@omnitron-dev/titan-auth';

import { UserRepo }     from './users/user.repo.js';
import { UsersService } from './users/users.service.js';
import { AuthService }  from './auth/auth.service.js';

@Module({
  imports: [
    ConfigModule.forRoot({ sources: [{ type: 'env', prefix: 'API_' }] }),
    TitanRedisModule.forRoot({ config: { url: process.env.REDIS_URL ?? 'redis://localhost:6379' } }),
    TitanDatabaseModule.forRoot({
      // `connection` is the whole connection, not just its URL — dialect and
      // DSN travel together, which is what lets `connections: { … }` name
      // several of them.
      connection: {
        dialect: 'postgres',
        connection: process.env['DATABASE_URL'] ?? 'postgres://postgres:dev@localhost:5432/platform',
      },
    }),
    TitanAuthModule.forRoot({
      algorithm:  'HS256',
      jwtSecret:  process.env.JWT_SECRET ?? 'dev-secret-do-not-use-in-prod',
      issuer:     'my-platform',
      audience:   'my-platform',
    }),
  ],
  providers: [UserRepo, UsersService, AuthService],
})
export class AppModule {}

Gate UsersService.list to admins

Update apps/api/src/users/users.service.ts:

import { Service } from '@omnitron-dev/titan';
import { Public } from '@omnitron-dev/titan/decorators';
import { RequireAuth, RequireRole } from '@omnitron-dev/titan-auth';
// ...

@Service('users@1.0.0')
export class UsersService {
  // ...

  @Public()
  @RequireAuth()                                        // any logged-in user
  async findById(id: string): Promise<User> {
    // ...
  }

  @Public()
  @RequireRole(['admin'])                                // admin only
  async list(): Promise<User[]> {
    return this.repo.list();
  }
}

Seed an admin user

node -e "
const argon2 = require('argon2');
const { Pool } = require('pg');
const { cuid } = require('@omnitron-dev/cuid');

(async () => {
  const pool = new Pool({ connectionString: 'postgres://postgres:dev@localhost__LUMEN_COLON__5432/platform' });
  const hash = await argon2.hash('correct-horse-battery-staple');
  await pool.query(
    'INSERT INTO users (id, email, name, password_hash, roles) VALUES (\$1, \$2, \$3, \$4, \$5)',
    [cuid(), 'admin@example.com', 'Admin', hash, ['admin']]
  );
  console.log('admin@example.com / correct-horse-battery-staple');
  await pool.end();
})();
"

Verify

JWT_SECRET=dev-secret-do-not-use-in-prod pnpm dev
node -e "
const { createClient } = require('@omnitron-dev/netron-browser');
(async () => {
  const c = createClient({ url: 'http://localhost:3001' });
  await c.connect();

  // Sign in:
  const { token, user } = await c.invoke('auth', 'signIn',
    [{ email: 'admin@example.com', password: 'correct-horse-battery-staple' }]);
  console.log('signed in:', user);

  // List (admin-only) with the token:
  const all = await c.invoke('users', 'list', [],
    { headers: { authorization: 'Bearer ' + token } });
  console.log('users:', all);
})();
"

Try without the token:

node -e "
const { createClient } = require('@omnitron-dev/netron-browser');
(async () => {
  const c = createClient({ url: 'http://localhost:3001' });
  await c.connect();
  try {
    await c.invoke('users', 'list', []);
  } catch (e) {
    console.log('rejected:', e.code, e.message);  // → UNAUTHORIZED
  }
})();
"

What's new

PieceWhat it does
TitanAuthModule.forRoot({...})Wires JWT signing + verification
IJWTServiceInject to sign / verify tokens manually
@RequireAuth()Method-level: any logged-in user
@RequireRole(['admin'])Method-level: specific roles
@Public({ auth: { allowAnonymous: true } })Method-level: explicit anonymous

For multi-app fan-out (one identity app + N specialists sharing the same JWT + Redis session registry), see Best practices / Shared auth.

Commit

git add .
git commit -m "step 3: JWT auth + role-gated methods"

Troubleshooting

SymptomFix
Cannot find module 'argon2'pnpm add argon2 in apps/api
JWT_SECRET is requiredExport it: export JWT_SECRET=dev-secret-do-not-use-in-prod
UNAUTHORIZED with valid tokenCheck JWT signing key matches verification key
FORBIDDEN (403)Token is valid but role insufficient