Files

44 KiB

Phase 2: Authentication & Multi-Tenancy - Research

Researched: 2026-06-18 Domain: Authentication, session management, RBAC, multi-tenant RLS, LDAP user sync Confidence: HIGH

Summary

Phase 2 implements user authentication (login/logout, session management, password reset), role-based access control (Super-Admin / Admin / User), multi-tenant data isolation via PostgreSQL Row-Level Security, and LDAP user import/sync. The existing NestJS 11 API and Next.js frontend (with portal shell, i18n, theming from Phase 1) provide the foundation.

Keycloak is NOT recommended for MVP scope. The phase requirements (local auth, LDAP sync, 3 roles, password reset) are well within custom NestJS auth capability. Keycloak requires 750MB-2GB JVM memory overhead and its primary value (SSO/OIDC, social logins, 2FA) is explicitly deferred to v2 (AUTH-V2-01..03). A clean auth abstraction layer enables adding Keycloak later for SSO without rearchitecting. [ASSUMED -- the user's CONTEXT.md leaves this as Claude's discretion; this recommendation should be confirmed.]

Primary recommendation: Build custom auth with NestJS Passport + JWT strategy, httpOnly session cookies on the frontend, Prisma Client Extensions for per-request RLS tenant context, argon2 for password hashing, and ldapts for LDAP sync. Use jose on the Next.js side for stateless session tokens.

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Login page as split-screen (left branding/image, right login form)
  • D-02: Session duration 30 days with "Remember me" checkbox
  • D-03: Password reset via email (self-service) + admin manual reset -- requires SMTP configuration
  • D-04: Login page shows no sidebar/header -- standalone layout
  • D-05: Initial admin via Docker ENV: TESSERA_ADMIN_USER, TESSERA_ADMIN_EMAIL, TESSERA_ADMIN_PASSWORD
  • D-06: Force-password-change on first login optionally configurable via ENV: TESSERA_FORCE_CHANGE=true/false
  • D-07: Admin created automatically on first container start if not exists
  • D-08: Tenant assignment per user (no URL difference, no subdomain routing)
  • D-09: One tenant per user for now -- multi-tenant membership comes later
  • D-10: Super-Admin role: can see/manage all tenants, create tenants, switch between them, change global settings
  • D-11: RLS on PostgreSQL level -- tenant ID set per request context
  • D-12: Three roles: Super-Admin (platform-wide), Admin (tenant-specific), User (tenant-specific)
  • D-13: Super-Admin created via initial admin account (first account = Super-Admin)
  • D-14: Manual button ("LDAP synchronisieren") + configurable auto-sync interval
  • D-15: Users removed from LDAP: deactivated in Tessera (not deleted)
  • D-16: Field mapping configurable with defaults: displayName -> Name, mail -> Email, sAMAccountName -> Username
  • D-17: Custom fields must also be mappable (extensible mapping table)
  • D-18: LDAP configuration per tenant (Server-URL, Base-DN, Bind-User, Filter, Mapping)

Claude's Discretion

  • Tenant identification mechanism (JWT claim, middleware pattern, header) -- decided: JWT claim with NestJS middleware
  • JWT vs session cookie implementation -- decided: JWT-based httpOnly cookies
  • SMTP configuration structure (ENV variables for mail server)
  • Keycloak usage -- decided: NO for MVP, custom auth instead (see Summary)

Deferred Ideas (OUT OF SCOPE)

None -- discussion stayed within phase scope </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
AUTH-01 Initial admin account created via Docker ENV at setup NestJS lifecycle hook (onApplicationBootstrap) checks ENV vars and creates admin with argon2-hashed password if not exists
AUTH-02 Admin can manually create, edit, and delete users NestJS CRUD controller with Prisma, protected by RoleGuard requiring Admin or Super-Admin role
AUTH-03 User can log in and log out Passport local strategy validates credentials, issues JWT in httpOnly cookie; logout clears cookie
AUTH-04 User session persists across browser refresh JWT stored in httpOnly cookie with 30-day expiry; Next.js middleware validates on each request
AUTH-05 Role-based access control (Admin, User) NestJS custom RoleGuard decorator checks JWT role claim; three roles: Super-Admin, Admin, User
AUTH-06 Users can be imported via LDAP/AD ldapts client connects to configured LDAP server, syncs users to local DB; manual trigger + cron interval
TNNT-01 Data isolated via PostgreSQL Row-Level Security per tenant RLS policies on all tenant-scoped tables; Prisma Client Extension sets session variable per request
TNNT-02 Admin can create and manage tenants Tenant CRUD endpoints; Super-Admin can manage all tenants, Admin manages own tenant
TNNT-03 Every request automatically assigned to correct tenant NestJS TenantMiddleware extracts tenantId from JWT, sets PostgreSQL session variable via Prisma Extension
</phase_requirements>

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Password hashing & validation API / Backend -- Credentials never leave the server; argon2 runs server-side only
JWT token issuance API / Backend -- Token signed with server secret, returned as httpOnly cookie
Session cookie management API / Backend Frontend Server (SSR) API sets/clears cookie; Next.js middleware reads cookie for route protection
Route protection (frontend) Frontend Server (SSR) Browser / Client Next.js middleware redirects unauthenticated users; client-side auth context for UI state
Route protection (API) API / Backend -- NestJS AuthGuard validates JWT on every request
RBAC enforcement API / Backend Frontend Server (SSR) Guards enforce roles server-side; frontend conditionally renders UI based on role
RLS tenant isolation Database / Storage API / Backend PostgreSQL RLS policies enforce at DB level; middleware sets session variable
Tenant context injection API / Backend -- Middleware extracts tenantId from JWT, sets on DB connection
LDAP sync API / Backend -- Server-side LDAP client; never expose LDAP credentials to frontend
Password reset emails API / Backend -- SMTP transport via nodemailer; tokens generated server-side
Login UI Browser / Client -- Client component with form; Server Action or API call for submission
User management UI Browser / Client Frontend Server (SSR) Admin pages with client-side forms; data fetched via API

Standard Stack

Core

Library Version Purpose Why Standard
@nestjs/jwt 11.0.2 JWT token creation/verification in NestJS Official NestJS JWT module; integrates with guards and DI [VERIFIED: npm registry]
@nestjs/passport 11.0.5 Passport.js integration for NestJS Official NestJS auth integration; strategy-based auth pattern [VERIFIED: npm registry]
passport 0.7.0 Authentication middleware De facto Node.js auth standard; 7.7M weekly downloads [VERIFIED: npm registry]
passport-jwt 4.0.1 JWT authentication strategy Standard JWT strategy for Passport; extracts and verifies JWTs [VERIFIED: npm registry]
passport-local 1.0.0 Username/password authentication Standard local strategy; used for login endpoint [VERIFIED: npm registry]
argon2 0.44.0 Password hashing Winner of Password Hashing Competition; memory-hard, resistant to GPU attacks [VERIFIED: npm registry]
jose 6.2.3 JWT operations for Next.js frontend Edge-compatible JWT library; recommended by Next.js docs for middleware [CITED: nextjs.org/docs/app/guides/authentication]
zod 4.4.3 Schema validation TypeScript-first validation; recommended by Next.js for form validation [CITED: nextjs.org/docs/app/guides/authentication]
class-validator 0.15.1 DTO validation in NestJS Standard NestJS validation pipe integration [VERIFIED: npm registry]
class-transformer 0.5.1 DTO transformation in NestJS Pairs with class-validator for request transformation [VERIFIED: npm registry]

Supporting

Library Version Purpose When to Use
ldapts 8.1.8 LDAP client for user sync LDAP sync feature (AUTH-06); replaces deprecated ldapjs [VERIFIED: npm registry]
@nestjs-modules/mailer 2.3.7 Email sending for NestJS Password reset emails (D-03) [VERIFIED: npm registry]
nodemailer 9.0.1 SMTP transport Underlying transport for @nestjs-modules/mailer [VERIFIED: npm registry]

Alternatives Considered

Instead of Could Use Tradeoff
Custom auth Keycloak 26.6 Keycloak adds 750MB-2GB RAM overhead for JVM; overkill for MVP scope (local auth + LDAP sync); recommended for v2 when SSO/OIDC needed [ASSUMED]
argon2 bcrypt bcrypt is simpler to install (no native deps); argon2 is more secure (memory-hard) and recommended for new projects [CITED: docs.nestjs.com/security/encryption-and-hashing]
ldapts ldapjs ldapjs is formally deprecated after maintainer abuse incident; ldapts is its TypeScript successor with promise-based API [CITED: socket.dev/blog/ldapjs-open-source-project-decommissioned]
jose (frontend) jsonwebtoken jose is Edge-compatible (works in Next.js middleware); jsonwebtoken requires Node.js runtime [CITED: nextjs.org/docs/app/guides/authentication]
class-validator zod (backend) class-validator integrates natively with NestJS ValidationPipe; zod used on frontend where NestJS decorators unavailable

Installation (API):

cd apps/api
pnpm add @nestjs/jwt @nestjs/passport passport passport-jwt passport-local argon2 class-validator class-transformer @nestjs-modules/mailer nodemailer ldapts
pnpm add -D @types/passport-jwt @types/passport-local @types/nodemailer

Installation (Web):

cd apps/web
pnpm add jose zod

Package Legitimacy Audit

Package Registry Age Downloads Source Repo Verdict Disposition
@nestjs/jwt npm 6+ yrs 4M/wk github.com/nestjs/jwt OK Approved
@nestjs/passport npm 6+ yrs 3.8M/wk github.com/nestjs/passport OK Approved
passport npm 12+ yrs 7.7M/wk github.com/jaredhanson/passport OK Approved
passport-jwt npm 9+ yrs 3.8M/wk github.com/mikenicholson/passport-jwt OK Approved
passport-local npm 12+ yrs 2M/wk github.com/jaredhanson/passport-local OK Approved
argon2 npm 8+ yrs 1.5M/wk github.com/ranisalt/node-argon2 OK Approved
jose npm 8+ yrs 87M/wk github.com/panva/jose OK Approved
zod npm 5+ yrs 201M/wk github.com/colinhacks/zod OK Approved
class-validator npm 7+ yrs 10M/wk github.com/typestack/class-validator OK Approved
class-transformer npm 7+ yrs 10M/wk github.com/typestack/class-transformer OK Approved
ldapts npm 6+ yrs 400K/wk github.com/ldapts/ldapts SUS (too-new publish) Flagged -- well-established package, recent publish is routine update; planner should add checkpoint
@nestjs-modules/mailer npm 5+ yrs 350K/wk github.com/nest-modules/mailer SUS (too-new publish) Flagged -- well-established package, recent publish is routine update; planner should add checkpoint
nodemailer npm 14+ yrs 17M/wk github.com/nodemailer/nodemailer SUS (too-new publish) Flagged -- ubiquitous email library, recent publish is routine update; planner should add checkpoint

Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: ldapts, @nestjs-modules/mailer, nodemailer -- all flagged only for "too-new" publish dates (routine updates on well-established packages with millions of cumulative downloads). Planner should add checkpoint:human-verify before installing these.

Note: ldapjs was initially considered but is formally deprecated. ldapts is its actively maintained TypeScript successor.

Architecture Patterns

System Architecture Diagram

Browser/Desktop
    |
    v
[Next.js Frontend]
    |  (1) Login form POST /api/auth/login
    |  (2) Cookie: session=<JWT> (httpOnly, secure, sameSite=lax, 30d)
    |  (3) Next.js middleware reads cookie, validates JWT with jose
    |      - Valid: NextResponse.next()
    |      - Invalid/missing: redirect to /login
    v
[NestJS API] (port 3001)
    |
    +--> [AuthModule]
    |     |-- PassportLocalStrategy (login: validate username+password via argon2)
    |     |-- PassportJwtStrategy (all other requests: extract JWT from cookie)
    |     |-- AuthGuard (global, extracts user from JWT)
    |     |-- RoleGuard (per-route, checks role claim)
    |     +-- AuthService (login, register, password-reset, token generation)
    |
    +--> [TenantModule]
    |     |-- TenantMiddleware (extracts tenantId from JWT, sets on request)
    |     |-- TenantService (CRUD, admin seeding)
    |     +-- PrismaExtension (SET app.current_tenant per query batch)
    |
    +--> [UserModule]
    |     |-- UserController (CRUD for admin)
    |     |-- UserService (create, update, deactivate, password management)
    |     +-- AdminSeedService (onApplicationBootstrap: create initial admin from ENV)
    |
    +--> [LdapModule]
    |     |-- LdapService (connect, search, sync users)
    |     |-- LdapConfigService (per-tenant LDAP settings)
    |     +-- LdapSyncScheduler (cron-based auto-sync)
    |
    +--> [MailModule]
          |-- MailService (send password reset, send welcome emails)
          +-- SMTP config from ENV
    |
    v
[PostgreSQL 16] (data-net, internal)
    |-- RLS policies on all tenant-scoped tables
    |-- Session variable: app.current_tenant
    +-- argon2 password hashes stored in User table
apps/api/src/
  auth/
    auth.module.ts
    auth.controller.ts
    auth.service.ts
    strategies/
      local.strategy.ts
      jwt.strategy.ts
    guards/
      jwt-auth.guard.ts
      roles.guard.ts
    decorators/
      public.decorator.ts
      roles.decorator.ts
      current-user.decorator.ts
    dto/
      login.dto.ts
      register.dto.ts
      reset-password.dto.ts
  tenant/
    tenant.module.ts
    tenant.controller.ts
    tenant.service.ts
    tenant.middleware.ts
    dto/
      create-tenant.dto.ts
  user/
    user.module.ts
    user.controller.ts
    user.service.ts
    admin-seed.service.ts
    dto/
      create-user.dto.ts
      update-user.dto.ts
  ldap/
    ldap.module.ts
    ldap.service.ts
    ldap-config.service.ts
    ldap-sync.scheduler.ts
    dto/
      ldap-config.dto.ts
  mail/
    mail.module.ts
    mail.service.ts
  prisma/
    prisma.module.ts
    prisma.service.ts
    prisma-tenant.extension.ts

apps/web/src/
  app/
    (auth)/                    # Route group: no AppShell layout
      login/
        page.tsx               # Split-screen login page
      reset-password/
        page.tsx               # Password reset request page
      reset-password/[token]/
        page.tsx               # Password reset form
      layout.tsx               # Standalone layout (no sidebar/header)
    (portal)/                  # Route group: AppShell layout
      layout.tsx               # AppShell wrapper
      page.tsx                 # Dashboard (existing)
      admin/
        users/
          page.tsx             # User management
        tenants/
          page.tsx             # Tenant management (Super-Admin only)
        ldap/
          page.tsx             # LDAP configuration
  lib/
    session.ts                 # JWT encrypt/decrypt with jose
    dal.ts                     # Data Access Layer (verifySession, getUser)
    auth-actions.ts            # Server Actions for login/logout/register
    stores/
      auth-store.ts            # Zustand store for client-side auth state
  middleware.ts                # Next.js middleware for route protection

What: Issue JWT tokens stored in httpOnly cookies rather than localStorage. The cookie is automatically sent with every request, and JavaScript cannot access it (XSS protection).

When to use: All authenticated requests between frontend and API.

// Source: NestJS docs + Next.js auth guide pattern
// apps/api/src/auth/auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private userService: UserService,
  ) {}

  async validateUser(username: string, password: string): Promise<User | null> {
    const user = await this.userService.findByUsername(username);
    if (!user || !user.isActive) return null;
    const valid = await argon2.verify(user.passwordHash, password);
    return valid ? user : null;
  }

  async login(user: User, res: Response) {
    const payload = {
      sub: user.id,
      username: user.username,
      role: user.role,
      tenantId: user.tenantId,
    };
    const token = this.jwtService.sign(payload, { expiresIn: '30d' });

    res.cookie('session', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
      path: '/',
    });

    return { user: { id: user.id, username: user.username, role: user.role } };
  }
}

Pattern 2: Prisma Client Extension for RLS Tenant Context

What: A Prisma Client Extension that wraps every query in a transaction, setting the PostgreSQL session variable app.current_tenant before executing the actual query. This activates RLS policies automatically.

When to use: Every database query on tenant-scoped tables.

// Source: github.com/prisma/prisma-client-extensions/tree/main/row-level-security
// apps/api/src/prisma/prisma-tenant.extension.ts
import { PrismaClient } from '@prisma/client';

export function forTenant(prisma: PrismaClient, tenantId: string) {
  return prisma.$extends({
    query: {
      $allOperations({ args, query }) {
        return prisma.$transaction(async (tx) => {
          await tx.$executeRawUnsafe(
            `SELECT set_config('app.current_tenant', '${tenantId}', true)`
          );
          return query(args);
        });
      },
    },
  });
}

Pattern 3: NestJS Global Auth Guard with @Public() Decorator

What: Register the JWT AuthGuard globally so ALL routes require authentication by default. Routes that should be public (login, health) use a custom @Public() decorator to skip the guard.

When to use: Applied once at app bootstrap; individual routes opt out.

// apps/api/src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// apps/api/src/auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) { super(); }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(
      IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]
    );
    if (isPublic) return true;
    return super.canActivate(context);
  }
}

// Register globally in app.module.ts
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }]

Pattern 4: Next.js Middleware for Frontend Route Protection

What: Next.js middleware intercepts every navigation request, checks for a valid session cookie, and redirects to /login if missing or expired. This is an optimistic check -- the API still validates the JWT on every request.

When to use: Runs on every frontend route.

// Source: nextjs.org/docs/app/guides/authentication
// apps/web/src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

const publicRoutes = ['/login', '/reset-password'];
const secret = new TextEncoder().encode(process.env.SESSION_SECRET);

export async function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname;
  if (publicRoutes.some(route => path.startsWith(route))) {
    return NextResponse.next();
  }

  const session = req.cookies.get('session')?.value;
  if (!session) {
    return NextResponse.redirect(new URL('/login', req.nextUrl));
  }

  try {
    await jwtVerify(session, secret, { algorithms: ['HS256'] });
    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL('/login', req.nextUrl));
  }
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

Pattern 5: Tenant Middleware with RLS Activation

What: NestJS middleware that extracts tenantId from the authenticated user's JWT, creates a tenant-scoped Prisma client, and attaches it to the request. Super-Admin can switch tenants via header.

When to use: Every authenticated API request.

// apps/api/src/tenant/tenant.middleware.ts
@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private prisma: PrismaService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const user = req.user; // Set by JWT auth guard
    if (!user) return next();

    // Super-Admin can switch tenant via header
    let tenantId = user.tenantId;
    if (user.role === 'SUPER_ADMIN' && req.headers['x-tenant-id']) {
      tenantId = req.headers['x-tenant-id'] as string;
    }

    if (!tenantId && user.role !== 'SUPER_ADMIN') {
      throw new ForbiddenException('No tenant context');
    }

    // Attach tenant-scoped Prisma client
    req.tenantPrisma = tenantId
      ? forTenant(this.prisma, tenantId)
      : this.prisma; // Super-Admin without tenant header gets unscoped access

    req.tenantId = tenantId;
    next();
  }
}

Anti-Patterns to Avoid

  • LDAP as authentication backend: Never bind user credentials directly to LDAP for auth. Use LDAP for directory sync only; authenticate against local password hashes. This prevents credential exposure if the app server is compromised. [CITED: blog.lithnet.io/2018/03/the-ldap-authentication-anti-pattern.html]
  • Manual tenant filtering in queries: Never write WHERE tenant_id = ? in application code. Use RLS at the database level so even application bugs cannot leak cross-tenant data. [CITED: aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/]
  • JWT in localStorage: Never store JWTs in localStorage; XSS attacks can steal them. Use httpOnly cookies instead. [CITED: nextjs.org/docs/app/guides/authentication]
  • Auth checks only in layouts: Next.js layouts do not re-render on navigation (partial rendering). Always verify auth in page components and Server Actions, not just layouts. [CITED: nextjs.org/docs/app/guides/authentication]

Don't Hand-Roll

Problem Don't Build Use Instead Why
Password hashing Custom hash function argon2 (or bcrypt) Cryptographic hashing has subtle timing-attack vectors; argon2 is memory-hard, peer-reviewed
JWT creation/verification Manual crypto @nestjs/jwt + jose Token format, expiry, signature verification have many edge cases
LDAP protocol Raw TCP/TLS socket handling ldapts LDAP protocol is complex (ASN.1/BER encoding, referrals, paging)
Email sending Raw SMTP socket @nestjs-modules/mailer + nodemailer SMTP has authentication, TLS negotiation, encoding, bounce handling
Input validation Manual if/else chains class-validator (API) + zod (frontend) Schema-based validation is declarative, composable, testable
CSRF protection Custom token system SameSite cookies + httpOnly Browser-native SameSite=lax on cookies prevents CSRF for same-site APIs
SQL injection in RLS String interpolation Parameterized queries via Prisma set_config() must use parameterized calls, never string interpolation

Key insight: Authentication and multi-tenancy are security-critical domains where custom implementations almost always have exploitable flaws. Use battle-tested libraries for all cryptographic and protocol operations.

Common Pitfalls

Pitfall 1: RLS Policies Not Applied on Prisma Direct Queries

What goes wrong: Prisma's $executeRaw and $queryRaw bypass the Client Extension's query hook, meaning RLS context might not be set for raw queries. Why it happens: The Prisma Client Extension wraps $allOperations but raw queries have a different code path. How to avoid: Always wrap raw queries inside the same transaction that sets the tenant context. Test that raw queries on tenant-scoped tables return only current tenant's data. Warning signs: Raw queries returning data from multiple tenants in test environments.

Pitfall 2: Tenant Context Lost in Background/Async Operations

What goes wrong: Background jobs (cron-based LDAP sync, scheduled tasks) have no HTTP request context, so no JWT and no tenant context. Why it happens: The TenantMiddleware runs per HTTP request; async operations bypass this. How to avoid: Every background job MUST explicitly include tenantId in its payload and set the RLS context before any database operation. The LDAP sync scheduler must iterate over tenants and set context per tenant. Warning signs: LDAP sync that processes all tenants with a single database connection.

Pitfall 3: Super-Admin RLS Bypass Leaks Data

What goes wrong: Super-Admin needs to see all tenants' data, but bypassing RLS incorrectly could leak data to regular users. Why it happens: Using a superuser DB connection that bypasses RLS for Super-Admin routes, then accidentally sharing that connection with regular user requests. How to avoid: Create two database roles: one with BYPASSRLS (for Super-Admin operations) and one without (for tenant-scoped operations). Never share the BYPASSRLS connection for regular requests. Alternative: Super-Admin queries iterate over tenants explicitly rather than bypassing RLS. Warning signs: A single database connection pool used for both Super-Admin and regular users.

What goes wrong: In development, the Next.js frontend (port 3000) and NestJS API (port 3001) are on different origins. The browser may not send cookies cross-origin. Why it happens: SameSite=lax cookies are not sent on cross-origin POST requests. Secure flag requires HTTPS. How to avoid: In development: set secure: false, ensure CORS credentials are enabled on the API (app.enableCors({ origin: 'http://localhost:3000', credentials: true })), and the frontend must send credentials: 'include' with fetch requests. In production behind Traefik, both are same-origin. Warning signs: Login succeeds but subsequent requests get 401 in development.

Pitfall 5: Force-Password-Change State Not Checked Everywhere

What goes wrong: User with mustChangePassword: true can access the API directly (bypassing the frontend redirect) and use the application without changing their password. Why it happens: Force-change is only checked in the frontend; API endpoints don't enforce it. How to avoid: Add a global NestJS interceptor that checks mustChangePassword on every request and returns 403 with a specific error code for all endpoints except the change-password endpoint. The frontend then redirects to the change-password page. Warning signs: Users with force-change flag accessing regular API endpoints.

Pitfall 6: LDAP Sync Race Condition with User Deactivation

What goes wrong: A user is actively logged in while LDAP sync deactivates their account. The existing JWT remains valid until expiry (30 days). Why it happens: JWT tokens are stateless; deactivation only affects future token validation if the user is checked against the DB. How to avoid: On every API request, the AuthGuard must verify user.isActive in the database (not just JWT validity). This adds one DB query per request but prevents deactivated users from continuing to operate. Cache this check with a short TTL (e.g., 5 minutes) for performance. Warning signs: Deactivated LDAP users still able to use the application.

Code Examples

PostgreSQL RLS Setup (Migration SQL)

-- Source: Prisma Client Extensions RLS example + AWS RLS multi-tenancy guide

-- Create a limited database role for the application (no BYPASSRLS)
CREATE ROLE tessera_app LOGIN PASSWORD 'app_password';
GRANT USAGE ON SCHEMA public TO tessera_app;

-- Create function to get current tenant
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$
  SELECT current_setting('app.current_tenant', true);
$$ LANGUAGE sql STABLE;

-- Enable RLS on tenant-scoped tables
ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "User" FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_policy ON "User"
  USING (tenant_id = current_tenant_id()::uuid);

-- Grant table access to app role
GRANT SELECT, INSERT, UPDATE, DELETE ON "User" TO tessera_app;

Initial Admin Seed Service

// apps/api/src/user/admin-seed.service.ts
@Injectable()
export class AdminSeedService implements OnApplicationBootstrap {
  constructor(
    private prisma: PrismaService,
    private configService: ConfigService,
  ) {}

  async onApplicationBootstrap() {
    const username = this.configService.get('TESSERA_ADMIN_USER');
    const email = this.configService.get('TESSERA_ADMIN_EMAIL');
    const password = this.configService.get('TESSERA_ADMIN_PASSWORD');
    const forceChange = this.configService.get('TESSERA_FORCE_CHANGE') === 'true';

    if (!username || !email || !password) return; // ENV not set, skip

    const exists = await this.prisma.user.findUnique({ where: { username } });
    if (exists) return; // Already seeded

    // Create default tenant
    const tenant = await this.prisma.tenant.upsert({
      where: { slug: 'default' },
      update: {},
      create: { name: 'Default', slug: 'default' },
    });

    // Create Super-Admin user
    const hash = await argon2.hash(password);
    await this.prisma.user.create({
      data: {
        username,
        email,
        passwordHash: hash,
        role: 'SUPER_ADMIN',
        tenantId: tenant.id,
        mustChangePassword: forceChange,
        isActive: true,
      },
    });
  }
}

Prisma Schema (Expanded for Phase 2)

// apps/api/prisma/schema.prisma

model Tenant {
  id        String   @id @default(uuid())
  name      String
  slug      String   @unique
  isActive  Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  users     User[]
  ldapConfig LdapConfig?
}

enum Role {
  SUPER_ADMIN
  ADMIN
  USER
}

model User {
  id                 String   @id @default(uuid())
  username           String   @unique
  email              String   @unique
  passwordHash       String?  // Null for LDAP-only users
  displayName        String?
  role               Role     @default(USER)
  isActive           Boolean  @default(true)
  mustChangePassword Boolean  @default(false)
  ldapDn             String?  // LDAP distinguished name if imported
  tenantId           String
  tenant             Tenant   @relation(fields: [tenantId], references: [id])
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt
  lastLoginAt        DateTime?
  passwordResetTokens PasswordResetToken[]

  @@index([tenantId])
  @@index([username])
  @@index([email])
}

model PasswordResetToken {
  id        String   @id @default(uuid())
  token     String   @unique
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  usedAt    DateTime?
  createdAt DateTime @default(now())
}

model LdapConfig {
  id              String   @id @default(uuid())
  tenantId        String   @unique
  tenant          Tenant   @relation(fields: [tenantId], references: [id])
  serverUrl       String   // ldap://ldap.example.com or ldaps://
  baseDn          String   // dc=example,dc=com
  bindDn          String   // cn=admin,dc=example,dc=com
  bindPassword    String   // Encrypted at rest
  searchFilter    String   @default("(objectClass=person)")
  syncIntervalMin Int      @default(60)  // Minutes between auto-syncs, 0 = disabled
  isActive        Boolean  @default(true)
  lastSyncAt      DateTime?
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt
  fieldMappings   LdapFieldMapping[]
}

model LdapFieldMapping {
  id           String   @id @default(uuid())
  ldapConfigId String
  ldapConfig   LdapConfig @relation(fields: [ldapConfigId], references: [id], onDelete: Cascade)
  ldapField    String   // e.g., "displayName", "mail", "sAMAccountName"
  tesseraField String   // e.g., "displayName", "email", "username"
  isDefault    Boolean  @default(false)  // True for system-provided defaults
  createdAt    DateTime @default(now())

  @@unique([ldapConfigId, ldapField])
}

SMTP Configuration ENV Variables

# Docker ENV for SMTP (D-03)
TESSERA_SMTP_HOST=smtp.example.com
TESSERA_SMTP_PORT=587
TESSERA_SMTP_SECURE=false       # true for port 465
TESSERA_SMTP_USER=tessera@example.com
TESSERA_SMTP_PASSWORD=secret
TESSERA_SMTP_FROM="Tessera <tessera@example.com>"
TESSERA_APP_URL=http://localhost  # For password reset link generation

State of the Art

Old Approach Current Approach When Changed Impact
ldapjs ldapts 2023 (ldapjs deprecated) Must use ldapts for LDAP; promise-based API, full TypeScript
jsonwebtoken (backend+frontend) jose (Edge-compatible) + @nestjs/jwt (backend) 2024 (Next.js middleware needs Edge) jose works in Next.js middleware; jsonwebtoken requires Node.js runtime
bcrypt default argon2 recommended 2023+ (industry shift) argon2 is PHC winner; NestJS docs recommend either, argon2 preferred for new projects
Prisma middleware for RLS Prisma Client Extensions 2023 (Prisma 4.16+) Extensions provide type-safe per-request client; middleware is deprecated for this pattern
Next.js middleware.ts Next.js proxy.ts (v16+) 2026 (Next.js 16) proxy.ts replaces middleware.ts for route interception; check actual project Next.js version

Deprecated/outdated:

  • ldapjs: Formally deprecated, maintainer stepped down. Use ldapts.
  • Prisma middleware ($use): Replaced by Client Extensions. Never use $use for RLS.
  • nest-keycloak-connect: Not needed when not using Keycloak for MVP.

Important version note: The project currently uses Next.js 15.3 (not 16.x as STACK.md recommends). Next.js 15.3 uses middleware.ts, not proxy.ts. All frontend auth patterns should use middleware.ts. If the project upgrades to Next.js 16+ during this phase, the file would be renamed to proxy.ts. [VERIFIED: apps/web/package.json shows next@^15.3.0]

Assumptions Log

# Claim Section Risk if Wrong
A1 Keycloak NOT recommended for MVP; custom auth instead Summary, Alternatives If user wants Keycloak, entire auth architecture changes; +750MB RAM, different token flow
A2 argon2 over bcrypt for password hashing Standard Stack Low risk -- bcrypt is equally acceptable; argon2 may need system deps (gcc, make) in Docker
A3 SMTP configuration via ENV variables (not UI-configurable) Code Examples If user expects UI-based SMTP config, need additional settings page
A4 Session cookie shared between Next.js and NestJS (same domain via Traefik) Architecture If deployed on separate domains, cookie sharing breaks; CORS complexity increases
A5 Super-Admin uses separate BYPASSRLS DB role Pitfall 3 Alternative: Super-Admin iterates tenants explicitly; both approaches valid
A6 User.passwordHash nullable for LDAP-only users Code Examples LDAP users who later need local password would need migration

Open Questions

  1. Next.js version: 15.3 vs 16.x

    • What we know: package.json pins next@^15.3.0; STACK.md recommends 16.2.x
    • What's unclear: Whether to upgrade to 16.x in this phase or keep 15.3
    • Recommendation: Stay on 15.3 for this phase (use middleware.ts). Upgrade to 16 can happen in a dedicated tech-debt phase.
  2. Super-Admin DB access pattern

    • What we know: Super-Admin must see all tenants' data; RLS blocks cross-tenant access by default
    • What's unclear: Whether to use a BYPASSRLS database role or iterate tenants explicitly
    • Recommendation: Use a separate database role with BYPASSRLS for Super-Admin operations, with strict separation from the tenant-scoped connection pool.
  3. Redis for session invalidation

    • What we know: JWT cookies are stateless (cannot be revoked server-side without extra state)
    • What's unclear: Whether to add Redis for session revocation (e.g., logout-everywhere, deactivation)
    • Recommendation: For MVP, check user.isActive on every API request (DB query with short cache TTL). Add Redis session store in a later phase if needed.

Environment Availability

Dependency Required By Available Version Fallback
Node.js API + Frontend Yes 24.16.0 --
Docker Container orchestration Yes 29.5.3 --
Docker Compose Service orchestration Yes v5.1.4 --
PostgreSQL Database (via Docker) Yes (container) 16-alpine --
Redis Session cache (optional) No -- Skip; use DB-based isActive checks
SMTP server Password reset emails Not verified -- Use MailHog/Mailpit in Docker for dev; real SMTP via ENV in prod
LDAP server LDAP sync testing Not verified -- Use Docker OpenLDAP for testing

Missing dependencies with no fallback: None -- all critical dependencies available.

Missing dependencies with fallback:

  • Redis: Not available but not required for MVP. Use DB-based checks.
  • SMTP: Need to add MailHog/Mailpit container to docker-compose.dev.yml for development testing.
  • LDAP: Need to add test LDAP container (e.g., osixia/openldap) to docker-compose.dev.yml for LDAP sync testing.

Validation Architecture

Test Framework

Property Value
Framework Vitest 3.x (recommended by STACK.md, not yet installed)
Config file none -- see Wave 0
Quick run command pnpm turbo test --filter=@tessera/api
Full suite command pnpm turbo test

Phase Requirements to Test Map

Req ID Behavior Test Type Automated Command File Exists?
AUTH-01 Admin seed from ENV vars creates Super-Admin integration vitest run tests/auth/admin-seed.test.ts No -- Wave 0
AUTH-02 User CRUD (create, update, delete) integration vitest run tests/user/user-crud.test.ts No -- Wave 0
AUTH-03 Login returns session cookie; logout clears it integration vitest run tests/auth/login-logout.test.ts No -- Wave 0
AUTH-04 Session cookie persists; middleware validates unit vitest run tests/auth/session.test.ts No -- Wave 0
AUTH-05 Role guard blocks unauthorized access unit vitest run tests/auth/role-guard.test.ts No -- Wave 0
AUTH-06 LDAP sync creates/deactivates users integration vitest run tests/ldap/ldap-sync.test.ts No -- Wave 0
TNNT-01 RLS blocks cross-tenant data access integration vitest run tests/tenant/rls-isolation.test.ts No -- Wave 0
TNNT-02 Tenant CRUD (create, update) integration vitest run tests/tenant/tenant-crud.test.ts No -- Wave 0
TNNT-03 Tenant middleware sets context from JWT unit vitest run tests/tenant/tenant-middleware.test.ts No -- Wave 0

Sampling Rate

  • Per task commit: pnpm turbo test --filter=@tessera/api
  • Per wave merge: pnpm turbo test
  • Phase gate: Full suite green before /gsd-verify-work

Wave 0 Gaps

  • apps/api/vitest.config.ts -- Vitest config for NestJS API
  • apps/api/tests/setup.ts -- Test setup with Prisma test client and test database
  • apps/web/vitest.config.ts -- Vitest config for Next.js frontend
  • Framework install: pnpm add -D vitest @vitest/coverage-v8 --filter=@tessera/api --filter=@tessera/web
  • Test database: Docker Compose test service or in-memory SQLite for unit tests

Security Domain

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication Yes argon2 password hashing, local strategy + JWT, account lockout
V3 Session Management Yes httpOnly cookies, 30-day expiry, SameSite=lax, secure flag in production
V4 Access Control Yes NestJS RoleGuard (SUPER_ADMIN / ADMIN / USER), PostgreSQL RLS
V5 Input Validation Yes class-validator (API DTOs), zod (frontend forms)
V6 Cryptography Yes argon2 (hashing), jose/HS256 (JWT signing) -- never hand-roll

Known Threat Patterns for NestJS + PostgreSQL + JWT

Pattern STRIDE Standard Mitigation
SQL injection via RLS bypass Tampering Parameterized queries via Prisma; never use string interpolation in set_config
JWT token theft via XSS Information Disclosure httpOnly cookies prevent JS access; Content-Security-Policy headers
Cross-tenant data leakage Information Disclosure PostgreSQL RLS as defense-in-depth; never rely on application-level filtering alone
Brute-force login Elevation of Privilege Rate limiting on login endpoint (NestJS throttler); account lockout after N attempts
LDAP injection Tampering Parameterize LDAP search filters; validate input before constructing filter strings
Password reset token reuse Repudiation Single-use tokens with expiry; mark as used after first use; short TTL (1 hour)
Credential exposure in LDAP bind Information Disclosure Use LDAP for sync only (not auth); bind with service account, not user credentials
Session fixation Spoofing Issue new JWT on login (never reuse); clear old cookies on authentication

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

Metadata

Confidence breakdown:

  • Standard stack: HIGH -- all packages verified on npm registry with high download counts
  • Architecture: HIGH -- patterns sourced from official NestJS/Next.js/Prisma docs and confirmed against codebase
  • Pitfalls: HIGH -- documented in research PITFALLS.md and confirmed against community guides
  • Keycloak decision: MEDIUM -- recommendation to skip Keycloak is reasoned but user has discretion

Research date: 2026-06-18 Valid until: 2026-07-18 (stable domain, 30 days)