fix some for iam
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { CurrentAdmin, Public } from './guards';
|
||||
@@ -26,9 +27,53 @@ export class AuthController {
|
||||
return this.authService.login(dto, ip, userAgent);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sso/start')
|
||||
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
const started = this.authService.beginBrowserSso(redirectUri);
|
||||
if (!started) return { enabled: false, authorize_url: null };
|
||||
res.setHeader('Set-Cookie', started.cookie);
|
||||
return { enabled: true, authorize_url: started.authorizeUrl };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('sso/callback')
|
||||
@HttpCode(200)
|
||||
ssoCallback(
|
||||
@Body() body: { code?: string; state?: string },
|
||||
@Headers('cookie') cookieHeader: string | undefined,
|
||||
@Ip() ip: string,
|
||||
@Headers('user-agent') userAgent: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
res.setHeader('Set-Cookie', 'gb_admin_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||
return this.authService.completeBrowserSso(
|
||||
readCookie(cookieHeader, 'gb_admin_iam_sso'),
|
||||
body.code ?? '',
|
||||
body.state ?? '',
|
||||
ip,
|
||||
userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sso/logout-url')
|
||||
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
|
||||
return { logout_url: this.authService.centralLogoutUrl(returnUrl) };
|
||||
}
|
||||
|
||||
/** Legacy `users/show` — the router guard's permission refresh. */
|
||||
@Get('me')
|
||||
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.authService.showData(admin.id);
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(header: string | undefined, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key === name) return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
type IamIdentity,
|
||||
} from '../iam/iam.service';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -73,6 +75,130 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket);
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
api_token: apiToken,
|
||||
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
|
||||
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirectUri = pickExactUri(requestedRedirect, [
|
||||
envOrNull('IAM_ADMIN_REDIRECT_URI'),
|
||||
...envList('IAM_ADMIN_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_admin_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, userAgent?: string) {
|
||||
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) {
|
||||
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findFirst({
|
||||
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('حساب مدیری با این شماره در این سامانه نیست');
|
||||
}
|
||||
|
||||
const identity: IamIdentity = {
|
||||
sub: claims.sub,
|
||||
email: null,
|
||||
phoneNumber: claims.phoneNumber,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
sessionId: claims.sessionId,
|
||||
browserTicket: null,
|
||||
};
|
||||
return this.issueAdminToken(user, identity, ip, userAgent);
|
||||
}
|
||||
|
||||
centralLogoutUrl(requestedReturn?: string): string | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirect = pickExactUri(requestedReturn, [
|
||||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI'),
|
||||
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL'),
|
||||
...envList('IAM_ADMIN_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 async issueAdminToken(
|
||||
user: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
userTypeId: number | null;
|
||||
branchId: string | null;
|
||||
userType: { permissions: { rid: string }[] } | null;
|
||||
},
|
||||
identity: IamIdentity | undefined,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const jti = this.sessions.newTokenId();
|
||||
const apiToken = await this.jwt.signAsync({
|
||||
sub: user.id,
|
||||
mobile: user.mobile,
|
||||
...(jti ? { jti } : {}),
|
||||
});
|
||||
if (jti) {
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
iamSessionId: identity?.sessionId ?? null,
|
||||
iamSub: identity?.sub ?? null,
|
||||
ipAddress: ip ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
@@ -86,6 +212,27 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
private ssoSecret(): string {
|
||||
return envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
|
||||
}
|
||||
|
||||
private readSsoCookie(value: string | null): { state: 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password check, optionally delegated to the central identity service.
|
||||
*
|
||||
@@ -168,3 +315,11 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 !== '');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, HttpCode, Ip, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { CustomerAuthService } from './customer-auth.service';
|
||||
import { CustomersService } from '../customers/customers.service';
|
||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||
@@ -43,6 +44,35 @@ export class CustomerAuthController {
|
||||
return this.auth.verifyOtp(dto.username, dto.password, ip);
|
||||
}
|
||||
|
||||
/** اگر نشست IAM در مرورگر باشد، آدرس authorize با prompt=none برمیگردد. */
|
||||
@Public()
|
||||
@Get('auth/sso/start')
|
||||
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
const started = this.auth.beginBrowserSso(redirectUri);
|
||||
if (!started) return { enabled: false, authorize_url: null };
|
||||
res.setHeader('Set-Cookie', started.cookie);
|
||||
return { enabled: true, authorize_url: started.authorizeUrl };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('auth/sso/callback')
|
||||
@HttpCode(200)
|
||||
ssoCallback(
|
||||
@Body() body: { code?: string; state?: string },
|
||||
@Headers('cookie') cookieHeader: string | undefined,
|
||||
@Ip() ip: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
res.setHeader('Set-Cookie', 'gb_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||
return this.auth.completeBrowserSso(readCookie(cookieHeader, 'gb_iam_sso'), body.code ?? '', body.state ?? '', ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('auth/sso/logout-url')
|
||||
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
|
||||
return { logout_url: this.auth.centralLogoutUrl(returnUrl) };
|
||||
}
|
||||
|
||||
@CustomerAuth()
|
||||
@Get('me')
|
||||
me(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
@@ -61,3 +91,12 @@ export class CustomerAuthController {
|
||||
return this.customers.getLedgerEntries(me.id);
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(header: string | undefined, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key === name) return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
type IamIdentity,
|
||||
} from '../iam/iam.service';
|
||||
import { AppSessionService } from '../sessions/app-session.service';
|
||||
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';
|
||||
@@ -61,6 +62,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)}`;
|
||||
}
|
||||
@@ -387,16 +396,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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -478,6 +493,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,
|
||||
|
||||
@@ -149,6 +149,7 @@ describe('IamService', () => {
|
||||
firstName: 'رضا',
|
||||
lastName: 'کاظمی',
|
||||
sessionId: 'sid-9',
|
||||
browserTicket: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface IamIdentity {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
sessionId: string | null;
|
||||
/** بلیت یکبارمصرف برای گذاشتن کوکی نشست روی دامنه IAM */
|
||||
browserTicket: string | null;
|
||||
}
|
||||
|
||||
/** The identity service rejected the credentials (unknown user or wrong password). */
|
||||
@@ -113,6 +115,8 @@ export class IamService {
|
||||
if (tenantId) body.tenant_id = tenantId;
|
||||
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
||||
if (clientIp) body.client_ip = clientIp;
|
||||
const returnTo = envOrNull('IAM_ADMIN_BROWSER_RETURN_URL') || envOrNull('IAM_BROWSER_RETURN_URL');
|
||||
if (returnTo) body.return_to = returnTo;
|
||||
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
|
||||
@@ -148,6 +152,7 @@ export class IamService {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,9 +182,8 @@ export class IamService {
|
||||
* 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.
|
||||
* explicit `passwordless_delegation_allowed` flag. An unknown mobile is
|
||||
* created there. A locked or inactive account is still refused.
|
||||
*/
|
||||
async attestOtpLogin(
|
||||
identifier: string,
|
||||
@@ -199,6 +203,8 @@ export class IamService {
|
||||
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
||||
if (clientIp) body.client_ip = clientIp;
|
||||
if (factorReference) body.factor_reference = factorReference;
|
||||
const returnTo = envOrNull('IAM_BROWSER_RETURN_URL');
|
||||
if (returnTo) body.return_to = returnTo;
|
||||
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
|
||||
@@ -228,6 +234,7 @@ export class IamService {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,4 +253,65 @@ export class IamService {
|
||||
this.logger.warn(`identity service returned ${response.status} for an OTP attestation`);
|
||||
throw new IamUnavailableError(`identity service returned ${response.status}`);
|
||||
}
|
||||
|
||||
browserBootstrapUrl(ticket: string | null | undefined): string | null {
|
||||
if (!ticket || !this.issuer) return null;
|
||||
return `${this.issuer}/auth/bootstrap?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
/** تبادل کد مجوز مرورگر. پاسخ از خود 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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user