Merge pull request #13 from NTumurbars/codex/update-auth-token-structure-and-consumers

Refine auth token handling
This commit is contained in:
NTumurbars 2025-09-18 16:45:57 +09:00 committed by GitHub
commit a4119a2db8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 119 additions and 34 deletions

View File

@ -23,11 +23,14 @@ 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 type { AuthTokens, 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 {
@ -721,14 +724,13 @@ export class AuthService {
// Helper methods
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;
const expiresIn = this.configService.get<string | number>("JWT_EXPIRES_IN", "7d");
const accessToken = this.jwtService.sign(payload, { expiresIn });
return {
accessToken,
tokenType,
expiresAt: this.resolveAccessTokenExpiry(accessToken),
expiresAt: calculateExpiryDate(expiresIn),
tokenType: "Bearer",
};
}

View File

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

View File

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

View File

@ -20,11 +20,24 @@ 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<AuthTokens, "expiresAt"> & { expiresAt: IsoDateTimeString | number | Date });
const toIsoString = (value: string | number | Date): IsoDateTimeString => {
| (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date })
| LegacyAuthTokens;
const toIsoString = (value: string | number | Date | null | undefined) => {
if (value instanceof Date) {
return value.toISOString();
}
@ -33,15 +46,40 @@ const toIsoString = (value: string | number | Date): IsoDateTimeString => {
return new Date(value).toISOString();
}
return value as IsoDateTimeString;
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) {