Enhance authentication and CSRF protection mechanisms
- Introduced optional JWT issuer and audience configurations in the JoseJwtService for improved token validation. - Updated CSRF middleware to streamline token validation and enhance security measures. - Added new environment variables for JWT issuer and audience, allowing for more flexible authentication setups. - Refactored CSRF controller and middleware to improve token handling and security checks. - Cleaned up and standardized cookie paths for access and refresh tokens in the AuthController. - Enhanced error handling in the TokenBlacklistService to manage Redis availability more effectively.
This commit is contained in:
parent
3f7fa02b83
commit
88b9ac0a19
@ -8,6 +8,8 @@ export const envSchema = z.object({
|
||||
|
||||
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
|
||||
JWT_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()
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -12,18 +12,9 @@ interface CsrfRequestBody {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type QueryValue = string | string[] | undefined;
|
||||
|
||||
type CsrfRequestQuery = Record<string, QueryValue>;
|
||||
|
||||
type CookieJar = Record<string, string | undefined>;
|
||||
|
||||
type BaseExpressRequest = Request<
|
||||
Record<string, string>,
|
||||
unknown,
|
||||
CsrfRequestBody,
|
||||
CsrfRequestQuery
|
||||
>;
|
||||
type BaseExpressRequest = Request<Record<string, string>, unknown, CsrfRequestBody>;
|
||||
|
||||
type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
|
||||
csrfToken?: string;
|
||||
@ -34,13 +25,15 @@ type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
|
||||
|
||||
/**
|
||||
* 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<string>;
|
||||
private readonly exemptMethods: Set<string>;
|
||||
private readonly safeMethods: Set<string>;
|
||||
|
||||
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();
|
||||
|
||||
@ -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<string>("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<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> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
@ -50,4 +91,3 @@ export class JoseJwtService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { 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<void> {
|
||||
// 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),
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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<Request, "cookies"> & {
|
||||
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 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")
|
||||
|
||||
@ -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<Request, "cookies" | "route">;
|
||||
@ -23,18 +24,6 @@ type RequestWithRoute = RequestWithCookies & {
|
||||
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()
|
||||
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}`)
|
||||
|
||||
46
apps/bff/src/modules/auth/utils/token-from-request.util.ts
Normal file
46
apps/bff/src/modules/auth/utils/token-from-request.util.ts
Normal file
@ -0,0 +1,46 @@
|
||||
type CookieValue = string | undefined;
|
||||
|
||||
type RequestHeadersLike = Record<string, unknown> | undefined;
|
||||
|
||||
type RequestWithCookies = {
|
||||
cookies?: Record<string, CookieValue>;
|
||||
headers?: RequestHeadersLike;
|
||||
};
|
||||
|
||||
const pickFirstStringHeader = (value: unknown): string | undefined => {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const normalized = pickFirstStringHeader(entry);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const resolveAuthorizationHeader = (headers: RequestHeadersLike): string | undefined => {
|
||||
const raw = headers?.authorization;
|
||||
return pickFirstStringHeader(raw);
|
||||
};
|
||||
|
||||
export const extractBearerToken = (headers: RequestHeadersLike): string | undefined => {
|
||||
const authHeader = resolveAuthorizationHeader(headers);
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.slice("Bearer ".length).trim();
|
||||
return token.length > 0 ? token : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const extractAccessTokenFromRequest = (request: RequestWithCookies): string | undefined => {
|
||||
const headerToken = extractBearerToken(request.headers);
|
||||
if (headerToken) return headerToken;
|
||||
|
||||
const cookieToken = request.cookies?.access_token;
|
||||
return typeof cookieToken === "string" && cookieToken.length > 0 ? cookieToken : undefined;
|
||||
};
|
||||
@ -212,15 +212,18 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
resetPassword: async (token: string, password: string) => {
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -133,7 +133,7 @@
|
||||
"typecheck": "pnpm run type-check"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "catalog:"
|
||||
"zod": "4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
|
||||
188
pnpm-lock.yaml
generated
188
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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}\`));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user