From e9dc9a93f64c85f425af3b764312efd7496a7ec8 Mon Sep 17 00:00:00 2001 From: NTumurbars <156628271+NTumurbars@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:40:43 +0900 Subject: [PATCH] Refine auth token handling --- apps/bff/src/modules/auth/auth.service.ts | 22 ++++--- .../auth/services/token-blacklist.service.ts | 21 +------ .../src/modules/auth/utils/jwt-expiry.util.ts | 62 +++++++++++++++++++ .../src/features/auth/services/auth.store.ts | 52 +++++++++++++--- 4 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 apps/bff/src/modules/auth/utils/jwt-expiry.util.ts diff --git a/apps/bff/src/modules/auth/auth.service.ts b/apps/bff/src/modules/auth/auth.service.ts index a862e099..81d40275 100644 --- a/apps/bff/src/modules/auth/auth.service.ts +++ b/apps/bff/src/modules/auth/auth.service.ts @@ -23,11 +23,12 @@ 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 { User as SharedUser, type AuthTokens } 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"; import { PrismaService } from "@bff/infra/database/prisma.service"; +import { calculateExpiryDate } from "./utils/jwt-expiry.util"; @Injectable() export class AuthService { @@ -384,7 +385,7 @@ export class AuthService { user: this.sanitizeUser( freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser) ), - ...tokens, + tokens, }; } catch (error) { // Log failed signup @@ -433,7 +434,7 @@ export class AuthService { const tokens = this.generateTokens(user); return { user: this.sanitizeUser(user), - ...tokens, + tokens, }; } @@ -589,7 +590,7 @@ export class AuthService { return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } @@ -717,10 +718,15 @@ 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 expiresIn = this.configService.get("JWT_EXPIRES_IN", "7d"); + const accessToken = this.jwtService.sign(payload, { expiresIn }); + return { - access_token: this.jwtService.sign(payload), + accessToken, + expiresAt: calculateExpiryDate(expiresIn), + tokenType: "Bearer", }; } @@ -854,7 +860,7 @@ export class AuthService { return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } catch (error) { this.logger.error("Reset password failed", { error: getErrorMessage(error) }); @@ -980,7 +986,7 @@ export class AuthService { const tokens = this.generateTokens(updatedUser); return { user: this.sanitizeUser(updatedUser), - ...tokens, + tokens, }; } diff --git a/apps/bff/src/modules/auth/services/token-blacklist.service.ts b/apps/bff/src/modules/auth/services/token-blacklist.service.ts index 4a8d35a7..ca56743e 100644 --- a/apps/bff/src/modules/auth/services/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/services/token-blacklist.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; +import { parseJwtExpiry } from "../utils/jwt-expiry.util"; @Injectable() export class TokenBlacklistService { @@ -27,7 +28,7 @@ export class TokenBlacklistService { } catch { // If we can't parse the token, blacklist it for the default JWT expiry time try { - const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); + const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); await this.redis.setex(`blacklist:${token}`, defaultTtl, "1"); } catch (err) { this.logger.warn( @@ -53,22 +54,4 @@ export class TokenBlacklistService { } } - private parseJwtExpiry(expiresIn: string): number { - // Convert JWT expiry string to seconds - const unit = expiresIn.slice(-1); - const value = parseInt(expiresIn.slice(0, -1), 10); - - switch (unit) { - case "s": - return value; - case "m": - return value * 60; - case "h": - return value * 60 * 60; - case "d": - return value * 24 * 60 * 60; - default: - return 7 * 24 * 60 * 60; // Default 7 days in seconds - } - } } diff --git a/apps/bff/src/modules/auth/utils/jwt-expiry.util.ts b/apps/bff/src/modules/auth/utils/jwt-expiry.util.ts new file mode 100644 index 00000000..52318fdb --- /dev/null +++ b/apps/bff/src/modules/auth/utils/jwt-expiry.util.ts @@ -0,0 +1,62 @@ +const DEFAULT_JWT_EXPIRY_SECONDS = 7 * 24 * 60 * 60; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value > 0; + +/** + * Parse a JWT expiry configuration string (e.g. "15m", "7d") into seconds. + * Falls back to a sensible default when parsing fails. + */ +export const parseJwtExpiry = (expiresIn: string | number | undefined | null): number => { + if (isFiniteNumber(expiresIn)) { + return Math.floor(expiresIn); + } + + if (typeof expiresIn !== "string") { + return DEFAULT_JWT_EXPIRY_SECONDS; + } + + const trimmed = expiresIn.trim(); + if (!trimmed) { + return DEFAULT_JWT_EXPIRY_SECONDS; + } + + const unit = trimmed.slice(-1); + const valuePortion = trimmed.slice(0, -1); + const parsedValue = parseInt(valuePortion, 10); + + const toSeconds = (multiplier: number) => { + if (Number.isNaN(parsedValue) || parsedValue <= 0) { + return DEFAULT_JWT_EXPIRY_SECONDS; + } + return parsedValue * multiplier; + }; + + switch (unit) { + case "s": + return toSeconds(1); + case "m": + return toSeconds(60); + case "h": + return toSeconds(60 * 60); + case "d": + return toSeconds(24 * 60 * 60); + default: { + const numeric = Number(trimmed); + if (isFiniteNumber(numeric)) { + return Math.floor(numeric); + } + return DEFAULT_JWT_EXPIRY_SECONDS; + } + } +}; + +export const calculateExpiryDate = ( + expiresIn: string | number | undefined | null, + referenceDate: number = Date.now() +): string => { + const seconds = parseJwtExpiry(expiresIn); + return new Date(referenceDate + seconds * 1000).toISOString(); +}; + +export const JWT_EXPIRY_DEFAULT_SECONDS = DEFAULT_JWT_EXPIRY_SECONDS; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index af1e644c..0361b9f2 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -19,11 +19,22 @@ import type { const DEFAULT_TOKEN_TYPE = "Bearer"; +type LegacyAuthTokens = { + access_token: string; + refresh_token?: string | null; + tokenType?: string | null; + token_type?: string | null; + refreshToken?: string | null; + expiresAt?: string | number | Date | null; + expires_at?: string | number | Date | null; +}; + type RawAuthTokens = | AuthTokens - | (Omit & { expiresAt: string | number | Date }); + | (Omit & { expiresAt: string | number | Date }) + | LegacyAuthTokens; -const toIsoString = (value: string | number | Date) => { +const toIsoString = (value: string | number | Date | null | undefined) => { if (value instanceof Date) { return value.toISOString(); } @@ -32,15 +43,38 @@ const toIsoString = (value: string | number | Date) => { return new Date(value).toISOString(); } - return value; + if (typeof value === "string" && value) { + return value; + } + + // Treat missing expiry as already expired to force re-authentication + return new Date(0).toISOString(); }; -const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({ - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - tokenType: tokens.tokenType ?? DEFAULT_TOKEN_TYPE, - expiresAt: toIsoString(tokens.expiresAt), -}); +const isLegacyTokens = (tokens: RawAuthTokens): tokens is LegacyAuthTokens => + typeof tokens === "object" && tokens !== null && "access_token" in tokens; + +const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => { + if (isLegacyTokens(tokens)) { + const refreshToken = tokens.refreshToken ?? tokens.refresh_token ?? undefined; + const tokenType = tokens.tokenType ?? tokens.token_type ?? undefined; + const expiresAt = tokens.expiresAt ?? tokens.expires_at ?? undefined; + + return { + accessToken: tokens.access_token, + refreshToken: refreshToken ?? undefined, + tokenType: tokenType ?? DEFAULT_TOKEN_TYPE, + expiresAt: toIsoString(expiresAt), + }; + } + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + tokenType: tokens.tokenType ?? DEFAULT_TOKEN_TYPE, + expiresAt: toIsoString(tokens.expiresAt), + }; +}; const getExpiryTime = (tokens?: AuthTokens | null) => { if (!tokens?.expiresAt) {