All files / apps/api-gateway/src/auth auth.service.ts

100% Statements 25/25
100% Branches 14/14
100% Functions 5/5
100% Lines 22/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 703x 3x 3x                             3x         3x 7x     7x 7x     19x 19x   7x 6x               7x   15x     7x     5x     2x 2x                 5x 5x 5x 3x      
import { timingSafeEqual } from 'node:crypto';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthenticatedUser, UserRole } from './auth.decorators';
 
interface DemoUser {
  email: string;
  password: string;
  role: UserRole;
}
 
/**
 * Demo credential store, seeded from AUTH_USERS ("email:password:role" CSV).
 * A real deployment delegates this to an identity provider (ADR-0008); the
 * seam that matters is that the rest of the system only ever sees the JWT.
 */
const DEFAULT_USERS =
  'manager@propflow.dev:propflow:manager,' +
  'tenant@propflow.dev:propflow:tenant,' +
  'tech@propflow.dev:propflow:technician';
 
@Injectable()
export class AuthService {
  private readonly logger = new Logger(AuthService.name);
  private readonly users: DemoUser[];
 
  constructor(private readonly jwt: JwtService) {
    this.users = (process.env.AUTH_USERS ?? DEFAULT_USERS)
      .split(',')
      .map((entry) => {
        const [email, password, role] = entry.split(':');
        return { email, password, role: role as UserRole };
      });
    if (!process.env.AUTH_USERS) {
      this.logger.warn('AUTH_USERS not set, using built-in demo users');
    }
  }
 
  async login(
    email: string,
    password: string,
  ): Promise<{ accessToken: string; role: UserRole }> {
    const user = this.users.find(
      (candidate) =>
        candidate.email === email &&
        this.passwordMatches(candidate.password, password),
    );
    if (!user) {
      // One error for both unknown user and wrong password: the response
      // must not reveal which emails exist.
      throw new UnauthorizedException('invalid credentials');
    }
 
    const payload: AuthenticatedUser = { sub: user.email, role: user.role };
    return {
      accessToken: await this.jwt.signAsync(payload),
      role: user.role,
    };
  }
 
  /** Constant-time comparison — a plain === leaks the match length/prefix
   * through timing, the classic credential-check mistake. */
  private passwordMatches(expected: string, given: string): boolean {
    const a = Buffer.from(expected);
    const b = Buffer.from(given);
    if (a.length !== b.length) return false;
    return timingSafeEqual(a, b);
  }
}