src/auth/auth.service.ts
Core authentication service.
Manages the full auth lifecycle including login (with optional 2FA), registration, token refresh, password management, and device token storage. All mutations are audit-logged and rate-limited via progressive account lockout.
Methods |
|
constructor(prisma: PrismaService, jwtService: JwtService, configService: ConfigService, totpService: TotpService)
|
|||||||||||||||
|
Defined in src/auth/auth.service.ts:56
|
|||||||||||||||
|
Parameters :
|
| Async changePassword | ||||||||||||||||
changePassword(userId: string, currentPassword: string, newPassword: string)
|
||||||||||||||||
|
Defined in src/auth/auth.service.ts:685
|
||||||||||||||||
|
Change the authenticated user's password. Requires the current password for verification. On success, clears the
Parameters :
Returns :
unknown
Success message with fresh access + refresh tokens. |
| Async disable2fa |
disable2fa(userId: string, currentPassword: string)
|
|
Defined in src/auth/auth.service.ts:299
|
|
Turn 2FA off. The user must re-confirm their password to prove possession of the account before we drop the second factor.
Returns :
unknown
|
| Async getProfile | ||||||||
getProfile(userId: string)
|
||||||||
|
Defined in src/auth/auth.service.ts:505
|
||||||||
|
Retrieve the authenticated user's full profile including organisation info.
Parameters :
Returns :
unknown
User profile with organisation details. |
| Async login | ||||||||||||
login(dto: LoginDto, ipAddress?: string)
|
||||||||||||
|
Defined in src/auth/auth.service.ts:83
|
||||||||||||
|
Authenticate a user with email and password. If the user has TOTP enabled, this returns a short-lived
Parameters :
Returns :
unknown
Full auth response (user + tokens) or a 2FA challenge. |
| Async refresh | ||||||||
refresh(refreshToken: string)
|
||||||||
|
Defined in src/auth/auth.service.ts:476
|
||||||||
|
Exchange a valid refresh token for a new access + refresh token pair. The old refresh token is not revoked (stateless JWTs). Token rotation is achieved by the short access-token TTL (24h default) and longer refresh-token TTL (7d default).
Parameters :
Returns :
unknown
A fresh access + refresh token pair. |
| Async register | ||||||
register(dto: RegisterDto)
|
||||||
|
Defined in src/auth/auth.service.ts:393
|
||||||
|
Register is restricted to Admin/Super Admin roles and requires a valid invite code. All other users must be created by an admin via the User Management page.
Parameters :
Returns :
unknown
|
| Async requestPasswordReset | ||||||||
requestPasswordReset(email: string)
|
||||||||
|
Defined in src/auth/auth.service.ts:571
|
||||||||
|
Initiate a password reset flow by generating a one-time reset token and emailing the user a reset link. The token is SHA-256-hashed before storage so a database leak cannot be used to reset arbitrary accounts. The link expires in 1 hour. If SMTP is not configured, the reset URL is logged to stdout as a fallback for development environments.
Parameters :
Returns :
Promise<literal type>
A generic success message (never reveals whether the email exists). |
| Async resetPassword | ||||||||||||||||
resetPassword(token: string, email: string, newPassword: string)
|
||||||||||||||||
|
Defined in src/auth/auth.service.ts:641
|
||||||||||||||||
|
Complete a password reset by validating the one-time token and setting a new password.
Parameters :
Returns :
Promise<literal type>
Success confirmation message. |
| Async saveDeviceToken | ||||||||||||||||
saveDeviceToken(userId: string, token: string, platform?: string)
|
||||||||||||||||
|
Defined in src/auth/auth.service.ts:548
|
||||||||||||||||
|
Register an FCM device token for push notifications. Called by the mobile driver app after login to enable trip dispatch notifications, bay timeout alerts, and reallocation messages.
Parameters :
Returns :
unknown
Confirmation message. |
| Async start2faEnrollment | ||||||
start2faEnrollment(userId: string)
|
||||||
|
Defined in src/auth/auth.service.ts:239
|
||||||
|
Begin 2FA enrollment for the current user. Returns the otpauth URI +
a QR data URL the front-end can render. The secret is stored
(encrypted) immediately but
Parameters :
Returns :
unknown
|
| Async verify2faEnrollment |
verify2faEnrollment(userId: string, code: string)
|
|
Defined in src/auth/auth.service.ts:261
|
|
Confirm enrollment by checking the user's first TOTP code matches
the secret we just stored. On success, flips
Returns :
unknown
|
| Async verify2faLogin |
verify2faLogin(twoFactorToken: string, code: string, ipAddress?: string)
|
|
Defined in src/auth/auth.service.ts:192
|
|
Second leg of 2FA login: validates the short-lived two-factor token
Returns :
unknown
|
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { TotpService } from './totp.service';
/**
* Core authentication service.
*
* Manages the full auth lifecycle including login (with optional 2FA),
* registration, token refresh, password management, and device token
* storage. All mutations are audit-logged and rate-limited via
* progressive account lockout.
*
* @dependencies
* - {@link PrismaService} — tenant-aware database access
* - {@link JwtService} — JWT signing and verification
* - {@link ConfigService} — reads JWT secrets and expiry from env
* - {@link TotpService} — TOTP secret management and code validation
*/
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private configService: ConfigService,
private totpService: TotpService,
) {}
/** Maximum consecutive failed login attempts before account lockout. */
private readonly MAX_FAILED_ATTEMPTS = 10;
/** Duration in minutes that a locked account remains inaccessible. */
private readonly LOCKOUT_MINUTES = 15;
/**
* Authenticate a user with email and password.
*
* If the user has TOTP enabled, this returns a short-lived `twoFactorToken`
* instead of the full access/refresh pair. The client must present that
* token along with a valid 6-digit TOTP code to `/auth/2fa/verify-login`
* to complete the login flow.
*
* @param dto - Login credentials (email, password).
* @param ipAddress - Caller's IP for audit logging and lockout tracking.
* @returns Full auth response (user + tokens) or a 2FA challenge.
* @throws {UnauthorizedException} Invalid credentials, locked account, or deactivated user.
*/
async login(dto: LoginDto, ipAddress?: string) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (!user) {
// Log failed attempt for non-existent user (don't reveal this to caller)
this.logFailedAttempt(null, dto.email, ipAddress);
throw new UnauthorizedException('Invalid credentials');
}
// Check if account is locked
if (user.lockedUntil && new Date() < user.lockedUntil) {
const remainingMin = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
this.logFailedAttempt(user.id, dto.email, ipAddress);
throw new UnauthorizedException(
`Account is temporarily locked. Try again in ${remainingMin} minute${remainingMin > 1 ? 's' : ''}.`
);
}
// Verify password
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!isPasswordValid) {
await this.handleFailedLogin(user.id, dto.email, ipAddress);
throw new UnauthorizedException('Invalid credentials');
}
if (!user.isActive) {
throw new UnauthorizedException('Account is deactivated');
}
// ── 2FA gate ──────────────────────────────────────────────────────
// If the user has TOTP enrolled, password alone is insufficient.
// Issue a short-lived "twoFactorToken" the client must present along
// with a valid 6-digit code to /auth/2fa/verify-login.
if (user.totpEnabled) {
const twoFactorToken = await this.jwtService.signAsync(
{ sub: user.id, scope: '2fa' },
{
secret: this.configService.get<string>('jwt.secret'),
expiresIn: '5m',
},
);
return {
requires2FA: true,
twoFactorToken,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
};
}
return this.completeLogin(user, ipAddress);
}
/**
* Shared post-password-verified path. Called directly when 2FA is off,
* or from {@link verify2faLogin} after the second factor is checked.
*/
private async completeLogin(user: any, ipAddress?: string) {
// Successful login — reset failed attempts
await this.prisma.user.update({
where: { id: user.id },
data: {
lastLoginAt: new Date(),
failedLogins: 0,
lockedUntil: null,
lastFailedAt: null,
lastFailedIp: null,
},
});
// Log successful login
await this.prisma.auditLog.create({
data: {
organizationId: user.organizationId,
userId: user.id,
action: 'auth.login',
entityType: 'User',
entityId: user.id,
ipAddress: ipAddress || null,
},
}).catch(() => {}); // Non-blocking
const tokens = await this.generateTokens(user.id, user.email, user.role, user.organizationId);
return {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
organizationId: user.organizationId,
mustChangePassword: user.mustChangePassword || false,
totpEnabled: user.totpEnabled || false,
},
...tokens,
};
}
/**
* Second leg of 2FA login: validates the short-lived two-factor token
* + the current TOTP code. On success, returns the same shape as a
* normal login response (access + refresh).
*/
async verify2faLogin(twoFactorToken: string, code: string, ipAddress?: string) {
let payload: { sub: string; scope: string };
try {
payload = await this.jwtService.verifyAsync(twoFactorToken, {
secret: this.configService.get<string>('jwt.secret'),
});
} catch {
throw new UnauthorizedException('2FA challenge expired — please sign in again');
}
if (payload.scope !== '2fa') {
throw new UnauthorizedException('Invalid 2FA token');
}
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
if (!user || !user.isActive) {
throw new UnauthorizedException('Account no longer available');
}
if (!user.totpEnabled || !user.totpSecret) {
throw new UnauthorizedException('2FA is not enabled for this account');
}
let secret: string;
try {
secret = this.totpService.decryptSecret(user.totpSecret);
} catch {
throw new UnauthorizedException('2FA configuration is invalid — contact your administrator');
}
if (!this.totpService.verifyCode(secret, code)) {
// Treat a wrong TOTP like a wrong password — increments failed
// counter and locks the account after MAX_FAILED_ATTEMPTS to deter
// brute-forcing the 6-digit code (1M space).
await this.handleFailedLogin(user.id, user.email, ipAddress);
throw new UnauthorizedException('Invalid 2FA code');
}
return this.completeLogin(user, ipAddress);
}
// ── 2FA enrollment / disable ──────────────────────────────────────────
/**
* Begin 2FA enrollment for the current user. Returns the otpauth URI +
* a QR data URL the front-end can render. The secret is stored
* (encrypted) immediately but `totpEnabled` stays false until the user
* verifies a code via {@link verify2faEnrollment}.
*/
async start2faEnrollment(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException('User not found');
if (user.totpEnabled) {
throw new BadRequestException('2FA is already enabled for this account');
}
const { secret, otpauthUrl, qrDataUrl } =
await this.totpService.generateEnrollment(user.email);
await this.prisma.user.update({
where: { id: userId },
data: { totpSecret: this.totpService.encryptSecret(secret) },
});
return { otpauthUrl, qrDataUrl };
}
/**
* Confirm enrollment by checking the user's first TOTP code matches
* the secret we just stored. On success, flips `totpEnabled = true`.
*/
async verify2faEnrollment(userId: string, code: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || !user.totpSecret) {
throw new BadRequestException('Start enrollment before verifying');
}
if (user.totpEnabled) {
throw new BadRequestException('2FA is already enabled');
}
const secret = this.totpService.decryptSecret(user.totpSecret);
if (!this.totpService.verifyCode(secret, code)) {
throw new BadRequestException('Invalid code — check your authenticator app and try again');
}
await this.prisma.user.update({
where: { id: userId },
data: { totpEnabled: true, totpEnrolledAt: new Date() },
});
await this.prisma.auditLog
.create({
data: {
organizationId: user.organizationId,
userId: user.id,
action: 'auth.2fa_enabled',
entityType: 'User',
entityId: user.id,
},
})
.catch(() => {});
return { success: true, message: '2FA is now enabled on this account' };
}
/**
* Turn 2FA off. The user must re-confirm their password to prove
* possession of the account before we drop the second factor.
*/
async disable2fa(userId: string, currentPassword: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException('User not found');
if (!user.totpEnabled) {
throw new BadRequestException('2FA is not currently enabled');
}
const ok = await bcrypt.compare(currentPassword, user.passwordHash);
if (!ok) throw new UnauthorizedException('Current password is incorrect');
await this.prisma.user.update({
where: { id: userId },
data: { totpEnabled: false, totpSecret: null, totpEnrolledAt: null },
});
await this.prisma.auditLog
.create({
data: {
organizationId: user.organizationId,
userId: user.id,
action: 'auth.2fa_disabled',
entityType: 'User',
entityId: user.id,
},
})
.catch(() => {});
return { success: true, message: '2FA disabled' };
}
/**
* Increment the failed login counter and lock the account if the threshold
* is exceeded. Also logs the failed attempt to the audit trail.
*
* @param userId - The user's database ID.
* @param email - Email address (for audit log).
* @param ipAddress - Caller's IP address.
*/
private async handleFailedLogin(userId: string, email: string, ipAddress?: string) {
const user = await this.prisma.user.update({
where: { id: userId },
data: {
failedLogins: { increment: 1 },
lastFailedAt: new Date(),
lastFailedIp: ipAddress || null,
},
});
// Lock account after MAX_FAILED_ATTEMPTS
if (user.failedLogins >= this.MAX_FAILED_ATTEMPTS) {
await this.prisma.user.update({
where: { id: userId },
data: {
lockedUntil: new Date(Date.now() + this.LOCKOUT_MINUTES * 60 * 1000),
},
});
}
// Log the failed attempt
this.logFailedAttempt(userId, email, ipAddress);
}
/**
* Write a failed-login audit log entry. Non-blocking (catches its own errors).
*
* @param userId - User ID if known, or null for non-existent accounts.
* @param email - The email address that was attempted.
* @param ipAddress - Caller's IP address.
*/
private async logFailedAttempt(userId: string | null, email: string, ipAddress?: string) {
// Find org for audit log
const org = userId
? await this.prisma.user.findUnique({ where: { id: userId }, select: { organizationId: true } })
: await this.prisma.organization.findFirst({ select: { id: true } });
if (org) {
await this.prisma.auditLog.create({
data: {
organizationId: 'organizationId' in org ? org.organizationId : org.id,
userId: userId,
action: 'auth.login_failed',
entityType: 'User',
entityId: email,
ipAddress: ipAddress || null,
newValues: { email, timestamp: new Date().toISOString() } as any,
},
}).catch(() => {}); // Non-blocking
}
}
/**
* Register is restricted to Admin/Super Admin roles and requires a valid invite code.
* All other users must be created by an admin via the User Management page.
*/
async register(dto: RegisterDto) {
// Invite code is REQUIRED
if (!dto.inviteCode) {
throw new BadRequestException('An invite code is required to create an account. Contact your administrator.');
}
// Validate invite code
const invite = await this.prisma.inviteCode.findUnique({
where: { code: dto.inviteCode },
});
if (!invite) {
throw new BadRequestException('Invalid invite code.');
}
if (invite.isUsed) {
throw new BadRequestException('This invite code has already been used.');
}
if (invite.expiresAt && invite.expiresAt < new Date()) {
throw new BadRequestException('This invite code has expired.');
}
// Only ADMIN and SUPER_ADMIN can self-register
const allowedRoles = ['ADMIN', 'SUPER_ADMIN'];
if (!allowedRoles.includes(invite.role)) {
throw new BadRequestException('This invite code is not valid for self-registration.');
}
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existingUser) {
throw new ConflictException('Email already registered');
}
const passwordHash = await bcrypt.hash(dto.password, 12);
const user = await this.prisma.user.create({
data: {
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
role: invite.role,
organizationId: invite.organizationId,
phone: dto.phone,
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
organizationId: true,
},
});
// Mark invite code as used
await this.prisma.inviteCode.update({
where: { id: invite.id },
data: {
isUsed: true,
usedByEmail: dto.email,
usedAt: new Date(),
},
});
const tokens = await this.generateTokens(user.id, user.email, user.role, user.organizationId);
return { user, ...tokens };
}
/**
* Exchange a valid refresh token for a new access + refresh token pair.
*
* The old refresh token is not revoked (stateless JWTs). Token rotation
* is achieved by the short access-token TTL (24h default) and longer
* refresh-token TTL (7d default).
*
* @param refreshToken - The refresh JWT issued during login.
* @returns A fresh access + refresh token pair.
* @throws {UnauthorizedException} Token is expired, malformed, or belongs to a deactivated user.
*/
async refresh(refreshToken: string) {
try {
const refreshSecret = this.configService.get<string>('jwt.refreshSecret');
if (!refreshSecret) throw new Error('JWT_REFRESH_SECRET environment variable is required');
const payload = this.jwtService.verify(refreshToken, {
secret: refreshSecret,
});
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('Invalid refresh token');
}
return this.generateTokens(user.id, user.email, user.role, user.organizationId);
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
}
/**
* Retrieve the authenticated user's full profile including organisation info.
*
* @param userId - The authenticated user's ID (from JWT `sub` claim).
* @returns User profile with organisation details.
* @throws {UnauthorizedException} User not found (e.g., deleted after token issued).
*/
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
phone: true,
avatarUrl: true,
organizationId: true,
isActive: true,
lastLoginAt: true,
mustChangePassword: true,
totpEnabled: true,
totpEnrolledAt: true,
preferences: true,
createdAt: true,
organization: {
select: { id: true, name: true, slug: true, logoUrl: true },
},
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
return user;
}
/**
* Register an FCM device token for push notifications.
*
* Called by the mobile driver app after login to enable trip dispatch
* notifications, bay timeout alerts, and reallocation messages.
*
* @param userId - The authenticated user's ID.
* @param token - Firebase Cloud Messaging device registration token.
* @param platform - Device platform identifier (defaults to `'android'`).
* @returns Confirmation message.
*/
async saveDeviceToken(userId: string, token: string, platform?: string) {
await this.prisma.user.update({
where: { id: userId },
data: {
fcmToken: token,
fcmPlatform: platform || 'android',
},
});
return { message: 'Device token registered' };
}
/**
* Initiate a password reset flow by generating a one-time reset token
* and emailing the user a reset link.
*
* The token is SHA-256-hashed before storage so a database leak cannot
* be used to reset arbitrary accounts. The link expires in 1 hour.
* If SMTP is not configured, the reset URL is logged to stdout as a
* fallback for development environments.
*
* @param email - The account email to reset.
* @returns A generic success message (never reveals whether the email exists).
*/
async requestPasswordReset(email: string): Promise<{ message: string }> {
const user = await this.prisma.user.findUnique({ where: { email } });
// Always return success (don't reveal if email exists)
if (!user) {
return { message: 'If an account with that email exists, a reset link has been sent.' };
}
const crypto = require('crypto');
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');
// Token expires in 1 hour
const resetTokenExp = new Date(Date.now() + 60 * 60 * 1000);
await this.prisma.user.update({
where: { id: user.id },
data: { resetToken: resetTokenHash, resetTokenExp },
});
const resetUrl = `${process.env.WEB_URL || 'http://localhost:3000'}/reset-password?token=${resetToken}&email=${encodeURIComponent(email)}`;
try {
const nodemailer = require('nodemailer');
if (process.env.SMTP_HOST) {
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
await transport.sendMail({
from: process.env.SMTP_FROM || 'FleetCommand <noreply@fleetcommand.io>',
to: email,
subject: 'FleetCommand — Password Reset',
html: `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
<div style="background:linear-gradient(135deg,#6366f1,#3b82f6);padding:20px 30px;border-radius:12px 12px 0 0">
<h2 style="color:white;margin:0">Password Reset</h2>
</div>
<div style="background:#f8fafc;padding:30px;border:1px solid #e2e8f0;border-radius:0 0 12px 12px">
<p>Hi ${user.firstName},</p>
<p>You requested a password reset. Click the button below to set a new password:</p>
<a href="${resetUrl}" style="display:inline-block;background:#6366f1;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;margin:16px 0">Reset Password</a>
<p style="color:#64748b;font-size:14px">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
<p style="color:#94a3b8;font-size:12px;margin-top:20px">FleetCommand TMS</p>
</div>
</div>
`,
});
} else {
console.log(`[PASSWORD RESET] ${email}: ${resetUrl}`);
}
} catch (err) {
console.error('Failed to send reset email:', err);
console.log(`[PASSWORD RESET FALLBACK] ${email}: ${resetUrl}`);
}
return { message: 'If an account with that email exists, a reset link has been sent.' };
}
/**
* Complete a password reset by validating the one-time token and setting
* a new password.
*
* @param token - The raw (unhashed) reset token from the email link.
* @param email - The account email address.
* @param newPassword - The new password to set (must meet policy requirements).
* @returns Success confirmation message.
* @throws {UnauthorizedException} Token is invalid, expired, or does not match the email.
*/
async resetPassword(token: string, email: string, newPassword: string): Promise<{ message: string }> {
const crypto = require('crypto');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const user = await this.prisma.user.findFirst({
where: {
email,
resetToken: tokenHash,
resetTokenExp: { gt: new Date() },
},
});
if (!user) {
throw new UnauthorizedException('Invalid or expired reset token');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
resetToken: null,
resetTokenExp: null,
},
});
return { message: 'Password reset successfully. You can now log in with your new password.' };
}
/**
* Change the authenticated user's password.
*
* Requires the current password for verification. On success, clears the
* `mustChangePassword` flag and returns fresh tokens so the current
* session remains valid.
*
* @param userId - The authenticated user's ID.
* @param currentPassword - The user's current password for verification.
* @param newPassword - The new password (min 8 characters, must differ from current).
* @returns Success message with fresh access + refresh tokens.
* @throws {UnauthorizedException} Current password is incorrect.
* @throws {BadRequestException} New password fails policy (too short or same as current).
*/
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new UnauthorizedException('User not found');
if (newPassword.length < 8) {
throw new BadRequestException('New password must be at least 8 characters');
}
if (newPassword === currentPassword) {
throw new BadRequestException('New password must be different from the current one');
}
const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isValid) throw new UnauthorizedException('Current password is incorrect');
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.prisma.user.update({
where: { id: userId },
data: {
passwordHash,
// Always clear the must-change flag after a successful self-change.
mustChangePassword: false,
},
});
// Generate new tokens so the current session stays valid
const freshTokens = await this.generateTokens(
user.id, user.email, user.role, user.organizationId,
);
return { message: 'Password changed successfully.', ...freshTokens };
}
/**
* Generate a JWT access + refresh token pair.
*
* The access token carries the user's `sub`, `email`, `role`, and
* `organizationId` claims and is used by the `JwtStrategy` guard on
* every protected route. The refresh token has a longer TTL and uses
* a separate signing secret so that a leaked access token cannot be
* used to mint new tokens.
*
* @param userId - User's database ID (becomes `sub` claim).
* @param email - User's email address.
* @param role - User's role (e.g., `ADMIN`, `DISPATCHER`, `DRIVER`).
* @param organizationId - Tenant organisation ID.
* @returns Object with `accessToken` and `refreshToken` strings.
* @throws {Error} If `JWT_SECRET` or `JWT_REFRESH_SECRET` env vars are missing.
*/
private async generateTokens(
userId: string,
email: string,
role: string,
organizationId: string,
) {
const payload = { sub: userId, email, role, organizationId };
const secret = this.configService.get<string>('jwt.secret');
if (!secret) throw new Error('JWT_SECRET environment variable is required');
const refreshSecret = this.configService.get<string>('jwt.refreshSecret');
if (!refreshSecret) throw new Error('JWT_REFRESH_SECRET environment variable is required');
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret,
expiresIn:
this.configService.get<string>('jwt.expiresIn') || '24h',
}),
this.jwtService.signAsync(payload, {
secret: refreshSecret,
expiresIn:
this.configService.get<string>('jwt.refreshExpiresIn') || '7d',
}),
]);
return { accessToken, refreshToken };
}
}