feat(auth): implement permission-based access control and centralized error handling

- Introduced PermissionsGuard to enforce permission checks on routes.
- Added RequirePermissions decorator for specifying required permissions on handlers.
- Created AUTH_ERRORS constants for consistent error messages across the auth module.
- Updated CsrfService to reduce CSRF token expiry time for enhanced security.
- Refactored auth cookie handling into utility functions for better maintainability.
- Enhanced TokenBlacklistService to default to fail-closed in production environments.
- Updated various DTOs and schemas for consistency and clarity.
- Removed legacy code and types related to SIM requests.
- Improved logging and error handling in GlobalAuthGuard.
- Added middleware for public path checks and optimistic authentication.
This commit is contained in:
barsa 2026-01-19 10:40:50 +09:00
parent b52b2874d6
commit d3b94b1ed3
26 changed files with 611 additions and 283 deletions

View File

@ -16,7 +16,7 @@ export const envSchema = z.object({
BCRYPT_ROUNDS: z.coerce.number().int().min(12).max(16).default(14),
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
CSRF_TOKEN_EXPIRY: z.coerce.number().int().positive().default(3600000),
CSRF_TOKEN_EXPIRY: z.coerce.number().int().positive().default(900000), // 15 minutes
CSRF_SECRET_KEY: z.string().min(32, "CSRF secret key must be at least 32 characters").optional(),
CSRF_COOKIE_NAME: z.string().default("csrf-secret"),
CSRF_HEADER_NAME: z.string().default("X-CSRF-Token"),
@ -46,7 +46,8 @@ 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"),
// Default handled by service: fail-closed in production, fail-open in development
AUTH_BLACKLIST_FAIL_CLOSED: z.enum(["true", "false"]).optional(),
AUTH_MAINTENANCE_MODE: z.enum(["true", "false"]).default("false"),
AUTH_MAINTENANCE_MESSAGE: z
.string()

View File

@ -140,13 +140,6 @@ export class CsrfMiddleware implements NestMiddleware {
// Store validated token in request for potential use by controllers
req.csrfToken = token;
this.logger.debug("CSRF validation successful", {
method: req.method,
path: req.path,
userId,
sessionId,
});
next();
}

View File

@ -42,7 +42,8 @@ export class CsrfService {
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "3600000"));
// Default to 15 minutes (900000ms) - shorter TTL reduces CSRF attack window
this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "900000"));
this.secretKey = this.configService.get("CSRF_SECRET_KEY") || this.generateSecretKey();
this.cookieName = this.configService.get("CSRF_COOKIE_NAME", "csrf-secret");
this.headerName = this.configService.get("CSRF_HEADER_NAME", "X-CSRF-Token");

View File

@ -8,6 +8,7 @@ import { UsersModule } from "@bff/modules/users/users.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js";
import { PermissionsGuard } from "./presentation/http/guards/permissions.guard.js";
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
import { TokenStorageService } from "./infra/token/token-storage.service.js";
import { TokenRevocationService } from "./infra/token/token-revocation.service.js";
@ -68,7 +69,11 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
provide: APP_GUARD,
useClass: GlobalAuthGuard,
},
{
provide: APP_GUARD,
useClass: PermissionsGuard,
},
],
exports: [AuthFacade, TokenBlacklistService, AuthTokenService],
exports: [AuthFacade, TokenBlacklistService, AuthTokenService, PermissionsGuard],
})
export class AuthModule {}

View File

@ -0,0 +1,115 @@
/**
* Centralized Authentication Error Catalog
*
* Provides consistent error codes and messages across the auth module.
* Use these constants instead of hardcoding error messages.
*/
export const AUTH_ERRORS = {
// Token errors
TOKEN_INVALID: {
code: "TOKEN_INVALID",
message: "Invalid token",
},
TOKEN_EXPIRED: {
code: "TOKEN_EXPIRED",
message: "Token has expired",
},
TOKEN_REVOKED: {
code: "TOKEN_REVOKED",
message: "Token has been revoked",
},
TOKEN_MISSING: {
code: "TOKEN_MISSING",
message: "Missing token",
},
TOKEN_INVALID_TYPE: {
code: "TOKEN_INVALID_TYPE",
message: "Invalid access token",
},
TOKEN_INVALID_PAYLOAD: {
code: "TOKEN_INVALID_PAYLOAD",
message: "Invalid token payload",
},
TOKEN_MISSING_EXPIRATION: {
code: "TOKEN_MISSING_EXPIRATION",
message: "Token missing expiration claim",
},
TOKEN_EXPIRING_SOON: {
code: "TOKEN_EXPIRING_SOON",
message: "Token expired or expiring soon",
},
TOKEN_SUBJECT_MISMATCH: {
code: "TOKEN_SUBJECT_MISMATCH",
message: "Token subject does not match user record",
},
// User errors
USER_NOT_FOUND: {
code: "USER_NOT_FOUND",
message: "User not found",
},
ACCOUNT_EXISTS: {
code: "ACCOUNT_EXISTS",
message: "Account already exists",
},
INVALID_CREDENTIALS: {
code: "INVALID_CREDENTIALS",
message: "Invalid credentials",
},
EMAIL_NOT_VERIFIED: {
code: "EMAIL_NOT_VERIFIED",
message: "Email not verified",
},
ACCOUNT_DISABLED: {
code: "ACCOUNT_DISABLED",
message: "Account has been disabled",
},
// Password errors
PASSWORD_TOO_WEAK: {
code: "PASSWORD_TOO_WEAK",
message: "Password does not meet requirements",
},
PASSWORD_MISMATCH: {
code: "PASSWORD_MISMATCH",
message: "Current password is incorrect",
},
PASSWORD_RESET_EXPIRED: {
code: "PASSWORD_RESET_EXPIRED",
message: "Password reset token has expired",
},
PASSWORD_RESET_INVALID: {
code: "PASSWORD_RESET_INVALID",
message: "Invalid password reset token",
},
// Session errors
SESSION_EXPIRED: {
code: "SESSION_EXPIRED",
message: "Session has expired",
},
SESSION_INVALID: {
code: "SESSION_INVALID",
message: "Invalid session",
},
// Request context errors
INVALID_REQUEST_CONTEXT: {
code: "INVALID_REQUEST_CONTEXT",
message: "Invalid request context",
},
// Service errors
SERVICE_UNAVAILABLE: {
code: "SERVICE_UNAVAILABLE",
message: "Authentication temporarily unavailable",
},
RATE_LIMITED: {
code: "RATE_LIMITED",
message: "Too many requests. Please try again later.",
},
} as const;
export type AuthErrorCode = keyof typeof AUTH_ERRORS;
export type AuthError = (typeof AUTH_ERRORS)[AuthErrorCode];

View File

@ -0,0 +1,25 @@
import { SetMetadata } from "@nestjs/common";
import type { Permission } from "@customer-portal/domain/auth";
export const REQUIRED_PERMISSIONS_KEY = "requiredPermissions";
/**
* Decorator to require specific permissions for a route.
*
* @example
* ```typescript
* @RequirePermissions(PERMISSIONS.ADMIN_USERS)
* @Get('admin/users')
* listUsers() { ... }
* ```
*
* @example
* ```typescript
* // Require any of the specified permissions
* @RequirePermissions(PERMISSIONS.BILLING_READ, PERMISSIONS.ADMIN_AUDIT)
* @Get('billing/summary')
* getBillingSummary() { ... }
* ```
*/
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);

View File

@ -16,7 +16,14 @@ export class TokenBlacklistService {
private readonly jwtService: JoseJwtService,
@Inject(Logger) private readonly logger: Logger
) {
this.failClosed = this.configService.get("AUTH_BLACKLIST_FAIL_CLOSED", "false") === "true";
// Default to fail-closed in production for security, fail-open in development for convenience
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
const explicitSetting = this.configService.get<string>("AUTH_BLACKLIST_FAIL_CLOSED");
if (explicitSetting === undefined) {
this.failClosed = nodeEnv === "production";
} else {
this.failClosed = explicitSetting === "true";
}
}
async blacklistToken(token: string): Promise<void> {

View File

@ -25,6 +25,14 @@ import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guard
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 {
setAuthCookies,
clearAuthCookies,
buildSessionInfo,
ACCESS_COOKIE_PATH,
REFRESH_COOKIE_PATH,
TOKEN_TYPE,
} from "./utils/auth-cookie.util.js";
// Import Zod schemas from domain
import {
@ -47,6 +55,9 @@ type RequestWithCookies = Omit<Request, "cookies"> & {
cookies?: Record<string, CookieValue>;
};
// Re-export for backward compatibility with tests
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
class SignupRequestDto extends createZodDto(signupRequestSchema) {}
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
@ -60,18 +71,6 @@ class SsoLinkRequestDto extends createZodDto(ssoLinkRequestSchema) {}
class LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {}
class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {}
const calculateCookieMaxAge = (isoTimestamp: string): number => {
const expiresAt = Date.parse(isoTimestamp);
if (Number.isNaN(expiresAt)) {
return 0;
}
return Math.max(0, expiresAt - Date.now());
};
const ACCESS_COOKIE_PATH = "/api";
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
const TOKEN_TYPE = "Bearer" as const;
@Controller("auth")
export class AuthController {
constructor(
@ -79,46 +78,6 @@ export class AuthController {
private readonly jwtService: JoseJwtService
) {}
private setAuthCookies(
res: Response,
tokens: {
accessToken: string;
refreshToken: string;
expiresAt: string;
refreshExpiresAt: string;
}
): void {
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt);
res.setSecureCookie("access_token", tokens.accessToken, {
maxAge: accessMaxAge,
path: ACCESS_COOKIE_PATH,
});
res.setSecureCookie("refresh_token", tokens.refreshToken, {
maxAge: refreshMaxAge,
path: REFRESH_COOKIE_PATH,
});
}
private toSession(tokens: { expiresAt: string; refreshExpiresAt: string }) {
return {
expiresAt: tokens.expiresAt,
refreshExpiresAt: tokens.refreshExpiresAt,
tokenType: TOKEN_TYPE,
};
}
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: "/" });
}
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
}
@ -145,8 +104,8 @@ export class AuthController {
@Res({ passthrough: true }) res: Response
) {
const result = await this.authFacade.signup(signupData, req);
this.setAuthCookies(res, result.tokens);
return { user: result.user, session: this.toSession(result.tokens) };
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
@Public()
@ -158,9 +117,9 @@ export class AuthController {
@Res({ passthrough: true }) res: Response
) {
const result = await this.authFacade.login(req.user, req);
this.setAuthCookies(res, result.tokens);
setAuthCookies(res, result.tokens);
this.applyAuthRateLimitHeaders(req, res);
return { user: result.user, session: this.toSession(result.tokens) };
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
@Public()
@ -182,7 +141,7 @@ export class AuthController {
await this.authFacade.logout(userId, token, req as Request);
// Always clear cookies, even if session expired
this.clearAuthCookies(res);
clearAuthCookies(res);
return { message: "Logout successful" };
}
@ -202,8 +161,8 @@ export class AuthController {
deviceId: body.deviceId ?? undefined,
userAgent: userAgent ?? undefined,
});
this.setAuthCookies(res, result.tokens);
return { user: result.user, session: this.toSession(result.tokens) };
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
@Public()
@ -226,8 +185,8 @@ export class AuthController {
@Res({ passthrough: true }) res: Response
) {
const result = await this.authFacade.setPassword(setPasswordData);
this.setAuthCookies(res, result.tokens);
return { user: result.user, session: this.toSession(result.tokens) };
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
@Public()
@ -264,7 +223,7 @@ export class AuthController {
await this.authFacade.resetPassword(body.token, body.password);
// Clear auth cookies after password reset to force re-login
this.clearAuthCookies(res);
clearAuthCookies(res);
return { message: "Password reset successful" };
}
@ -277,8 +236,8 @@ export class AuthController {
@Res({ passthrough: true }) res: Response
) {
const result = await this.authFacade.changePassword(req.user.id, body, req);
this.setAuthCookies(res, result.tokens);
return { user: result.user, session: this.toSession(result.tokens) };
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
/**

View File

@ -20,6 +20,7 @@ import {
import type { User } from "@customer-portal/domain/customer";
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
import { setAuthCookies, buildSessionInfo, type SessionInfo } from "./utils/auth-cookie.util.js";
// DTO classes using Zod schemas
class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {}
@ -32,72 +33,11 @@ class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchem
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
// Cookie configuration
const ACCESS_COOKIE_PATH = "/api";
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
const TOKEN_TYPE = "Bearer" as const;
interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: string;
refreshExpiresAt: string;
}
interface SessionInfo {
expiresAt: string;
refreshExpiresAt: string;
tokenType: typeof TOKEN_TYPE;
}
interface AuthSuccessResponse {
user: User;
session: SessionInfo;
}
/**
* Calculate cookie max age from ISO timestamp
*/
function calculateCookieMaxAge(isoTimestamp: string): number {
const expiresAt = Date.parse(isoTimestamp);
if (Number.isNaN(expiresAt)) return 0;
return Math.max(0, expiresAt - Date.now());
}
/**
* Set authentication cookies (httpOnly, secure in production)
*/
function setAuthCookies(res: Response, tokens: AuthTokens): void {
const isProduction = process.env["NODE_ENV"] === "production";
res.cookie("access_token", tokens.accessToken, {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
path: ACCESS_COOKIE_PATH,
maxAge: calculateCookieMaxAge(tokens.expiresAt),
});
res.cookie("refresh_token", tokens.refreshToken, {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
path: REFRESH_COOKIE_PATH,
maxAge: calculateCookieMaxAge(tokens.refreshExpiresAt),
});
}
/**
* Build session info from tokens
*/
function buildSessionInfo(tokens: AuthTokens): SessionInfo {
return {
expiresAt: tokens.expiresAt,
refreshExpiresAt: tokens.refreshExpiresAt,
tokenType: TOKEN_TYPE,
};
}
/**
* Get Started Controller
*

View File

@ -55,22 +55,17 @@ export class GlobalAuthGuard implements CanActivate {
]);
if (isPublic) {
if (isPublicNoSession) {
this.logger.debug(`Strict public route accessed (no session attach): ${route}`);
return true;
}
const token = extractAccessTokenFromRequest(request);
if (token) {
try {
await this.attachUserFromToken(request, token);
this.logger.debug(`Authenticated session detected on public route: ${route}`);
} catch {
// Public endpoints should remain accessible even if the session is missing/expired/invalid.
this.logger.debug(`Ignoring invalid session on public route: ${route}`);
if (!isPublicNoSession) {
// Try to attach user from token if present (optional)
const token = extractAccessTokenFromRequest(request);
if (token) {
try {
await this.attachUserFromToken(request, token);
} catch {
// Public endpoints should remain accessible even if the session is missing/expired/invalid
}
}
}
this.logger.debug(`Public route accessed: ${route}`);
return true;
}
@ -85,48 +80,35 @@ export class GlobalAuthGuard implements CanActivate {
const token = extractAccessTokenFromRequest(request);
if (!token) {
// No token = not logged in, allow request with no user attached
this.logger.debug(`Optional auth route accessed without token: ${route}`);
return true;
}
// Token present - validate it strictly (invalid token = 401)
try {
await this.attachUserFromToken(request, token, route);
this.logger.debug(`Optional auth route accessed with valid token: ${route}`);
return true;
} catch (error) {
// Token is invalid/expired - return 401 to signal "session expired"
this.logger.debug(`Optional auth route - invalid token, returning 401: ${route}`);
throw error;
}
await this.attachUserFromToken(request, token, route);
return true;
}
try {
const token = extractAccessTokenFromRequest(request);
if (!token) {
if (isLogoutRoute) {
this.logger.debug(`Allowing logout request without active session: ${route}`);
return true;
}
throw new UnauthorizedException("Missing token");
}
await this.attachUserFromToken(request, token, route);
this.logger.debug(`Authenticated access to: ${route}`);
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
if (isLogoutRoute) {
this.logger.debug(`Allowing logout request with expired or invalid token: ${route}`);
return true;
}
// Only log warnings for requests with an invalid token (not missing tokens)
const token = extractAccessTokenFromRequest(request);
const log =
typeof token === "string"
? () => this.logger.warn(`Unauthorized access attempt to ${route}`)
: () => this.logger.debug(`Unauthenticated request blocked for ${route}`);
log();
if (typeof token === "string") {
this.logger.warn(`Unauthorized access attempt to ${route}`);
}
} else {
this.logger.error(`Authentication error for route ${route}: ${extractErrorMessage(error)}`);
}

View File

@ -0,0 +1,68 @@
import { Injectable, ForbiddenException, Logger } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { Request } from "express";
import { REQUIRED_PERMISSIONS_KEY } from "../../../decorators/permissions.decorator.js";
import { hasAnyPermission, type Permission } from "@customer-portal/domain/auth";
interface UserWithRole {
id: string;
role?: string;
}
type RequestWithUser = Request & { user?: UserWithRole };
/**
* Guard to enforce permission-based access control.
*
* This guard checks if the authenticated user has any of the required
* permissions specified via the @RequirePermissions decorator.
*
* Note: This guard must be used after the GlobalAuthGuard, which
* attaches the user to the request.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
private readonly logger = new Logger(PermissionsGuard.name);
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[] | undefined>(
REQUIRED_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()]
);
// No permissions required - allow access
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user) {
this.logger.warn("Permissions check failed - no user on request");
throw new ForbiddenException("Access denied");
}
if (!user.role) {
this.logger.warn("User has no role assigned", { userId: user.id });
throw new ForbiddenException("User role not configured");
}
const hasAccess = hasAnyPermission(user.role, requiredPermissions);
if (!hasAccess) {
this.logger.warn("Permission denied", {
userId: user.id,
role: user.role,
requiredPermissions,
});
throw new ForbiddenException("Insufficient permissions");
}
return true;
}
}

View File

@ -0,0 +1,142 @@
import type { Response, CookieOptions } from "express";
/**
* Auth tokens structure for cookie setting
*/
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: string;
refreshExpiresAt: string;
}
/**
* Session info returned to client (excludes actual tokens)
*/
export interface SessionInfo {
expiresAt: string;
refreshExpiresAt: string;
tokenType: "Bearer";
}
/**
* Custom setSecureCookie function signature
* This is added by our security middleware
*/
type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void;
/**
* Get setSecureCookie function from response if available
* Returns null if the custom helper is not present
*/
function getSecureCookie(res: Response): SetSecureCookieFn | null {
const maybeSecure = res as Response & { setSecureCookie?: unknown };
if (typeof maybeSecure.setSecureCookie === "function") {
return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn;
}
return null;
}
// Cookie paths - access token needs broader access, refresh token only for refresh endpoint
export const ACCESS_COOKIE_PATH = "/api";
export const REFRESH_COOKIE_PATH = "/api/auth/refresh";
export const TOKEN_TYPE = "Bearer" as const;
/**
* Calculate cookie max age from ISO timestamp
*/
export function calculateCookieMaxAge(isoTimestamp: string): number {
const expiresAt = Date.parse(isoTimestamp);
if (Number.isNaN(expiresAt)) {
return 0;
}
return Math.max(0, expiresAt - Date.now());
}
/**
* Set authentication cookies on the response.
*
* Uses res.setSecureCookie if available (custom helper that sets
* httpOnly, secure, sameSite), otherwise falls back to standard res.cookie.
*/
export function setAuthCookies(res: Response, tokens: AuthTokens): void {
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt);
const setSecureCookie = getSecureCookie(res);
if (setSecureCookie) {
// Use the custom setSecureCookie helper if available
setSecureCookie("access_token", tokens.accessToken, {
maxAge: accessMaxAge,
path: ACCESS_COOKIE_PATH,
});
setSecureCookie("refresh_token", tokens.refreshToken, {
maxAge: refreshMaxAge,
path: REFRESH_COOKIE_PATH,
});
} else {
// Fallback to standard cookie with secure options
const isProduction = process.env["NODE_ENV"] === "production";
res.cookie("access_token", tokens.accessToken, {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
path: ACCESS_COOKIE_PATH,
maxAge: accessMaxAge,
});
res.cookie("refresh_token", tokens.refreshToken, {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
path: REFRESH_COOKIE_PATH,
maxAge: refreshMaxAge,
});
}
}
/**
* Clear authentication cookies from the response.
* Also clears legacy cookies that may have been set on "/" path.
*/
export function clearAuthCookies(res: Response): void {
const setSecureCookie = getSecureCookie(res);
if (setSecureCookie) {
// Clear current cookie paths
setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
// DEPRECATED: Clear legacy cookies that were set on `/`
// TODO: Remove after 90 days (2025-04-XX) when legacy cookies have expired
setSecureCookie("access_token", "", { maxAge: 0, path: "/" });
setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
} else {
// Fallback to standard cookie clearing
const isProduction = process.env["NODE_ENV"] === "production";
const clearOptions = {
httpOnly: true,
secure: isProduction,
sameSite: "lax" as const,
maxAge: 0,
};
res.cookie("access_token", "", { ...clearOptions, path: ACCESS_COOKIE_PATH });
res.cookie("refresh_token", "", { ...clearOptions, path: REFRESH_COOKIE_PATH });
// DEPRECATED: Clear legacy cookies
res.cookie("access_token", "", { ...clearOptions, path: "/" });
res.cookie("refresh_token", "", { ...clearOptions, path: "/" });
}
}
/**
* Build session info from tokens (for client response, excludes actual tokens)
*/
export function buildSessionInfo(tokens: AuthTokens): SessionInfo {
return {
expiresAt: tokens.expiresAt,
refreshExpiresAt: tokens.refreshExpiresAt,
tokenType: TOKEN_TYPE,
};
}

View File

@ -25,10 +25,10 @@ import {
} from "@customer-portal/domain/subscriptions";
import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
import {
simTopupRequestSchema,
simChangePlanRequestSchema,
simTopUpRequestSchema,
simPlanChangeRequestSchema,
simCancelRequestSchema,
simFeaturesRequestSchema,
simFeaturesUpdateRequestSchema,
simTopUpHistoryRequestSchema,
simChangePlanFullRequestSchema,
simCancelFullRequestSchema,
@ -49,10 +49,10 @@ import {
// DTOs
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {}
class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {}
class SimTopUpRequestDto extends createZodDto(simTopUpRequestSchema) {}
class SimPlanChangeRequestDto extends createZodDto(simPlanChangeRequestSchema) {}
class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {}
class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {}
class SimFeaturesUpdateRequestDto extends createZodDto(simFeaturesUpdateRequestSchema) {}
class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {}
class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {}
@ -155,7 +155,7 @@ export class SimController {
async topUpSim(
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto,
@Body() body: SimTopupRequestDto
@Body() body: SimTopUpRequestDto
): Promise<SimActionResponse> {
await this.simManagementService.topUpSim(req.user.id, params.id, body);
return { message: "SIM top-up completed successfully" };
@ -166,7 +166,7 @@ export class SimController {
async changeSimPlan(
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto,
@Body() body: SimChangePlanRequestDto
@Body() body: SimPlanChangeRequestDto
): Promise<SimPlanChangeResult> {
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body);
return {
@ -202,7 +202,7 @@ export class SimController {
async updateSimFeatures(
@Request() req: RequestWithUser,
@Param() params: SubscriptionIdParamDto,
@Body() body: SimFeaturesRequestDto
@Body() body: SimFeaturesUpdateRequestDto
): Promise<SimActionResponse> {
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body);
return { message: "SIM features updated successfully" };

View File

@ -1,26 +0,0 @@
export interface SimTopUpRequest {
quotaMb: number;
amount?: number;
currency?: string;
}
export interface SimPlanChangeRequest {
newPlanCode: "5GB" | "10GB" | "25GB" | "50GB";
effectiveDate?: string;
}
export interface SimCancelRequest {
scheduledAt?: string; // YYYYMMDD - optional, immediate if omitted
}
export interface SimTopUpHistoryRequest {
fromDate: string; // YYYYMMDD
toDate: string; // YYYYMMDD
}
export interface SimFeaturesUpdateRequest {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: "4G" | "5G";
}

View File

@ -9,7 +9,12 @@ import { useCallback, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuthStore } from "../stores/auth.store";
import { getPostLoginRedirect } from "@/features/auth/utils/route-protection";
import type { SignupRequest, LoginRequest } from "@customer-portal/domain/auth";
import type { SignupRequest, LoginRequest, Permission } from "@customer-portal/domain/auth";
import {
PERMISSIONS,
hasPermission as checkPermission,
hasAnyPermission as checkAnyPermission,
} from "@customer-portal/domain/auth";
import type { LogoutReason } from "@/features/auth/utils/logout-reason";
/**
@ -279,22 +284,34 @@ export function useUser() {
*/
export function usePermissions() {
const { user } = useAuth();
const role = user?.role ?? "USER";
const hasRole = useCallback(
(role: string) => {
(targetRole: string) => {
if (!user?.role) return false;
return user.role === role;
return user.role === targetRole;
},
[user?.role]
);
const hasAnyRole = useCallback((roles: string[]) => roles.some(role => hasRole(role)), [hasRole]);
const hasAnyRole = useCallback(
(roles: string[]) => roles.some(targetRole => hasRole(targetRole)),
[hasRole]
);
const hasPermission = useCallback(() => false, []);
const hasAnyPermission = useCallback(() => false, []);
const hasPermission = useCallback(
(permission: Permission) => checkPermission(role, permission),
[role]
);
const hasAnyPermission = useCallback(
(permissions: Permission[]) => checkAnyPermission(role, permissions),
[role]
);
return {
role: user?.role,
permissions: PERMISSIONS,
hasRole,
hasAnyRole,
hasPermission,

View File

@ -1,4 +1,3 @@
import { normalizeOrderType } from "@customer-portal/domain/checkout";
import {
ORDER_TYPE,
buildOrderConfigurations,
@ -35,10 +34,12 @@ export class CheckoutParamsService {
return ORDER_TYPE.INTERNET;
}
// Try to normalize using domain logic
const normalized = normalizeOrderType(typeParam);
if (normalized) {
return normalized;
// Case-insensitive match against valid order types
const validTypes = Object.values(ORDER_TYPE) as string[];
const matchedType = validTypes.find(type => type.toLowerCase() === typeParam.toLowerCase());
if (matchedType) {
return matchedType as OrderTypeValue;
}
throw new Error(`Unsupported order type: ${typeParam}`);

View File

@ -104,31 +104,8 @@ export const useCheckoutStore = create<CheckoutStore>()(
}),
{
name: "checkout-store",
version: 3,
version: 1,
storage: createJSONStorage(() => localStorage),
migrate: (persistedState: unknown, version: number) => {
if (!persistedState || typeof persistedState !== "object") {
return initialState;
}
const state = persistedState as Partial<CheckoutState>;
// Migration from v1/v2: strip out removed fields
if (version < 3) {
return {
cartItem: state.cartItem ?? null,
cartParamsSignature: state.cartParamsSignature ?? null,
checkoutSessionId: state.checkoutSessionId ?? null,
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt ?? null,
cartUpdatedAt: state.cartUpdatedAt ?? null,
} as CheckoutState;
}
return {
...initialState,
...state,
} as CheckoutState;
},
partialize: state => ({
// Persist only essential data
cartItem: state.cartItem

View File

@ -91,7 +91,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
<div className="mt-auto">
<Button
as="a"
href={`/order?type=vpn&planSku=${encodeURIComponent(plan.sku)}`}
href={`/order?type=VPN&planSku=${encodeURIComponent(plan.sku)}`}
className="w-full"
rightIcon={<ArrowRight className="w-4 h-4" />}
>

View File

@ -0,0 +1,67 @@
import { NextResponse, type NextRequest } from "next/server";
/**
* Public paths that don't require authentication.
* These routes are accessible without auth cookies.
*/
const PUBLIC_PATHS = ["/auth/", "/", "/services", "/about", "/contact", "/vpn-configuration"];
/**
* Check if a path is public (doesn't require authentication)
*/
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(publicPath => {
if (publicPath.endsWith("/")) {
// Path prefix match (e.g., /auth/ matches /auth/login)
return pathname.startsWith(publicPath);
}
// Exact match
return pathname === publicPath;
});
}
/**
* Edge middleware for optimistic auth checks.
*
* This middleware runs at the edge before requests reach the origin.
* It performs a fast cookie-based check to redirect unauthenticated
* users to the login page, reducing unnecessary BFF calls.
*
* Note: This is an optimistic check. The BFF performs the authoritative
* token validation. This middleware only checks for cookie presence.
*/
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public routes
if (isPublicPath(pathname)) {
return NextResponse.next();
}
// Check for auth cookies (optimistic check - BFF validates the token)
const hasAccessToken = request.cookies.has("access_token");
const hasRefreshToken = request.cookies.has("refresh_token");
if (!hasAccessToken && !hasRefreshToken) {
// No auth cookies present - redirect to login
const loginUrl = new URL("/auth/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public assets (images, fonts, etc.)
*/
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$).*)",
],
};

View File

@ -105,3 +105,17 @@ export {
MIGRATION_STEPS,
type PasswordRequirementKey,
} from "./forms.js";
// ============================================================================
// RBAC Permissions
// ============================================================================
export {
PERMISSIONS,
ROLE_PERMISSIONS,
hasPermission,
hasAnyPermission,
hasAllPermissions,
getPermissionsForRole,
type Permission,
} from "./permissions.js";

View File

@ -0,0 +1,82 @@
/**
* RBAC Permissions
*
* Defines the permission constants and role-permission mappings
* for the customer portal authorization system.
*/
export const PERMISSIONS = {
// Account permissions
ACCOUNT_READ: "account:read",
ACCOUNT_UPDATE: "account:update",
// Billing permissions
BILLING_READ: "billing:read",
BILLING_PAY: "billing:pay",
// Orders permissions
ORDERS_READ: "orders:read",
ORDERS_CREATE: "orders:create",
// Services permissions
SERVICES_READ: "services:read",
SERVICES_MANAGE: "services:manage",
// Support permissions
SUPPORT_READ: "support:read",
SUPPORT_CREATE: "support:create",
// Admin permissions
ADMIN_USERS: "admin:users",
ADMIN_AUDIT: "admin:audit",
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
USER: [
PERMISSIONS.ACCOUNT_READ,
PERMISSIONS.ACCOUNT_UPDATE,
PERMISSIONS.BILLING_READ,
PERMISSIONS.BILLING_PAY,
PERMISSIONS.ORDERS_READ,
PERMISSIONS.ORDERS_CREATE,
PERMISSIONS.SERVICES_READ,
PERMISSIONS.SERVICES_MANAGE,
PERMISSIONS.SUPPORT_READ,
PERMISSIONS.SUPPORT_CREATE,
],
ADMIN: Object.values(PERMISSIONS) as Permission[],
};
/**
* Check if a role has a specific permission
*/
export function hasPermission(role: string, permission: Permission): boolean {
const rolePermissions = ROLE_PERMISSIONS[role];
if (!rolePermissions) {
return false;
}
return rolePermissions.includes(permission);
}
/**
* Check if a role has any of the specified permissions
*/
export function hasAnyPermission(role: string, permissions: Permission[]): boolean {
return permissions.some(permission => hasPermission(role, permission));
}
/**
* Check if a role has all of the specified permissions
*/
export function hasAllPermissions(role: string, permissions: Permission[]): boolean {
return permissions.every(permission => hasPermission(role, permission));
}
/**
* Get all permissions for a role
*/
export function getPermissionsForRole(role: string): Permission[] {
return ROLE_PERMISSIONS[role] ?? [];
}

View File

@ -19,25 +19,5 @@ export const CHECKOUT_ORDER_TYPE = {
export type CheckoutOrderTypeValue = (typeof CHECKOUT_ORDER_TYPE)[keyof typeof CHECKOUT_ORDER_TYPE];
/**
* Convert legacy uppercase order type to PascalCase
* Used for migrating old localStorage data
*/
export function normalizeOrderType(value: unknown): CheckoutOrderTypeValue | null {
if (typeof value !== "string") return null;
const upper = value.trim().toUpperCase();
switch (upper) {
case "INTERNET":
return "Internet";
case "SIM":
return "SIM";
case "VPN":
return "VPN";
default:
return null;
}
}
// Re-export types from schema
export type { OrderType, PriceBreakdownItem, CartItem } from "./schema.js";

View File

@ -8,7 +8,6 @@
export {
ORDER_TYPE,
CHECKOUT_ORDER_TYPE,
normalizeOrderType,
type OrderTypeValue,
type CheckoutOrderTypeValue,
} from "./contract.js";

View File

@ -28,7 +28,6 @@ export type {
Address, // Address structure (not "CustomerAddress")
AddressFormData, // Address form validation
ProfileEditFormData, // Profile edit form data
ProfileDisplayData, // Profile display data (alias)
ResidenceCardVerificationStatus,
ResidenceCardVerification,
UserProfile, // Alias for User
@ -46,7 +45,6 @@ export {
addressSchema,
addressFormSchema,
profileEditFormSchema,
profileDisplayDataSchema,
residenceCardVerificationStatusSchema,
residenceCardVerificationSchema,

View File

@ -95,14 +95,6 @@ export const profileEditFormSchema = z.object({
phonenumber: z.string().optional(),
});
/**
* Profile display data - includes email for display (read-only)
* Used for displaying profile information
*/
export const profileDisplayDataSchema = profileEditFormSchema.extend({
// no extra fields (kept for backwards compatibility)
});
// ============================================================================
// UserAuth Schema (Portal Database - Auth State Only)
// ============================================================================
@ -421,7 +413,6 @@ export type UserRole = "USER" | "ADMIN";
export type Address = z.infer<typeof addressSchema>;
export type AddressFormData = z.infer<typeof addressFormSchema>;
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
export type ResidenceCardVerificationStatus = z.infer<typeof residenceCardVerificationStatusSchema>;
export type ResidenceCardVerification = z.infer<typeof residenceCardVerificationSchema>;

View File

@ -564,16 +564,6 @@ export type SimOrderActivationRequest = z.infer<typeof simOrderActivationRequest
export type SimOrderActivationMnp = z.infer<typeof simOrderActivationMnpSchema>;
export type SimOrderActivationAddons = z.infer<typeof simOrderActivationAddonsSchema>;
// Legacy aliases for backward compatibility
export const simTopupRequestSchema = simTopUpRequestSchema;
export type SimTopupRequest = SimTopUpRequest;
export const simChangePlanRequestSchema = simPlanChangeRequestSchema;
export type SimChangePlanRequest = SimPlanChangeRequest;
export const simFeaturesRequestSchema = simFeaturesUpdateRequestSchema;
export type SimFeaturesRequest = SimFeaturesUpdateRequest;
// ============================================================================
// Inferred Types from Schemas (Schema-First Approach)
// ============================================================================