Merge pull request #13 from NTumurbars/codex/update-auth-token-structure-and-consumers
Refine auth token handling
This commit is contained in:
commit
a4119a2db8
@ -23,11 +23,14 @@ 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 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 { 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";
|
||||||
import { PrismaService } from "@bff/infra/database/prisma.service";
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||||
|
import { calculateExpiryDate } from "./utils/jwt-expiry.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -721,14 +724,13 @@ export class AuthService {
|
|||||||
// Helper methods
|
// Helper methods
|
||||||
private generateTokens(user: { id: string; email: string; role?: string }): AuthTokens {
|
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 expiresIn = this.configService.get<string | number>("JWT_EXPIRES_IN", "7d");
|
||||||
|
const accessToken = this.jwtService.sign(payload, { expiresIn });
|
||||||
const tokenType = this.configService.get<string>("JWT_TOKEN_TYPE") ?? this.DEFAULT_TOKEN_TYPE;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
tokenType,
|
expiresAt: calculateExpiryDate(expiresIn),
|
||||||
expiresAt: this.resolveAccessTokenExpiry(accessToken),
|
tokenType: "Bearer",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
|
|||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { parseJwtExpiry } from "../utils/jwt-expiry.util";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenBlacklistService {
|
export class TokenBlacklistService {
|
||||||
@ -27,7 +28,7 @@ export class TokenBlacklistService {
|
|||||||
} catch {
|
} catch {
|
||||||
// If we can't parse the token, blacklist it for the default JWT expiry time
|
// If we can't parse the token, blacklist it for the default JWT expiry time
|
||||||
try {
|
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");
|
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
62
apps/bff/src/modules/auth/utils/jwt-expiry.util.ts
Normal file
62
apps/bff/src/modules/auth/utils/jwt-expiry.util.ts
Normal 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;
|
||||||
@ -20,11 +20,24 @@ import type {
|
|||||||
|
|
||||||
const DEFAULT_TOKEN_TYPE = "Bearer";
|
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 =
|
type RawAuthTokens =
|
||||||
| AuthTokens
|
| 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) {
|
if (value instanceof Date) {
|
||||||
return value.toISOString();
|
return value.toISOString();
|
||||||
}
|
}
|
||||||
@ -33,15 +46,40 @@ const toIsoString = (value: string | number | Date): IsoDateTimeString => {
|
|||||||
return new Date(value).toISOString();
|
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 => ({
|
const isLegacyTokens = (tokens: RawAuthTokens): tokens is LegacyAuthTokens =>
|
||||||
accessToken: tokens.accessToken,
|
typeof tokens === "object" && tokens !== null && "access_token" in tokens;
|
||||||
refreshToken: tokens.refreshToken,
|
|
||||||
tokenType: tokens.tokenType ?? DEFAULT_TOKEN_TYPE,
|
const normalizeAuthTokens = (tokens: RawAuthTokens): AuthTokens => {
|
||||||
expiresAt: toIsoString(tokens.expiresAt),
|
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) => {
|
const getExpiryTime = (tokens?: AuthTokens | null) => {
|
||||||
if (!tokens?.expiresAt) {
|
if (!tokens?.expiresAt) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user