Initial commit: Hedena Gold monorepo.

First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
@@ -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 = 'hedena_gold_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,180 @@
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(false);
let key = kid ? this.keys.get(kid) : [...this.keys.values()][0];
if (!key && kid) {
await this.loadKeys(true);
key = this.keys.get(kid);
}
if (!key) throw new IamLogoutTokenError('no matching signing key at the identity service');
return key;
}
private async loadKeys(bypassCache: boolean): Promise<void> {
const uri = bypassCache
? `${this.iam.issuer}/.well-known/jwks.json?_=${Date.now()}`
: `${this.iam.issuer}/.well-known/jwks.json`;
let response: Response;
try {
response = await fetch(uri, {
headers: {
accept: 'application/json',
...(bypassCache ? { 'cache-control': 'no-cache', pragma: 'no-cache' } : {}),
},
});
} 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,214 @@
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: 'hedena_gold_api',
IAM_CLIENT_SECRET: 'server-secret',
IAM_TENANT_SLUG: 'hedena-gold',
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('hedena_gold_api');
expect(body.client_secret).toBe('server-secret');
expect(body.tenant_slug).toBe('hedena-gold');
// 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',
browserTicket: null,
});
});
/* ─────────────── 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,
);
});
});
+362
View File
@@ -0,0 +1,362 @@
import { Injectable, Logger } from '@nestjs/common';
import { envList, 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;
/** بلیت یکبارمصرف برای گذاشتن کوکی نشست روی دامنه IAM */
browserTicket: 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 returnTo = this.adminBrowserReturnUrl();
if (returnTo) body.return_to = returnTo;
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,
browserTicket: typeof payload.browser_ticket === 'string' ? payload.browser_ticket : 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. An unknown mobile is
* created there. A locked or inactive account is still refused.
*/
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 returnTo = this.customerBrowserReturnUrl();
if (returnTo) body.return_to = returnTo;
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,
browserTicket: typeof payload.browser_ticket === 'string' ? payload.browser_ticket : 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}`);
}
/**
* مقصد بلیت مرورگر. خروج از فهرست گسترده‌تری استفاده می‌کند؛ اگر ورود فقط
* `IAM_BROWSER_RETURN_URL` را ببیند و آن خالی باشد، کوکی مشترک گذاشته نمی‌شود
* و فقط خروج به IAM می‌رود.
*/
private customerBrowserReturnUrl(): string | null {
return (
envOrNull('IAM_BROWSER_RETURN_URL') ||
envOrNull('IAM_POST_LOGOUT_REDIRECT_URI') ||
envList('IAM_ALLOWED_RETURN_URLS')[0] ||
null
);
}
private adminBrowserReturnUrl(): string | null {
return (
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL') ||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI') ||
envList('IAM_ADMIN_ALLOWED_RETURN_URLS')[0] ||
this.customerBrowserReturnUrl()
);
}
browserBootstrapUrl(ticket: string | null | undefined): string | null {
if (!ticket || !this.issuer) return null;
return `${this.issuer}/auth/bootstrap?ticket=${encodeURIComponent(ticket)}`;
}
/**
* پایان همهٔ نشست‌های IAM این شخص، از جمله نشستی که اپ دیگر با آن وارد شده.
* کلاینت باید خودش در همان نشست شرکت کرده باشد.
*/
async endDelegatedSession(sessionId: string): Promise<void> {
if (!this.enabled || !this.issuer || !this.clientId) return;
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
const response = await fetch(`${this.issuer}/api/v1/auth/session/end`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
client_id: this.clientId,
client_secret: envOrNull('IAM_CLIENT_SECRET'),
session_id: sessionId,
}),
signal: AbortSignal.timeout(timeout),
});
if (!response.ok && response.status !== 403) {
throw new IamUnavailableError(`identity service refused session end (${response.status})`);
}
}
/** تبادل کد مجوز مرورگر. پاسخ از خود issuer و با راز کلاینت آمده است. */
async exchangeAuthorizationCode(
code: string,
codeVerifier: string,
redirectUri: string,
): Promise<{ sub: string; phoneNumber: string | null; sessionId: string | null }> {
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
client_id: this.clientId as string,
client_secret: envOrNull('IAM_CLIENT_SECRET') as string,
});
let response: Response;
try {
response = await fetch(`${this.issuer}/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
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 || typeof payload.id_token !== 'string') {
throw new IamUnavailableError(`identity service refused the authorization code (${response.status})`);
}
const claims = decodeIdToken(payload.id_token);
if (claims.iss !== this.issuer) {
throw new IamUnavailableError('identity token issuer does not match');
}
const aud = claims.aud;
const audiences = Array.isArray(aud) ? aud : [aud];
if (!audiences.includes(this.clientId)) {
throw new IamUnavailableError('identity token audience does not match');
}
return {
sub: String(claims.sub ?? ''),
phoneNumber: typeof claims.phone_number === 'string' ? claims.phone_number : null,
sessionId: typeof claims.sid === 'string' ? claims.sid : null,
};
}
}
function decodeIdToken(token: string): Record<string, any> {
const part = token.split('.')[1];
if (!part) throw new IamUnavailableError('identity token is malformed');
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as Record<string, any>;
}