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:
barsa 2025-12-12 15:00:11 +09:00
parent 3f7fa02b83
commit 88b9ac0a19
15 changed files with 296 additions and 330 deletions

View File

@ -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()

View File

@ -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"),

View File

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

View File

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

View File

@ -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),
}); });

View File

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

View File

@ -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")

View File

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

View 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;
};

View File

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

View File

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

View File

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

View File

@ -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
View File

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

View File

@ -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}\`));
} }
}); });