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

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

View File

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

View File

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

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

View File

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

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

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

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) => {
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,

View File

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

View File

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

View File

@ -133,7 +133,7 @@
"typecheck": "pnpm run type-check"
},
"peerDependencies": {
"zod": "catalog:"
"zod": "4.1.13"
},
"devDependencies": {
"typescript": "catalog:",

188
pnpm-lock.yaml generated
View File

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

View File

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