Feat add iam auth

This commit is contained in:
mahmood
2026-09-25 19:04:48 +03:30
parent 2de90c6b35
commit 36f239e02c
28 changed files with 2738 additions and 19 deletions
@@ -3,6 +3,7 @@ import { AdminRole, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { AdminSessionService } from '../sessions/admin-session.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { faToEn } from '../customer-auth/digits';
@@ -24,6 +25,7 @@ export class AdminUsersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly sessions: AdminSessionService,
) {}
private static readonly ALLOWED = [
@@ -332,7 +334,7 @@ export class AdminUsersService {
};
}
return this.prisma.adminUser.update({
const updated = await this.prisma.adminUser.update({
where: { id },
data: patch,
include: {
@@ -342,6 +344,18 @@ export class AdminUsersService {
salesExpertProfile: { include: { city: true } },
},
});
// Disabling an account or changing its password used to leave every already
// issued token working for up to 12h. With session tracking on, those tokens
// end here; with it off, nothing happens and the behaviour is as before.
if (patch.isActive === false || patch.passwordHash !== undefined) {
await this.sessions.revokeAllForAdmin(
id,
patch.isActive === false ? 'account_disabled' : 'password_changed',
);
}
return updated;
}
/**
+6 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import { Body, Controller, Get, Headers, HttpCode, Ip, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { CurrentAdmin, Public } from './guards';
@@ -19,8 +19,11 @@ export class AuthController {
@Public()
@Post('login')
@HttpCode(200)
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
login(@Body() dto: LoginDto, @Ip() ip: string, @Headers('user-agent') userAgent?: string) {
// The IP is forwarded to the identity service (when delegation is on) so its
// per-IP rate limit applies to the real admin, not to this API server; both
// are also recorded on the session row when session tracking is on.
return this.authService.login(dto, ip, userAgent);
}
/** Legacy `users/show` — the router guard's permission refresh. */
@@ -0,0 +1,271 @@
import { UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
/**
* `@nestjs/jwt` v12 ships as ESM and this repo's jest does not transform
* node_modules, so importing AuthService would fail to load. The real class is
* never exercised here (the test injects its own stub), so replacing the module
* keeps the suite runnable without touching the shared jest config.
*/
jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} }));
import { AuthService } from './auth.service';
import type { AdminSessionService } from '../sessions/admin-session.service';
import {
IamInvalidCredentialsError,
IamMfaRequiredError,
IamService,
IamUnavailableError,
} from '../iam/iam.service';
/**
* Admin login with the identity service in the loop.
*
* The contract being locked down: with the feature off, login behaves exactly
* as before (one bcrypt compare, no network). With it on, an IAM problem must
* never block an admin who still has a valid local password.
*/
describe('AuthService login', () => {
const PASSWORD = 'Secret123!';
let passwordHash: string;
const adminRow = () => ({
id: 'admin-1',
mobile: '09120000000',
fullName: 'مدیر سامانه',
role: 'admin',
userTypeId: 'ut-1',
branchId: 'br-1',
isActive: true,
passwordHash,
userType: { permissions: [{ rid: 10 }, { rid: 20 }] },
});
/** Session tracking off — the shape of a deployment that does not use it. */
const untrackedSessions = (): Partial<AdminSessionService> => ({
newTokenId: jest.fn().mockReturnValue(null),
record: jest.fn(),
});
const build = (
iam: Partial<IamService>,
user: unknown = adminRow(),
sessions: Partial<AdminSessionService> = untrackedSessions(),
) => {
const prisma = {
adminUser: { findUnique: jest.fn().mockResolvedValue(user) },
};
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
return {
service: new AuthService(
prisma as never,
jwt as never,
iam as IamService,
sessions as AdminSessionService,
),
prisma,
jwt,
sessions,
};
};
const disabledIam = (): Partial<IamService> => ({
enabled: false,
allowsLocalFallback: true,
verifyCredentials: jest.fn(),
});
beforeAll(async () => {
passwordHash = await bcrypt.hash(PASSWORD, 8);
});
/* ─────────────── feature off: unchanged behaviour ─────────────── */
it('logs in with the local password and never calls the identity service', async () => {
const iam = disabledIam();
const { service, jwt } = build(iam);
const result = await service.login({ phone: '09120000000', password: PASSWORD });
expect(result.api_token).toBe('local.jwt.token');
expect(result.roles).toEqual([{ rid: 10 }, { rid: 20 }]);
expect(jwt.signAsync).toHaveBeenCalledTimes(1);
expect(iam.verifyCredentials).not.toHaveBeenCalled();
});
it('still rejects a wrong local password', async () => {
const { service } = build(disabledIam());
await expect(
service.login({ phone: '09120000000', password: 'wrong' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('still rejects an inactive admin', async () => {
const { service } = build(disabledIam(), { ...adminRow(), isActive: false });
await expect(
service.login({ phone: '09120000000', password: PASSWORD }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('still rejects an unknown phone without leaking that it is unknown', async () => {
const { service } = build(disabledIam(), null);
await expect(
service.login({ phone: '09129999999', password: PASSWORD }),
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
});
/* ─────────────── feature on ─────────────── */
it('accepts a password the identity service approves', async () => {
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
// Deliberately not the local password: the IAM is the authority here
const result = await service.login({ phone: '09120000000', password: 'iam-side-password' });
expect(result.api_token).toBe('local.jwt.token');
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'iam-side-password', undefined);
});
it('forwards the end-user IP to the identity service', async () => {
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5');
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'x', '203.0.113.5');
});
/* ─────────────── the safety net ─────────────── */
it('falls back to the local password when the account is unknown there', async () => {
const verifyCredentials = jest
.fn()
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
const result = await service.login({ phone: '09120000000', password: PASSWORD });
expect(result.api_token).toBe('local.jwt.token');
});
it('falls back to the local password when the identity service is down', async () => {
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
// The single most important guarantee: an IAM outage cannot lock admins out
const result = await service.login({ phone: '09120000000', password: PASSWORD });
expect(result.api_token).toBe('local.jwt.token');
});
it('rejects a wrong password even when falling back', async () => {
const verifyCredentials = jest
.fn()
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
await expect(
service.login({ phone: '09120000000', password: 'wrong' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
/* ─────────────── exclusive mode ─────────────── */
it('refuses the local password in exclusive mode', async () => {
const verifyCredentials = jest
.fn()
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
await expect(
service.login({ phone: '09120000000', password: PASSWORD }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('fails loudly in exclusive mode when the identity service is down', async () => {
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
await expect(
service.login({ phone: '09120000000', password: PASSWORD }),
).rejects.toThrow('سرویس هویت در دسترس نیست');
});
/* ─────────────── revocable sessions ─────────────── */
it('signs the same token as before while session tracking is off', async () => {
const { service, jwt, sessions } = build(disabledIam());
await service.login({ phone: '09120000000', password: PASSWORD });
// No jti, no row: the previous behaviour, byte for byte
expect(jwt.signAsync).toHaveBeenCalledWith({ sub: 'admin-1', mobile: '09120000000' });
expect(sessions.record).not.toHaveBeenCalled();
});
it('records the issued token with the IAM session id when tracking is on', async () => {
const tracked: Partial<AdminSessionService> = {
newTokenId: jest.fn().mockReturnValue('jti-1'),
record: jest.fn(),
};
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
const { service, jwt } = build(
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
adminRow(),
tracked,
);
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5', 'Firefox');
expect(jwt.signAsync).toHaveBeenCalledWith({
sub: 'admin-1',
mobile: '09120000000',
jti: 'jti-1',
});
// Without the IAM session id, a back-channel logout could not find this session
expect(tracked.record).toHaveBeenCalledWith({
jti: 'jti-1',
adminUserId: 'admin-1',
iamSessionId: 'sid-1',
iamSub: 'sub-1',
ipAddress: '203.0.113.5',
userAgent: 'Firefox',
});
});
it('still records a session when the login fell back to the local password', async () => {
const tracked: Partial<AdminSessionService> = {
newTokenId: jest.fn().mockReturnValue('jti-2'),
record: jest.fn(),
};
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
const { service } = build(
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
adminRow(),
tracked,
);
await service.login({ phone: '09120000000', password: PASSWORD });
// No IAM identity to attach, but the session must still be revocable locally
expect(tracked.record).toHaveBeenCalledWith(
expect.objectContaining({ jti: 'jti-2', iamSessionId: null, iamSub: null }),
);
});
/* ─────────────── two-factor ─────────────── */
it('explains that a two-factor account cannot log in through this path', async () => {
const verifyCredentials = jest.fn().mockRejectedValue(new IamMfaRequiredError('mfa'));
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
await expect(
service.login({ phone: '09120000000', password: PASSWORD }),
).rejects.toThrow('دو مرحله‌ای');
});
});
+86 -5
View File
@@ -1,14 +1,25 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import {
IamInvalidCredentialsError,
IamMfaRequiredError,
IamService,
type IamIdentity,
} from '../iam/iam.service';
import { AdminSessionService } from '../sessions/admin-session.service';
import type { LoginDto } from './dto/login.dto';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly iam: IamService,
private readonly sessions: AdminSessionService,
) {}
/**
@@ -25,7 +36,7 @@ export class AuthService {
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
* a wrong password, which let anyone enumerate valid admin phone numbers.
*/
async login(dto: LoginDto) {
async login(dto: LoginDto, clientIp?: string, userAgent?: string) {
const user = await this.prisma.adminUser.findUnique({
where: { mobile: dto.phone },
include: { userType: { include: { permissions: true } } },
@@ -35,12 +46,32 @@ export class AuthService {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
const passwordOk = await bcrypt.compare(dto.password, user.passwordHash);
if (!passwordOk) {
const verified = await this.verifyPassword(dto, user.passwordHash, clientIp);
if (!verified.ok) {
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
}
const apiToken = await this.jwt.signAsync({ sub: user.id, mobile: user.mobile });
// While session tracking is off this is null and the token is signed exactly
// as before — same payload, same claims.
const jti = this.sessions.newTokenId();
const apiToken = await this.jwt.signAsync({
sub: user.id,
mobile: user.mobile,
...(jti ? { jti } : {}),
});
if (jti) {
// Recorded so a central sign-out (or an admin revoking the session) can end
// this token before it expires — see AdminSessionService.
await this.sessions.record({
jti,
adminUserId: user.id,
iamSessionId: verified.identity?.sessionId ?? null,
iamSub: verified.identity?.sub ?? null,
ipAddress: clientIp ?? null,
userAgent: userAgent ?? null,
});
}
return {
id: user.id,
@@ -55,6 +86,56 @@ export class AuthService {
};
}
/**
* Password check, optionally delegated to the central identity service.
*
* With `IAM_ENABLED=false` (the default) this is exactly the previous
* behaviour: a single bcrypt compare. With it on, the identity service is
* asked first and the local hash is only consulted when that check could not
* give a definite answer — so an IAM outage never blocks admin login.
*/
private async verifyPassword(
dto: LoginDto,
passwordHash: string,
clientIp?: string,
): Promise<{ ok: boolean; identity?: IamIdentity }> {
if (!this.iam.enabled) {
return { ok: await bcrypt.compare(dto.password, passwordHash) };
}
try {
const identity = await this.iam.verifyCredentials(dto.phone, dto.password, clientIp);
// The IAM session id travels with the recorded session so a back-channel
// logout can find and end exactly this session.
return { ok: true, identity };
} catch (error) {
if (error instanceof IamMfaRequiredError) {
// Nowhere to collect the code on this UI-less path; say so plainly
// instead of silently falling back to a password the IAM may have retired.
throw new UnauthorizedException(
'برای این حساب احراز هویت دو مرحله‌ای فعال است و از این مسیر قابل تکمیل نیست',
);
}
if (error instanceof IamInvalidCredentialsError && !this.iam.allowsLocalFallback) {
return { ok: false };
}
if (!(error instanceof IamInvalidCredentialsError)) {
this.logger.warn(
`falling back to the local password check: ${(error as Error).message}`,
);
if (!this.iam.allowsLocalFallback) {
throw new UnauthorizedException('ورود در حال حاضر ممکن نیست؛ سرویس هویت در دسترس نیست');
}
}
// fallback mode: the account may not have been migrated to the IAM yet
return { ok: await bcrypt.compare(dto.password, passwordHash) };
}
}
/**
* Port of legacy UserController::showData, which the router guard calls on every
* cold load to refresh the permission list:
+19 -1
View File
@@ -3,11 +3,19 @@ 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.
@@ -46,7 +54,10 @@ export interface AuthenticatedAdmin {
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private readonly prisma: PrismaService) {
constructor(
private readonly prisma: PrismaService,
private readonly sessions: AdminSessionService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
@@ -60,6 +71,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
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 } } },
@@ -33,6 +33,13 @@ export interface AppJwtPayload {
sub: string;
mobile: string;
typ: AppTokenType;
/**
* Present only on tokens issued while session tracking is on. It identifies
* the AppSession row, which is what makes ending a session before its 30-day
* expiry possible (central sign-out, or revoking locally). A token without it
* predates tracking and stays valid until it expires.
*/
jti?: string;
}
/**
@@ -0,0 +1,166 @@
import { UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
/**
* `@nestjs/jwt` v12 is ESM and this repo's jest does not transform node_modules,
* so the real class cannot be imported here. The test injects its own stub.
*/
jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} }));
import { CustomerAuthService } from './customer-auth.service';
import { IamAccessDeniedError, IamUnavailableError, IamUserUnknownError } from '../iam/iam.service';
/**
* The app's OTP login and the central identity service.
*
* This login has **no password**, so it never reached the identity service at
* all: no central session, nothing in the security log, and a central sign-out
* could not end it. These tests lock down the fix and, more importantly, the
* boundaries: an identity-service problem must never keep a customer out of the
* app, while a refusal (locked / deactivated / two-factor) must be honoured.
*/
describe('CustomerAuthService — OTP login with the identity service', () => {
const MOBILE = '09120000000';
const CODE = '1234';
const CUSTOMER = { id: 'cust-1', mobile: MOBILE, isActive: true, isArchived: false };
let prisma: any;
let jwt: { signAsync: jest.Mock };
let sms: { send: jest.Mock };
const challenge = async () => ({
id: 'otp-1',
mobile: MOBILE,
codeHash: await bcrypt.hash(CODE, 8),
attempts: 0,
consumedAt: null,
expiresAt: new Date(Date.now() + 60_000),
customer: CUSTOMER,
});
const build = (iam: Record<string, unknown>, sessions?: Record<string, unknown>) => {
const session = sessions ?? { newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() };
const service = new CustomerAuthService(
prisma as never,
jwt as never,
sms as never,
iam as never,
session as never,
);
jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined);
return { service, sessions: session };
};
beforeEach(async () => {
delete process.env.OTP_MASTER_CODE;
process.env.JWT_CUSTOMER_SECRET = 'test-secret';
prisma = {
customer: { findFirst: jest.fn().mockResolvedValue(CUSTOMER) },
customerOtp: {
findFirst: jest.fn().mockResolvedValue(await challenge()),
update: jest.fn().mockResolvedValue({}),
create: jest.fn().mockResolvedValue({}),
count: jest.fn().mockResolvedValue(0),
},
adminUser: { findFirst: jest.fn().mockResolvedValue(null) },
appSession: { create: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
};
jwt = { signAsync: jest.fn().mockResolvedValue('signed.jwt.token') };
sms = { send: jest.fn().mockResolvedValue({ ok: true }) };
});
/* ─────────────── feature off: unchanged behaviour ─────────────── */
it('never contacts the identity service while the feature is off', async () => {
const attestOtpLogin = jest.fn();
const { service } = build({ enabled: false, attestOtpLogin });
const result = await service.verifyOtp(MOBILE, CODE, '203.0.113.5');
expect(result.token).toBe('signed.jwt.token');
expect(attestOtpLogin).not.toHaveBeenCalled();
// بدون ردیابی نشست، توکن دقیقاً همان ادعاهای قبلی را دارد
expect(jwt.signAsync.mock.calls[0][0]).toEqual({ sub: 'cust-1', mobile: MOBILE, typ: 'customer' });
});
/* ─────────────── feature on ─────────────── */
it('reports the verified code with the end-user IP and a masked number', async () => {
const attestOtpLogin = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
const { service } = build({ enabled: true, attestOtpLogin });
await service.verifyOtp(MOBILE, CODE, '203.0.113.5');
// بدون IP کاربر نهایی، محدودیت نرخ IdP روی IP همین سرور می‌افتاد
expect(attestOtpLogin).toHaveBeenCalledWith(MOBILE, '203.0.113.5', '0912***0000');
});
it('records the session with the identity service ids so a central sign-out can end it', async () => {
const attestOtpLogin = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
const tracked = { newTokenId: jest.fn().mockReturnValue('jti-1'), record: jest.fn() };
const { service } = build({ enabled: true, attestOtpLogin }, tracked);
await service.verifyOtp(MOBILE, CODE, '203.0.113.5');
expect(jwt.signAsync.mock.calls[0][0]).toMatchObject({ jti: 'jti-1' });
expect(tracked.record).toHaveBeenCalledWith(
expect.objectContaining({
jti: 'jti-1',
subjectId: 'cust-1',
subjectType: 'customer',
iamSessionId: 'sid-1',
iamSub: 'sub-1',
}),
);
});
/* ─────────────── the safety net ─────────────── */
it('lets the customer in when the identity service does not know them yet', async () => {
const attestOtpLogin = jest.fn().mockRejectedValue(new IamUserUnknownError(MOBILE));
const { service } = build({ enabled: true, attestOtpLogin });
const result = await service.verifyOtp(MOBILE, CODE, undefined);
// مناسب مهاجرت تدریجی: هنوز همه کاربران آنجا نیستند
expect(result.token).toBe('signed.jwt.token');
});
it('lets the customer in when the identity service is down', async () => {
const attestOtpLogin = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
const { service } = build({ enabled: true, attestOtpLogin });
// مهم‌ترین تضمین: خرابی IdP نباید کاربر را از اپ بیرون نگه دارد
await expect(service.verifyOtp(MOBILE, CODE, undefined)).resolves.toMatchObject({
token: 'signed.jwt.token',
});
});
it('honours a refusal from the identity service (locked / deactivated / two-factor)', async () => {
const attestOtpLogin = jest.fn().mockRejectedValue(new IamAccessDeniedError('refused', 'account_locked'));
const { service } = build({ enabled: true, attestOtpLogin });
// اینجا «نمی‌دانم» نیست، «نه» است — دور زدنش قفل مرکزی را بی‌اثر می‌کرد
await expect(service.verifyOtp(MOBILE, CODE, undefined)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('still refuses a wrong code before any of this runs', async () => {
const attestOtpLogin = jest.fn();
const { service } = build({ enabled: true, attestOtpLogin });
await expect(service.verifyOtp(MOBILE, '9999', undefined)).rejects.toBeInstanceOf(UnauthorizedException);
expect(attestOtpLogin).not.toHaveBeenCalled();
});
it('does not report the delivery master code as a verified factor', async () => {
process.env.OTP_MASTER_CODE = '4321';
const attestOtpLogin = jest.fn();
const { service } = build({ enabled: true, attestOtpLogin });
await service.verifyOtp(MOBILE, '4321', undefined);
// کد اضطراری، عامل واقعی کاربر نیست؛ نشست مرکزی روی آن ساخته نمی‌شود
expect(attestOtpLogin).not.toHaveBeenCalled();
});
});
@@ -3,6 +3,13 @@ import { JwtService } from '@nestjs/jwt';
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import {
IamAccessDeniedError,
IamService,
IamUserUnknownError,
type IamIdentity,
} from '../iam/iam.service';
import { AppSessionService } from '../sessions/app-session.service';
import { envOr, envOrNull } from '../../common/env';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits';
@@ -47,6 +54,17 @@ export function masterOtpEnabled(): boolean {
return masterOtpCode() !== null;
}
/** اطلاعات جانبی صدور توکن: هویت تأییدشده در IdP و IP کاربر */
interface IssueContext {
identity?: IamIdentity | null;
ip?: string;
}
/** شماره ماسک‌شده برای لاگ امنیتی IdP — بدون افشای کامل شماره */
function maskMobile(mobile: string): string | null {
return mobile.length < 7 ? null : `${mobile.slice(0, 4)}***${mobile.slice(-4)}`;
}
@Injectable()
export class CustomerAuthService {
private readonly logger = new Logger(CustomerAuthService.name);
@@ -55,8 +73,41 @@ export class CustomerAuthService {
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly sms: SmsService,
private readonly iam: IamService,
private readonly sessions: AppSessionService,
) {}
/**
* Tell the central identity service about a code this app just verified.
*
* The app's login has no password, so this is the only way an OTP login can
* reach the identity service at all. Deliberate asymmetry in how failures are
* treated, mirroring the admin path:
*
* - unknown user / service down / rate limited → local login continues
* (an IAM problem must never lock customers out of the app);
* - locked, deactivated or two-factor there → the login is refused,
* because that is a decision, not a gap.
*/
private async attestToIam(mobile: string, ip: string | undefined): Promise<IamIdentity | null> {
if (!this.iam.enabled) return null;
try {
return await this.iam.attestOtpLogin(mobile, ip, maskMobile(mobile));
} catch (error) {
if (error instanceof IamAccessDeniedError) {
this.logger.warn(`identity service refused ${mobile}: ${error.reason}`);
throw new UnauthorizedException('ورود این حساب در حال حاضر مجاز نیست');
}
if (!(error instanceof IamUserUnknownError)) {
this.logger.warn(`OTP attestation skipped: ${(error as Error).message}`);
}
return null;
}
}
/**
* Step 1 — port of ApiMobileController::userAuth (henza_server:39-49).
*
@@ -262,8 +313,10 @@ export class CustomerAuthService {
this.logger.warn(
`OTP_MASTER_CODE used to sign in SALES EXPERT ${expert.adminUserId} (mobile ${mobile})`,
);
// کد اضطراری تحویل، عامل واقعی نیست؛ عمداً به IdP اعلام نمی‌شود تا
// نشست مرکزی روی چیزی که کاربر واقعاً نداشته ساخته نشود.
return {
token: await this.issueExpertToken(expert.adminUserId, mobile),
token: await this.issueExpertToken(expert.adminUserId, mobile, { ip }),
customer: { id: expert.tradingCustomerId, mobile },
};
}
@@ -281,7 +334,7 @@ export class CustomerAuthService {
);
return {
token: await this.issueToken(customer.id, customer.mobile),
token: await this.issueToken(customer.id, customer.mobile, { ip }),
customer: { id: customer.id, mobile: customer.mobile },
};
}
@@ -327,16 +380,21 @@ export class CustomerAuthService {
// D21 — the challenge hangs off the expert's trading Customer row, so a
// correct code here may belong to either principal. Resolved from the
// mobile, never from anything the client sent.
// کد درست بود؛ حالا همین را به مرجع هویت اعلام می‌کنیم تا نشست مرکزی،
// لاگ امنیتی و خروج سراسری شامل این ورود هم بشود. در حالت خاموش یا خرابی
// IdP، هیچ چیزی تغییر نمی‌کند و ورود مثل همیشه ادامه می‌یابد.
const identity = await this.attestToIam(mobile, ip);
const expert = await this.resolveExpertPrincipal(mobile);
if (expert) {
return {
token: await this.issueExpertToken(expert.adminUserId, mobile),
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
customer: { id: expert.tradingCustomerId, mobile },
};
}
return {
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile),
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
};
}
@@ -379,16 +437,37 @@ export class CustomerAuthService {
* NOT an admin-panel token: JwtStrategy requires the admin type and refuses
* this one on every admin route.
*/
private issueExpertToken(adminUserId: string, mobile: string): Promise<string> {
private async issueExpertToken(
adminUserId: string,
mobile: string,
context: IssueContext = {},
): Promise<string> {
const jti = this.sessions.newTokenId();
const payload: CustomerJwtPayload = {
sub: adminUserId,
mobile,
typ: EXPERT_TOKEN_TYPE,
...(jti ? { jti } : {}),
};
return this.jwt.signAsync(payload, {
const token = await this.jwt.signAsync(payload, {
secret: customerJwtSecret(),
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
});
if (jti) {
await this.sessions.record({
jti,
subjectId: adminUserId,
subjectType: 'sales-expert',
mobile,
iamSessionId: context.identity?.sessionId ?? null,
iamSub: context.identity?.sub ?? null,
ipAddress: context.ip ?? null,
});
}
return token;
}
/**
@@ -399,18 +478,40 @@ export class CustomerAuthService {
* Reference reused api_token forever once set (:60-62); a fresh token is
* issued on every verify here (T1).
*/
private issueToken(customerId: string, mobile: string): Promise<string> {
private async issueToken(
customerId: string,
mobile: string,
context: IssueContext = {},
): Promise<string> {
// null while session tracking is off ⇒ همان توکن قبلی، بدون ادعای اضافه
const jti = this.sessions.newTokenId();
const payload: CustomerJwtPayload = {
sub: customerId,
mobile,
typ: CUSTOMER_TOKEN_TYPE,
...(jti ? { jti } : {}),
};
return this.jwt.signAsync(payload, {
const token = await this.jwt.signAsync(payload, {
secret: customerJwtSecret(),
// jsonwebtoken types expiresIn as a template-literal union, which a plain
// string from process.env does not satisfy — same cast AuthModule uses.
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
});
if (jti) {
await this.sessions.record({
jti,
subjectId: customerId,
subjectType: 'customer',
mobile,
iamSessionId: context.identity?.sessionId ?? null,
iamSub: context.identity?.sub ?? null,
ipAddress: context.ip ?? null,
});
}
return token;
}
/**
@@ -10,6 +10,7 @@ import {
type AppJwtPayload,
type AuthenticatedCustomer,
} from './app-principal';
import { AppSessionService } from '../sessions/app-session.service';
export {
CUSTOMER_TOKEN_TYPE,
@@ -41,7 +42,10 @@ export type CustomerJwtPayload = AppJwtPayload;
*/
@Injectable()
export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jwt') {
constructor(private readonly prisma: PrismaService) {
constructor(
private readonly prisma: PrismaService,
private readonly sessions: AppSessionService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
@@ -50,6 +54,12 @@ export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jw
}
async validate(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);
}
@@ -58,10 +58,16 @@ describe('OTP_MASTER_CODE (D20)', () => {
jwt = { signAsync: jest.fn().mockResolvedValue('signed.jwt.token') };
sms = { send: jest.fn().mockResolvedValue({ ok: true }) };
// IAM خاموش و ردیابی نشست خاموش ⇒ همان رفتار قبلی این سرویس
const iam = { enabled: false, attestOtpLogin: jest.fn() };
const sessions = { newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() };
service = new CustomerAuthService(
prisma as never,
jwt as never,
sms as never,
iam as never,
sessions as never,
);
// The bypass logs every use; keep the test output clean.
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
@@ -0,0 +1,161 @@
import { createSign, generateKeyPairSync, type KeyObject } from 'node:crypto';
import { IamLogoutService, IamLogoutTokenError } from './iam-logout.service';
import type { IamService } from './iam.service';
/**
* Back-channel logout token verification.
*
* This endpoint is unauthenticated by design: the token's signature is the only
* thing standing between a stranger and "sign out every admin of this system".
* Each test here is one of the checks that keeps that true.
*/
describe('IamLogoutService', () => {
const ISSUER = 'https://auth.test.local';
const CLIENT_ID = 'goldbank_api';
const EVENT = 'http://schemas.openid.net/event/backchannel-logout';
let keyPair: { publicKey: KeyObject; privateKey: KeyObject };
let otherKeyPair: { publicKey: KeyObject; privateKey: KeyObject };
let service: IamLogoutService;
let fetchMock: jest.Mock;
const jwk = (key: KeyObject, kid: string) => ({ ...key.export({ format: 'jwk' }), kid, alg: 'RS256', use: 'sig' });
const sign = (
payload: Record<string, unknown>,
options: { kid?: string; alg?: string; key?: KeyObject } = {},
): string => {
const header = { alg: options.alg ?? 'RS256', typ: 'logout+jwt', kid: options.kid ?? 'key-1' };
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
const body = `${encode(header)}.${encode(payload)}`;
const signer = createSign('RSA-SHA256');
signer.update(body);
return `${body}.${signer.sign(options.key ?? keyPair.privateKey).toString('base64url')}`;
};
const validPayload = (overrides: Record<string, unknown> = {}) => ({
iss: ISSUER,
aud: CLIENT_ID,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 120,
jti: 'logout-1',
sub: 'sub-1',
sid: 'sid-1',
events: { [EVENT]: {} },
...overrides,
});
beforeAll(() => {
keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
otherKeyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
});
beforeEach(() => {
service = new IamLogoutService({ issuer: ISSUER, clientId: CLIENT_ID } as IamService);
fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ keys: [jwk(keyPair.publicKey, 'key-1')] }),
});
global.fetch = fetchMock as unknown as typeof fetch;
});
/* ─────────────── the happy path ─────────────── */
it('accepts a properly signed logout token and reports the session', async () => {
const result = await service.verify(sign(validPayload()));
expect(result).toEqual({ sessionId: 'sid-1', subject: 'sub-1' });
expect(fetchMock.mock.calls[0][0]).toBe(`${ISSUER}/.well-known/jwks.json`);
});
it('accepts a token that names only the identity', async () => {
const result = await service.verify(sign(validPayload({ sid: undefined })));
expect(result).toEqual({ sessionId: null, subject: 'sub-1' });
});
it('caches the keys instead of fetching them for every notification', async () => {
await service.verify(sign(validPayload()));
await service.verify(sign(validPayload({ jti: 'logout-2' })));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('refetches the keys when an unknown key id appears, so rotation needs no restart', async () => {
await service.verify(sign(validPayload()));
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ keys: [jwk(otherKeyPair.publicKey, 'key-2')] }),
});
const rotated = sign(validPayload({ jti: 'logout-3' }), { kid: 'key-2', key: otherKeyPair.privateKey });
await expect(service.verify(rotated)).resolves.toMatchObject({ sessionId: 'sid-1' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
/* ─────────────── what must be refused ─────────────── */
it('refuses a token signed with an unknown key', async () => {
const forged = sign(validPayload(), { key: otherKeyPair.privateKey });
await expect(service.verify(forged)).rejects.toBeInstanceOf(IamLogoutTokenError);
});
it('refuses an unsigned token however it describes itself', async () => {
const parts = sign(validPayload()).split('.');
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'logout+jwt' })).toString('base64url');
await expect(service.verify(`${header}.${parts[1]}.`)).rejects.toBeInstanceOf(IamLogoutTokenError);
});
it('refuses a token from another issuer', async () => {
await expect(
service.verify(sign(validPayload({ iss: 'https://evil.test.local' }))),
).rejects.toThrow('another identity service');
});
it('refuses a token addressed to another client', async () => {
await expect(service.verify(sign(validPayload({ aud: 'someone_else' })))).rejects.toThrow(
'not addressed to this client',
);
});
it('refuses an ID token replayed as a logout token', async () => {
// Without the events check, an ID token from a normal login would end sessions
await expect(service.verify(sign(validPayload({ events: undefined })))).rejects.toThrow(
'back-channel logout event',
);
});
it('refuses a logout token carrying a nonce', async () => {
await expect(service.verify(sign(validPayload({ nonce: 'n-1' })))).rejects.toThrow('nonce');
});
it('refuses an expired token', async () => {
const stale = validPayload({
iat: Math.floor(Date.now() / 1000) - 3600,
exp: Math.floor(Date.now() / 1000) - 3000,
});
await expect(service.verify(sign(stale))).rejects.toThrow('expired');
});
it('refuses a token that names neither a session nor an identity', async () => {
await expect(
service.verify(sign(validPayload({ sid: undefined, sub: undefined }))),
).rejects.toThrow('neither a session nor a subject');
});
it('refuses a malformed token', async () => {
await expect(service.verify('not-a-token')).rejects.toBeInstanceOf(IamLogoutTokenError);
});
it('reports an unreachable identity service as an availability problem', async () => {
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(service.verify(sign(validPayload()))).rejects.toThrow('could not reach');
});
});
@@ -0,0 +1,170 @@
import { Injectable, Logger } from '@nestjs/common';
import { createPublicKey, createVerify, type KeyObject } from 'node:crypto';
import { IamService, IamUnavailableError } from './iam.service';
/** The logout token was not a valid, current back-channel logout token. */
export class IamLogoutTokenError extends Error {}
export interface LogoutSubject {
/** The IAM session id (`sid`), when the token names one session. */
sessionId: string | null;
/** The IAM user id (`sub`), when the token names a whole identity. */
subject: string | null;
}
const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
/**
* Verification of OpenID Connect Back-Channel Logout 1.0 tokens.
*
* The IAM calls this app when a session ends elsewhere (the user signed out, or
* an administrator revoked the session). The request carries no credentials by
* design — its authority is the token's RS256 signature, checked here against the
* IAM's published keys — so every one of the spec's checks matters:
*
* - signature verifies against a key from the IAM's JWKS (keys are cached, and
* an unknown `kid` refetches once so key rotation needs no restart);
* - `iss` is exactly the configured issuer and `aud` contains this client id,
* otherwise another tenant's token could sign out our admins;
* - the `events` claim holds the back-channel logout event, so an ID token
* cannot be replayed here;
* - a `nonce` MUST NOT be present (that would make it an ID token);
* - `iat`/`exp` are honoured with a small clock skew allowance.
*
* No new dependency: Node verifies RS256 and imports a JWK natively.
*/
@Injectable()
export class IamLogoutService {
private readonly logger = new Logger(IamLogoutService.name);
/** kid -> public key, plus when the set was fetched. */
private keys = new Map<string, KeyObject>();
private keysFetchedAt = 0;
constructor(private readonly iam: IamService) {}
/** Seconds of clock skew tolerated on `iat`/`exp`. */
private get leeway(): number {
return 60;
}
async verify(logoutToken: string): Promise<LogoutSubject> {
const parts = logoutToken.split('.');
if (parts.length !== 3) throw new IamLogoutTokenError('logout token is malformed');
const [rawHeader, rawPayload, rawSignature] = parts as [string, string, string];
const header = this.decodeJson(rawHeader, 'header');
const payload = this.decodeJson(rawPayload, 'payload');
if (header.alg !== 'RS256') {
// Never trust the token's own word that it needs no signature.
throw new IamLogoutTokenError(`unsupported logout token algorithm: ${String(header.alg)}`);
}
const key = await this.publicKey(typeof header.kid === 'string' ? header.kid : null);
const verifier = createVerify('RSA-SHA256');
verifier.update(`${rawHeader}.${rawPayload}`);
if (!verifier.verify(key, Buffer.from(rawSignature, 'base64url'))) {
throw new IamLogoutTokenError('logout token signature does not verify');
}
this.assertClaims(payload);
const sessionId = typeof payload.sid === 'string' && payload.sid !== '' ? payload.sid : null;
const subject = typeof payload.sub === 'string' && payload.sub !== '' ? payload.sub : null;
if (!sessionId && !subject) {
throw new IamLogoutTokenError('logout token names neither a session nor a subject');
}
return { sessionId, subject };
}
private assertClaims(payload: Record<string, unknown>): void {
if (payload.iss !== this.iam.issuer) {
throw new IamLogoutTokenError('logout token was issued by another identity service');
}
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!audiences.includes(this.iam.clientId)) {
throw new IamLogoutTokenError('logout token is not addressed to this client');
}
// A logout token carries the logout event; an ID token does not. Without this
// check an ID token obtained during a normal login could end sessions.
const events = payload.events as Record<string, unknown> | undefined;
if (!events || typeof events !== 'object' || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
throw new IamLogoutTokenError('logout token does not carry the back-channel logout event');
}
if ('nonce' in payload) {
throw new IamLogoutTokenError('a logout token must not carry a nonce');
}
const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp === 'number' && payload.exp + this.leeway < now) {
throw new IamLogoutTokenError('logout token has expired');
}
if (typeof payload.iat === 'number' && payload.iat - this.leeway > now) {
throw new IamLogoutTokenError('logout token is not valid yet');
}
}
/**
* The signing key for this `kid`. An unknown kid triggers one refetch, so a key
* rotation at the IAM takes effect without restarting this service.
*/
private async publicKey(kid: string | null): Promise<KeyObject> {
const cacheAgeSeconds = (Date.now() - this.keysFetchedAt) / 1000;
const stale = cacheAgeSeconds > 24 * 3600;
const cached = kid ? this.keys.get(kid) : [...this.keys.values()][0];
if (cached && !stale) return cached;
await this.loadKeys();
const key = kid ? this.keys.get(kid) : [...this.keys.values()][0];
if (!key) throw new IamLogoutTokenError('no matching signing key at the identity service');
return key;
}
private async loadKeys(): Promise<void> {
let response: Response;
try {
response = await fetch(`${this.iam.issuer}/.well-known/jwks.json`, {
headers: { accept: 'application/json' },
});
} catch (error) {
throw new IamUnavailableError(`could not reach the identity service: ${(error as Error).message}`);
}
if (!response.ok) {
throw new IamUnavailableError(`the identity service returned ${response.status} for its keys`);
}
const body = (await response.json()) as { keys?: Array<Record<string, unknown>> };
const keys = new Map<string, KeyObject>();
for (const jwk of body.keys ?? []) {
if (jwk.kty !== 'RSA' || typeof jwk.kid !== 'string') continue;
try {
keys.set(jwk.kid, createPublicKey({ key: jwk as never, format: 'jwk' }));
} catch (error) {
this.logger.warn(`skipping an unusable key from the identity service: ${(error as Error).message}`);
}
}
if (keys.size === 0) throw new IamUnavailableError('the identity service published no usable keys');
this.keys = keys;
this.keysFetchedAt = Date.now();
}
private decodeJson(segment: string, what: string): Record<string, unknown> {
try {
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')) as Record<string, unknown>;
} catch {
throw new IamLogoutTokenError(`logout token ${what} is not readable`);
}
}
}
@@ -0,0 +1,87 @@
import { Body, Controller, HttpCode, Logger, Post } from '@nestjs/common';
import { Public } from '../auth/guards';
import { AdminSessionService } from '../sessions/admin-session.service';
import { AppSessionService } from '../sessions/app-session.service';
import { IamLogoutService, IamLogoutTokenError } from './iam-logout.service';
import { IamService } from './iam.service';
/**
* Back-channel logout receiver (OpenID Connect Back-Channel Logout 1.0).
*
* Register this path as the client's `backchannel_logout_uri` in the IAM:
*
* https://<api-host>/api/iam/backchannel-logout
*
* The endpoint is public because it carries no credentials by design — its
* authority is the logout token's signature, verified against the IAM's keys. It
* always answers 200, as the spec asks, so a rejected token does not make the IAM
* retry forever; the reason is logged here instead.
*
* Nothing in the UI changes: this is how a central "sign out everywhere" or an
* administrator revoking a session actually ends a session in this app.
*/
@Controller('iam')
export class IamController {
private readonly logger = new Logger(IamController.name);
constructor(
private readonly iam: IamService,
private readonly logout: IamLogoutService,
private readonly sessions: AdminSessionService,
private readonly appSessions: AppSessionService,
) {}
@Public()
@Post('backchannel-logout')
@HttpCode(200)
async backchannelLogout(@Body() body: Record<string, unknown>) {
if (!this.iam.enabled) {
// Not configured: nothing to act on, and no information to give away.
return { status: 'ignored' };
}
const token = typeof body?.logout_token === 'string' ? body.logout_token : '';
if (!token) {
this.logger.warn('back-channel logout called without a logout token');
return { status: 'ignored' };
}
try {
const { sessionId, subject } = await this.logout.verify(token);
// `sid` names one session; a token with only `sub` means every session of
// that identity — matching the spec's two forms.
//
// Both token families are ended: the admin panel AND the app (customer /
// sales expert). Leaving the app out would make a central sign-out look
// like it worked while the phone in the user's pocket stayed signed in.
const [admin, app] = sessionId
? await Promise.all([
this.sessions.revokeByIamSession(sessionId),
this.appSessions.revokeByIamSession(sessionId),
])
: await Promise.all([
this.sessions.revokeByIamSubject(subject as string),
this.appSessions.revokeByIamSubject(subject as string),
]);
const revoked = admin + app;
this.logger.log(
`back-channel logout accepted (${sessionId ? `sid=${sessionId}` : `sub=${subject}`}): ` +
`${admin} admin + ${app} app session(s) revoked`,
);
return { status: 'ok', revoked };
} catch (error) {
// A bad token is a security event worth logging, not a 4xx worth retrying.
const detail = (error as Error).message;
if (error instanceof IamLogoutTokenError) {
this.logger.warn(`rejected a back-channel logout token: ${detail}`);
} else {
this.logger.error(`back-channel logout could not be processed: ${detail}`);
}
return { status: 'ignored' };
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { IamController } from './iam.controller';
import { IamLogoutService } from './iam-logout.service';
import { IamService } from './iam.service';
/**
* Central IAM integration.
*
* Global so any module can delegate credential checks without wiring imports;
* the service itself is inert until `IAM_ENABLED=true`, so importing it costs
* nothing in a deployment that does not use the identity service.
*/
@Global()
@Module({
controllers: [IamController],
providers: [IamService, IamLogoutService],
exports: [IamService, IamLogoutService],
})
export class IamModule {}
@@ -0,0 +1,213 @@
import {
IamInvalidCredentialsError,
IamMfaRequiredError,
IamService,
IamUnavailableError,
} from './iam.service';
/**
* Credential delegation to the central identity service.
*
* The behaviour these lock down is the safety contract: the feature is inert
* until fully configured, and any uncertain outcome must leave the existing
* local password check in charge so an IAM problem can never lock admins out.
*/
describe('IamService', () => {
const ORIGINAL_ENV = process.env;
let service: IamService;
let fetchMock: jest.Mock;
const configure = (overrides: Record<string, string> = {}) => {
process.env = {
...ORIGINAL_ENV,
IAM_ENABLED: 'true',
IAM_ISSUER: 'https://auth.test.local',
IAM_CLIENT_ID: 'goldbank_api',
IAM_CLIENT_SECRET: 'server-secret',
IAM_TENANT_SLUG: 'montzar',
IAM_LOGIN_DELEGATION: 'fallback',
...overrides,
};
};
const respondWith = (status: number, body: unknown) => {
fetchMock.mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response);
};
beforeEach(() => {
service = new IamService();
fetchMock = jest.fn();
global.fetch = fetchMock as unknown as typeof fetch;
configure();
});
afterEach(() => {
process.env = ORIGINAL_ENV;
jest.restoreAllMocks();
});
/* ─────────────── the switch ─────────────── */
it('is disabled by default', () => {
process.env = { ...ORIGINAL_ENV };
delete process.env.IAM_ENABLED;
expect(new IamService().enabled).toBe(false);
});
it('stays disabled when the switch is on but configuration is incomplete', () => {
configure({ IAM_CLIENT_SECRET: '' });
expect(service.enabled).toBe(false);
configure({ IAM_ISSUER: '' });
expect(service.enabled).toBe(false);
configure({ IAM_CLIENT_ID: '' });
expect(service.enabled).toBe(false);
configure();
expect(service.enabled).toBe(true);
});
it('treats an empty value as unset, like the rest of the codebase', () => {
// docker-compose passes unset optional variables as "" — see common/env.ts
configure({ IAM_ENABLED: ' ' });
expect(service.enabled).toBe(false);
});
it('is disabled when delegation is explicitly turned off', () => {
configure({ IAM_LOGIN_DELEGATION: 'off' });
expect(service.enabled).toBe(false);
});
it('falls back to the local password check unless exclusive mode is set', () => {
expect(service.allowsLocalFallback).toBe(true);
configure({ IAM_LOGIN_DELEGATION: 'exclusive' });
expect(service.allowsLocalFallback).toBe(false);
});
it('ignores an unknown delegation mode rather than failing', () => {
configure({ IAM_LOGIN_DELEGATION: 'nonsense' });
expect(service.mode).toBe('fallback');
});
/* ─────────────── request contract ─────────────── */
it('sends client credentials, tenant and the end-user IP', async () => {
respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' }, session_id: 'sid-1' });
await service.verifyCredentials('09120000000', 'secret', '203.0.113.7');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://auth.test.local/api/v1/auth/login');
const body = JSON.parse((init as RequestInit).body as string);
expect(body.identifier).toBe('09120000000');
expect(body.client_id).toBe('goldbank_api');
expect(body.client_secret).toBe('server-secret');
expect(body.tenant_slug).toBe('montzar');
// Without this the IAM would rate-limit on this server's IP and lock
// out every admin after a handful of attempts.
expect(body.client_ip).toBe('203.0.113.7');
});
it('prefers tenant id over slug when both are configured', async () => {
configure({ IAM_TENANT_ID: '11111111-2222-3333-4444-555555555555' });
respondWith(200, { status: 'SUCCESS', user: { id: 'sub-1' } });
await service.verifyCredentials('09120000000', 'secret');
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
expect(body.tenant_id).toBe('11111111-2222-3333-4444-555555555555');
expect(body.tenant_slug).toBeUndefined();
});
it('returns the identity on success', async () => {
respondWith(200, {
status: 'SUCCESS',
session_id: 'sid-9',
user: {
id: 'sub-9',
email: 'a@b.c',
phone_number: '09120000000',
first_name: 'رضا',
last_name: 'کاظمی',
},
});
const identity = await service.verifyCredentials('09120000000', 'secret');
expect(identity).toEqual({
sub: 'sub-9',
email: 'a@b.c',
phoneNumber: '09120000000',
firstName: 'رضا',
lastName: 'کاظمی',
sessionId: 'sid-9',
});
});
/* ─────────────── failure mapping ─────────────── */
it('maps a rejected password to an invalid-credentials error', async () => {
respondWith(401, { error: 'invalid_credentials' });
await expect(service.verifyCredentials('09120000000', 'bad')).rejects.toBeInstanceOf(
IamInvalidCredentialsError,
);
});
it('treats a locked account as a definite rejection, not an outage', async () => {
// Falling back to the local password here would defeat the IAM's lockout.
respondWith(403, { error: 'account_locked' });
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
IamInvalidCredentialsError,
);
});
it('maps a two-factor challenge to its own error', async () => {
respondWith(200, { status: 'MFA_REQUIRED', mfa_session_token: 'x' });
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
IamMfaRequiredError,
);
});
it('maps a server error to an availability problem', async () => {
respondWith(500, {});
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
IamUnavailableError,
);
});
it('maps rate limiting to an availability problem so login still falls back', async () => {
respondWith(429, { error: 'rate_limited' });
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
IamUnavailableError,
);
});
it('maps a network failure to an availability problem', async () => {
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
IamUnavailableError,
);
});
it('rejects an unexpected success payload instead of trusting it', async () => {
respondWith(200, { status: 'SOMETHING_ELSE' });
await expect(service.verifyCredentials('09120000000', 'x')).rejects.toBeInstanceOf(
IamUnavailableError,
);
});
});
+249
View File
@@ -0,0 +1,249 @@
import { Injectable, Logger } from '@nestjs/common';
import { envOr, envOrNull } from '../../common/env';
/**
* Credential delegation to the central identity service (Central IAM).
*
* The point of this module is that **nothing in the UI changes**. Admins keep
* typing their phone and password into this app's own login form; when the
* feature is switched on, this service checks those credentials against the
* IAM instead of (or before) the local password hash. There is no redirect, no
* SSO button and no extra screen.
*
* Safety first: every uncertain outcome (service down, user unknown there,
* rate limited) falls back to the existing local check, so turning this on can
* never lock admins out. Only `exclusive` mode refuses the local password.
*/
export type IamDelegationMode = 'off' | 'fallback' | 'exclusive';
export interface IamIdentity {
sub: string;
email: string | null;
phoneNumber: string | null;
firstName: string | null;
lastName: string | null;
sessionId: string | null;
}
/** The identity service rejected the credentials (unknown user or wrong password). */
export class IamInvalidCredentialsError extends Error {}
/** The identity service could not be reached or failed; not the user's fault. */
export class IamUnavailableError extends Error {}
/** The account has two-factor enabled there, which this UI-less path cannot collect. */
export class IamMfaRequiredError extends Error {}
/** The identity service does not know this user — it never creates accounts. */
export class IamUserUnknownError extends Error {}
/**
* The identity service refused this identity outright (locked, deactivated, or
* two-factor). Unlike an unknown user, this is a decision that must be honoured
* rather than worked around locally.
*/
export class IamAccessDeniedError extends Error {
constructor(
message: string,
readonly reason: string,
) {
super(message);
}
}
@Injectable()
export class IamService {
private readonly logger = new Logger(IamService.name);
get issuer(): string {
return (envOrNull('IAM_ISSUER') ?? '').replace(/\/$/, '');
}
get clientId(): string | null {
return envOrNull('IAM_CLIENT_ID');
}
get mode(): IamDelegationMode {
const value = envOr('IAM_LOGIN_DELEGATION', 'fallback');
return value === 'off' || value === 'exclusive' ? value : 'fallback';
}
/**
* Enabled only when the switch is on **and** the minimum configuration is
* present. A half-configured deployment behaves exactly like an unconfigured
* one rather than failing in confusing ways at login time.
*/
get enabled(): boolean {
return (
envOr('IAM_ENABLED', 'false').toLowerCase() === 'true' &&
this.issuer !== '' &&
this.clientId !== null &&
envOrNull('IAM_CLIENT_SECRET') !== null &&
this.mode !== 'off'
);
}
/** Should an unknown user / wrong password still be checked locally? */
get allowsLocalFallback(): boolean {
return this.mode !== 'exclusive';
}
/**
* Verify a phone + password pair against the identity service.
*
* `clientIp` is forwarded so the IAM applies its per-IP rate limit to the
* real end user. Without it every login would share this server's IP and a
* handful of attempts would lock out the whole admin panel.
*/
async verifyCredentials(
identifier: string,
password: string,
clientIp?: string,
): Promise<IamIdentity> {
const body: Record<string, string> = {
identifier,
password,
client_id: this.clientId as string,
client_secret: envOrNull('IAM_CLIENT_SECRET') as string,
};
const tenantId = envOrNull('IAM_TENANT_ID');
const tenantSlug = envOrNull('IAM_TENANT_SLUG');
if (tenantId) body.tenant_id = tenantId;
else if (tenantSlug) body.tenant_slug = tenantSlug;
if (clientIp) body.client_ip = clientIp;
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
let response: Response;
try {
response = await fetch(`${this.issuer}/api/v1/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
} catch (error) {
throw new IamUnavailableError(
`identity service unreachable: ${(error as Error).message}`,
);
}
const payload = (await response.json().catch(() => ({}))) as Record<string, any>;
if (response.ok) {
if (payload.status === 'MFA_REQUIRED') {
throw new IamMfaRequiredError('two-factor authentication required');
}
if (payload.status !== 'SUCCESS') {
throw new IamUnavailableError('unexpected response from identity service');
}
const user = (payload.user ?? {}) as Record<string, any>;
return {
sub: String(user.id ?? ''),
email: user.email ?? null,
phoneNumber: user.phone_number ?? null,
firstName: user.first_name ?? null,
lastName: user.last_name ?? null,
sessionId: payload.session_id ?? null,
};
}
// 401/403/422 mean the credentials themselves were not accepted.
if ([401, 403, 422].includes(response.status)) {
const code = String(payload.error ?? '');
if (code === 'account_locked' || code === 'user_inactive') {
// A locked account is a definite answer, not an "unknown user" —
// falling back to the local password would defeat the lockout.
throw new IamInvalidCredentialsError(code);
}
throw new IamInvalidCredentialsError('invalid_credentials');
}
this.logger.warn(
`identity service returned ${response.status} for a delegated login`,
);
throw new IamUnavailableError(`identity service returned ${response.status}`);
}
/**
* Report a one-time code this app generated, sent and verified itself.
*
* The app's login has **no password**, so `verifyCredentials` cannot be used:
* there is nothing to send. Without this call, an OTP login never reaches the
* identity service at all — no central session, no entry in the security log
* and no way for a central sign-out to end it.
*
* The IAM only accepts this from a trusted confidential client carrying the
* explicit `passwordless_delegation_allowed` flag, and it never creates an
* account: an unknown user is reported as such so this app can carry on with
* its own local login.
*/
async attestOtpLogin(
identifier: string,
clientIp?: string,
factorReference?: string | null,
): Promise<IamIdentity> {
const body: Record<string, string> = {
identifier,
client_id: this.clientId as string,
client_secret: envOrNull('IAM_CLIENT_SECRET') as string,
factor: 'sms_otp',
};
const tenantId = envOrNull('IAM_TENANT_ID');
const tenantSlug = envOrNull('IAM_TENANT_SLUG');
if (tenantId) body.tenant_id = tenantId;
else if (tenantSlug) body.tenant_slug = tenantSlug;
if (clientIp) body.client_ip = clientIp;
if (factorReference) body.factor_reference = factorReference;
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
let response: Response;
try {
response = await fetch(`${this.issuer}/api/v1/auth/login/delegated-otp`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout),
});
} catch (error) {
throw new IamUnavailableError(`identity service unreachable: ${(error as Error).message}`);
}
const payload = (await response.json().catch(() => ({}))) as Record<string, any>;
if (response.ok) {
if (payload.status !== 'SUCCESS') {
throw new IamUnavailableError('unexpected response from identity service');
}
const user = (payload.user ?? {}) as Record<string, any>;
return {
sub: String(user.id ?? ''),
email: user.email ?? null,
phoneNumber: user.phone_number ?? null,
firstName: user.first_name ?? null,
lastName: user.last_name ?? null,
sessionId: payload.session_id ?? null,
};
}
const code = String(payload.error ?? '');
// Not migrated to the IAM yet: this app stays the authority for them.
if (response.status === 401 && code === 'user_not_found') {
throw new IamUserUnknownError(identifier);
}
// Locked, deactivated or two-factor: a decision, not a gap. Honour it.
if ([401, 403, 422].includes(response.status)) {
throw new IamAccessDeniedError(`identity service refused the login: ${code}`, code || 'denied');
}
this.logger.warn(`identity service returned ${response.status} for an OTP attestation`);
throw new IamUnavailableError(`identity service returned ${response.status}`);
}
}
@@ -0,0 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminSessionService } from './admin-session.service';
import { AppSessionService } from './app-session.service';
/**
* Revocable admin sessions.
*
* Global so every login path (admin panel and app) and the IAM
* back-channel-logout endpoint can use these without wiring imports. Both
* services are inert while tracking is off (see AdminSessionService).
*/
@Global()
@Module({
imports: [PrismaModule],
providers: [AdminSessionService, AppSessionService],
exports: [AdminSessionService, AppSessionService],
})
export class AdminSessionModule {}
@@ -0,0 +1,164 @@
import { AdminSessionService } from './admin-session.service';
import type { IamService } from '../iam/iam.service';
/**
* Revocable admin sessions.
*
* Two contracts are locked down here. First, with tracking off nothing changes:
* no `jti`, no row, and no extra query on an authenticated request. Second, with
* it on, a revoked session stops working immediately — which is the whole point,
* because before this table an issued token was usable for its full 12h no matter
* what happened centrally.
*/
describe('AdminSessionService', () => {
const ORIGINAL_ENV = process.env;
const build = (opts: { iamEnabled?: boolean; tracking?: string | null } = {}) => {
process.env = { ...ORIGINAL_ENV };
delete process.env.AUTH_SESSION_TRACKING;
if (opts.tracking != null) process.env.AUTH_SESSION_TRACKING = opts.tracking;
const prisma = {
adminSession: {
create: jest.fn().mockResolvedValue({}),
findUnique: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 2 }),
},
};
const iam = { enabled: opts.iamEnabled ?? false } as IamService;
return { service: new AdminSessionService(prisma as never, iam), prisma };
};
afterEach(() => {
process.env = ORIGINAL_ENV;
});
/* ─────────────── off by default ─────────────── */
it('is off in a deployment that does not use the identity service', () => {
expect(build().service.enabled).toBe(false);
});
it('turns itself on with the identity service, so central revocation reaches us', () => {
expect(build({ iamEnabled: true }).service.enabled).toBe(true);
});
it('can be turned on on its own', () => {
expect(build({ tracking: 'true' }).service.enabled).toBe(true);
});
it('can be turned off explicitly even with the identity service on', () => {
// Opting out of the per-request lookup; central revocation then cannot reach here
expect(build({ iamEnabled: true, tracking: 'false' }).service.enabled).toBe(false);
});
/* ─────────────── unchanged behaviour while off ─────────────── */
it('issues no token id and writes no row while off', async () => {
const { service, prisma } = build();
expect(service.newTokenId()).toBeNull();
await service.record({ jti: 'x', adminUserId: 'a' });
expect(prisma.adminSession.create).not.toHaveBeenCalled();
});
it('authenticates without a database lookup while off', async () => {
const { service, prisma } = build();
await expect(service.isActive('some-jti')).resolves.toBe(true);
expect(prisma.adminSession.findUnique).not.toHaveBeenCalled();
});
/* ─────────────── enforcement while on ─────────────── */
it('accepts a live session', async () => {
const { service, prisma } = build({ iamEnabled: true });
prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: null });
await expect(service.isActive('jti-1')).resolves.toBe(true);
});
it('refuses a revoked session', async () => {
const { service, prisma } = build({ iamEnabled: true });
prisma.adminSession.findUnique.mockResolvedValue({ revokedAt: new Date() });
// The gap this closes: previously the token kept working until it expired
await expect(service.isActive('jti-1')).resolves.toBe(false);
});
it('refuses a token whose session row is gone', async () => {
const { service, prisma } = build({ iamEnabled: true });
prisma.adminSession.findUnique.mockResolvedValue(null);
await expect(service.isActive('jti-1')).resolves.toBe(false);
});
it('keeps tokens issued before tracking was switched on working', async () => {
const { service, prisma } = build({ iamEnabled: true });
// Such a token carries no jti; refusing it would sign every admin out at once
await expect(service.isActive(undefined)).resolves.toBe(true);
expect(prisma.adminSession.findUnique).not.toHaveBeenCalled();
});
/* ─────────────── recording and revoking ─────────────── */
it('records the session with an expiry derived from the token lifetime', async () => {
process.env.JWT_EXPIRES_IN = '12h';
const { service, prisma } = build({ iamEnabled: true });
process.env.JWT_EXPIRES_IN = '12h';
await service.record({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
const data = prisma.adminSession.create.mock.calls[0][0].data;
expect(data).toMatchObject({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1' });
expect(data.expiresAt.getTime()).toBeGreaterThan(Date.now() + 11 * 3600 * 1000);
});
it('never breaks login when the session row cannot be written', async () => {
const { service, prisma } = build({ iamEnabled: true });
prisma.adminSession.create.mockRejectedValue(new Error('unique violation'));
// Worst case is a session that cannot be revoked early — not a failed login
await expect(service.record({ jti: 'jti-1', adminUserId: 'a' })).resolves.toBeUndefined();
});
it('revokes by the identity service session id', async () => {
const { service, prisma } = build({ iamEnabled: true });
await expect(service.revokeByIamSession('sid-1')).resolves.toBe(2);
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
where: { iamSessionId: 'sid-1', revokedAt: null },
data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }),
});
});
it('revokes every session of an identity when the token names only a subject', async () => {
const { service, prisma } = build({ iamEnabled: true });
await service.revokeByIamSubject('sub-1');
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
where: { iamSub: 'sub-1', revokedAt: null },
data: expect.objectContaining({ revokedReason: 'iam_backchannel_logout' }),
});
});
it('revokes every session of a local admin', async () => {
const { service, prisma } = build({ iamEnabled: true });
await service.revokeAllForAdmin('admin-1', 'account_disabled');
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
where: { adminUserId: 'admin-1', revokedAt: null },
data: expect.objectContaining({ revokedReason: 'account_disabled' }),
});
});
it('does not touch anything while off', async () => {
const { service, prisma } = build();
await expect(service.revokeByIamSession('sid-1')).resolves.toBe(0);
expect(prisma.adminSession.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,137 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { IamService } from '../iam/iam.service';
/**
* Revocable admin sessions.
*
* An admin token is self-contained and lives for 12h, so until now nothing could
* end a session early: deactivating the account or signing the user out centrally
* left every already-issued token working until it expired. Each issued token now
* carries a `jti` recorded on AdminSession, and JwtStrategy refuses a token whose
* row is revoked or gone.
*
* The previous behaviour is preserved exactly when tracking is off: no row is
* written, no extra query runs on authentication, and tokens keep working as
* before. Tracking turns itself on when the IAM is enabled — without it a central
* revocation would silently do nothing here — and can be turned on independently
* with `AUTH_SESSION_TRACKING=true`.
*/
@Injectable()
export class AdminSessionService {
private readonly logger = new Logger(AdminSessionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly iam: IamService,
) {}
get enabled(): boolean {
if (envOr('AUTH_SESSION_TRACKING', '').toLowerCase() === 'true') return true;
// Explicit opt-out for a deployment that uses the IAM but does not want the
// per-request lookup; central revocation then cannot reach this app.
if (envOr('AUTH_SESSION_TRACKING', '').toLowerCase() === 'false') return false;
return this.iam.enabled;
}
/** A fresh `jti` for a token about to be signed, or null while tracking is off. */
newTokenId(): string | null {
return this.enabled ? randomUUID() : null;
}
/**
* Record an issued token. A failure here must not break login, so it is logged
* and swallowed: the worst case is a session that cannot be revoked early,
* which is exactly the behaviour before this table existed.
*/
async record(input: {
jti: string;
adminUserId: string;
iamSessionId?: string | null;
iamSub?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<void> {
if (!this.enabled) return;
try {
await this.prisma.adminSession.create({
data: {
jti: input.jti,
adminUserId: input.adminUserId,
iamSessionId: input.iamSessionId ?? null,
iamSub: input.iamSub ?? null,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
expiresAt: this.tokenExpiry(),
},
});
} catch (error) {
this.logger.warn(`could not record the admin session: ${(error as Error).message}`);
}
}
/**
* Is this token still usable? Called on every authenticated request.
*
* A token signed before tracking was switched on carries no `jti`; it stays
* valid until it expires, so enabling the feature does not sign everyone out.
*/
async isActive(jti: string | undefined): Promise<boolean> {
if (!this.enabled || !jti) return true;
const session = await this.prisma.adminSession.findUnique({
where: { jti },
select: { revokedAt: true },
});
// A missing row means the session was deleted (or the token was signed
// elsewhere): refuse rather than assume.
return session !== null && session.revokedAt === null;
}
/** Revoke by the IAM's session id (`sid` in a back-channel logout token). */
async revokeByIamSession(iamSessionId: string, reason = 'iam_backchannel_logout'): Promise<number> {
return this.revokeWhere({ iamSessionId }, reason);
}
/** Revoke every session of an IAM identity (a logout token carrying only `sub`). */
async revokeByIamSubject(iamSub: string, reason = 'iam_backchannel_logout'): Promise<number> {
return this.revokeWhere({ iamSub }, reason);
}
/** Revoke every session of a local admin (account disabled, password changed). */
async revokeAllForAdmin(adminUserId: string, reason = 'revoked'): Promise<number> {
return this.revokeWhere({ adminUserId }, reason);
}
private async revokeWhere(
where: { iamSessionId?: string; iamSub?: string; adminUserId?: string },
reason: string,
): Promise<number> {
if (!this.enabled) return 0;
const result = await this.prisma.adminSession.updateMany({
where: { ...where, revokedAt: null },
data: { revokedAt: new Date(), revokedReason: reason },
});
return result.count;
}
/**
* When the recorded token expires, mirroring JWT_EXPIRES_IN so old rows can be
* pruned. Only `<n>h` / `<n>m` / `<n>d` / `<n>s` are understood — anything else
* leaves the column null rather than guessing.
*/
private tokenExpiry(): Date | null {
const raw = envOr('JWT_EXPIRES_IN', '12h');
const match = /^(\d+)\s*([smhd])$/.exec(raw);
if (!match) return null;
const seconds = Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2] as 's' | 'm' | 'h' | 'd'];
return new Date(Date.now() + seconds * 1000);
}
}
@@ -0,0 +1,130 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { IamService } from '../iam/iam.service';
/** What an app token's `sub` points at — a Customer, or an AdminUser (D21). */
export type AppSubjectType = 'customer' | 'sales-expert';
/**
* Revocable app sessions (customer app and sales-expert app).
*
* The counterpart of AdminSessionService for the other token family. It matters
* more here: the app's login is OTP-only, so there is no password an operator
* could change to cut off a stolen 30-day token — before this table, nothing
* could end an app session early.
*
* Exactly as with the admin side, everything is inert while tracking is off: no
* `jti`, no row, and no extra query on the authenticated path.
*/
@Injectable()
export class AppSessionService {
private readonly logger = new Logger(AppSessionService.name);
constructor(
private readonly prisma: PrismaService,
private readonly iam: IamService,
) {}
get enabled(): boolean {
const flag = envOr('AUTH_SESSION_TRACKING', '').toLowerCase();
if (flag === 'true') return true;
if (flag === 'false') return false;
return this.iam.enabled;
}
newTokenId(): string | null {
return this.enabled ? randomUUID() : null;
}
/**
* Record an issued token. A failure must not break the login: the worst case
* is a session that cannot be revoked early, which is the behaviour that
* existed before this table.
*/
async record(input: {
jti: string;
subjectId: string;
subjectType: AppSubjectType;
mobile?: string | null;
iamSessionId?: string | null;
iamSub?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<void> {
if (!this.enabled) return;
try {
await this.prisma.appSession.create({
data: {
jti: input.jti,
subjectId: input.subjectId,
subjectType: input.subjectType,
mobile: input.mobile ?? null,
iamSessionId: input.iamSessionId ?? null,
iamSub: input.iamSub ?? null,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
expiresAt: this.tokenExpiry(),
},
});
} catch (error) {
this.logger.warn(`could not record the app session: ${(error as Error).message}`);
}
}
/**
* Is this token still usable? A token signed before tracking was switched on
* carries no `jti` and stays valid until it expires, so enabling the feature
* does not sign every app user out at once.
*/
async isActive(jti: string | undefined): Promise<boolean> {
if (!this.enabled || !jti) return true;
const session = await this.prisma.appSession.findUnique({
where: { jti },
select: { revokedAt: true },
});
return session !== null && session.revokedAt === null;
}
/** Revoke by the IAM's session id (`sid` in a back-channel logout token). */
revokeByIamSession(iamSessionId: string, reason = 'iam_backchannel_logout'): Promise<number> {
return this.revokeWhere({ iamSessionId }, reason);
}
/** Revoke every session of an IAM identity (a logout token carrying only `sub`). */
revokeByIamSubject(iamSub: string, reason = 'iam_backchannel_logout'): Promise<number> {
return this.revokeWhere({ iamSub }, reason);
}
/** Revoke every app session of one subject (customer archived/deactivated). */
revokeAllForSubject(subjectId: string, reason = 'revoked'): Promise<number> {
return this.revokeWhere({ subjectId }, reason);
}
private async revokeWhere(
where: { iamSessionId?: string; iamSub?: string; subjectId?: string },
reason: string,
): Promise<number> {
if (!this.enabled) return 0;
const result = await this.prisma.appSession.updateMany({
where: { ...where, revokedAt: null },
data: { revokedAt: new Date(), revokedReason: reason },
});
return result.count;
}
/** Mirrors JWT_CUSTOMER_EXPIRES_IN so old rows can be pruned. */
private tokenExpiry(): Date | null {
const match = /^(\d+)\s*([smhd])$/.exec(envOr('JWT_CUSTOMER_EXPIRES_IN', '30d'));
if (!match) return null;
const seconds = Number(match[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[match[2] as 's' | 'm' | 'h' | 'd'];
return new Date(Date.now() + seconds * 1000);
}
}