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 { 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 => ({
|
||||||
|
|||||||
@ -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
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user