Files
tehran_tala/apps/api/src/modules/customer-auth/customer-jwt.strategy.ts
T
s.esmaeiliandClaude Opus 5 ddcc8c5649 feat(apps): a sales expert can work inside their customer's account (D29)
Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.

Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.

Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:

  - ownership is re-checked on EVERY request, with referredByAgentId in
    the same where clause as the id, so another expert's customer and a
    non-existent id both answer 403, and a customer moved to another
    expert loses access at once rather than at token expiry;
  - nothing durable is handed out — the reference's token sat in
    localStorage with no expiry and kept working after the expert left;
  - adminUserId survives, so an order placed this way is attributable;
    in the reference the request was literally the customer's;
  - 403 rather than 401, so the app clears the act-as state instead of
    logging a perfectly valid session out.

Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.

Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.

Added with no reference equivalent: a permanent «در حال مشاهده به‌عنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.

CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.

api: 308/308 tests, 28 suites; both apps vue-tsc clean.

اپ: کارشناس فروش می‌تواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 11:48:59 +03:30

200 lines
7.6 KiB
TypeScript

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<AuthenticatedCustomer> {
// نشست باطل‌شده (خروج سراسری از 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<string, unknown> | 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<AuthenticatedCustomer> {
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,
};
}
}