Merge release/delivery (IAM single sign-on) into the triage work

Brings the four release/delivery commits (IAM SSO for the admin panel,
both apps and the API; IAM env in docker-compose) onto fix/issues-triage.

Conflicts:
- customer-auth.service.ts imports: both kept.
- both apps' api/client.ts: both kept (AccountingBalance/WageQuote, and
  iamAppUrl).
- all build output (admin-panel, user-app, user-app-v2 dist, and the api
  release bundle): rebuilt from the merged source instead of picking a
  side.

Fixed while verifying:
- The IAM change calls iam.browserBootstrapUrl after every login, but
  the identity-service stand-ins in three test files lacked it. 15 tests
  failed on release/delivery itself.
- SMS relay: SMS_RELAY_PLATFORM was read but never sent, so the gateway
  never got `platform` (documented body: phone, host, text, platform).
  relayConfig() now returns it ("default" → null) and the request carries
  it. sms.service.spec now passes.

All 249 API tests pass; the three front-ends type-check and build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-10 09:03:53 +03:30
co-authored by Claude Opus 5.5
315 changed files with 82919 additions and 7020 deletions
@@ -1,3 +1,4 @@
import { createHash, createHmac, randomBytes } from 'node:crypto';
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
@@ -11,7 +12,7 @@ import {
} from '../iam/iam.service';
import { AppSessionService } from '../sessions/app-session.service';
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { envOr, envOrNull } from '../../common/env';
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits';
import { SmsService, smsConfigured } from '../sms/sms.service';
@@ -62,6 +63,14 @@ interface IssueContext {
}
/** شماره ماسک‌شده برای لاگ امنیتی IdP — بدون افشای کامل شماره */
function mobileVariants(raw: string): string[] {
const digits = raw.replace(/\D/g, '');
const variants = new Set<string>([raw.trim(), digits]);
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
return [...variants].filter((value) => value !== '');
}
function maskMobile(mobile: string): string | null {
return mobile.length < 7 ? null : `${mobile.slice(0, 4)}***${mobile.slice(-4)}`;
}
@@ -395,16 +404,22 @@ export class CustomerAuthService {
const expert = await this.resolveExpertPrincipal(mobile);
if (expert) {
return {
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
customer: { id: expert.tradingCustomerId, mobile },
};
return this.withBootstrap(
{
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
customer: { id: expert.tradingCustomerId, mobile },
},
identity,
);
}
return {
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
};
return this.withBootstrap(
{
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
},
identity,
);
}
/**
@@ -486,6 +501,140 @@ export class CustomerAuthService {
* Reference reused api_token forever once set (:60-62); a fresh token is
* issued on every verify here (T1).
*/
/**
* شروع بررسی بی‌صدای نشست IAM. کوکی امضاشده code_verifier را نگه می‌دارد
* تا در آدرس بازگشت دیده نشود.
*/
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
if (!this.iam.enabled) return null;
const redirectUri = pickExactUri(requestedRedirect, [
envOrNull('IAM_REDIRECT_URI'),
...envList('IAM_REDIRECT_URIS'),
]);
const clientId = envOrNull('IAM_CLIENT_ID');
if (!redirectUri || !clientId) return null;
const state = randomBytes(24).toString('base64url');
const nonce = randomBytes(24).toString('base64url');
const verifier = randomBytes(48).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
const secure = redirectUri.startsWith('https:');
const cookie =
`gb_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
(secure ? '; Secure' : '');
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('nonce', nonce);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('prompt', 'none');
return { authorizeUrl: url.toString(), cookie };
}
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip: string | undefined) {
const pending = this.readSsoCookie(cookieValue);
if (!pending || pending.state !== state) {
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
}
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
if (!claims.phoneNumber || !claims.sub) {
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
}
const mobiles = mobileVariants(claims.phoneNumber);
let expert: { adminUserId: string; tradingCustomerId: string } | null = null;
let expertMobile = claims.phoneNumber;
for (const candidate of mobiles) {
expert = await this.resolveExpertPrincipal(candidate);
if (expert) {
expertMobile = candidate;
break;
}
}
const identity: IamIdentity = {
sub: claims.sub,
email: null,
phoneNumber: claims.phoneNumber,
firstName: null,
lastName: null,
sessionId: claims.sessionId,
browserTicket: null,
};
if (expert) {
return {
token: await this.issueExpertToken(expert.adminUserId, expertMobile, { identity, ip }),
customer: { id: expert.tradingCustomerId, mobile: expertMobile },
};
}
const customer = await this.prisma.customer.findFirst({
where: { mobile: { in: mobiles }, isActive: true, isArchived: false },
select: { id: true, mobile: true },
});
if (!customer) {
throw new UnauthorizedException('حسابی با این شماره در این سامانه نیست');
}
return {
token: await this.issueToken(customer.id, customer.mobile, { identity, ip }),
customer: { id: customer.id, mobile: customer.mobile },
};
}
centralLogoutUrl(requestedReturn?: string): string | null {
if (!this.iam.enabled) return null;
const redirect = pickExactUri(requestedReturn, [
envOrNull('IAM_POST_LOGOUT_REDIRECT_URI'),
envOrNull('IAM_BROWSER_RETURN_URL'),
...envList('IAM_ALLOWED_RETURN_URLS'),
]);
const clientId = envOrNull('IAM_CLIENT_ID');
if (!redirect || !clientId) return null;
const url = new URL(`${this.iam.issuer}/oauth/logout`);
url.searchParams.set('client_id', clientId);
url.searchParams.set('post_logout_redirect_uri', redirect);
return url.toString();
}
private withBootstrap<T extends { token: string; customer: { id: string; mobile: string } }>(
body: T,
identity: IamIdentity | null,
): T & { iamBootstrapUrl?: string } {
const url = this.iam.browserBootstrapUrl(identity?.browserTicket);
return url ? { ...body, iamBootstrapUrl: url } : body;
}
private ssoSecret(): string {
return envOrNull('JWT_CUSTOMER_SECRET') || envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
}
private readSsoCookie(value: string | null): { state: string; nonce: string; verifier: string; redirectUri: string } | null {
if (!value || !value.includes('.')) return null;
const split = value.lastIndexOf('.');
const payload = value.slice(0, split);
const sig = value.slice(split + 1);
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
if (expected.length !== sig.length) return null;
let same = 0;
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
if (same !== 0) return null;
try {
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return null;
}
}
private async issueToken(
customerId: string,
mobile: string,