Feat add iam auth
This commit is contained in:
@@ -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('دو مرحلهای');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user