Normalize auth token expiry handling

This commit is contained in:
NTumurbars 2025-09-18 16:39:18 +09:00
parent 143aad11d8
commit 0749bb1fa0
5 changed files with 131 additions and 64 deletions

View File

@ -23,7 +23,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util"; import { sanitizeWhmcsRedirectPath } from "@bff/core/utils/sso.util";
import { EmailService } from "@bff/infra/email/email.service"; 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 { User as PrismaUser } from "@prisma/client";
import type { Request } from "express"; import type { Request } from "express";
import { WhmcsClientResponse } from "@bff/integrations/whmcs/types/whmcs-api.types"; 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 { export class AuthService {
private readonly MAX_LOGIN_ATTEMPTS = 5; private readonly MAX_LOGIN_ATTEMPTS = 5;
private readonly LOCKOUT_DURATION_MINUTES = 15; private readonly LOCKOUT_DURATION_MINUTES = 15;
private readonly DEFAULT_TOKEN_TYPE = "Bearer";
private readonly DEFAULT_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
@ -384,7 +386,7 @@ export class AuthService {
user: this.sanitizeUser( user: this.sanitizeUser(
freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser) freshUser ?? ({ id: createdUserId, email } as unknown as PrismaUser)
), ),
...tokens, tokens,
}; };
} catch (error) { } catch (error) {
// Log failed signup // Log failed signup
@ -433,7 +435,7 @@ export class AuthService {
const tokens = this.generateTokens(user); const tokens = this.generateTokens(user);
return { return {
user: this.sanitizeUser(user), user: this.sanitizeUser(user),
...tokens, tokens,
}; };
} }
@ -589,7 +591,7 @@ export class AuthService {
return { return {
user: this.sanitizeUser(updatedUser), user: this.sanitizeUser(updatedUser),
...tokens, tokens,
}; };
} }
@ -717,10 +719,16 @@ export class AuthService {
} }
// Helper methods // 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 payload = { email: user.email, sub: user.id, role: user.role };
const accessToken = this.jwtService.sign(payload);
const tokenType = this.configService.get<string>("JWT_TOKEN_TYPE") ?? this.DEFAULT_TOKEN_TYPE;
return { return {
access_token: this.jwtService.sign(payload), accessToken,
tokenType,
expiresAt: this.resolveAccessTokenExpiry(accessToken),
}; };
} }
@ -797,6 +805,61 @@ export class AuthService {
return sanitizeWhmcsRedirectPath(path); 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<string | number>("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<void> { async requestPasswordReset(email: string): Promise<void> {
const user = await this.usersService.findByEmailInternal(email); const user = await this.usersService.findByEmailInternal(email);
// Always act as if successful to avoid account enumeration // Always act as if successful to avoid account enumeration
@ -854,7 +917,7 @@ export class AuthService {
return { return {
user: this.sanitizeUser(updatedUser), user: this.sanitizeUser(updatedUser),
...tokens, tokens,
}; };
} catch (error) { } catch (error) {
this.logger.error("Reset password failed", { error: getErrorMessage(error) }); this.logger.error("Reset password failed", { error: getErrorMessage(error) });
@ -980,7 +1043,7 @@ export class AuthService {
const tokens = this.generateTokens(updatedUser); const tokens = this.generateTokens(updatedUser);
return { return {
user: this.sanitizeUser(updatedUser), user: this.sanitizeUser(updatedUser),
...tokens, tokens,
}; };
} }

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { logger } from "@customer-portal/logging"; 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 { useAuthStore } from "@/features/auth/services/auth.store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,70 +15,70 @@ export function SessionTimeoutWarning({
const { isAuthenticated, tokens, logout, checkAuth } = useAuthStore(); const { isAuthenticated, tokens, logout, checkAuth } = useAuthStore();
const [showWarning, setShowWarning] = useState(false); const [showWarning, setShowWarning] = useState(false);
const [timeLeft, setTimeLeft] = useState<number>(0); const [timeLeft, setTimeLeft] = useState<number>(0);
const expiryRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
if (!isAuthenticated || !tokens?.accessToken) { if (!isAuthenticated || !tokens?.expiresAt) {
expiryRef.current = null;
setShowWarning(false);
setTimeLeft(0);
return undefined; return undefined;
} }
// Parse JWT to get expiry time const expiryTime = Date.parse(tokens.expiresAt);
try { if (Number.isNaN(expiryTime)) {
const parts = tokens.accessToken.split("."); logger.warn("Invalid expiresAt on auth tokens", { expiresAt: tokens.expiresAt });
if (parts.length !== 3) { expiryRef.current = null;
throw new Error("Invalid token format"); setShowWarning(false);
} setTimeLeft(0);
return undefined;
}
const payload = JSON.parse(atob(parts[1])) as { exp?: number }; expiryRef.current = expiryTime;
if (!payload.exp) {
logger.warn("Token does not have expiration time");
return undefined;
}
const expiryTime = payload.exp * 1000; // Convert to milliseconds if (Date.now() >= expiryTime) {
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");
void logout(); void logout();
return undefined; 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(() => { useEffect(() => {
if (!showWarning) return undefined; if (!showWarning || !expiryRef.current) return undefined;
const interval = setInterval(() => { const interval = setInterval(() => {
setTimeLeft(prev => { const expiryTime = expiryRef.current;
if (prev <= 1) { if (!expiryTime) {
void logout(); return;
return 0; }
}
return prev - 1; const remaining = expiryTime - Date.now();
}); if (remaining <= 0) {
setTimeLeft(0);
void logout();
return;
}
setTimeLeft(Math.max(1, Math.ceil(remaining / (60 * 1000))));
}, 60000); }, 60000);
return () => clearInterval(interval); return () => clearInterval(interval);

View File

@ -15,15 +15,16 @@ import type {
ResetPasswordRequest, ResetPasswordRequest,
ChangePasswordRequest, ChangePasswordRequest,
AuthError, AuthError,
IsoDateTimeString,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
const DEFAULT_TOKEN_TYPE = "Bearer"; const DEFAULT_TOKEN_TYPE = "Bearer";
type RawAuthTokens = type RawAuthTokens =
| AuthTokens | AuthTokens
| (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date }); | (Omit<AuthTokens, "expiresAt"> & { expiresAt: IsoDateTimeString | number | Date });
const toIsoString = (value: string | number | Date) => { const toIsoString = (value: string | number | Date): IsoDateTimeString => {
if (value instanceof Date) { if (value instanceof Date) {
return value.toISOString(); return value.toISOString();
} }
@ -32,7 +33,7 @@ const toIsoString = (value: string | number | Date) => {
return new Date(value).toISOString(); return new Date(value).toISOString();
} }
return value; return value as IsoDateTimeString;
}; };
const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({ const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => ({

View File

@ -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 isInvoiceId = (id: string): id is InvoiceId => typeof id === 'string';
export const isWhmcsClientId = (id: number): id is WhmcsClientId => typeof id === 'number'; 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 // BASE ENTITY INTERFACES
// ===================================================== // =====================================================

View File

@ -1,5 +1,5 @@
// User and authentication types // User and authentication types
import type { BaseEntity, Address } from "../common"; import type { BaseEntity, Address, IsoDateTimeString } from "../common";
export interface User extends BaseEntity { export interface User extends BaseEntity {
email: string; email: string;
@ -46,7 +46,7 @@ export interface Activity {
export interface AuthTokens { export interface AuthTokens {
accessToken: string; accessToken: string;
refreshToken?: string; refreshToken?: string;
expiresAt: string; // ISO expiresAt: IsoDateTimeString;
tokenType?: string; tokenType?: string;
} }