From d3b94b1ed389cb3af604aadc445848480bd97b9a Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 19 Jan 2026 10:40:50 +0900 Subject: [PATCH] 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. --- apps/bff/src/core/config/env.validation.ts | 5 +- .../security/middleware/csrf.middleware.ts | 7 - .../core/security/services/csrf.service.ts | 3 +- apps/bff/src/modules/auth/auth.module.ts | 7 +- .../src/modules/auth/constants/auth-errors.ts | 115 ++++++++++++++ .../auth/decorators/permissions.decorator.ts | 25 +++ .../infra/token/token-blacklist.service.ts | 9 +- .../auth/presentation/http/auth.controller.ts | 87 +++-------- .../http/get-started.controller.ts | 62 +------- .../http/guards/global-auth.guard.ts | 48 ++---- .../http/guards/permissions.guard.ts | 68 +++++++++ .../http/utils/auth-cookie.util.ts | 142 ++++++++++++++++++ .../sim-management/sim.controller.ts | 18 +-- .../types/sim-requests.types.ts | 26 ---- .../src/features/auth/hooks/use-auth.ts | 29 +++- .../checkout/api/checkout-params.api.ts | 11 +- .../checkout/stores/checkout.store.ts | 25 +-- .../services/components/vpn/VpnPlanCard.tsx | 2 +- apps/portal/src/middleware.ts | 67 +++++++++ packages/domain/auth/index.ts | 14 ++ packages/domain/auth/permissions.ts | 82 ++++++++++ packages/domain/checkout/contract.ts | 20 --- packages/domain/checkout/index.ts | 1 - packages/domain/customer/index.ts | 2 - packages/domain/customer/schema.ts | 9 -- packages/domain/sim/schema.ts | 10 -- 26 files changed, 611 insertions(+), 283 deletions(-) create mode 100644 apps/bff/src/modules/auth/constants/auth-errors.ts create mode 100644 apps/bff/src/modules/auth/decorators/permissions.decorator.ts create mode 100644 apps/bff/src/modules/auth/presentation/http/guards/permissions.guard.ts create mode 100644 apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts create mode 100644 apps/portal/src/middleware.ts create mode 100644 packages/domain/auth/permissions.ts diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index a75f4013..8f6fd014 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -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() diff --git a/apps/bff/src/core/security/middleware/csrf.middleware.ts b/apps/bff/src/core/security/middleware/csrf.middleware.ts index 2fef75e1..68880173 100644 --- a/apps/bff/src/core/security/middleware/csrf.middleware.ts +++ b/apps/bff/src/core/security/middleware/csrf.middleware.ts @@ -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(); } diff --git a/apps/bff/src/core/security/services/csrf.service.ts b/apps/bff/src/core/security/services/csrf.service.ts index 5d8e17fe..bdc731e4 100644 --- a/apps/bff/src/core/security/services/csrf.service.ts +++ b/apps/bff/src/core/security/services/csrf.service.ts @@ -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"); diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 2bcc74ce..07526220 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/bff/src/modules/auth/constants/auth-errors.ts b/apps/bff/src/modules/auth/constants/auth-errors.ts new file mode 100644 index 00000000..8aee8e2a --- /dev/null +++ b/apps/bff/src/modules/auth/constants/auth-errors.ts @@ -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]; diff --git a/apps/bff/src/modules/auth/decorators/permissions.decorator.ts b/apps/bff/src/modules/auth/decorators/permissions.decorator.ts new file mode 100644 index 00000000..b300143d --- /dev/null +++ b/apps/bff/src/modules/auth/decorators/permissions.decorator.ts @@ -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); diff --git a/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts b/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts index 97488594..e514371d 100644 --- a/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts @@ -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("NODE_ENV", "development"); + const explicitSetting = this.configService.get("AUTH_BLACKLIST_FAIL_CLOSED"); + if (explicitSetting === undefined) { + this.failClosed = nodeEnv === "production"; + } else { + this.failClosed = explicitSetting === "true"; + } } async blacklistToken(token: string): Promise { diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index d9a22e5f..1fd9497b 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -25,6 +25,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 & { cookies?: Record; }; +// 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) }; } /** diff --git a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts index 8474477a..cfc9333c 100644 --- a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts @@ -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 * diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index a3bd196d..21afac6c 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -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)}`); } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/permissions.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/permissions.guard.ts new file mode 100644 index 00000000..c79d7a3d --- /dev/null +++ b/apps/bff/src/modules/auth/presentation/http/guards/permissions.guard.ts @@ -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( + REQUIRED_PERMISSIONS_KEY, + [context.getHandler(), context.getClass()] + ); + + // No permissions required - allow access + if (!requiredPermissions || requiredPermissions.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts b/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts new file mode 100644 index 00000000..9becec4d --- /dev/null +++ b/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts @@ -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, + }; +} diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts index 89d00ad1..8954ff9b 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts @@ -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 { 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 { 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 { await this.simManagementService.updateSimFeatures(req.user.id, params.id, body); return { message: "SIM features updated successfully" }; diff --git a/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts b/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts deleted file mode 100644 index c018b4e8..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts +++ /dev/null @@ -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"; -} diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index a9ecaeb3..0a8f2978 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -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, diff --git a/apps/portal/src/features/checkout/api/checkout-params.api.ts b/apps/portal/src/features/checkout/api/checkout-params.api.ts index 317ed4b4..ec0d91c4 100644 --- a/apps/portal/src/features/checkout/api/checkout-params.api.ts +++ b/apps/portal/src/features/checkout/api/checkout-params.api.ts @@ -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}`); diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts index a21cacd7..56169e03 100644 --- a/apps/portal/src/features/checkout/stores/checkout.store.ts +++ b/apps/portal/src/features/checkout/stores/checkout.store.ts @@ -104,31 +104,8 @@ export const useCheckoutStore = create()( }), { 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; - - // 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 diff --git a/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx index d1f397fc..109e0ba1 100644 --- a/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx +++ b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx @@ -91,7 +91,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {