diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index a862e099..ad2be45f 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -23,7 +23,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util"; import { EmailService } from "@bff/infra/email/email.service"; -import { User as SharedUser } from "@customer-portal/domain"; +import type { AuthTokens, User as SharedUser } from "@customer-portal/domain"; import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types"; @@ -33,6 +33,8 @@ import { PrismaService } from "@bff/infra/database/prisma.service"; export class AuthService { private readonly MAX_LOGIN_ATTEMPTS = 5; private readonly LOCKOUT_DURATION_MINUTES = 15; + private readonly DEFAULT_TOKEN_TYPE = "Bearer"; + private readonly DEFAULT_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; constructor( private usersService: UsersService, @@ -384,7 +386,7 @@ export class AuthService { user: this.sanitizeUser( freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser) ), - ...tokens, + tokens, }; } catch (error) { // Log failed signup @@ -433,7 +435,7 @@ export class AuthService { const tokens = this.generateTokens(user); return { user: this.sanitizeUser(user), - ...tokens, + tokens, }; } @@ -589,7 +591,7 @@ export class AuthService { return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } @@ -717,10 +719,16 @@ export class AuthService { } // Helper methods - private generateTokens(user: { id: string; email: string; role?: string }) { + private generateTokens(user: { id: string; email: string; role?: string }): AuthTokens { const payload = { email: user.email, sub: user.id, role: user.role }; + const accessToken = this.jwtService.sign(payload); + + const tokenType = this.configService.get("JWT_TOKEN_TYPE") ?? this.DEFAULT_TOKEN_TYPE; + return { - access_token: this.jwtService.sign(payload), + accessToken, + tokenType, + expiresAt: this.resolveAccessTokenExpiry(accessToken), }; } @@ -797,6 +805,61 @@ export class AuthService { return sanitizeWhmcsRedirectPath(path); } + private resolveAccessTokenExpiry(accessToken: string): string { + try { + const decoded = this.jwtService.decode(accessToken); + if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") { + return new Date(decoded.exp * 1000).toISOString(); + } + } catch (error) { + this.logger.debug("Failed to decode JWT for expiry", { error: getErrorMessage(error) }); + } + + const configuredExpiry = this.configService.get("JWT_EXPIRES_IN", "7d"); + const fallbackMs = this.parseExpiresInToMs(configuredExpiry); + return new Date(Date.now() + fallbackMs).toISOString(); + } + + private parseExpiresInToMs(expiresIn: string | number | undefined): number { + if (typeof expiresIn === "number" && Number.isFinite(expiresIn)) { + return expiresIn * 1000; + } + + if (!expiresIn) { + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + + const raw = expiresIn.toString().trim(); + if (!raw) { + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + + const unit = raw.slice(-1); + const magnitude = Number(raw.slice(0, -1)); + + if (Number.isFinite(magnitude)) { + switch (unit) { + case "s": + return magnitude * 1000; + case "m": + return magnitude * 60 * 1000; + case "h": + return magnitude * 60 * 60 * 1000; + case "d": + return magnitude * 24 * 60 * 60 * 1000; + default: + break; + } + } + + const numericValue = Number(raw); + if (Number.isFinite(numericValue)) { + return numericValue * 1000; + } + + return this.DEFAULT_TOKEN_EXPIRY_MS; + } + async requestPasswordReset(email: string): Promise { const user = await this.usersService.findByEmailInternal(email); // Always act as if successful to avoid account enumeration @@ -854,7 +917,7 @@ export class AuthService { return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } catch (error) { this.logger.error("Reset password failed", { error: getErrorMessage(error) }); @@ -980,7 +1043,7 @@ export class AuthService { const tokens = this.generateTokens(updatedUser); return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index 5a08d27e..e8a25074 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -1,7 +1,7 @@ "use client"; import { logger } from "@customer-portal/logging"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useAuthStore } from "@/features/auth/services/auth.store"; import { Button } from "@/components/ui/button"; @@ -15,70 +15,70 @@ export function SessionTimeoutWarning({ const { isAuthenticated, tokens, logout, checkAuth } = useAuthStore(); const [showWarning, setShowWarning] = useState(false); const [timeLeft, setTimeLeft] = useState(0); + const expiryRef = useRef(null); useEffect(() => { - if (!isAuthenticated || !tokens?.accessToken) { + if (!isAuthenticated || !tokens?.expiresAt) { + expiryRef.current = null; + setShowWarning(false); + setTimeLeft(0); return undefined; } - // Parse JWT to get expiry time - try { - const parts = tokens.accessToken.split("."); - if (parts.length !== 3) { - throw new Error("Invalid token format"); - } + const expiryTime = Date.parse(tokens.expiresAt); + if (Number.isNaN(expiryTime)) { + logger.warn("Invalid expiresAt on auth tokens", { expiresAt: tokens.expiresAt }); + expiryRef.current = null; + setShowWarning(false); + setTimeLeft(0); + return undefined; + } - const payload = JSON.parse(atob(parts[1])) as { exp?: number }; - if (!payload.exp) { - logger.warn("Token does not have expiration time"); - return undefined; - } + expiryRef.current = expiryTime; - const expiryTime = payload.exp * 1000; // Convert to milliseconds - const currentTime = Date.now(); - const warningThreshold = warningTime * 60 * 1000; // Convert to milliseconds - - const timeUntilExpiry = expiryTime - currentTime; - const timeUntilWarning = timeUntilExpiry - warningThreshold; - - if (timeUntilExpiry <= 0) { - // Token already expired - void logout(); - return undefined; - } - - if (timeUntilWarning <= 0) { - // Should show warning immediately - setShowWarning(true); - setTimeLeft(Math.ceil(timeUntilExpiry / 1000 / 60)); // Minutes left - return undefined; - } else { - // Set timeout to show warning - const warningTimeout = setTimeout(() => { - setShowWarning(true); - setTimeLeft(warningTime); - }, timeUntilWarning); - - return () => clearTimeout(warningTimeout); - } - } catch (error) { - logger.error(error, "Error parsing JWT token"); + if (Date.now() >= expiryTime) { void logout(); return undefined; } - }, [isAuthenticated, tokens, warningTime, logout]); + + const warningThreshold = warningTime * 60 * 1000; + const now = Date.now(); + const timeUntilExpiry = expiryTime - now; + const timeUntilWarning = timeUntilExpiry - warningThreshold; + + if (timeUntilWarning <= 0) { + setShowWarning(true); + setTimeLeft(Math.max(1, Math.ceil(timeUntilExpiry / (60 * 1000)))); + return undefined; + } + + const warningTimeout = setTimeout(() => { + setShowWarning(true); + setTimeLeft( + Math.max(1, Math.ceil((expiryRef.current! - Date.now()) / (60 * 1000))) + ); + }, timeUntilWarning); + + return () => clearTimeout(warningTimeout); + }, [isAuthenticated, tokens?.expiresAt, warningTime, logout]); useEffect(() => { - if (!showWarning) return undefined; + if (!showWarning || !expiryRef.current) return undefined; const interval = setInterval(() => { - setTimeLeft(prev => { - if (prev <= 1) { - void logout(); - return 0; - } - return prev - 1; - }); + const expiryTime = expiryRef.current; + if (!expiryTime) { + return; + } + + const remaining = expiryTime - Date.now(); + if (remaining <= 0) { + setTimeLeft(0); + void logout(); + return; + } + + setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000)))); }, 60000); return () => clearInterval(interval); diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index af1e644c..0d21afdb 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -15,15 +15,16 @@ import type { ResetPasswordRequest, ChangePasswordRequest, AuthError, + IsoDateTimeString, } from "@customer-portal/domain"; const DEFAULT_TOKEN_TYPE = "Bearer"; type RawAuthTokens = | AuthTokens - | (Omit & { expiresAt: string | number | Date }); + | (Omit & { expiresAt: IsoDateTimeString | number | Date }); -const toIsoString = (value: string | number | Date) => { +const toIsoString = (value: string | number | Date): IsoDateTimeString => { if (value instanceof Date) { return value.toISOString(); } @@ -32,7 +33,7 @@ const toIsoString = (value: string | number | Date) => { return new Date(value).toISOString(); } - return value; + return value as IsoDateTimeString; }; const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({ diff --git a/packages/domain/src/common.ts b/packages/domain/src/common.ts index a89b954b..3f1980c5 100644 --- a/packages/domain/src/common.ts +++ b/packages/domain/src/common.ts @@ -46,6 +46,9 @@ export const isOrderId = (id: string): id is OrderId => typeof id === 'string'; export const isInvoiceId = (id: string): id is InvoiceId => typeof id === 'string'; export const isWhmcsClientId = (id: number): id is WhmcsClientId => typeof id === 'number'; +// Shared ISO8601 timestamp string type used for serialized dates +export type IsoDateTimeString = string; + // ===================================================== // BASE ENTITY INTERFACES // ===================================================== diff --git a/packages/domain/src/entities/user.ts b/packages/domain/src/entities/user.ts index 79b00b5c..556f71b2 100644 --- a/packages/domain/src/entities/user.ts +++ b/packages/domain/src/entities/user.ts @@ -1,5 +1,5 @@ // User and authentication types -import type { BaseEntity, Address } from "../common"; +import type { BaseEntity, Address, IsoDateTimeString } from "../common"; export interface User extends BaseEntity { email: string; @@ -46,7 +46,7 @@ export interface Activity { export interface AuthTokens { accessToken: string; refreshToken?: string; - expiresAt: string; // ISO + expiresAt: IsoDateTimeString; tokenType?: string; }