import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PrismaService } from '../../prisma/prisma.service'; import { envOr } from '../../common/env'; import { AdminSessionService } from '../sessions/admin-session.service'; import { expandWithParents } from './acl.constants'; export interface JwtPayload { sub: string; mobile: string; /** * Present only on tokens issued while session tracking is on. It identifies the * AdminSession row, which is what makes revoking a token before it expires * possible. A token without it predates tracking and stays valid until it * expires, so switching the feature on does not sign everyone out. */ jti?: string; /** * Set on APP tokens only ('customer' or 'sales-expert'). An admin-panel * token has no `typ`. Checked in validate() below — see the note there. */ typ?: string; } /** * App token types, which must NEVER authenticate an admin-panel route. * * This matters because a sales expert's app token carries their AdminUser id * in `sub` (D21). The two token families are signed with different secrets, so * normally the signature alone stops it — but `JWT_CUSTOMER_SECRET` falls back * to `JWT_SECRET` when unset, and in that configuration an expert's app token * WOULD verify here and be handed full admin-panel permissions. This check * closes that regardless of how the secrets are configured. */ const APP_TOKEN_TYPES = new Set(['customer', 'sales-expert']); /** * The authenticated principal attached to `req.user`. * * `permissions` is already expanded with ancestors, so guards can test a rid with * a plain `includes()` — the same shape the legacy frontend held in its Vuex store * after checkParent(). */ export interface AuthenticatedAdmin { id: string; fullName: string; mobile: string; role: string; userTypeId: number | null; branchId: string | null; permissions: string[]; } @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { constructor( private readonly prisma: PrismaService, private readonly sessions: AdminSessionService, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: envOr('JWT_SECRET', 'dev-only-insecure-secret'), }); } async validate(payload: JwtPayload): Promise { // An app token is never an admin-panel token, whatever it is signed with. if (payload?.typ && APP_TOKEN_TYPES.has(payload.typ)) { throw new UnauthorizedException(); } // Revoked centrally (IAM back-channel logout) or locally? While tracking is // off this returns true without touching the database, so the request costs // exactly what it did before. if (!(await this.sessions.isActive(payload.jti))) { throw new UnauthorizedException(); } const user = await this.prisma.adminUser.findUnique({ where: { id: payload.sub }, include: { userType: { include: { permissions: true } } }, }); if (!user || !user.isActive) { throw new UnauthorizedException(); } // Permissions are re-read on every request rather than baked into the token, // so a permission change takes effect immediately instead of at next login. const rids = user.userType?.permissions.map((p) => p.rid) ?? []; return { id: user.id, fullName: user.fullName, mobile: user.mobile, role: user.role, userTypeId: user.userTypeId, branchId: user.branchId, permissions: expandWithParents(rids), }; } }