From 88b9ac0a19de7a9771d05461726854fdce230eaa Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 12 Dec 2025 15:00:11 +0900 Subject: [PATCH] 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. --- apps/bff/src/core/config/env.validation.ts | 3 + .../security/controllers/csrf.controller.ts | 43 ++-- .../security/middleware/csrf.middleware.ts | 109 ++-------- .../auth/infra/token/jose-jwt.service.ts | 50 ++++- .../infra/token/token-blacklist.service.ts | 17 +- .../modules/auth/infra/token/token.service.ts | 12 +- .../auth/presentation/http/auth.controller.ts | 66 ++---- .../http/guards/global-auth.guard.ts | 22 +- .../auth/utils/token-from-request.util.ts | 46 +++++ .../src/features/auth/services/auth.store.ts | 15 +- apps/portal/src/proxy.ts | 1 - docs/auth/AUTH-MODULE-ARCHITECTURE.md | 29 ++- packages/domain/package.json | 2 +- pnpm-lock.yaml | 188 +++++++----------- scripts/validate-deps.sh | 23 ++- 15 files changed, 296 insertions(+), 330 deletions(-) create mode 100644 apps/bff/src/modules/auth/utils/token-from-request.util.ts diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index 33a1da72..ffb13e62 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -8,6 +8,8 @@ export const envSchema = z.object({ JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"), 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), 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"), AUTH_ALLOW_REDIS_TOKEN_FAILOPEN: 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_MESSAGE: z .string() diff --git a/apps/bff/src/core/security/controllers/csrf.controller.ts b/apps/bff/src/core/security/controllers/csrf.controller.ts index 15725c8e..48da2324 100644 --- a/apps/bff/src/core/security/controllers/csrf.controller.ts +++ b/apps/bff/src/core/security/controllers/csrf.controller.ts @@ -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 { Logger } from "nestjs-pino"; import { CsrfService } from "../services/csrf.service.js"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; +import type { UserAuth } from "@customer-portal/domain/customer"; export type AuthenticatedRequest = Request & { - user?: { id: string; sessionId?: string }; + user?: UserAuth; sessionID?: string; }; @@ -13,25 +15,28 @@ export type AuthenticatedRequest = Request & { export class CsrfController { constructor( private readonly csrfService: CsrfService, + private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} @Public() @Get("token") 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; // Generate new CSRF token 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 - res.cookie("csrf-secret", tokenData.secret, { + res.cookie(cookieName, tokenData.secret, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: isProduction, sameSite: "strict", - maxAge: 3600000, // 1 hour - path: "/", + maxAge: this.csrfService.getTokenTtl(), + path: "/api", }); this.logger.debug("CSRF token requested", { @@ -51,22 +56,21 @@ export class CsrfController { @Public() @Post("refresh") refreshCsrfToken(@Req() req: AuthenticatedRequest, @Res() res: Response) { - const sessionId = req.user?.sessionId || this.extractSessionId(req) || undefined; - const userId = req.user?.id || "anonymous"; // Default for unauthenticated users - - // Invalidate existing tokens for this user - this.csrfService.invalidateUserTokens(userId); + const sessionId = this.extractSessionId(req) || undefined; + const userId = req.user?.id; // Generate new CSRF token 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 - res.cookie("csrf-secret", tokenData.secret, { + res.cookie(cookieName, tokenData.secret, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: isProduction, sameSite: "strict", - maxAge: 3600000, // 1 hour - path: "/", + maxAge: this.csrfService.getTokenTtl(), + path: "/api", }); this.logger.debug("CSRF token refreshed", { @@ -85,9 +89,12 @@ export class CsrfController { @Get("stats") 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", { userId, userAgent: req.get("user-agent"), diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index fa22d804..55689ef4 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -12,18 +12,9 @@ interface CsrfRequestBody { [key: string]: unknown; } -type QueryValue = string | string[] | undefined; - -type CsrfRequestQuery = Record; - type CookieJar = Record; -type BaseExpressRequest = Request< - Record, - unknown, - CsrfRequestBody, - CsrfRequestQuery ->; +type BaseExpressRequest = Request, unknown, CsrfRequestBody>; type CsrfRequest = Omit & { csrfToken?: string; @@ -34,13 +25,15 @@ type CsrfRequest = Omit & { /** * 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() export class CsrfMiddleware implements NestMiddleware { private readonly isProduction: boolean; private readonly exemptPaths: Set; - private readonly exemptMethods: Set; + private readonly safeMethods: Set; constructor( private readonly csrfService: CsrfService, @@ -66,7 +59,7 @@ export class CsrfMiddleware implements NestMiddleware { ]); // 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 { @@ -81,31 +74,18 @@ export class CsrfMiddleware implements NestMiddleware { return next(); } - // Skip CSRF protection for exempt paths and methods - if (this.isExempt(req)) { + // Skip CSRF protection for exempt paths and safe methods + if (this.isExemptPath(req) || this.safeMethods.has(req.method)) { return next(); } - // For state-changing requests, validate CSRF token - if (this.requiresCsrfProtection(req)) { - this.validateCsrfToken(req, res, next); - } else { - // For safe requests, generate and set CSRF token if needed - this.ensureCsrfToken(req, res, next); - } + // For unsafe requests, validate CSRF token + this.validateCsrfToken(req, res, next); } - private isExempt(req: CsrfRequest): boolean { + private isExemptPath(req: CsrfRequest): boolean { // Check if path is exempt - if (this.exemptPaths.has(req.path)) { - return true; - } - - // Check if method is exempt (safe methods) - if (this.exemptMethods.has(req.method)) { - return true; - } - + if (this.exemptPaths.has(req.path)) return true; // Check for API endpoints that might be exempt if (req.path.startsWith("/api/webhooks/")) { return true; @@ -114,11 +94,6 @@ export class CsrfMiddleware implements NestMiddleware { 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 { const token = this.extractTokenFromRequest(req); const secret = this.extractSecretFromCookie(req); @@ -178,64 +153,20 @@ export class CsrfMiddleware implements NestMiddleware { 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 { // Check multiple possible locations for the CSRF token // 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; - // 2. X-Requested-With header (alternative) - 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) + // 2. Request body (for form submissions) const bodyToken = this.normalizeTokenValue(req.body._csrf) ?? this.normalizeTokenValue(req.body.csrfToken); if (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; } @@ -261,18 +192,6 @@ export class CsrfMiddleware implements NestMiddleware { 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 { if (typeof value === "string") { const trimmed = value.trim(); diff --git a/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts b/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts index 4800190f..8f24092c 100644 --- a/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts +++ b/apps/bff/src/modules/auth/infra/token/jose-jwt.service.ts @@ -6,6 +6,8 @@ import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js"; @Injectable() export class JoseJwtService { private readonly secretKey: Uint8Array; + private readonly issuer?: string; + private readonly audience?: string | string[]; constructor(private readonly configService: ConfigService) { const secret = configService.get("JWT_SECRET"); @@ -13,21 +15,60 @@ export class JoseJwtService { throw new Error("JWT_SECRET is required in environment variables"); } this.secretKey = new TextEncoder().encode(secret); + + const issuer = configService.get("JWT_ISSUER"); + this.issuer = issuer && issuer.trim().length > 0 ? issuer.trim() : undefined; + + const audienceRaw = configService.get("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 { const expiresInSeconds = parseJwtExpiry(expiresIn); 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" }) .setIssuedAt(nowSeconds) - .setExpirationTime(nowSeconds + expiresInSeconds) - .sign(this.secretKey); + .setExpirationTime(nowSeconds + expiresInSeconds); + + 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(token: string): Promise { - 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; } @@ -50,4 +91,3 @@ export class JoseJwtService { } } } - diff --git a/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts b/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts index 900cc2e5..87f14a59 100644 --- a/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { createHash } from "crypto"; import { Redis } from "ioredis"; @@ -8,12 +8,16 @@ import { JoseJwtService } from "./jose-jwt.service.js"; @Injectable() export class TokenBlacklistService { + private readonly failClosed: boolean; + constructor( @Inject("REDIS_CLIENT") private readonly redis: Redis, private readonly configService: ConfigService, private readonly jwtService: JoseJwtService, @Inject(Logger) private readonly logger: Logger - ) {} + ) { + this.failClosed = this.configService.get("AUTH_BLACKLIST_FAIL_CLOSED", "false") === "true"; + } async blacklistToken(token: string, _expiresIn?: number): Promise { // Validate token format first @@ -69,7 +73,14 @@ export class TokenBlacklistService { const result = await this.redis.get(this.buildBlacklistKey(token)); return result !== null; } 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", { error: err instanceof Error ? err.message : String(err), }); diff --git a/apps/bff/src/modules/auth/infra/token/token.service.ts b/apps/bff/src/modules/auth/infra/token/token.service.ts index 8ee5a626..d8245308 100644 --- a/apps/bff/src/modules/auth/infra/token/token.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token.service.ts @@ -10,7 +10,7 @@ import { Logger } from "nestjs-pino"; import { randomBytes, createHash } from "crypto"; import type { JWTPayload } from "jose"; 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 { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import { JoseJwtService } from "./jose-jwt.service.js"; @@ -94,7 +94,7 @@ export class AuthTokenService { user: { id: string; email: string; - role?: string; + role?: UserRole; }, deviceInfo?: { deviceId?: string; @@ -110,7 +110,7 @@ export class AuthTokenService { const accessPayload = { sub: user.id, email: user.email, - role: user.role || "user", + role: user.role || "USER", tokenId, type: "access", }; @@ -216,6 +216,12 @@ export class AuthTokenService { }); 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); diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 9aed1037..9beadeab 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -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 { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.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 { @@ -60,42 +62,6 @@ type RequestWithCookies = Omit & { cookies?: Record; }; -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 expiresAt = Date.parse(isoTimestamp); if (Number.isNaN(expiresAt)) { @@ -104,6 +70,9 @@ const calculateCookieMaxAge = (isoTimestamp: string): number => { return Math.max(0, expiresAt - Date.now()); }; +const ACCESS_COOKIE_PATH = "/api"; +const REFRESH_COOKIE_PATH = "/api/auth/refresh"; + @Controller("auth") export class AuthController { constructor( @@ -117,15 +86,20 @@ export class AuthController { res.setSecureCookie("access_token", tokens.accessToken, { maxAge: accessMaxAge, - path: "/", + path: ACCESS_COOKIE_PATH, }); res.setSecureCookie("refresh_token", tokens.refreshToken, { maxAge: refreshMaxAge, - path: "/", + path: REFRESH_COOKIE_PATH, }); } 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("refresh_token", "", { maxAge: 0, path: "/" }); } @@ -201,7 +175,7 @@ export class AuthController { @Req() req: RequestWithCookies & { user?: { id: string } }, @Res({ passthrough: true }) res: Response ) { - const token = extractTokenFromRequest(req); + const token = extractAccessTokenFromRequest(req); let userId = req.user?.id; if (!userId && token) { @@ -286,6 +260,8 @@ export class AuthController { @Public() @Post("reset-password") @HttpCode(200) + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations) @UsePipes(new ZodValidationPipe(passwordResetSchema)) async resetPassword( @Body() body: ResetPasswordRequest, @@ -313,16 +289,8 @@ export class AuthController { } @Get("me") - getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role: string } }) { - // Return basic auth info only - full profile should use /api/me - return { - isAuthenticated: true, - user: { - id: req.user.id, - email: req.user.email, - role: req.user.role, - }, - }; + getAuthStatus(@Req() req: Request & { user: UserAuth }) { + return { isAuthenticated: true, user: req.user }; } @Post("sso-link") diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index dd658783..fb7368a0 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -11,6 +11,7 @@ import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; import type { UserAuth } from "@customer-portal/domain/customer"; +import { extractAccessTokenFromRequest } from "../../../utils/token-from-request.util.js"; type CookieValue = string | undefined; type RequestBase = Omit; @@ -23,18 +24,6 @@ type RequestWithRoute = RequestWithCookies & { route?: { path?: string }; }; -const extractTokenFromRequest = (request: RequestWithCookies): string | undefined => { - const rawHeader = (request as unknown as { headers?: Record }).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() export class GlobalAuthGuard implements CanActivate { private readonly logger = new Logger(GlobalAuthGuard.name); @@ -63,7 +52,7 @@ export class GlobalAuthGuard implements CanActivate { } try { - const token = extractTokenFromRequest(request); + const token = extractAccessTokenFromRequest(request); if (!token) { if (isLogoutRoute) { this.logger.debug(`Allowing logout request without active session: ${route}`); @@ -76,6 +65,11 @@ export class GlobalAuthGuard implements CanActivate { 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) { 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}`); return true; } - const token = extractTokenFromRequest(request); + const token = extractAccessTokenFromRequest(request); const log = typeof token === "string" ? () => this.logger.warn(`Unauthorized access attempt to ${route}`) diff --git a/apps/bff/src/modules/auth/utils/token-from-request.util.ts b/apps/bff/src/modules/auth/utils/token-from-request.util.ts new file mode 100644 index 00000000..89c0dfae --- /dev/null +++ b/apps/bff/src/modules/auth/utils/token-from-request.util.ts @@ -0,0 +1,46 @@ +type CookieValue = string | undefined; + +type RequestHeadersLike = Record | undefined; + +type RequestWithCookies = { + cookies?: Record; + 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; +}; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 1a9e1741..b65c061b 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -212,15 +212,18 @@ export const useAuthStore = create()((set, get) => { resetPassword: async (token: string, password: string) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST("/api/auth/reset-password", { + await apiClient.POST("/api/auth/reset-password", { body: { token, password }, disableCsrf: true, // Public auth endpoint, exempt from CSRF }); - const parsed = authResponseSchema.safeParse(response.data); - if (!parsed.success) { - throw new Error(parsed.error.issues?.[0]?.message ?? "Password reset failed"); - } - applyAuthResponse(parsed.data); + // Password reset does NOT create a session; BFF clears auth cookies to force re-login. + set({ + user: null, + session: {}, + isAuthenticated: false, + loading: false, + error: null, + }); } catch (error) { set({ loading: false, diff --git a/apps/portal/src/proxy.ts b/apps/portal/src/proxy.ts index 4b48a901..1757af4c 100644 --- a/apps/portal/src/proxy.ts +++ b/apps/portal/src/proxy.ts @@ -38,7 +38,6 @@ export function proxy(request: NextRequest) { response.headers.set("X-Frame-Options", "DENY"); response.headers.set("X-Content-Type-Options", "nosniff"); 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=()"); return response; diff --git a/docs/auth/AUTH-MODULE-ARCHITECTURE.md b/docs/auth/AUTH-MODULE-ARCHITECTURE.md index e355f915..7fe5988a 100644 --- a/docs/auth/AUTH-MODULE-ARCHITECTURE.md +++ b/docs/auth/AUTH-MODULE-ARCHITECTURE.md @@ -25,36 +25,41 @@ modules/auth/ ### Layer Responsibilities -| Layer | Purpose | -| -------------- | ----------------------------------------------------------------------------- | -| `presentation` | HTTP surface area (controllers, guards, interceptors, Passport strategies). | -| `application` | Use-case orchestration (`AuthFacade`), coordinating infra + audit logging. | -| `infra` | Technical services: token issuance, rate limiting, WHMCS/SF workflows. | -| `decorators` | Shared Nest decorators (e.g., `@Public`). | -| `domain` | (Future) domain policies, constants, pure type definitions. | +| Layer | Purpose | +| -------------- | --------------------------------------------------------------------------- | +| `presentation` | HTTP surface area (controllers, guards, interceptors, Passport strategies). | +| `application` | Use-case orchestration (`AuthFacade`), coordinating infra + audit logging. | +| `infra` | Technical services: token issuance, rate limiting, WHMCS/SF workflows. | +| `decorators` | Shared Nest decorators (e.g., `@Public`). | +| `domain` | (Future) domain policies, constants, pure type definitions. | ## Presentation Layer ### Controllers (`presentation/http/auth.controller.ts`) + - Routes mirror previous `auth-zod.controller.ts` functionality. - All validation still uses Zod schemas from `@customer-portal/domain` applied via `ZodValidationPipe`. - Cookies are managed with `setSecureCookie` helper registered in `bootstrap.ts`. ### Guards + - `GlobalAuthGuard`: wraps Passport JWT, checks blacklist via `TokenBlacklistService`. - `AuthThrottleGuard`: Nest Throttler-based guard for general rate limits. - `FailedLoginThrottleGuard`: uses `AuthRateLimitService` for login attempt limiting. ### Interceptors + - `LoginResultInterceptor`: ties into `FailedLoginThrottleGuard` to clear counters on success/failure. ### Strategies (`presentation/strategies`) + - `LocalStrategy`: delegates credential validation to `AuthFacade.validateUser`. - `JwtStrategy`: loads user via `UsersService` and maps to public profile. ## Application Layer ### `AuthFacade` + - 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. - Acts as single DI target for controllers, strategies, and guards. @@ -62,15 +67,18 @@ modules/auth/ ## Infrastructure Layer ### Token (`infra/token`) + - `token.service.ts`: issues/rotates access + refresh tokens, enforces Redis-backed refresh token families. - `token-blacklist.service.ts`: stores revoked access tokens. ### Rate Limiting (`infra/rate-limiting/auth-rate-limit.service.ts`) + - Built on `rate-limiter-flexible` with Redis storage. - Exposes methods for login, signup, password reset, refresh token throttling. - Handles CAPTCHA escalation via headers (config-driven, see env variables). ### Workflows (`infra/workflows`) + - `signup-workflow.service.ts`: orchestrates Salesforce + WHMCS checks and user creation. - `password-workflow.service.ts`: request reset, change password, set password flows. - `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 -- `core/security/middleware/csrf.middleware.ts` now issues stateless HMAC tokens. -- On safe requests (GET/HEAD), middleware refreshes token + cookie automatically. -- Controllers just rely on middleware; no manual token handling needed. +- `core/security/controllers/csrf.controller.ts` issues stateless HMAC tokens at `GET /api/security/csrf/token`. +- Portal fetches the token once and sends it as `X-CSRF-Token` for unsafe methods. +- `core/security/middleware/csrf.middleware.ts` validates `X-CSRF-Token` for unsafe methods and skips safe methods/exempt routes. ## 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 - diff --git a/packages/domain/package.json b/packages/domain/package.json index 52cbce58..948ef8f9 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -133,7 +133,7 @@ "typecheck": "pnpm run type-check" }, "peerDependencies": { - "zod": "catalog:" + "zod": "4.1.13" }, "devDependencies": { "typescript": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8043032e..b3ac5fcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,11 +5,20 @@ settings: excludeLinksFromLockfile: false 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: js-yaml: ">=4.1.1" - typescript: 5.9.3 - "@types/node": 24.10.3 - zod: 4.1.13 importers: .: @@ -21,14 +30,11 @@ importers: specifier: 16.0.9 version: 16.0.9 "@types/node": - specifier: 24.10.3 + specifier: "catalog:" version: 24.10.3 eslint: specifier: ^9.39.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: specifier: ^7.0.1 version: 7.0.1(eslint@9.39.1(jiti@2.6.1)) @@ -48,7 +54,7 @@ importers: specifier: ^4.21.0 version: 4.21.0 typescript: - specifier: 5.9.3 + specifier: "catalog:" version: 5.9.3 typescript-eslint: specifier: ^8.49.0 @@ -135,7 +141,7 @@ importers: specifier: ^12.0.1 version: 12.0.1 zod: - specifier: 4.1.13 + specifier: "catalog:" version: 4.1.13 devDependencies: "@nestjs/cli": @@ -166,7 +172,7 @@ importers: specifier: ^1.8.16 version: 1.8.16 typescript: - specifier: 5.9.3 + specifier: "catalog:" version: 5.9.3 apps/portal: @@ -205,7 +211,7 @@ importers: specifier: ^5.1.0 version: 5.1.0 zod: - specifier: 4.1.13 + specifier: "catalog:" version: 4.1.13 zustand: specifier: ^5.0.9 @@ -230,18 +236,17 @@ importers: specifier: ^4.1.17 version: 4.1.17 typescript: - specifier: 5.9.3 + specifier: "catalog:" version: 5.9.3 packages/domain: - dependencies: - zod: - specifier: 4.1.13 - version: 4.1.13 devDependencies: typescript: - specifier: 5.9.3 + specifier: "catalog:" version: 5.9.3 + zod: + specifier: "catalog:" + version: 4.1.13 packages: "@alloc/quick-lru@5.2.0": @@ -1115,7 +1120,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1127,7 +1132,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1139,7 +1144,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1151,7 +1156,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1163,7 +1168,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1175,7 +1180,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1194,7 +1199,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1206,7 +1211,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1218,7 +1223,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1230,7 +1235,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1242,7 +1247,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1254,7 +1259,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1266,7 +1271,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1278,7 +1283,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1290,7 +1295,7 @@ packages: } engines: { node: ">=18" } peerDependencies: - "@types/node": 24.10.3 + "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true @@ -1698,7 +1703,7 @@ packages: integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==, } peerDependencies: - typescript: 5.9.3 + typescript: ">=4.8.2" "@nestjs/swagger@11.2.0": resolution: @@ -1868,13 +1873,6 @@ packages: 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": resolution: { @@ -1901,7 +1899,7 @@ packages: engines: { node: ^20.19 || ^22.12 || >=24.0 } peerDependencies: prisma: "*" - typescript: 5.9.3 + typescript: ">=5.4.0" peerDependenciesMeta: prisma: optional: true @@ -2482,6 +2480,12 @@ packages: integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, } + "@types/node@18.19.130": + resolution: + { + integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==, + } + "@types/node@24.10.3": resolution: { @@ -2559,7 +2563,7 @@ packages: peerDependencies: "@typescript-eslint/parser": ^8.49.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": resolution: @@ -2569,7 +2573,7 @@ packages: engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: 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": resolution: @@ -2578,7 +2582,7 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: - typescript: 5.9.3 + typescript: ">=4.8.4 <6.0.0" "@typescript-eslint/scope-manager@8.49.0": resolution: @@ -2594,7 +2598,7 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: - typescript: 5.9.3 + typescript: ">=4.8.4 <6.0.0" "@typescript-eslint/type-utils@8.49.0": resolution: @@ -2604,7 +2608,7 @@ packages: engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: 5.9.3 + typescript: ">=4.8.4 <6.0.0" "@typescript-eslint/types@8.49.0": resolution: @@ -2620,7 +2624,7 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: - typescript: 5.9.3 + typescript: ">=4.8.4 <6.0.0" "@typescript-eslint/utils@8.49.0": resolution: @@ -2630,7 +2634,7 @@ packages: engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: 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": resolution: @@ -3604,7 +3608,7 @@ packages: } engines: { node: ">=14" } peerDependencies: - typescript: 5.9.3 + typescript: ">=4.9.5" peerDependenciesMeta: typescript: optional: true @@ -3960,23 +3964,6 @@ packages: } 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: resolution: { @@ -4156,12 +4143,6 @@ packages: integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, } - fast-diff@1.3.0: - resolution: - { - integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==, - } - fast-fifo@1.3.2: resolution: { @@ -4346,7 +4327,7 @@ packages: } engines: { node: ">=14.21.3" } peerDependencies: - typescript: 5.9.3 + typescript: ">3.6.0" webpack: ^5.11.0 form-data-encoder@2.1.4: @@ -5537,7 +5518,7 @@ packages: "@nestjs/common": ^10.0.0 || ^11.0.0 "@nestjs/swagger": ^7.4.2 || ^8.0.0 || ^11.0.0 rxjs: ^7.0.0 - zod: 4.1.13 + zod: ^3.25.0 || ^4.0.0 peerDependenciesMeta: "@nestjs/swagger": optional: true @@ -6069,13 +6050,6 @@ packages: } 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: resolution: { @@ -6093,7 +6067,7 @@ packages: hasBin: true peerDependencies: better-sqlite3: ">=9.0.0" - typescript: 5.9.3 + typescript: ">=5.4.0" peerDependenciesMeta: better-sqlite3: optional: true @@ -6858,13 +6832,6 @@ packages: } 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: resolution: { @@ -7010,7 +6977,7 @@ packages: } engines: { node: ">=18.12" } peerDependencies: - typescript: 5.9.3 + typescript: ">=4.8.4" tsc-alias@1.8.16: resolution: @@ -7109,7 +7076,7 @@ packages: engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: 5.9.3 + typescript: ">=4.8.4 <6.0.0" typescript@5.9.3: resolution: @@ -7145,6 +7112,12 @@ packages: integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==, } + undici-types@5.26.5: + resolution: + { + integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==, + } + undici-types@7.16.0: resolution: { @@ -7206,7 +7179,7 @@ packages: integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==, } peerDependencies: - typescript: 5.9.3 + typescript: ">=5" peerDependenciesMeta: typescript: optional: true @@ -7452,7 +7425,7 @@ packages: } engines: { node: ">=18.0.0" } peerDependencies: - zod: 4.1.13 + zod: ^3.25.0 || ^4.0.0 zod@4.1.13: resolution: @@ -8402,8 +8375,6 @@ snapshots: "@pinojs/redact@0.4.0": {} - "@pkgr/core@0.2.9": {} - "@polka/url@1.0.0-next.29": {} "@prisma/adapter-pg@7.1.0": @@ -8776,6 +8747,10 @@ snapshots: "@types/json-schema@7.0.15": {} + "@types/node@18.19.130": + dependencies: + undici-types: 5.26.5 + "@types/node@24.10.3": dependencies: undici-types: 7.16.0 @@ -8813,7 +8788,7 @@ snapshots: "@types/ssh2@1.15.5": dependencies: - "@types/node": 24.10.3 + "@types/node": 18.19.130 "@types/validator@13.15.10": optional: true @@ -9727,15 +9702,6 @@ snapshots: 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)): dependencies: "@babel/core": 7.28.5 @@ -9903,8 +9869,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-fifo@1.3.2: optional: true @@ -10991,10 +10955,6 @@ snapshots: prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.0: - dependencies: - fast-diff: 1.3.0 - 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): @@ -11483,10 +11443,6 @@ snapshots: symbol-observable@4.0.0: {} - synckit@0.11.11: - dependencies: - "@pkgr/core": 0.2.9 - tailwind-merge@3.4.0: {} tailwindcss@4.1.17: {} @@ -11657,6 +11613,8 @@ snapshots: underscore@1.13.7: {} + undici-types@5.26.5: {} + undici-types@7.16.0: {} undici@7.16.0: {} diff --git a/scripts/validate-deps.sh b/scripts/validate-deps.sh index 65741bdb..b9245653 100755 --- a/scripts/validate-deps.sh +++ b/scripts/validate-deps.sh @@ -34,26 +34,31 @@ pnpm list --recursive --depth=0 --json > /tmp/deps.json node -e " const fs = require('fs'); const deps = JSON.parse(fs.readFileSync('/tmp/deps.json', 'utf8')); +// depName -> Map(version -> string[]) const allDeps = new Map(); deps.forEach(pkg => { if (pkg.dependencies) { Object.entries(pkg.dependencies).forEach(([name, info]) => { const version = info.version; - if (!allDeps.has(name)) { - allDeps.set(name, new Set()); - } - allDeps.get(name).add(\`\${pkg.name}@\${version}\`); + if (!allDeps.has(name)) allDeps.set(name, new Map()); + + const byVersion = allDeps.get(name); + if (!byVersion.has(version)) byVersion.set(version, []); + byVersion.get(version).push(pkg.name); }); } }); let hasDrift = false; -allDeps.forEach((versions, depName) => { - if (versions.size > 1) { - console.log(\`❌ Version drift detected for \${depName}:\`); - versions.forEach(v => console.log(\` - \${v}\`)); - hasDrift = true; +allDeps.forEach((byVersion, depName) => { + if (byVersion.size <= 1) return; + + 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}\`)); } });