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
@@ -0,0 +1,38 @@
-- Revocable admin sessions.
--
-- Until now an admin-panel token was purely self-contained: once signed it was
-- valid for its full 12h and nothing could cut it short — not deactivating the
-- account, not a central "sign out everywhere". Connecting the central IAM made
-- that gap concrete, because an IAM session revocation had no effect on a token
-- this API had already issued.
--
-- Each issued token now carries a `jti` recorded here, and JwtStrategy rejects a
-- token whose row is revoked. Rows are only written (and only enforced) while
-- session tracking is enabled, so a deployment that leaves it off keeps exactly
-- the previous behaviour and the previous query count.
CREATE TABLE "AdminSession" (
"id" TEXT NOT NULL,
"jti" TEXT NOT NULL,
"adminUserId" TEXT NOT NULL,
"iamSessionId" TEXT,
"iamSub" TEXT,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
"lastSeenAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"revokedReason" TEXT,
CONSTRAINT "AdminSession_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AdminSession_jti_key" ON "AdminSession"("jti");
CREATE INDEX "AdminSession_adminUserId_idx" ON "AdminSession"("adminUserId");
CREATE INDEX "AdminSession_iamSessionId_idx" ON "AdminSession"("iamSessionId");
CREATE INDEX "AdminSession_iamSub_idx" ON "AdminSession"("iamSub");
CREATE INDEX "AdminSession_expiresAt_idx" ON "AdminSession"("expiresAt");
ALTER TABLE "AdminSession"
ADD CONSTRAINT "AdminSession_adminUserId_fkey"
FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
-- Revocable app sessions (customer app and sales-expert app).
--
-- The admin panel got this in 20260925120000_admin_session; the app token family
-- had the same gap: a 30-day self-contained token that nothing could end early.
-- That matters more here, because the app's login is OTP-only — there is no
-- password an operator could change to cut access.
--
-- No foreign key on purpose: an app token's subject is a Customer for a customer
-- and an AdminUser for a sales expert (D21), which one FK cannot express.
CREATE TABLE "AppSession" (
"id" TEXT NOT NULL,
"jti" TEXT NOT NULL,
"subjectId" TEXT NOT NULL,
"subjectType" TEXT NOT NULL,
"mobile" TEXT,
"iamSessionId" TEXT,
"iamSub" TEXT,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3),
"lastSeenAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"revokedReason" TEXT,
CONSTRAINT "AppSession_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AppSession_jti_key" ON "AppSession"("jti");
CREATE INDEX "AppSession_subjectId_idx" ON "AppSession"("subjectId");
CREATE INDEX "AppSession_iamSessionId_idx" ON "AppSession"("iamSessionId");
CREATE INDEX "AppSession_iamSub_idx" ON "AppSession"("iamSub");
CREATE INDEX "AppSession_expiresAt_idx" ON "AppSession"("expiresAt");
+93
View File
@@ -184,6 +184,11 @@ model AdminUser {
/// Only ever set for role = SALES_EXPERT (D17).
salesExpertProfile SalesExpertProfile?
/// Issued admin-panel tokens, recorded only while session tracking is on
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
/// token can be revoked before it expires. See AdminSession.
sessions AdminSession[]
@@index([role])
@@index([userTypeId])
@@index([avatarFileId])
@@ -191,6 +196,94 @@ model AdminUser {
@@index([lastName])
}
/// An issued APP token (customer app / sales-expert app), so it can be revoked
/// before it expires — the counterpart of AdminSession for the other token family.
///
/// WHY A SEPARATE TABLE AND NO FOREIGN KEY
/// ---------------------------------------
/// An app token's `sub` is a Customer id for a customer and an **AdminUser id**
/// for a sales expert (D21). One FK cannot point at two tables, so the subject is
/// stored as a plain id plus its type. The rows are short-lived operational data,
/// not part of the accounting model, so the missing FK costs nothing here.
model AppSession {
id String @id @default(cuid())
/// The token's `jti` claim. Unique: one row per issued token.
jti String @unique
/// Customer.id, or AdminUser.id when subjectType is "sales-expert".
subjectId String
subjectType String
mobile String?
/// Set when the login was confirmed by the central IAM: its session id (`sid`)
/// and stable user id (`sub`), which is what a back-channel logout arrives with.
iamSessionId String?
iamSub String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
expiresAt DateTime?
lastSeenAt DateTime?
revokedAt DateTime?
revokedReason String?
@@index([subjectId])
@@index([iamSessionId])
@@index([iamSub])
@@index([expiresAt])
}
/// An issued admin-panel JWT, so it can be revoked before it expires.
///
/// WHY THIS EXISTS
/// ---------------
/// The admin token is self-contained and valid for 12h, which means nothing —
/// not disabling the account, not a central "sign out everywhere" — could cut a
/// session short: the token kept working until it expired. With the central IAM
/// in the loop that gap became visible (an IAM session revocation had no effect
/// here), so every issued token now carries a `jti` recorded on this table and
/// JwtStrategy refuses a token whose row is revoked.
///
/// Rows are written ONLY while tracking is enabled, and JwtStrategy only
/// enforces them while it is enabled, so a deployment that does not want the
/// extra lookup behaves exactly as before.
model AdminSession {
id String @id @default(cuid())
/// The token's `jti` claim. Unique: one row per issued token.
jti String @unique
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
/// Set when the login was verified by the IAM: its session id (`sid`) and the
/// stable user id (`sub`) there. Back-channel logout arrives keyed by these —
/// deliberately here rather than as columns on AdminUser, so enabling the
/// feature needs no change to an existing table.
iamSessionId String?
iamSub String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
expiresAt DateTime?
lastSeenAt DateTime?
revokedAt DateTime?
revokedReason String?
@@index([adminUserId])
@@index([iamSessionId])
@@index([iamSub])
@@index([expiresAt])
}
/// The sales-expert fields legacy kept on the `customers` row (type=1) and that
/// were lost when a sales expert became an AdminUser (D17).
///
+5
View File
@@ -11,6 +11,8 @@ import { OrdersModule } from './modules/orders/orders.module';
import { AccountingModule } from './modules/accounting/accounting.module';
import { PricingModule } from './modules/pricing/pricing.module';
import { AuthModule } from './modules/auth/auth.module';
import { IamModule } from './modules/iam/iam.module';
import { AdminSessionModule } from './modules/sessions/admin-session.module';
import { AdminTableModule } from './modules/admin-table/admin-table.module';
import { AdminOrdersModule } from './modules/admin-orders/admin-orders.module';
import { AdminCitiesModule } from './modules/admin-cities/admin-cities.module';
@@ -32,6 +34,9 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
@Module({
imports: [
// Inert unless IAM_ENABLED=true — see modules/iam/README.md
IamModule,
AdminSessionModule,
PrismaModule,
AuthModule,
AdminTableModule,
@@ -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);
}
}