import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { AdminRole } from '@prisma/client'; import type { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PrismaService } from '../../prisma/prisma.service'; import { customerJwtSecret } from './customer-auth.service'; import { ACT_AS_CUSTOMER_HEADER, CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type AppJwtPayload, type AuthenticatedCustomer, } from './app-principal'; import { AppSessionService } from '../sessions/app-session.service'; export { ACT_AS_CUSTOMER_HEADER, CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, isActingAsCustomer, type AuthenticatedCustomer, } from './app-principal'; /** Kept for the existing imports; the payload now covers both principals. */ export type CustomerJwtPayload = AppJwtPayload; /** * The app principal, kept entirely separate from JwtStrategy (which reads * AdminUser for the admin PANEL). The reference had the same split: `auth:api` * for admins and `auth('api-customer')` for the app. * * This strategy accepts TWO token types and resolves both to one shape: * * typ 'customer' -> the Customer row named by `sub` * typ 'sales-expert' -> the AdminUser named by `sub`, plus their trading * Customer row, which becomes `id` * * Because `id` is always a Customer id, every route already written for a * customer works unchanged for an expert — which is exactly the reference's * behaviour, where an agent was a customer with extra screens. * * An expert's app token is NOT an admin-panel token: its `typ` is * 'sales-expert', while JwtStrategy requires the admin type, so it is refused * on every admin route. */ @Injectable() export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jwt') { constructor( private readonly prisma: PrismaService, private readonly sessions: AppSessionService, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: customerJwtSecret(), // Needed only to read the act-as header; the token itself is still the // only thing that establishes WHO is calling. passReqToCallback: true, }); } async validate(req: Request, payload: AppJwtPayload): Promise { // نشست باطل‌شده (خروج سراسری از IdP یا ابطال محلی). در حالت خاموشِ ردیابی، // بدون هیچ کوئری true برمی‌گردد ⇒ هزینه و رفتار دقیقاً مثل قبل. if (!(await this.sessions.isActive(payload?.jti))) { throw new UnauthorizedException(); } if (payload?.typ === EXPERT_TOKEN_TYPE) { return this.validateExpert(payload, this.actAsHeader(req)); } // A token that is not explicitly a customer token is refused, whatever // else it carries. if (payload?.typ !== CUSTOMER_TOKEN_TYPE) { throw new UnauthorizedException(); } // Re-read on every request, so deactivating or archiving a customer takes // effect immediately instead of at token expiry. const customer = await this.prisma.customer.findUnique({ where: { id: payload.sub }, select: { id: true, mobile: true, isActive: true, isArchived: true }, }); if (!customer || !customer.isActive || customer.isArchived) { throw new UnauthorizedException(); } return { id: customer.id, mobile: customer.mobile, type: CUSTOMER_TOKEN_TYPE }; } /** * The act-as header, or null. Deliberately read ONLY for an expert token: on * a customer's token the header has no effect at all, rather than being an * error — a customer cannot grant themselves anything by sending it. */ private actAsHeader(req: Request): string | null { const raw = (req?.headers as Record | undefined)?.[ACT_AS_CUSTOMER_HEADER]; const value = Array.isArray(raw) ? raw[0] : raw; return typeof value === 'string' && value.trim() ? value.trim() : null; } /** * A sales expert. `sub` is their AdminUser id, so the role and active flag * are re-checked on every request: demoting an expert or deactivating them * locks them out immediately rather than at token expiry. * * The trading Customer row is resolved here rather than stored in the token, * so a token issued before that row existed keeps working. * * ACTING AS A CUSTOMER («ورود به حساب مشتری», owner request 2026-10-12) * * With `actAs` set, the returned principal's `id` is that customer, so every * customer route — orders, me, ledger, report, favourites, documents — serves * the customer's own data with no change to any route. That is the whole * feature, and it is why this is the only place the behaviour lives. * * How this differs from the reference, which did the same thing by handing the * expert the customer's own `api_token` (ApiMobileController.php:197-201): * * - no token is minted, so nothing durable leaks into a device's storage * and ending the app session ends the impersonation; * - ownership is re-checked on EVERY request, so moving a customer to * another expert cuts access off at once instead of at token expiry; * - `adminUserId` survives, so the real actor is auditable — in the * reference the request was indistinguishable from the customer's own. */ private async validateExpert( payload: AppJwtPayload, actAs: string | null, ): Promise { const admin = await this.prisma.adminUser.findUnique({ where: { id: payload.sub }, select: { id: true, mobile: true, role: true, isActive: true, salesExpertProfile: { select: { tradingCustomerId: true, tradingCustomer: { select: { id: true, isActive: true, isArchived: true } }, }, }, }, }); if (!admin || !admin.isActive || admin.role !== AdminRole.SALES_EXPERT) { throw new UnauthorizedException(); } if (actAs) { // Scoped by referredByAgentId in the same query as the id, so another // expert's customer and a non-existent id are the same answer: no row, // 403. The lookup cannot be talked out of its scope. const customer = await this.prisma.customer.findFirst({ where: { id: actAs, referredByAgentId: admin.id, isActive: true, isArchived: false, }, select: { id: true, mobile: true }, }); if (!customer) { // Not 401: the expert's own session is perfectly valid, it is this // particular impersonation that is refused — so the app can clear the // act-as state instead of logging them out. throw new ForbiddenException('دسترسی به این مشتری مجاز نیست'); } return { id: customer.id, mobile: customer.mobile, type: EXPERT_TOKEN_TYPE, adminUserId: admin.id, actingAsCustomerId: customer.id, }; } const trading = admin.salesExpertProfile?.tradingCustomer; if (!trading || !trading.isActive || trading.isArchived) { // The account is created at sign-in; if it is missing or has been // deactivated, the session cannot act. Refused rather than silently // degraded, so an expert never places an order against nothing. throw new UnauthorizedException(); } return { id: trading.id, mobile: admin.mobile, type: EXPERT_TOKEN_TYPE, adminUserId: admin.id, }; } }