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 { 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<string>("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<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> {
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,
};
}

View File

@ -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<number>(0);
const expiryRef = useRef<number | null>(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 payload = JSON.parse(atob(parts[1])) as { exp?: number };
if (!payload.exp) {
logger.warn("Token does not have expiration time");
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 expiryTime = payload.exp * 1000; // Convert to milliseconds
const currentTime = Date.now();
const warningThreshold = warningTime * 60 * 1000; // Convert to milliseconds
expiryRef.current = expiryTime;
const timeUntilExpiry = expiryTime - currentTime;
const timeUntilWarning = timeUntilExpiry - warningThreshold;
if (timeUntilExpiry <= 0) {
// Token already expired
if (Date.now() >= expiryTime) {
void logout();
return undefined;
}
const warningThreshold = warningTime * 60 * 1000;
const now = Date.now();
const timeUntilExpiry = expiryTime - now;
const timeUntilWarning = timeUntilExpiry - warningThreshold;
if (timeUntilWarning <= 0) {
// Should show warning immediately
setShowWarning(true);
setTimeLeft(Math.ceil(timeUntilExpiry / 1000 / 60)); // Minutes left
setTimeLeft(Math.max(1, Math.ceil(timeUntilExpiry / (60 * 1000))));
return undefined;
} else {
// Set timeout to show warning
}
const warningTimeout = setTimeout(() => {
setShowWarning(true);
setTimeLeft(warningTime);
setTimeLeft(
Math.max(1, Math.ceil((expiryRef.current! - Date.now()) / (60 * 1000)))
);
}, timeUntilWarning);
return () => clearTimeout(warningTimeout);
}
} catch (error) {
logger.error(error, "Error parsing JWT token");
void logout();
return undefined;
}
}, [isAuthenticated, tokens, warningTime, logout]);
}, [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;
const expiryTime = expiryRef.current;
if (!expiryTime) {
return;
}
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);
return () => clearInterval(interval);

View File

@ -15,15 +15,16 @@ import type {
ResetPasswordRequest,
ChangePasswordRequest,
AuthError,
IsoDateTimeString,
} from "@customer-portal/domain";
const DEFAULT_TOKEN_TYPE = "Bearer";
type RawAuthTokens =
| 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) {
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 => ({

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 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
// =====================================================

View File

@ -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;
}