Enhance authentication and CSRF protection mechanisms
- Introduced optional JWT issuer and audience configurations in the JoseJwtService for improved token validation. - Updated CSRF middleware to streamline token validation and enhance security measures. - Added new environment variables for JWT issuer and audience, allowing for more flexible authentication setups. - Refactored CSRF controller and middleware to improve token handling and security checks. - Cleaned up and standardized cookie paths for access and refresh tokens in the AuthController. - Enhanced error handling in the TokenBlacklistService to manage Redis availability more effectively.
This commit is contained in:
parent
3f7fa02b83
commit
88b9ac0a19
@ -8,6 +8,8 @@ export const envSchema = z.object({
|
|||||||
|
|
||||||
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
|
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
|
||||||
JWT_EXPIRES_IN: z.string().default("7d"),
|
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||||
|
JWT_ISSUER: z.string().min(1).optional(),
|
||||||
|
JWT_AUDIENCE: z.string().min(1).optional(), // supports CSV: "portal,admin"
|
||||||
BCRYPT_ROUNDS: z.coerce.number().int().min(12).max(16).default(14),
|
BCRYPT_ROUNDS: z.coerce.number().int().min(12).max(16).default(14),
|
||||||
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
|
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
|
||||||
|
|
||||||
@ -41,6 +43,7 @@ export const envSchema = z.object({
|
|||||||
REDIS_URL: z.string().url().default("redis://localhost:6379"),
|
REDIS_URL: z.string().url().default("redis://localhost:6379"),
|
||||||
AUTH_ALLOW_REDIS_TOKEN_FAILOPEN: z.enum(["true", "false"]).default("false"),
|
AUTH_ALLOW_REDIS_TOKEN_FAILOPEN: z.enum(["true", "false"]).default("false"),
|
||||||
AUTH_REQUIRE_REDIS_FOR_TOKENS: z.enum(["true", "false"]).default("false"),
|
AUTH_REQUIRE_REDIS_FOR_TOKENS: z.enum(["true", "false"]).default("false"),
|
||||||
|
AUTH_BLACKLIST_FAIL_CLOSED: z.enum(["true", "false"]).default("false"),
|
||||||
AUTH_MAINTENANCE_MODE: z.enum(["true", "false"]).default("false"),
|
AUTH_MAINTENANCE_MODE: z.enum(["true", "false"]).default("false"),
|
||||||
AUTH_MAINTENANCE_MESSAGE: z
|
AUTH_MAINTENANCE_MESSAGE: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { Controller, Get, Post, Req, Res, Inject } from "@nestjs/common";
|
import { Controller, Get, Post, Req, Res, Inject, ForbiddenException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { CsrfService } from "../services/csrf.service.js";
|
import { CsrfService } from "../services/csrf.service.js";
|
||||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||||
|
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
export type AuthenticatedRequest = Request & {
|
export type AuthenticatedRequest = Request & {
|
||||||
user?: { id: string; sessionId?: string };
|
user?: UserAuth;
|
||||||
sessionID?: string;
|
sessionID?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -13,25 +15,28 @@ export type AuthenticatedRequest = Request & {
|
|||||||
export class CsrfController {
|
export class CsrfController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly csrfService: CsrfService,
|
private readonly csrfService: CsrfService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get("token")
|
@Get("token")
|
||||||
getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
|
getCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
|
||||||
const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined;
|
const sessionId = this.extractSessionId(req) || undefined;
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
|
|
||||||
// Generate new CSRF token
|
// Generate new CSRF token
|
||||||
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
|
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
|
||||||
|
const isProduction = this.configService.get("NODE_ENV") === "production";
|
||||||
|
const cookieName = this.csrfService.getCookieName();
|
||||||
|
|
||||||
// Set CSRF secret in secure cookie
|
// Set CSRF secret in secure cookie
|
||||||
res.cookie("csrf-secret", tokenData.secret, {
|
res.cookie(cookieName, tokenData.secret, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: isProduction,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 3600000, // 1 hour
|
maxAge: this.csrfService.getTokenTtl(),
|
||||||
path: "/",
|
path: "/api",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.debug("CSRF token requested", {
|
this.logger.debug("CSRF token requested", {
|
||||||
@ -51,22 +56,21 @@ export class CsrfController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post("refresh")
|
@Post("refresh")
|
||||||
refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
|
refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) {
|
||||||
const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined;
|
const sessionId = this.extractSessionId(req) || undefined;
|
||||||
const userId = req.user?.id || "anonymous"; // Default for unauthenticated users
|
const userId = req.user?.id;
|
||||||
|
|
||||||
// Invalidate existing tokens for this user
|
|
||||||
this.csrfService.invalidateUserTokens(userId);
|
|
||||||
|
|
||||||
// Generate new CSRF token
|
// Generate new CSRF token
|
||||||
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
|
const tokenData = this.csrfService.generateToken(undefined, sessionId, userId);
|
||||||
|
const isProduction = this.configService.get("NODE_ENV") === "production";
|
||||||
|
const cookieName = this.csrfService.getCookieName();
|
||||||
|
|
||||||
// Set CSRF secret in secure cookie
|
// Set CSRF secret in secure cookie
|
||||||
res.cookie("csrf-secret", tokenData.secret, {
|
res.cookie(cookieName, tokenData.secret, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: isProduction,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
maxAge: 3600000, // 1 hour
|
maxAge: this.csrfService.getTokenTtl(),
|
||||||
path: "/",
|
path: "/api",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.debug("CSRF token refreshed", {
|
this.logger.debug("CSRF token refreshed", {
|
||||||
@ -85,9 +89,12 @@ export class CsrfController {
|
|||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
getCsrfStats(@Req() req: AuthenticatedRequest) {
|
getCsrfStats(@Req() req: AuthenticatedRequest) {
|
||||||
const userId = req.user?.id || "anonymous";
|
const userId = req.user?.id;
|
||||||
|
const role = req.user?.role;
|
||||||
|
if (role !== "ADMIN") {
|
||||||
|
throw new ForbiddenException("Admin access required");
|
||||||
|
}
|
||||||
|
|
||||||
// Only allow admin users to see stats (you might want to add role checking)
|
|
||||||
this.logger.debug("CSRF stats requested", {
|
this.logger.debug("CSRF stats requested", {
|
||||||
userId,
|
userId,
|
||||||
userAgent: req.get("user-agent"),
|
userAgent: req.get("user-agent"),
|
||||||
|
|||||||
@ -12,18 +12,9 @@ interface CsrfRequestBody {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryValue = string | string[] | undefined;
|
|
||||||
|
|
||||||
type CsrfRequestQuery = Record<string, QueryValue>;
|
|
||||||
|
|
||||||
type CookieJar = Record<string, string | undefined>;
|
type CookieJar = Record<string, string | undefined>;
|
||||||
|
|
||||||
type BaseExpressRequest = Request<
|
type BaseExpressRequest = Request<Record<string, string>, unknown, CsrfRequestBody>;
|
||||||
Record<string, string>,
|
|
||||||
unknown,
|
|
||||||
CsrfRequestBody,
|
|
||||||
CsrfRequestQuery
|
|
||||||
>;
|
|
||||||
|
|
||||||
type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
|
type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
@ -34,13 +25,15 @@ type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* CSRF Protection Middleware
|
* CSRF Protection Middleware
|
||||||
* Implements double-submit cookie pattern with additional security measures
|
* Implements a double-submit cookie pattern:
|
||||||
|
* - Portal fetches a token from `/api/security/csrf/token` (sets secret cookie + returns token)
|
||||||
|
* - All unsafe methods must include `X-CSRF-Token`
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CsrfMiddleware implements NestMiddleware {
|
export class CsrfMiddleware implements NestMiddleware {
|
||||||
private readonly isProduction: boolean;
|
private readonly isProduction: boolean;
|
||||||
private readonly exemptPaths: Set<string>;
|
private readonly exemptPaths: Set<string>;
|
||||||
private readonly exemptMethods: Set<string>;
|
private readonly safeMethods: Set<string>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly csrfService: CsrfService,
|
private readonly csrfService: CsrfService,
|
||||||
@ -66,7 +59,7 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Methods that don't require CSRF protection (safe methods)
|
// Methods that don't require CSRF protection (safe methods)
|
||||||
this.exemptMethods = new Set(["GET", "HEAD", "OPTIONS"]);
|
this.safeMethods = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
use(req: CsrfRequest, res: Response, next: NextFunction): void {
|
use(req: CsrfRequest, res: Response, next: NextFunction): void {
|
||||||
@ -81,31 +74,18 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip CSRF protection for exempt paths and methods
|
// Skip CSRF protection for exempt paths and safe methods
|
||||||
if (this.isExempt(req)) {
|
if (this.isExemptPath(req) || this.safeMethods.has(req.method)) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// For state-changing requests, validate CSRF token
|
// For unsafe requests, validate CSRF token
|
||||||
if (this.requiresCsrfProtection(req)) {
|
this.validateCsrfToken(req, res, next);
|
||||||
this.validateCsrfToken(req, res, next);
|
|
||||||
} else {
|
|
||||||
// For safe requests, generate and set CSRF token if needed
|
|
||||||
this.ensureCsrfToken(req, res, next);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isExempt(req: CsrfRequest): boolean {
|
private isExemptPath(req: CsrfRequest): boolean {
|
||||||
// Check if path is exempt
|
// Check if path is exempt
|
||||||
if (this.exemptPaths.has(req.path)) {
|
if (this.exemptPaths.has(req.path)) return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if method is exempt (safe methods)
|
|
||||||
if (this.exemptMethods.has(req.method)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for API endpoints that might be exempt
|
// Check for API endpoints that might be exempt
|
||||||
if (req.path.startsWith("/api/webhooks/")) {
|
if (req.path.startsWith("/api/webhooks/")) {
|
||||||
return true;
|
return true;
|
||||||
@ -114,11 +94,6 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private requiresCsrfProtection(req: CsrfRequest): boolean {
|
|
||||||
// State-changing methods require CSRF protection
|
|
||||||
return ["POST", "PUT", "PATCH", "DELETE"].includes(req.method);
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateCsrfToken(req: CsrfRequest, res: Response, next: NextFunction): void {
|
private validateCsrfToken(req: CsrfRequest, res: Response, next: NextFunction): void {
|
||||||
const token = this.extractTokenFromRequest(req);
|
const token = this.extractTokenFromRequest(req);
|
||||||
const secret = this.extractSecretFromCookie(req);
|
const secret = this.extractSecretFromCookie(req);
|
||||||
@ -178,64 +153,20 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureCsrfToken(req: CsrfRequest, res: Response, next: NextFunction): void {
|
|
||||||
const existingSecret = this.extractSecretFromCookie(req);
|
|
||||||
const sessionId = req.user?.sessionId || this.extractSessionId(req);
|
|
||||||
const userId = req.user?.id;
|
|
||||||
|
|
||||||
const tokenData = this.csrfService.generateToken(
|
|
||||||
existingSecret,
|
|
||||||
sessionId ?? undefined,
|
|
||||||
userId
|
|
||||||
);
|
|
||||||
|
|
||||||
this.setCsrfSecretCookie(res, tokenData.secret, tokenData.expiresAt);
|
|
||||||
|
|
||||||
res.setHeader(this.csrfService.getHeaderName(), tokenData.token);
|
|
||||||
|
|
||||||
this.logger.debug("CSRF token generated and set", {
|
|
||||||
method: req.method,
|
|
||||||
path: req.path,
|
|
||||||
userId,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractTokenFromRequest(req: CsrfRequest): string | null {
|
private extractTokenFromRequest(req: CsrfRequest): string | null {
|
||||||
// Check multiple possible locations for the CSRF token
|
// Check multiple possible locations for the CSRF token
|
||||||
|
|
||||||
// 1. X-CSRF-Token header (most common)
|
// 1. X-CSRF-Token header (most common)
|
||||||
let token = req.get("X-CSRF-Token");
|
const token = req.get("X-CSRF-Token");
|
||||||
if (token) return token;
|
if (token) return token;
|
||||||
|
|
||||||
// 2. X-Requested-With header (alternative)
|
// 2. Request body (for form submissions)
|
||||||
token = req.get("X-Requested-With");
|
|
||||||
if (token && token !== "XMLHttpRequest") return token;
|
|
||||||
|
|
||||||
// 3. Authorization header (if using Bearer token pattern)
|
|
||||||
const authHeader = req.get("Authorization");
|
|
||||||
if (authHeader && authHeader.startsWith("CSRF ")) {
|
|
||||||
return authHeader.substring(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Request body (for form submissions)
|
|
||||||
const bodyToken =
|
const bodyToken =
|
||||||
this.normalizeTokenValue(req.body._csrf) ?? this.normalizeTokenValue(req.body.csrfToken);
|
this.normalizeTokenValue(req.body._csrf) ?? this.normalizeTokenValue(req.body.csrfToken);
|
||||||
if (bodyToken) {
|
if (bodyToken) {
|
||||||
return bodyToken;
|
return bodyToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Query parameter (least secure, only for GET requests)
|
|
||||||
if (req.method === "GET") {
|
|
||||||
const queryToken =
|
|
||||||
this.normalizeTokenValue(req.query._csrf) ?? this.normalizeTokenValue(req.query.csrfToken);
|
|
||||||
if (queryToken) {
|
|
||||||
return queryToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,18 +192,6 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
return sessionId ?? null;
|
return sessionId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCsrfSecretCookie(res: Response, secret: string, expiresAt?: Date): void {
|
|
||||||
const cookieOptions = {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: this.isProduction,
|
|
||||||
sameSite: "strict" as const,
|
|
||||||
maxAge: expiresAt ? Math.max(0, expiresAt.getTime() - Date.now()) : undefined,
|
|
||||||
path: "/",
|
|
||||||
};
|
|
||||||
|
|
||||||
res.cookie(this.csrfService.getCookieName(), secret, cookieOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeTokenValue(value: string | string[] | undefined): string | null {
|
private normalizeTokenValue(value: string | string[] | undefined): string | null {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class JoseJwtService {
|
export class JoseJwtService {
|
||||||
private readonly secretKey: Uint8Array;
|
private readonly secretKey: Uint8Array;
|
||||||
|
private readonly issuer?: string;
|
||||||
|
private readonly audience?: string | string[];
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const secret = configService.get<string>("JWT_SECRET");
|
const secret = configService.get<string>("JWT_SECRET");
|
||||||
@ -13,21 +15,60 @@ export class JoseJwtService {
|
|||||||
throw new Error("JWT_SECRET is required in environment variables");
|
throw new Error("JWT_SECRET is required in environment variables");
|
||||||
}
|
}
|
||||||
this.secretKey = new TextEncoder().encode(secret);
|
this.secretKey = new TextEncoder().encode(secret);
|
||||||
|
|
||||||
|
const issuer = configService.get<string | undefined>("JWT_ISSUER");
|
||||||
|
this.issuer = issuer && issuer.trim().length > 0 ? issuer.trim() : undefined;
|
||||||
|
|
||||||
|
const audienceRaw = configService.get<string | undefined>("JWT_AUDIENCE");
|
||||||
|
const parsedAudience = this.parseAudience(audienceRaw);
|
||||||
|
this.audience = parsedAudience;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAudience(raw: string | undefined): string | string[] | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const parts = trimmed
|
||||||
|
.split(",")
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length === 0) return undefined;
|
||||||
|
return parts.length === 1 ? parts[0] : parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sign(payload: JWTPayload, expiresIn: string): Promise<string> {
|
async sign(payload: JWTPayload, expiresIn: string): Promise<string> {
|
||||||
const expiresInSeconds = parseJwtExpiry(expiresIn);
|
const expiresInSeconds = parseJwtExpiry(expiresIn);
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
return new SignJWT(payload)
|
const tokenId = (payload as { tokenId?: unknown }).tokenId;
|
||||||
|
|
||||||
|
let builder = new SignJWT(payload)
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setIssuedAt(nowSeconds)
|
.setIssuedAt(nowSeconds)
|
||||||
.setExpirationTime(nowSeconds + expiresInSeconds)
|
.setExpirationTime(nowSeconds + expiresInSeconds);
|
||||||
.sign(this.secretKey);
|
|
||||||
|
if (this.issuer) {
|
||||||
|
builder = builder.setIssuer(this.issuer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audience) {
|
||||||
|
builder = builder.setAudience(this.audience);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: set standard JWT ID when a tokenId is present in the payload
|
||||||
|
if (typeof tokenId === "string" && tokenId.length > 0) {
|
||||||
|
builder = builder.setJti(tokenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.sign(this.secretKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify<T extends JWTPayload>(token: string): Promise<T> {
|
async verify<T extends JWTPayload>(token: string): Promise<T> {
|
||||||
const { payload } = await jwtVerify(token, this.secretKey);
|
const { payload } = await jwtVerify(token, this.secretKey, {
|
||||||
|
algorithms: ["HS256"],
|
||||||
|
issuer: this.issuer,
|
||||||
|
audience: this.audience,
|
||||||
|
});
|
||||||
return payload as T;
|
return payload as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,4 +91,3 @@ export class JoseJwtService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
@ -8,12 +8,16 @@ import { JoseJwtService } from "./jose-jwt.service.js";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenBlacklistService {
|
export class TokenBlacklistService {
|
||||||
|
private readonly failClosed: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly jwtService: JoseJwtService,
|
private readonly jwtService: JoseJwtService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {
|
||||||
|
this.failClosed = this.configService.get("AUTH_BLACKLIST_FAIL_CLOSED", "false") === "true";
|
||||||
|
}
|
||||||
|
|
||||||
async blacklistToken(token: string, _expiresIn?: number): Promise<void> {
|
async blacklistToken(token: string, _expiresIn?: number): Promise<void> {
|
||||||
// Validate token format first
|
// Validate token format first
|
||||||
@ -69,7 +73,14 @@ export class TokenBlacklistService {
|
|||||||
const result = await this.redis.get(this.buildBlacklistKey(token));
|
const result = await this.redis.get(this.buildBlacklistKey(token));
|
||||||
return result !== null;
|
return result !== null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If Redis is unavailable, treat as not blacklisted to avoid blocking auth
|
if (this.failClosed) {
|
||||||
|
this.logger.error("Redis unavailable during blacklist check; failing closed", {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
throw new ServiceUnavailableException("Authentication temporarily unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: fail open to avoid blocking auth when Redis is unavailable
|
||||||
this.logger.warn("Redis unavailable during blacklist check; allowing request", {
|
this.logger.warn("Redis unavailable during blacklist check; allowing request", {
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { randomBytes, createHash } from "crypto";
|
import { randomBytes, createHash } from "crypto";
|
||||||
import type { JWTPayload } from "jose";
|
import type { JWTPayload } from "jose";
|
||||||
import type { AuthTokens } from "@customer-portal/domain/auth";
|
import type { AuthTokens } from "@customer-portal/domain/auth";
|
||||||
import type { User } from "@customer-portal/domain/customer";
|
import type { User, UserRole } from "@customer-portal/domain/customer";
|
||||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||||
import { JoseJwtService } from "./jose-jwt.service.js";
|
import { JoseJwtService } from "./jose-jwt.service.js";
|
||||||
@ -94,7 +94,7 @@ export class AuthTokenService {
|
|||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role?: string;
|
role?: UserRole;
|
||||||
},
|
},
|
||||||
deviceInfo?: {
|
deviceInfo?: {
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
@ -110,7 +110,7 @@ export class AuthTokenService {
|
|||||||
const accessPayload = {
|
const accessPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role || "user",
|
role: user.role || "USER",
|
||||||
tokenId,
|
tokenId,
|
||||||
type: "access",
|
type: "access",
|
||||||
};
|
};
|
||||||
@ -216,6 +216,12 @@ export class AuthTokenService {
|
|||||||
});
|
});
|
||||||
throw new UnauthorizedException("Invalid refresh token");
|
throw new UnauthorizedException("Invalid refresh token");
|
||||||
}
|
}
|
||||||
|
if (!payload.userId || typeof payload.userId !== "string") {
|
||||||
|
throw new UnauthorizedException("Invalid refresh token");
|
||||||
|
}
|
||||||
|
if (!payload.tokenId || typeof payload.tokenId !== "string") {
|
||||||
|
throw new UnauthorizedException("Invalid refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
const refreshTokenHash = this.hashToken(refreshToken);
|
const refreshTokenHash = this.hashToken(refreshToken);
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
|||||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||||
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
||||||
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
||||||
|
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||||
|
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
|
||||||
|
|
||||||
// Import Zod schemas from domain
|
// Import Zod schemas from domain
|
||||||
import {
|
import {
|
||||||
@ -60,42 +62,6 @@ type RequestWithCookies = Omit<Request, "cookies"> & {
|
|||||||
cookies?: Record<string, CookieValue>;
|
cookies?: Record<string, CookieValue>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => {
|
|
||||||
const rawHeader = req.headers?.authorization;
|
|
||||||
|
|
||||||
if (typeof rawHeader === "string") {
|
|
||||||
return rawHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(rawHeader)) {
|
|
||||||
const headerValues: string[] = rawHeader;
|
|
||||||
for (const candidate of headerValues) {
|
|
||||||
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractBearerToken = (req: RequestWithCookies): string | undefined => {
|
|
||||||
const authHeader = resolveAuthorizationHeader(req);
|
|
||||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
||||||
return authHeader.slice(7);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractTokenFromRequest = (req: RequestWithCookies): string | undefined => {
|
|
||||||
const headerToken = extractBearerToken(req);
|
|
||||||
if (headerToken) {
|
|
||||||
return headerToken;
|
|
||||||
}
|
|
||||||
const cookieToken = req.cookies?.access_token;
|
|
||||||
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
||||||
const expiresAt = Date.parse(isoTimestamp);
|
const expiresAt = Date.parse(isoTimestamp);
|
||||||
if (Number.isNaN(expiresAt)) {
|
if (Number.isNaN(expiresAt)) {
|
||||||
@ -104,6 +70,9 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
|||||||
return Math.max(0, expiresAt - Date.now());
|
return Math.max(0, expiresAt - Date.now());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACCESS_COOKIE_PATH = "/api";
|
||||||
|
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(
|
constructor(
|
||||||
@ -117,15 +86,20 @@ export class AuthController {
|
|||||||
|
|
||||||
res.setSecureCookie("access_token", tokens.accessToken, {
|
res.setSecureCookie("access_token", tokens.accessToken, {
|
||||||
maxAge: accessMaxAge,
|
maxAge: accessMaxAge,
|
||||||
path: "/",
|
path: ACCESS_COOKIE_PATH,
|
||||||
});
|
});
|
||||||
res.setSecureCookie("refresh_token", tokens.refreshToken, {
|
res.setSecureCookie("refresh_token", tokens.refreshToken, {
|
||||||
maxAge: refreshMaxAge,
|
maxAge: refreshMaxAge,
|
||||||
path: "/",
|
path: REFRESH_COOKIE_PATH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearAuthCookies(res: Response): void {
|
private clearAuthCookies(res: Response): void {
|
||||||
|
// Clear current cookie paths
|
||||||
|
res.setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
|
||||||
|
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
|
||||||
|
|
||||||
|
// Backward-compat: clear legacy cookies that were set on `/`
|
||||||
res.setSecureCookie("access_token", "", { maxAge: 0, path: "/" });
|
res.setSecureCookie("access_token", "", { maxAge: 0, path: "/" });
|
||||||
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
||||||
}
|
}
|
||||||
@ -201,7 +175,7 @@ export class AuthController {
|
|||||||
@Req() req: RequestWithCookies & { user?: { id: string } },
|
@Req() req: RequestWithCookies & { user?: { id: string } },
|
||||||
@Res({ passthrough: true }) res: Response
|
@Res({ passthrough: true }) res: Response
|
||||||
) {
|
) {
|
||||||
const token = extractTokenFromRequest(req);
|
const token = extractAccessTokenFromRequest(req);
|
||||||
let userId = req.user?.id;
|
let userId = req.user?.id;
|
||||||
|
|
||||||
if (!userId && token) {
|
if (!userId && token) {
|
||||||
@ -286,6 +260,8 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post("reset-password")
|
@Post("reset-password")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@UseGuards(RateLimitGuard)
|
||||||
|
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
|
||||||
@UsePipes(new ZodValidationPipe(passwordResetSchema))
|
@UsePipes(new ZodValidationPipe(passwordResetSchema))
|
||||||
async resetPassword(
|
async resetPassword(
|
||||||
@Body() body: ResetPasswordRequest,
|
@Body() body: ResetPasswordRequest,
|
||||||
@ -313,16 +289,8 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("me")
|
@Get("me")
|
||||||
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) {
|
getAuthStatus(@Req() req: Request & { user: UserAuth }) {
|
||||||
// Return basic auth info only - full profile should use /api/me
|
return { isAuthenticated: true, user: req.user };
|
||||||
return {
|
|
||||||
isAuthenticated: true,
|
|
||||||
user: {
|
|
||||||
id: req.user.id,
|
|
||||||
email: req.user.email,
|
|
||||||
role: req.user.role,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("sso-link")
|
@Post("sso-link")
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
|
|||||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||||
|
import { extractAccessTokenFromRequest } from "../../../utils/token-from-request.util.js";
|
||||||
|
|
||||||
type CookieValue = string | undefined;
|
type CookieValue = string | undefined;
|
||||||
type RequestBase = Omit<Request, "cookies" | "route">;
|
type RequestBase = Omit<Request, "cookies" | "route">;
|
||||||
@ -23,18 +24,6 @@ type RequestWithRoute = RequestWithCookies & {
|
|||||||
route?: { path?: string };
|
route?: { path?: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => {
|
|
||||||
const rawHeader = (request as unknown as { headers?: Record<string, unknown> }).headers?.[
|
|
||||||
"authorization"
|
|
||||||
];
|
|
||||||
const authHeader = typeof rawHeader === "string" ? rawHeader : undefined;
|
|
||||||
const headerToken =
|
|
||||||
authHeader && authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
|
|
||||||
if (headerToken && headerToken.length > 0) return headerToken;
|
|
||||||
const cookieToken = request.cookies?.access_token;
|
|
||||||
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GlobalAuthGuard implements CanActivate {
|
export class GlobalAuthGuard implements CanActivate {
|
||||||
private readonly logger = new Logger(GlobalAuthGuard.name);
|
private readonly logger = new Logger(GlobalAuthGuard.name);
|
||||||
@ -63,7 +52,7 @@ export class GlobalAuthGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = extractTokenFromRequest(request);
|
const token = extractAccessTokenFromRequest(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (isLogoutRoute) {
|
if (isLogoutRoute) {
|
||||||
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
||||||
@ -76,6 +65,11 @@ export class GlobalAuthGuard implements CanActivate {
|
|||||||
token
|
token
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tokenType = (payload as { type?: unknown }).type;
|
||||||
|
if (typeof tokenType === "string" && tokenType !== "access") {
|
||||||
|
throw new UnauthorizedException("Invalid access token");
|
||||||
|
}
|
||||||
|
|
||||||
if (!payload.sub || !payload.email) {
|
if (!payload.sub || !payload.email) {
|
||||||
throw new UnauthorizedException("Invalid token payload");
|
throw new UnauthorizedException("Invalid token payload");
|
||||||
}
|
}
|
||||||
@ -115,7 +109,7 @@ export class GlobalAuthGuard implements CanActivate {
|
|||||||
this.logger.debug(`Allowing logout request with expired or invalid token: ${route}`);
|
this.logger.debug(`Allowing logout request with expired or invalid token: ${route}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const token = extractTokenFromRequest(request);
|
const token = extractAccessTokenFromRequest(request);
|
||||||
const log =
|
const log =
|
||||||
typeof token === "string"
|
typeof token === "string"
|
||||||
? () => this.logger.warn(`Unauthorized access attempt to ${route}`)
|
? () => this.logger.warn(`Unauthorized access attempt to ${route}`)
|
||||||
|
|||||||
46
apps/bff/src/modules/auth/utils/token-from-request.util.ts
Normal file
46
apps/bff/src/modules/auth/utils/token-from-request.util.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
type CookieValue = string | undefined;
|
||||||
|
|
||||||
|
type RequestHeadersLike = Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
type RequestWithCookies = {
|
||||||
|
cookies?: Record<string, CookieValue>;
|
||||||
|
headers?: RequestHeadersLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickFirstStringHeader = (value: unknown): string | undefined => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
const normalized = pickFirstStringHeader(entry);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAuthorizationHeader = (headers: RequestHeadersLike): string | undefined => {
|
||||||
|
const raw = headers?.authorization;
|
||||||
|
return pickFirstStringHeader(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractBearerToken = (headers: RequestHeadersLike): string | undefined => {
|
||||||
|
const authHeader = resolveAuthorizationHeader(headers);
|
||||||
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||||
|
const token = authHeader.slice("Bearer ".length).trim();
|
||||||
|
return token.length > 0 ? token : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractAccessTokenFromRequest = (request: RequestWithCookies): string | undefined => {
|
||||||
|
const headerToken = extractBearerToken(request.headers);
|
||||||
|
if (headerToken) return headerToken;
|
||||||
|
|
||||||
|
const cookieToken = request.cookies?.access_token;
|
||||||
|
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
||||||
|
};
|
||||||
@ -212,15 +212,18 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
resetPassword: async (token: string, password: string) => {
|
resetPassword: async (token: string, password: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST("/api/auth/reset-password", {
|
await apiClient.POST("/api/auth/reset-password", {
|
||||||
body: { token, password },
|
body: { token, password },
|
||||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
});
|
});
|
||||||
const parsed = authResponseSchema.safeParse(response.data);
|
// Password reset does NOT create a session; BFF clears auth cookies to force re-login.
|
||||||
if (!parsed.success) {
|
set({
|
||||||
throw new Error(parsed.error.issues?.[0]?.message ?? "Password reset failed");
|
user: null,
|
||||||
}
|
session: {},
|
||||||
applyAuthResponse(parsed.data);
|
isAuthenticated: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@ -38,7 +38,6 @@ export function proxy(request: NextRequest) {
|
|||||||
response.headers.set("X-Frame-Options", "DENY");
|
response.headers.set("X-Frame-Options", "DENY");
|
||||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
response.headers.set("X-XSS-Protection", "1; mode=block");
|
|
||||||
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@ -25,36 +25,41 @@ modules/auth/
|
|||||||
|
|
||||||
### Layer Responsibilities
|
### Layer Responsibilities
|
||||||
|
|
||||||
| Layer | Purpose |
|
| Layer | Purpose |
|
||||||
| -------------- | ----------------------------------------------------------------------------- |
|
| -------------- | --------------------------------------------------------------------------- |
|
||||||
| `presentation` | HTTP surface area (controllers, guards, interceptors, Passport strategies). |
|
| `presentation` | HTTP surface area (controllers, guards, interceptors, Passport strategies). |
|
||||||
| `application` | Use-case orchestration (`AuthFacade`), coordinating infra + audit logging. |
|
| `application` | Use-case orchestration (`AuthFacade`), coordinating infra + audit logging. |
|
||||||
| `infra` | Technical services: token issuance, rate limiting, WHMCS/SF workflows. |
|
| `infra` | Technical services: token issuance, rate limiting, WHMCS/SF workflows. |
|
||||||
| `decorators` | Shared Nest decorators (e.g., `@Public`). |
|
| `decorators` | Shared Nest decorators (e.g., `@Public`). |
|
||||||
| `domain` | (Future) domain policies, constants, pure type definitions. |
|
| `domain` | (Future) domain policies, constants, pure type definitions. |
|
||||||
|
|
||||||
## Presentation Layer
|
## Presentation Layer
|
||||||
|
|
||||||
### Controllers (`presentation/http/auth.controller.ts`)
|
### Controllers (`presentation/http/auth.controller.ts`)
|
||||||
|
|
||||||
- Routes mirror previous `auth-zod.controller.ts` functionality.
|
- Routes mirror previous `auth-zod.controller.ts` functionality.
|
||||||
- All validation still uses Zod schemas from `@customer-portal/domain` applied via `ZodValidationPipe`.
|
- All validation still uses Zod schemas from `@customer-portal/domain` applied via `ZodValidationPipe`.
|
||||||
- Cookies are managed with `setSecureCookie` helper registered in `bootstrap.ts`.
|
- Cookies are managed with `setSecureCookie` helper registered in `bootstrap.ts`.
|
||||||
|
|
||||||
### Guards
|
### Guards
|
||||||
|
|
||||||
- `GlobalAuthGuard`: wraps Passport JWT, checks blacklist via `TokenBlacklistService`.
|
- `GlobalAuthGuard`: wraps Passport JWT, checks blacklist via `TokenBlacklistService`.
|
||||||
- `AuthThrottleGuard`: Nest Throttler-based guard for general rate limits.
|
- `AuthThrottleGuard`: Nest Throttler-based guard for general rate limits.
|
||||||
- `FailedLoginThrottleGuard`: uses `AuthRateLimitService` for login attempt limiting.
|
- `FailedLoginThrottleGuard`: uses `AuthRateLimitService` for login attempt limiting.
|
||||||
|
|
||||||
### Interceptors
|
### Interceptors
|
||||||
|
|
||||||
- `LoginResultInterceptor`: ties into `FailedLoginThrottleGuard` to clear counters on success/failure.
|
- `LoginResultInterceptor`: ties into `FailedLoginThrottleGuard` to clear counters on success/failure.
|
||||||
|
|
||||||
### Strategies (`presentation/strategies`)
|
### Strategies (`presentation/strategies`)
|
||||||
|
|
||||||
- `LocalStrategy`: delegates credential validation to `AuthFacade.validateUser`.
|
- `LocalStrategy`: delegates credential validation to `AuthFacade.validateUser`.
|
||||||
- `JwtStrategy`: loads user via `UsersService` and maps to public profile.
|
- `JwtStrategy`: loads user via `UsersService` and maps to public profile.
|
||||||
|
|
||||||
## Application Layer
|
## Application Layer
|
||||||
|
|
||||||
### `AuthFacade`
|
### `AuthFacade`
|
||||||
|
|
||||||
- Aggregates all auth flows: login/logout, signup, password flows, WHMCS link, token refresh, session logout.
|
- Aggregates all auth flows: login/logout, signup, password flows, WHMCS link, token refresh, session logout.
|
||||||
- Injects infrastructure services (token, workflows, rate limiting), audit logging, config, and Prisma.
|
- Injects infrastructure services (token, workflows, rate limiting), audit logging, config, and Prisma.
|
||||||
- Acts as single DI target for controllers, strategies, and guards.
|
- Acts as single DI target for controllers, strategies, and guards.
|
||||||
@ -62,15 +67,18 @@ modules/auth/
|
|||||||
## Infrastructure Layer
|
## Infrastructure Layer
|
||||||
|
|
||||||
### Token (`infra/token`)
|
### Token (`infra/token`)
|
||||||
|
|
||||||
- `token.service.ts`: issues/rotates access + refresh tokens, enforces Redis-backed refresh token families.
|
- `token.service.ts`: issues/rotates access + refresh tokens, enforces Redis-backed refresh token families.
|
||||||
- `token-blacklist.service.ts`: stores revoked access tokens.
|
- `token-blacklist.service.ts`: stores revoked access tokens.
|
||||||
|
|
||||||
### Rate Limiting (`infra/rate-limiting/auth-rate-limit.service.ts`)
|
### Rate Limiting (`infra/rate-limiting/auth-rate-limit.service.ts`)
|
||||||
|
|
||||||
- Built on `rate-limiter-flexible` with Redis storage.
|
- Built on `rate-limiter-flexible` with Redis storage.
|
||||||
- Exposes methods for login, signup, password reset, refresh token throttling.
|
- Exposes methods for login, signup, password reset, refresh token throttling.
|
||||||
- Handles CAPTCHA escalation via headers (config-driven, see env variables).
|
- Handles CAPTCHA escalation via headers (config-driven, see env variables).
|
||||||
|
|
||||||
### Workflows (`infra/workflows`)
|
### Workflows (`infra/workflows`)
|
||||||
|
|
||||||
- `signup-workflow.service.ts`: orchestrates Salesforce + WHMCS checks and user creation.
|
- `signup-workflow.service.ts`: orchestrates Salesforce + WHMCS checks and user creation.
|
||||||
- `password-workflow.service.ts`: request reset, change password, set password flows.
|
- `password-workflow.service.ts`: request reset, change password, set password flows.
|
||||||
- `whmcs-link-workflow.service.ts`: links existing WHMCS accounts with portal users.
|
- `whmcs-link-workflow.service.ts`: links existing WHMCS accounts with portal users.
|
||||||
@ -103,9 +111,9 @@ Refer to `DEVELOPMENT-AUTH-SETUP.md` for dev-only overrides (`DISABLE_CSRF`, etc
|
|||||||
|
|
||||||
## CSRF Summary
|
## CSRF Summary
|
||||||
|
|
||||||
- `core/security/middleware/csrf.middleware.ts` now issues stateless HMAC tokens.
|
- `core/security/controllers/csrf.controller.ts` issues stateless HMAC tokens at `GET /api/security/csrf/token`.
|
||||||
- On safe requests (GET/HEAD), middleware refreshes token + cookie automatically.
|
- Portal fetches the token once and sends it as `X-CSRF-Token` for unsafe methods.
|
||||||
- Controllers just rely on middleware; no manual token handling needed.
|
- `core/security/middleware/csrf.middleware.ts` validates `X-CSRF-Token` for unsafe methods and skips safe methods/exempt routes.
|
||||||
|
|
||||||
## Rate Limiting Summary
|
## Rate Limiting Summary
|
||||||
|
|
||||||
@ -127,4 +135,3 @@ Refer to `DEVELOPMENT-AUTH-SETUP.md` for dev-only overrides (`DISABLE_CSRF`, etc
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2025-10-02
|
**Last Updated:** 2025-10-02
|
||||||
|
|
||||||
|
|||||||
@ -133,7 +133,7 @@
|
|||||||
"typecheck": "pnpm run type-check"
|
"typecheck": "pnpm run type-check"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "catalog:"
|
"zod": "4.1.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
188
pnpm-lock.yaml
generated
188
pnpm-lock.yaml
generated
@ -5,11 +5,20 @@ settings:
|
|||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
injectWorkspacePackages: true
|
injectWorkspacePackages: true
|
||||||
|
|
||||||
|
catalogs:
|
||||||
|
default:
|
||||||
|
"@types/node":
|
||||||
|
specifier: 24.10.3
|
||||||
|
version: 24.10.3
|
||||||
|
typescript:
|
||||||
|
specifier: 5.9.3
|
||||||
|
version: 5.9.3
|
||||||
|
zod:
|
||||||
|
specifier: 4.1.13
|
||||||
|
version: 4.1.13
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
js-yaml: ">=4.1.1"
|
js-yaml: ">=4.1.1"
|
||||||
typescript: 5.9.3
|
|
||||||
"@types/node": 24.10.3
|
|
||||||
zod: 4.1.13
|
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
.:
|
.:
|
||||||
@ -21,14 +30,11 @@ importers:
|
|||||||
specifier: 16.0.9
|
specifier: 16.0.9
|
||||||
version: 16.0.9
|
version: 16.0.9
|
||||||
"@types/node":
|
"@types/node":
|
||||||
specifier: 24.10.3
|
specifier: "catalog:"
|
||||||
version: 24.10.3
|
version: 24.10.3
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
version: 9.39.1(jiti@2.6.1)
|
version: 9.39.1(jiti@2.6.1)
|
||||||
eslint-plugin-prettier:
|
|
||||||
specifier: ^5.5.4
|
|
||||||
version: 5.5.4(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4)
|
|
||||||
eslint-plugin-react-hooks:
|
eslint-plugin-react-hooks:
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1(eslint@9.39.1(jiti@2.6.1))
|
version: 7.0.1(eslint@9.39.1(jiti@2.6.1))
|
||||||
@ -48,7 +54,7 @@ importers:
|
|||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.9.3
|
specifier: "catalog:"
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
typescript-eslint:
|
typescript-eslint:
|
||||||
specifier: ^8.49.0
|
specifier: ^8.49.0
|
||||||
@ -135,7 +141,7 @@ importers:
|
|||||||
specifier: ^12.0.1
|
specifier: ^12.0.1
|
||||||
version: 12.0.1
|
version: 12.0.1
|
||||||
zod:
|
zod:
|
||||||
specifier: 4.1.13
|
specifier: "catalog:"
|
||||||
version: 4.1.13
|
version: 4.1.13
|
||||||
devDependencies:
|
devDependencies:
|
||||||
"@nestjs/cli":
|
"@nestjs/cli":
|
||||||
@ -166,7 +172,7 @@ importers:
|
|||||||
specifier: ^1.8.16
|
specifier: ^1.8.16
|
||||||
version: 1.8.16
|
version: 1.8.16
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.9.3
|
specifier: "catalog:"
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
apps/portal:
|
apps/portal:
|
||||||
@ -205,7 +211,7 @@ importers:
|
|||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
zod:
|
zod:
|
||||||
specifier: 4.1.13
|
specifier: "catalog:"
|
||||||
version: 4.1.13
|
version: 4.1.13
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.9
|
specifier: ^5.0.9
|
||||||
@ -230,18 +236,17 @@ importers:
|
|||||||
specifier: ^4.1.17
|
specifier: ^4.1.17
|
||||||
version: 4.1.17
|
version: 4.1.17
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.9.3
|
specifier: "catalog:"
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
packages/domain:
|
packages/domain:
|
||||||
dependencies:
|
|
||||||
zod:
|
|
||||||
specifier: 4.1.13
|
|
||||||
version: 4.1.13
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.9.3
|
specifier: "catalog:"
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
zod:
|
||||||
|
specifier: "catalog:"
|
||||||
|
version: 4.1.13
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
"@alloc/quick-lru@5.2.0":
|
"@alloc/quick-lru@5.2.0":
|
||||||
@ -1115,7 +1120,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1127,7 +1132,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1139,7 +1144,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1151,7 +1156,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1163,7 +1168,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1175,7 +1180,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1194,7 +1199,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1206,7 +1211,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1218,7 +1223,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1230,7 +1235,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1242,7 +1247,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1254,7 +1259,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1266,7 +1271,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1278,7 +1283,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1290,7 +1295,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18" }
|
engines: { node: ">=18" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": ">=18"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@types/node":
|
"@types/node":
|
||||||
optional: true
|
optional: true
|
||||||
@ -1698,7 +1703,7 @@ packages:
|
|||||||
integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==,
|
integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==,
|
||||||
}
|
}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.2"
|
||||||
|
|
||||||
"@nestjs/swagger@11.2.0":
|
"@nestjs/swagger@11.2.0":
|
||||||
resolution:
|
resolution:
|
||||||
@ -1868,13 +1873,6 @@ packages:
|
|||||||
integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==,
|
integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==,
|
||||||
}
|
}
|
||||||
|
|
||||||
"@pkgr/core@0.2.9":
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==,
|
|
||||||
}
|
|
||||||
engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 }
|
|
||||||
|
|
||||||
"@polka/url@1.0.0-next.29":
|
"@polka/url@1.0.0-next.29":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -1901,7 +1899,7 @@ packages:
|
|||||||
engines: { node: ^20.19 || ^22.12 || >=24.0 }
|
engines: { node: ^20.19 || ^22.12 || >=24.0 }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
prisma: "*"
|
prisma: "*"
|
||||||
typescript: 5.9.3
|
typescript: ">=5.4.0"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
prisma:
|
prisma:
|
||||||
optional: true
|
optional: true
|
||||||
@ -2482,6 +2480,12 @@ packages:
|
|||||||
integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==,
|
integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"@types/node@18.19.130":
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==,
|
||||||
|
}
|
||||||
|
|
||||||
"@types/node@24.10.3":
|
"@types/node@24.10.3":
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -2559,7 +2563,7 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@typescript-eslint/parser": ^8.49.0
|
"@typescript-eslint/parser": ^8.49.0
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
|
|
||||||
"@typescript-eslint/parser@8.49.0":
|
"@typescript-eslint/parser@8.49.0":
|
||||||
resolution:
|
resolution:
|
||||||
@ -2569,7 +2573,7 @@ packages:
|
|||||||
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
|
|
||||||
"@typescript-eslint/project-service@8.49.0":
|
"@typescript-eslint/project-service@8.49.0":
|
||||||
resolution:
|
resolution:
|
||||||
@ -2578,7 +2582,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
|
|
||||||
"@typescript-eslint/scope-manager@8.49.0":
|
"@typescript-eslint/scope-manager@8.49.0":
|
||||||
resolution:
|
resolution:
|
||||||
@ -2594,7 +2598,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
|
|
||||||
"@typescript-eslint/type-utils@8.49.0":
|
"@typescript-eslint/type-utils@8.49.0":
|
||||||
resolution:
|
resolution:
|
||||||
@ -2604,7 +2608,7 @@ packages:
|
|||||||
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
|
|
||||||
"@typescript-eslint/types@8.49.0":
|
"@typescript-eslint/types@8.49.0":
|
||||||
resolution:
|
resolution:
|
||||||
@ -2620,7 +2624,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
|
|
||||||
"@typescript-eslint/utils@8.49.0":
|
"@typescript-eslint/utils@8.49.0":
|
||||||
resolution:
|
resolution:
|
||||||
@ -2630,7 +2634,7 @@ packages:
|
|||||||
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
|
|
||||||
"@typescript-eslint/visitor-keys@8.49.0":
|
"@typescript-eslint/visitor-keys@8.49.0":
|
||||||
resolution:
|
resolution:
|
||||||
@ -3604,7 +3608,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=14" }
|
engines: { node: ">=14" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: 5.9.3
|
typescript: ">=4.9.5"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
@ -3960,23 +3964,6 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=10" }
|
engines: { node: ">=10" }
|
||||||
|
|
||||||
eslint-plugin-prettier@5.5.4:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==,
|
|
||||||
}
|
|
||||||
engines: { node: ^14.18.0 || >=16.0.0 }
|
|
||||||
peerDependencies:
|
|
||||||
"@types/eslint": ">=8.0.0"
|
|
||||||
eslint: ">=8.0.0"
|
|
||||||
eslint-config-prettier: ">= 7.0.0 <10.0.0 || >=10.1.0"
|
|
||||||
prettier: ">=3.0.0"
|
|
||||||
peerDependenciesMeta:
|
|
||||||
"@types/eslint":
|
|
||||||
optional: true
|
|
||||||
eslint-config-prettier:
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
eslint-plugin-react-hooks@7.0.1:
|
eslint-plugin-react-hooks@7.0.1:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -4156,12 +4143,6 @@ packages:
|
|||||||
integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==,
|
integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==,
|
||||||
}
|
}
|
||||||
|
|
||||||
fast-diff@1.3.0:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==,
|
|
||||||
}
|
|
||||||
|
|
||||||
fast-fifo@1.3.2:
|
fast-fifo@1.3.2:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -4346,7 +4327,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=14.21.3" }
|
engines: { node: ">=14.21.3" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: 5.9.3
|
typescript: ">3.6.0"
|
||||||
webpack: ^5.11.0
|
webpack: ^5.11.0
|
||||||
|
|
||||||
form-data-encoder@2.1.4:
|
form-data-encoder@2.1.4:
|
||||||
@ -5537,7 +5518,7 @@ packages:
|
|||||||
"@nestjs/common": ^10.0.0 || ^11.0.0
|
"@nestjs/common": ^10.0.0 || ^11.0.0
|
||||||
"@nestjs/swagger": ^7.4.2 || ^8.0.0 || ^11.0.0
|
"@nestjs/swagger": ^7.4.2 || ^8.0.0 || ^11.0.0
|
||||||
rxjs: ^7.0.0
|
rxjs: ^7.0.0
|
||||||
zod: 4.1.13
|
zod: ^3.25.0 || ^4.0.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@nestjs/swagger":
|
"@nestjs/swagger":
|
||||||
optional: true
|
optional: true
|
||||||
@ -6069,13 +6050,6 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">= 0.8.0" }
|
engines: { node: ">= 0.8.0" }
|
||||||
|
|
||||||
prettier-linter-helpers@1.0.0:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==,
|
|
||||||
}
|
|
||||||
engines: { node: ">=6.0.0" }
|
|
||||||
|
|
||||||
prettier@3.7.4:
|
prettier@3.7.4:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -6093,7 +6067,7 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
better-sqlite3: ">=9.0.0"
|
better-sqlite3: ">=9.0.0"
|
||||||
typescript: 5.9.3
|
typescript: ">=5.4.0"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
optional: true
|
optional: true
|
||||||
@ -6858,13 +6832,6 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=0.10" }
|
engines: { node: ">=0.10" }
|
||||||
|
|
||||||
synckit@0.11.11:
|
|
||||||
resolution:
|
|
||||||
{
|
|
||||||
integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==,
|
|
||||||
}
|
|
||||||
engines: { node: ^14.18.0 || >=16.0.0 }
|
|
||||||
|
|
||||||
tailwind-merge@3.4.0:
|
tailwind-merge@3.4.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -7010,7 +6977,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18.12" }
|
engines: { node: ">=18.12" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4"
|
||||||
|
|
||||||
tsc-alias@1.8.16:
|
tsc-alias@1.8.16:
|
||||||
resolution:
|
resolution:
|
||||||
@ -7109,7 +7076,7 @@ packages:
|
|||||||
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^8.57.0 || ^9.0.0
|
eslint: ^8.57.0 || ^9.0.0
|
||||||
typescript: 5.9.3
|
typescript: ">=4.8.4 <6.0.0"
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution:
|
resolution:
|
||||||
@ -7145,6 +7112,12 @@ packages:
|
|||||||
integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==,
|
integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
undici-types@5.26.5:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==,
|
||||||
|
}
|
||||||
|
|
||||||
undici-types@7.16.0:
|
undici-types@7.16.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -7206,7 +7179,7 @@ packages:
|
|||||||
integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==,
|
integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==,
|
||||||
}
|
}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: 5.9.3
|
typescript: ">=5"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
@ -7452,7 +7425,7 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=18.0.0" }
|
engines: { node: ">=18.0.0" }
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: 4.1.13
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
|
||||||
zod@4.1.13:
|
zod@4.1.13:
|
||||||
resolution:
|
resolution:
|
||||||
@ -8402,8 +8375,6 @@ snapshots:
|
|||||||
|
|
||||||
"@pinojs/redact@0.4.0": {}
|
"@pinojs/redact@0.4.0": {}
|
||||||
|
|
||||||
"@pkgr/core@0.2.9": {}
|
|
||||||
|
|
||||||
"@polka/url@1.0.0-next.29": {}
|
"@polka/url@1.0.0-next.29": {}
|
||||||
|
|
||||||
"@prisma/adapter-pg@7.1.0":
|
"@prisma/adapter-pg@7.1.0":
|
||||||
@ -8776,6 +8747,10 @@ snapshots:
|
|||||||
|
|
||||||
"@types/json-schema@7.0.15": {}
|
"@types/json-schema@7.0.15": {}
|
||||||
|
|
||||||
|
"@types/node@18.19.130":
|
||||||
|
dependencies:
|
||||||
|
undici-types: 5.26.5
|
||||||
|
|
||||||
"@types/node@24.10.3":
|
"@types/node@24.10.3":
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@ -8813,7 +8788,7 @@ snapshots:
|
|||||||
|
|
||||||
"@types/ssh2@1.15.5":
|
"@types/ssh2@1.15.5":
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/node": 24.10.3
|
"@types/node": 18.19.130
|
||||||
|
|
||||||
"@types/validator@13.15.10":
|
"@types/validator@13.15.10":
|
||||||
optional: true
|
optional: true
|
||||||
@ -9727,15 +9702,6 @@ snapshots:
|
|||||||
|
|
||||||
escape-string-regexp@4.0.0: {}
|
escape-string-regexp@4.0.0: {}
|
||||||
|
|
||||||
eslint-plugin-prettier@5.5.4(@types/eslint@9.6.1)(eslint@9.39.1(jiti@2.6.1))(prettier@3.7.4):
|
|
||||||
dependencies:
|
|
||||||
eslint: 9.39.1(jiti@2.6.1)
|
|
||||||
prettier: 3.7.4
|
|
||||||
prettier-linter-helpers: 1.0.0
|
|
||||||
synckit: 0.11.11
|
|
||||||
optionalDependencies:
|
|
||||||
"@types/eslint": 9.6.1
|
|
||||||
|
|
||||||
eslint-plugin-react-hooks@7.0.1(eslint@9.39.1(jiti@2.6.1)):
|
eslint-plugin-react-hooks@7.0.1(eslint@9.39.1(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/core": 7.28.5
|
"@babel/core": 7.28.5
|
||||||
@ -9903,8 +9869,6 @@ snapshots:
|
|||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-diff@1.3.0: {}
|
|
||||||
|
|
||||||
fast-fifo@1.3.2:
|
fast-fifo@1.3.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -10991,10 +10955,6 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier-linter-helpers@1.0.0:
|
|
||||||
dependencies:
|
|
||||||
fast-diff: 1.3.0
|
|
||||||
|
|
||||||
prettier@3.7.4: {}
|
prettier@3.7.4: {}
|
||||||
|
|
||||||
prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3):
|
prisma@7.1.0(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3):
|
||||||
@ -11483,10 +11443,6 @@ snapshots:
|
|||||||
|
|
||||||
symbol-observable@4.0.0: {}
|
symbol-observable@4.0.0: {}
|
||||||
|
|
||||||
synckit@0.11.11:
|
|
||||||
dependencies:
|
|
||||||
"@pkgr/core": 0.2.9
|
|
||||||
|
|
||||||
tailwind-merge@3.4.0: {}
|
tailwind-merge@3.4.0: {}
|
||||||
|
|
||||||
tailwindcss@4.1.17: {}
|
tailwindcss@4.1.17: {}
|
||||||
@ -11657,6 +11613,8 @@ snapshots:
|
|||||||
|
|
||||||
underscore@1.13.7: {}
|
underscore@1.13.7: {}
|
||||||
|
|
||||||
|
undici-types@5.26.5: {}
|
||||||
|
|
||||||
undici-types@7.16.0: {}
|
undici-types@7.16.0: {}
|
||||||
|
|
||||||
undici@7.16.0: {}
|
undici@7.16.0: {}
|
||||||
|
|||||||
@ -34,26 +34,31 @@ pnpm list --recursive --depth=0 --json > /tmp/deps.json
|
|||||||
node -e "
|
node -e "
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const deps = JSON.parse(fs.readFileSync('/tmp/deps.json', 'utf8'));
|
const deps = JSON.parse(fs.readFileSync('/tmp/deps.json', 'utf8'));
|
||||||
|
// depName -> Map(version -> string[])
|
||||||
const allDeps = new Map();
|
const allDeps = new Map();
|
||||||
|
|
||||||
deps.forEach(pkg => {
|
deps.forEach(pkg => {
|
||||||
if (pkg.dependencies) {
|
if (pkg.dependencies) {
|
||||||
Object.entries(pkg.dependencies).forEach(([name, info]) => {
|
Object.entries(pkg.dependencies).forEach(([name, info]) => {
|
||||||
const version = info.version;
|
const version = info.version;
|
||||||
if (!allDeps.has(name)) {
|
if (!allDeps.has(name)) allDeps.set(name, new Map());
|
||||||
allDeps.set(name, new Set());
|
|
||||||
}
|
const byVersion = allDeps.get(name);
|
||||||
allDeps.get(name).add(\`\${pkg.name}@\${version}\`);
|
if (!byVersion.has(version)) byVersion.set(version, []);
|
||||||
|
byVersion.get(version).push(pkg.name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let hasDrift = false;
|
let hasDrift = false;
|
||||||
allDeps.forEach((versions, depName) => {
|
allDeps.forEach((byVersion, depName) => {
|
||||||
if (versions.size > 1) {
|
if (byVersion.size <= 1) return;
|
||||||
console.log(\`❌ Version drift detected for \${depName}:\`);
|
|
||||||
versions.forEach(v => console.log(\` - \${v}\`));
|
hasDrift = true;
|
||||||
hasDrift = true;
|
console.log(\`❌ Version drift detected for \${depName}:\`);
|
||||||
|
for (const [version, pkgs] of [...byVersion.entries()].sort(([a], [b]) => String(a).localeCompare(String(b)))) {
|
||||||
|
console.log(\` - \${version}\`);
|
||||||
|
pkgs.sort().forEach(p => console.log(\` • \${p}\`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user