Normalize auth token expiry handling
This commit is contained in:
parent
143aad11d8
commit
0749bb1fa0
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 => ({
|
||||
|
||||
@ -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
|
||||
// =====================================================
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user